diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 30c98070f312..7bf1d8cd43b6 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -2,6 +2,10 @@ name: Install Node dependencies description: Installs the Node toolchain and repository dependencies for CI jobs, with optional Electron archive caching. inputs: + cache-pnpm-verification: + description: Restore pnpm's policy-checked lockfile verification record on Linux. + required: false + default: 'true' native-runtime: description: Native runtime to prepare after the script-free install (none, node, or electron). required: false @@ -24,6 +28,15 @@ inputs: default: 'false' outputs: + verification-cache-hit: + description: Whether pnpm's verification record was restored. + value: ${{ steps.verification-cache-restore.outputs.cache-hit }} + verification-cache-path: + description: The small pnpm-owned verification record, without registry metadata. + value: ${{ steps.verification-cache.outputs.path }} + verification-cache-key: + description: Exact verification record key, also used by the isolated PR benchmark. + value: ${{ steps.verification-cache.outputs.key }} node-version: description: Resolved Node.js version used for the install. value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }} @@ -50,8 +63,9 @@ runs: uses: actions/setup-node@v6 with: node-version-file: package.json - cache: pnpm + cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} + package-manager-cache: false - name: Setup requested Node.js id: requested-node @@ -59,8 +73,50 @@ runs: uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - cache: pnpm + cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} + package-manager-cache: false + + # PR-local stores compete with reusable build caches for the repository quota. + - name: Resolve pnpm download store + id: pnpm-store + if: github.event_name == 'pull_request' + shell: bash + env: + LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }} + run: | + test -n "$LOCKFILE_HASH" + cache_path="$(pnpm store path --silent)" + test -n "$cache_path" + printf 'path=%s\n' "$cache_path" >> "$GITHUB_OUTPUT" + printf 'arch=%s\n' "$(node -p 'require("node:os").arch()')" >> "$GITHUB_OUTPUT" + + # Match setup-node's key and path so existing default-branch stores remain reusable. + - name: Restore pnpm download store without saving + if: github.event_name == 'pull_request' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }} + + - name: Resolve pnpm verification cache + id: verification-cache + if: runner.os == 'Linux' && inputs.cache-pnpm-verification == 'true' + shell: bash + env: + POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }} + run: | + printf 'path=%s/lockfile-verified.jsonl\n' "$(pnpm cache path)" >> "$GITHUB_OUTPUT" + printf 'key=pnpm-verification-v1-%s-%s-%s-%s\n' "$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH" >> "$GITHUB_OUTPUT" + + - name: Restore pnpm verification record + id: verification-cache-restore + if: steps.verification-cache.outputs.key != '' + continue-on-error: true + uses: actions/cache/restore@v5 + with: + path: ${{ steps.verification-cache.outputs.path }} + key: ${{ steps.verification-cache.outputs.key }} - name: Validate native runtime shell: bash @@ -96,6 +152,15 @@ runs: git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml fi + # pnpm checks the cached record's policy and validity; never bypass verification. + - name: Save pnpm verification record on main + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache-restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v5 + with: + path: ${{ steps.verification-cache.outputs.path }} + key: ${{ steps.verification-cache.outputs.key }} + - name: Resolve Electron package cache id: electron-package-cache if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true' @@ -116,12 +181,19 @@ runs: printf 'version=%s\n' "$(node -p "require('./node_modules/electron/package.json').version")" >> "$GITHUB_OUTPUT" - name: Cache Electron package archive - if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true' + if: (github.event_name != 'pull_request' || runner.os != 'Linux') && steps.electron-package-cache.outputs.version != '' uses: actions/cache@v5 with: path: ${{ steps.electron-package-cache.outputs.cache-root }} key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }} + - name: Restore Electron package archive without saving + if: github.event_name == 'pull_request' && runner.os == 'Linux' && steps.electron-package-cache.outputs.version != '' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.electron-package-cache.outputs.cache-root }} + key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }} + # Why cached: `--ignore-scripts` leaves node-pty without build/Release, so # ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime. # The artifacts are ABI-bound, so the key carries the target runtime, the resolved diff --git a/.github/actions/prepare-git-compatibility/action.yml b/.github/actions/prepare-git-compatibility/action.yml new file mode 100644 index 000000000000..058d31a064c7 --- /dev/null +++ b/.github/actions/prepare-git-compatibility/action.yml @@ -0,0 +1,32 @@ +name: Prepare baseline Git compatibility binary +description: Restore or build the pinned Linux Git binary for the compatibility contract. + +runs: + using: composite + steps: + # The default-branch warmer shares this key across PRs; a PR save is private to its merge ref. + # Cache eviction remains possible, so keep the checksum-verified cold build below. + - name: Cache baseline Git build + uses: actions/cache@v5 + with: + path: ~/.cache/orca-git-compat/git-2.25.5 + key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 + + # Finish the CPU-heavy build before any timed compatibility lanes start. + - name: Build the baseline Git binary + shell: bash + run: | + archive="$RUNNER_TEMP/git-2.25.5.tar.gz" + source="$HOME/.cache/orca-git-compat/git-2.25.5" + if [ -x "$source/git" ]; then + exit 0 + fi + curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" + echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ + | sha256sum --check + mkdir -p "$source" + tar -xzf "$archive" -C "$source" --strip-components=1 + make -C "$source" -j"$(nproc)" \ + NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git + # Object files are no longer needed after linking the cached binary. + find "$source" -name '*.o' -delete diff --git a/.github/actions/prepare-linux-package-fixture/action.yml b/.github/actions/prepare-linux-package-fixture/action.yml new file mode 100644 index 000000000000..a84568b3e062 --- /dev/null +++ b/.github/actions/prepare-linux-package-fixture/action.yml @@ -0,0 +1,63 @@ +name: Prepare cached Linux package fixture +description: Restore Docker build layers; only the main warmer builds and saves missing images. +inputs: + fixture: + description: Directory name below config/docker. + required: true + save-cache: + description: Build and save a missing fixture on the default branch. + default: 'false' +outputs: + image: + description: Loaded image to use as a Docker build cache, or empty on a cache miss. + value: ${{ steps.image.outputs.image }} +runs: + using: composite + steps: + - name: Restore fixture image + id: cache + uses: actions/cache/restore@v5 + continue-on-error: true + with: + path: ${{ runner.temp }}/orca-package-fixtures/${{ inputs.fixture }}.tar + key: linux-package-fixture-v1-${{ runner.os }}-${{ runner.arch }}-linux-amd64-${{ inputs.fixture }}-${{ hashFiles(format('config/docker/{0}/**', inputs.fixture), '.github/actions/prepare-linux-package-fixture/action.yml') }} + + - name: Load or seed fixture build cache + id: image + shell: bash + env: + FIXTURE: ${{ inputs.fixture }} + SAVE_CACHE: ${{ inputs.save-cache }} + CACHE_HIT: ${{ steps.cache.outputs.cache-hit }} + run: | + set -euo pipefail + case "$FIXTURE" in + headless-serve-shutdown|cli-launch-contract) ;; + *) echo "Unsupported package fixture: $FIXTURE" >&2; exit 1 ;; + esac + archive="$RUNNER_TEMP/orca-package-fixtures/$FIXTURE.tar" + image="orca-package-fixture-$FIXTURE:cache" + if [[ "$CACHE_HIT" == true ]] && docker load --input "$archive"; then + if docker image inspect "$image" > /dev/null; then + echo "image=$image" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + if [[ "$SAVE_CACHE" != true ]]; then + echo 'No usable fixture cache; the package runner will build normally.' + exit 0 + fi + docker build --platform linux/amd64 --build-arg BUILDKIT_INLINE_CACHE=1 \ + --tag "$image" --file "config/docker/$FIXTURE/Dockerfile" "config/docker/$FIXTURE" + mkdir -p "$(dirname "$archive")" + docker save --output "$archive" "$image" + echo "image=$image" >> "$GITHUB_OUTPUT" + echo 'built=true' >> "$GITHUB_OUTPUT" + + - name: Save fixture image + if: inputs.save-cache == 'true' && steps.cache.outputs.cache-hit != 'true' && steps.image.outputs.built == 'true' + uses: actions/cache/save@v5 + continue-on-error: true + with: + path: ${{ runner.temp }}/orca-package-fixtures/${{ inputs.fixture }}.tar + key: ${{ steps.cache.outputs.cache-primary-key }} diff --git a/.github/actions/prepare-xterm-dependencies/action.yml b/.github/actions/prepare-xterm-dependencies/action.yml new file mode 100644 index 000000000000..8fa54bd85ae4 --- /dev/null +++ b/.github/actions/prepare-xterm-dependencies/action.yml @@ -0,0 +1,33 @@ +name: Restore xterm build dependencies +description: Reuse installed dependencies for the pinned upstream checkout. +outputs: + cache-key: + description: Exact dependency cache key. + value: ${{ steps.restore.outputs.cache-primary-key }} + cache-hit: + description: Whether the exact installed dependency cache was restored. + value: ${{ steps.restore.outputs.cache-hit }} +runs: + using: composite + steps: + - id: runtime + shell: bash + run: | + . /etc/os-release + echo "image=$ID-$VERSION_ID" >> "$GITHUB_OUTPUT" + echo "node=$(node --version)" >> "$GITHUB_OUTPUT" + - id: restore + uses: actions/cache/restore@v5 + with: + path: | + ${{ runner.temp }}/xterm-patch-build/upstream/.git + ${{ runner.temp }}/xterm-patch-build/upstream/node_modules + key: xterm-dependencies-v1-${{ runner.os }}-${{ steps.runtime.outputs.image }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ hashFiles('config/patches/xterm-upstream.json', 'config/scripts/regenerate-xterm-patches.mjs', 'config/scripts/xterm-patch-text.mjs', '.github/actions/prepare-xterm-dependencies/action.yml') }} + - name: Restore download cache on an installed-dependency miss + if: steps.restore.outputs.cache-hit != 'true' + uses: actions/cache/restore@v5 + with: + path: | + ~/.npm + ${{ runner.temp }}/xterm-patch-build/upstream/.git + key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9d7a73a6b88a..326de25e1531 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,7 +12,8 @@ ## Linked Issue - + + Fixes # diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml new file mode 100644 index 000000000000..5fae18f2f84e --- /dev/null +++ b/.github/workflows/bun-profile-tests.yml @@ -0,0 +1,149 @@ +name: Bun profile persistence + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'src/**' + - 'config/**' + - 'native/**' + - 'tests/**' + - 'resources/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.json' + - '.npmrc' + - '.pnpmfile.cjs' + - '.github/actions/install-node-dependencies/**' + - '.github/workflows/bun-profile-tests.yml' + workflow_dispatch: + schedule: + - cron: '30 11 * * *' + +permissions: + contents: read + +concurrency: + group: bun-profile-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + should_run: ${{ steps.scope.outputs.should_run }} + qualification: ${{ steps.scope.outputs.qualification }} + runners: ${{ steps.scope.outputs.runners }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Detect Bun build and test inputs + id: scope + shell: bash + run: | + # Compare the tested merge with its base, retaining both sides of renames. + if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/bun-changes"; then + node config/scripts/bun-profile-change-scope.mjs "$RUNNER_TEMP/bun-changes" + else + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + + persistence: + needs: changes + # Missing/failed detection runs the full matrix; manual runs remain unconditional. + if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-14","macos-15-intel","windows-2022","windows-11-arm"]') }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }} + # build:orcad reuses the host addon validated by native-runtime preparation. + - run: pnpm build:orcad + - run: pnpm test:bun:profile --artifact + - uses: actions/setup-node@v6 + if: runner.arch == 'X64' + with: + node-version: '18' + - name: Verify Node 18 loads and hands off to bundled Bun + if: runner.arch == 'X64' + run: | + node out/orcad/orcad.js --orcad-smoke-load-check + node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018 + + linux_glibc_floor: + needs: [changes, persistence] + # A failed smoke already blocks qualification; missing scope still selects every platform. + if: >- + ${{ !cancelled() && needs.persistence.result == 'success' && + needs.changes.outputs.should_run != 'false' && + needs.changes.outputs.qualification != 'false' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + container: ubuntu:20.04 + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - name: Install Ubuntu 20.04 prerequisites + run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Trust the checked-out workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: ./.github/actions/install-node-dependencies + - run: pnpm build:orcad + - run: pnpm test:bun:profile --artifact + + linux_musl: + needs: [changes, persistence] + # A failed smoke already blocks qualification; missing scope still selects every platform. + if: >- + ${{ !cancelled() && needs.persistence.result == 'success' && + needs.changes.outputs.should_run != 'false' && + needs.changes.outputs.qualification != 'false' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Verify native Alpine artifact and persistence + run: | + docker run --rm --init -i \ + -e ORCA_BACKGROUND_LAUNCH=1 \ + -v "$GITHUB_WORKSPACE:/work" -w /work \ + node:24-alpine3.23 sh -s <<'BUN_QUALIFICATION' + set -eu + apk add --no-cache bash git libstdc++ python3 make g++ + git config --global --add safe.directory /work + npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" + pnpm install --frozen-lockfile --ignore-scripts + pnpm build:orcad + pnpm test:bun:profile --artifact + BUN_QUALIFICATION diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml new file mode 100644 index 000000000000..7309f7e39123 --- /dev/null +++ b/.github/workflows/ci-cache-warmup.yml @@ -0,0 +1,131 @@ +name: Warm shared CI caches + +on: + schedule: + - cron: '41 * * * *' + workflow_dispatch: + push: + branches: [main] + paths: + - '.github/workflows/ci-cache-warmup.yml' + - '.github/actions/install-node-dependencies/**' + - '.github/actions/prepare-git-compatibility/**' + - '.github/actions/prepare-linux-package-fixture/**' + - 'config/docker/headless-serve-shutdown/**' + - 'config/docker/cli-launch-contract/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'config/tsconfig*.json' + - '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/**' + pull_request: + paths: + - '.github/workflows/ci-cache-warmup.yml' + - '.github/actions/prepare-git-compatibility/**' + - '.github/actions/prepare-linux-package-fixture/**' + - 'config/docker/headless-serve-shutdown/**' + - 'config/docker/cli-launch-contract/**' + - 'config/scripts/ci-cache-warmup-workflow.test.mjs' + +permissions: + contents: read + +concurrency: + group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + warm: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # Default-branch caches can be restored by every PR; PR caches cannot. + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: '24' + cache-electron-package: 'true' + + - name: Populate shared Electron archive + run: node config/scripts/install-electron-package-binary.mjs + + - uses: ./.github/actions/prepare-git-compatibility + + - name: Cache TypeScript incremental state + id: typecheck-cache + uses: actions/cache@v5 + with: + path: config/*.tsbuildinfo + key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }} + restore-keys: | + tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}- + + - name: Refresh TypeScript state for this commit + if: steps.typecheck-cache.outputs.cache-hit != 'true' + run: pnpm run typecheck + + warm-linux-arm: + runs-on: ubuntu-24.04-arm + timeout-minutes: 10 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: '24' + cache-electron-package: 'true' + - name: Populate shared Electron archive + run: node config/scripts/install-electron-package-binary.mjs + - name: Verify native cache is usable + run: node config/scripts/ensure-native-runtime.mjs --check-only + + warm-windows: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-11-arm] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Verify native cache is usable + run: node config/scripts/ensure-native-runtime.mjs --check-only + + warm-linux-package-fixtures: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/prepare-linux-package-fixture + with: + fixture: headless-serve-shutdown + save-cache: ${{ github.event_name != 'pull_request' }} + - uses: ./.github/actions/prepare-linux-package-fixture + with: + fixture: cli-launch-contract + save-cache: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/ci-closed-pr-caches.yml b/.github/workflows/ci-closed-pr-caches.yml new file mode 100644 index 000000000000..38cf5803a146 --- /dev/null +++ b/.github/workflows/ci-closed-pr-caches.yml @@ -0,0 +1,33 @@ +name: Clean closed PR caches + +on: + pull_request_target: + types: [closed] + +permissions: + actions: write + +jobs: + clean: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # No checkout: this runs trusted default-branch code, including for fork PRs. + - uses: actions/github-script@v8 + with: + script: | + const ref = `refs/pull/${context.payload.pull_request.number}/merge` + const caches = await github.paginate(github.rest.actions.getActionsCacheList, { + ...context.repo, ref, per_page: 100 + }) + for (const cache of caches) { + if (cache.ref !== ref) throw new Error(`Unexpected cache ref: ${cache.ref}`) + try { + await github.rest.actions.deleteActionsCacheById({ + ...context.repo, cache_id: cache.id + }) + } catch (error) { + if (error.status !== 404) throw error + } + } + core.info(`Removed ${caches.length} caches scoped to ${ref}`) diff --git a/.github/workflows/ci-pnpm-verification-pilot.yml b/.github/workflows/ci-pnpm-verification-pilot.yml new file mode 100644 index 000000000000..2a35a52fcd0e --- /dev/null +++ b/.github/workflows/ci-pnpm-verification-pilot.yml @@ -0,0 +1,65 @@ +name: pnpm verification cache pilot + +on: + pull_request: + paths: ['.github/workflows/ci-pnpm-verification-pilot.yml'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pnpm-verification-pilot-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + ORCA_BACKGROUND_LAUNCH: '1' + +jobs: + seed: + strategy: + matrix: + runner: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + id: deps + # PR caches are scoped to the PR merge ref; main never restores them. + - uses: actions/cache/save@v5 + if: steps.deps.outputs.verification-cache-hit != 'true' + with: + path: ${{ steps.deps.outputs.verification-cache-path }} + key: ${{ steps.deps.outputs.verification-cache-key }} + + measure: + needs: seed + name: measure ${{ matrix.runner }} sample ${{ matrix.sample }} cache ${{ matrix.cache }} + strategy: + fail-fast: false + max-parallel: 4 + matrix: + runner: [ubuntu-latest, ubuntu-24.04-arm] + sample: [1, 2, 3] + cache: ['false', 'true'] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + id: deps + with: + cache-pnpm-verification: ${{ matrix.cache }} + - name: Validate treatment and frozen install + env: + CACHE_ENABLED: ${{ matrix.cache }} + CACHE_HIT: ${{ steps.deps.outputs.verification-cache-hit }} + run: | + if [ "$CACHE_ENABLED" = true ]; then test "$CACHE_HIT" = true; fi + git diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml + node -e "require.resolve('vitest'); require.resolve('electron')" diff --git a/.github/workflows/ci-runner-demand.yml b/.github/workflows/ci-runner-demand.yml new file mode 100644 index 000000000000..966813a65e40 --- /dev/null +++ b/.github/workflows/ci-runner-demand.yml @@ -0,0 +1,33 @@ +name: CI runner demand + +on: + schedule: + - cron: '23 4 * * *' + workflow_dispatch: + +permissions: + contents: read + actions: read + +concurrency: + group: ci-runner-demand + cancel-in-progress: false + +jobs: + report: + runs-on: ubuntu-slim + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts + persist-credentials: false + - name: Measure the previous complete 24 hours + env: + GH_TOKEN: ${{ github.token }} + run: node config/scripts/ci-runner-demand.mjs + - uses: actions/upload-artifact@v7 + with: + name: ci-runner-demand-${{ github.run_id }} + path: ci-demand/ + retention-days: 30 diff --git a/.github/workflows/ci-xterm-cache.yml b/.github/workflows/ci-xterm-cache.yml new file mode 100644 index 000000000000..fada7ea8cab4 --- /dev/null +++ b/.github/workflows/ci-xterm-cache.yml @@ -0,0 +1,55 @@ +name: Shared xterm dependency cache +on: + push: + branches: [main] + paths: + - '.github/workflows/ci-xterm-cache.yml' + - '.github/actions/prepare-xterm-dependencies/**' + - 'config/patches/xterm-upstream.json' + - 'config/scripts/regenerate-xterm-patches.mjs' + - 'config/scripts/xterm-patch-text.mjs' + - 'package.json' + # Refresh after Node patch releases or cache eviction without rebuilding on hits. + schedule: + - cron: '17 5 * * *' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: xterm-cache-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +env: + ORCA_BACKGROUND_LAUNCH: '1' +jobs: + seed: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04-arm + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + package-manager-cache: false + - uses: ./.github/actions/prepare-xterm-dependencies + id: cache + - name: Verify and populate dependencies + if: steps.cache.outputs.cache-hit != 'true' + run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$RUNNER_TEMP/xterm-patch-build" + - uses: actions/cache/save@v5 + if: steps.cache.outputs.cache-hit != 'true' + with: + path: | + ${{ runner.temp }}/xterm-patch-build/upstream/.git + ${{ runner.temp }}/xterm-patch-build/upstream/node_modules + key: ${{ steps.cache.outputs.cache-key }} + - name: Preserve fallback downloads + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.npm + ${{ runner.temp }}/xterm-patch-build/upstream/.git + key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }} diff --git a/.github/workflows/cloud-verify.yml b/.github/workflows/cloud-verify.yml index 6191552dab9a..6c4cb9a53a12 100644 --- a/.github/workflows/cloud-verify.yml +++ b/.github/workflows/cloud-verify.yml @@ -32,9 +32,30 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 + + - name: Pull Gitleaks image + id: gitleaks-image + background: true + run: docker pull zricethezav/gitleaks@sha256:cdbb7c955abce02001a9f6c9f602fb195b7fadc1e812065883f695d1eeaba854 + + - name: Pull TruffleHog image + id: trufflehog-image + background: true + run: docker pull trufflesecurity/trufflehog@sha256:5dc064868ba7933601b5cbaea6954954d524ddd5dc6222a9667acea70068bf7d + + # Scan every ancestor of HEAD without downloading unrelated branch/tag histories. + - name: Fetch complete scan history + working-directory: . + run: | + git fetch --no-tags --unshallow origin "$GITHUB_SHA" + test "$(git rev-parse --is-shallow-repository)" = false + + - wait: [gitleaks-image, trufflehog-image] - name: Scan Cloud history reachable from HEAD with Gitleaks + id: history-scan + background: true run: >- docker run --rm --volume "${GITHUB_WORKSPACE}:/repo:ro" @@ -51,6 +72,8 @@ jobs: --include-paths=/repo/cloud/.trufflehog-include-paths.txt --exclude-paths=/repo/cloud/.trufflehog-exclude-paths.txt + - wait: history-scan + # Compiles the workspace. No Postgres service: nothing here reaches a # database, and the service container costs ~13s of startup. build: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f42d1184ce93..4177b141d8a4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -32,9 +32,8 @@ on: required: false type: string schedule: - # Why: GitHub cron uses UTC; these slots map to 10am and 3pm - # America/Phoenix for the default-branch E2E run. - - cron: '0 17,22 * * *' + # One complete daily reference run; targeted PR coverage remains unchanged. + - cron: '0 17 * * *' jobs: build: @@ -62,11 +61,26 @@ jobs: status=0 pnpm run build:relay & relay_pid=$! - npx electron-vite build --mode e2e || status=1 - pnpm run build:web-from-renderer || status=1 + pnpm run build:electron-vite:parallel --mode e2e || status=1 wait "$relay_pid" || status=1 exit "$status" + # The CLI emits into out/main too; start only after Electron finishes clearing that tree. + - name: Build shared E2E CLI + id: e2e-cli + background: true + run: | + if node -e 'process.exit(require("./package.json").scripts["prepare:cli-output"] ? 0 : 1)'; then + pnpm run build:cli + else + echo "Older ref: let each consumer build and install its CLI." + fi + + - name: Project shared E2E web client + run: pnpm run build:web-from-renderer + + - wait: e2e-cli + - name: Upload E2E build output uses: actions/upload-artifact@v7 with: @@ -186,7 +200,14 @@ jobs: 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 --test-list=ci-shards/selected.txt + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: ci-shards/results.json + 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 --reporter=list,json + + - name: Summarize E2E failures + if: always() + continue-on-error: true + run: node config/scripts/ci-e2e-failure-summary.mjs ci-shards/results.json - name: Upload E2E shard assignment if: always() @@ -221,8 +242,6 @@ jobs: needs: [build, prepare-native-cache] if: inputs.test_files != '' runs-on: ubuntu-latest - # Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can - # pay a container image build plus ~22 serial SSH tests on top of the changed specs. timeout-minutes: 45 steps: @@ -256,14 +275,38 @@ jobs: # ORCA_E2E_NATIVE_IBUS_HANGUL, which this lane cannot set because it has no ibus # session. Running it here reported a green skip as coverage. mapfile -t TEST_FILES < <(jq -r '.[] | select( + . != "tests/e2e/local-ssh-browser-routing.spec.ts" and + . != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and + . != "tests/e2e/pty-input-write-queue-ssh.spec.ts" and + . != "tests/e2e/ssh-ai-vault-session-history.spec.ts" and + . != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and + . != "tests/e2e/ssh-cold-activation-restore.spec.ts" and + . != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and + . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and + . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and + . != "tests/e2e/ssh-docker-half-open-link.spec.ts" and + . != "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts" and + . != "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts" and + . != "tests/e2e/ssh-docker-relay-stall-credential.spec.ts" and + . != "tests/e2e/ssh-docker-resource-accumulation.spec.ts" and + . != "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts" and + . != "tests/e2e/ssh-external-image-preview.spec.ts" and + . != "tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts" and + . != "tests/e2e/ssh-pi-compatible-agent-title.spec.ts" and + . != "tests/e2e/ssh-port-forward-lifecycle.spec.ts" and + . != "tests/e2e/ssh-reconnect-tab-destruction.spec.ts" and + . != "tests/e2e/ssh-restart-tab-accumulation.spec.ts" and + . != "tests/e2e/ssh-skill-installation.spec.ts" and + . != "tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts" and + . != "tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts" and + . != "tests/e2e/terminal-inline-images-ssh.spec.ts" and + . != "tests/e2e/ssh-docker-watcher-isolation.spec.ts" and + . != "tests/e2e/ssh-terminal-parking.spec.ts" and + . != "tests/e2e/terminal-retention-budget.spec.ts" and . != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and . != "tests/e2e/paired-startup-exec-readiness.spec.ts" and - . != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and - . != "tests/e2e/local-ssh-browser-routing.spec.ts" and . != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and . != "tests/e2e/ssh-localhost.spec.ts" and - . != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and - . != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and . != "tests/e2e/terminal-ibus-hangul-native.spec.ts" )' <<<"$TEST_FILES_JSON") if [ "${#TEST_FILES[@]}" -eq 0 ]; then @@ -295,25 +338,47 @@ jobs: if-no-files-found: ignore ssh-docker-watcher-isolation: - name: ssh docker watcher isolation + name: ssh docker watcher isolation (${{ matrix.shard }}/4) needs: [build, prepare-native-cache] - # effect of one route listing a startup-readiness spec — pruning that spec would have - # silently retired the whole lane. The signal is now derived from the SSH routes directly. - # The explicit spec clauses stay for their honest purpose: changed-e2e hands these specs to this - # lane, so editing one must still run it here. + # Each excluded changed spec must trigger its dedicated owner. if: >- inputs.test_files == '' || inputs.ssh_source_changed == 'true' || - contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') || contains(inputs.test_files, 'tests/e2e/local-ssh-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts') || - contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') || + contains(inputs.test_files, 'tests/e2e/pty-input-write-queue-ssh.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-ai-vault-session-history.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-cold-activation-restore.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-half-open-link.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-resource-accumulation.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-external-image-preview.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-port-forward-lifecycle.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-restart-tab-accumulation.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-skill-installation.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts') || + contains(inputs.test_files, 'tests/e2e/terminal-inline-images-ssh.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-docker-watcher-isolation.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-terminal-parking.spec.ts') || + contains(inputs.test_files, 'tests/e2e/terminal-retention-budget.spec.ts') || + contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') || contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts') runs-on: ubuntu-latest - # Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average - # ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35 - # — and the sharded lanes already show that a lane which times out is a lane nobody trusts. + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] timeout-minutes: 60 steps: @@ -338,13 +403,14 @@ jobs: # Why: this is the release-path proof that the deployed Linux relay keeps # its PTY and explorer live across a real watcher SIGSEGV. - name: Run Docker SSH watcher isolation E2E + if: matrix.shard == 1 run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation # Why: Playwright empties test-results/ when it starts, so each step here used to # destroy the previous step's traces. Only the last lane's failure was ever # diagnosable from the artifact; set each lane aside before the next one runs. - name: Keep watcher-isolation traces - if: always() + if: always() && matrix.shard == 1 run: | if [ -d test-results ]; then mkdir -p e2e-traces @@ -354,24 +420,21 @@ jobs: # Why always(): this lane gates SSH parking/retention plus startup-exec # readiness across live SSH, headed paired, and headless serve topologies. - name: Run Docker SSH terminal parking + startup readiness E2E - if: always() + if: always() && matrix.shard == 1 run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking - name: Keep terminal-parking traces - if: always() + if: always() && matrix.shard == 1 run: | if [ -d test-results ]; then mkdir -p e2e-traces mv test-results "e2e-traces/terminal-parking" fi - # Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every - # spec below skipped itself while the shard still reported green. Running them on this one - # VM pays the fixture image build once instead of ten times, and keeps an SSH regression - # legible as an SSH-named failure. + # Separate VMs isolate destructive SSH fixtures while keeping one worker per shard. - name: Run remaining Docker SSH E2E if: always() - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker --shard=${{ matrix.shard }}/4 - name: Keep remaining-ssh-docker traces if: always() @@ -385,7 +448,7 @@ jobs: if: failure() uses: actions/upload-artifact@v7 with: - name: playwright-traces-ssh-docker-watcher-isolation + name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }} path: e2e-traces/ retention-days: 7 if-no-files-found: ignore diff --git a/.github/workflows/git-command-termination-runtime.yml b/.github/workflows/git-command-termination-runtime.yml index 1602bae956a9..6b49177ed560 100644 --- a/.github/workflows/git-command-termination-runtime.yml +++ b/.github/workflows/git-command-termination-runtime.yml @@ -7,6 +7,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: git-command-termination-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: windows-exit: runs-on: windows-latest diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 11cfa866c676..0b80662d7166 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -66,10 +66,10 @@ jobs: - name: Run golden E2E tests on Linux if: runner.os == 'Linux' run: | - xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then - xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts fi xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden @@ -80,10 +80,10 @@ jobs: - name: Run golden E2E tests on macOS if: runner.os == 'macOS' run: | - env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then - env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts fi env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden @@ -104,7 +104,7 @@ jobs: pnpm run --if-present test:e2e:tab-bar-agent-launch-golden if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) { - pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } pnpm run --if-present test:e2e:source-control-golden diff --git a/.github/workflows/issue-os-labeler.yaml b/.github/workflows/issue-os-labeler.yaml index c437db7f21a5..cf0ee59a8425 100644 --- a/.github/workflows/issue-os-labeler.yaml +++ b/.github/workflows/issue-os-labeler.yaml @@ -12,7 +12,7 @@ permissions: jobs: apply-os-label: - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Apply OS label from issue form diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index aae50234bf94..7ec322b02036 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -6,7 +6,6 @@ on: - opened - synchronize - reopened - - ready_for_review paths: - 'mobile/**' # Mobile launch contracts exercise the real host dispatcher and durable receipt store. @@ -38,6 +37,10 @@ on: - '.github/workflows/mobile.yml' - '.github/actions/install-node-dependencies/**' - '.github/workflows/mobile-ios-release.yml' + - 'config/scripts/mobile-release-check-scope*' + - 'config/scripts/mobile-test-change-scope*' + - 'config/scripts/pr-code-change-scope.mjs' + - 'config/scripts/mobile-recording-pin-checkout.test.mjs' # Why main too: a behaviour-change branch legitimately pins its own last fenced commit, and that # commit only stops being reachable when the branch squash-merges. The pull_request run cannot # see that; this one is where the pin guard finds it. @@ -58,7 +61,10 @@ concurrency: jobs: verify: if: github.event_name == 'pull_request' - runs-on: ubuntu-latest + # Why ARM: 209s of this job is Vitest and nothing here needs x86: no Android SDK, emulator, gradle, + # Hermes or Watchman, no docker, and no artifacts. The Gemfile.lock lists the generic `ruby` + # platform, so frozen bundler installs without an aarch64-linux entry. + runs-on: ubuntu-24.04-arm env: # Why: an unfrozen bundler silently re-resolves when Gemfile.lock drifts @@ -73,6 +79,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 2 - uses: ./.github/actions/install-node-dependencies with: @@ -80,10 +88,23 @@ jobs: pnpm-lock.yaml mobile/pnpm-lock.yaml + - name: Detect Ruby release inputs + id: ruby-scope + shell: bash + working-directory: . + run: | + # Keep deletions when release files move into an application directory. + if ! git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/mobile-release-changes"; then + echo 'should_run=true' >> "$GITHUB_OUTPUT" + elif ! node config/scripts/mobile-release-check-scope.mjs "$RUNNER_TEMP/mobile-release-changes"; then + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + # bundler-cache installs mobile/Gemfile.lock, so this job is also what # proves the pinned fastlane the release workflow depends on still # resolves — before a release run finds out. - name: Setup Ruby and fastlane + if: steps.ruby-scope.outputs.should_run != 'false' uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' @@ -93,7 +114,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # Both compilers are read-only; finish them before starting the test workers. - name: Typecheck + id: production-types + background: true run: pnpm typecheck # Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until @@ -103,19 +127,37 @@ jobs: - name: Typecheck tests (ratchet) run: pnpm run check:tests-typecheck + - wait: production-types + # This includes the bridged replay of the whole recording corpus, which used to be a second # step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point: # it fails when a divergence class grows, when a divergence lands in no class at all, or when # one of the 103 goldens inside the C1 page closure changes the verdict it is pinned to. It is # ~3 min of test time on its own, and Vitest runs it on a worker beside the rest of the suite, # so folding it in costs a fraction of that in wall time and one step less to skip. + - name: Detect mobile test inputs + id: test-scope + shell: bash + working-directory: . + run: | + if ! git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/mobile-test-changes"; then + echo 'should_run=true' >> "$GITHUB_OUTPUT" + elif ! node config/scripts/mobile-test-change-scope.mjs "$RUNNER_TEMP/mobile-test-changes"; then + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + - name: Test + if: steps.test-scope.outputs.should_run != 'false' + env: + ORCA_BACKGROUND_LAUNCH: '1' run: pnpm test - name: Test iOS release version resolution + if: steps.ruby-scope.outputs.should_run != 'false' run: ruby fastlane/ios_release_version_test.rb - name: Test TestFlight lane arguments + if: steps.ruby-scope.outputs.should_run != 'false' run: ruby fastlane/fastfile_testflight_arguments_test.rb # Why: nothing else in CI loads the Fastfile, so a syntax error, a broken @@ -124,6 +166,7 @@ jobs: # loads and lists, so it needs no App Store Connect credentials and makes # no network calls to Apple. - name: Smoke-check the Fastfile + if: steps.ruby-scope.outputs.should_run != 'false' env: FASTLANE_SKIP_UPDATE_CHECK: '1' FASTLANE_OPT_OUT_USAGE: '1' @@ -137,7 +180,8 @@ jobs: recording-pin: name: RPC recording pin - runs-on: ubuntu-latest + # Why ARM: pure Node plus git; the golden comparison masks `platform`. + runs-on: ubuntu-24.04-arm defaults: run: @@ -152,6 +196,8 @@ jobs: # answer at all rather than reporting a pass it has no evidence for -- and the pinned tree # below has to be checkable out. fetch-depth: 0 + # Ancestry needs commits; the pinned worktree fetches its historical blobs on demand. + filter: blob:none - uses: ./.github/actions/install-node-dependencies with: diff --git a/.github/workflows/node-next-compat.yml b/.github/workflows/node-next-compat.yml index 43556c864dc1..cb0d303f8c5d 100644 --- a/.github/workflows/node-next-compat.yml +++ b/.github/workflows/node-next-compat.yml @@ -1,8 +1,8 @@ -name: Node next compatibility +name: Scheduled x86 unit compatibility on: schedule: - # Full future-runtime coverage is useful, but not worth doubling every PR matrix. + # Keep current and future Node coverage on x86 while PR unit shards use ARM. - cron: '0 10 * * *' workflow_dispatch: @@ -14,9 +14,13 @@ permissions: contents: read jobs: - # A cold cache would otherwise make all eight Node 26 shards compile the same native addons. + # Prime each Node ABI before its shards restore native modules. test_native_cache: - name: prepare test native cache node 26 + name: prepare test native cache node ${{ matrix.node }} + strategy: + fail-fast: false + matrix: + node: ['24', '26'] runs-on: ubuntu-latest steps: @@ -28,10 +32,10 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node - node-version: '26' + node-version: ${{ matrix.node }} test: needs: [test_native_cache] uses: ./.github/workflows/unit-tests.yml with: - node_versions: '["26"]' + node_versions: '["24", "26"]' diff --git a/.github/workflows/packaged-browser-e2e.yml b/.github/workflows/packaged-browser-e2e.yml index 2a23ac58988c..7d36e7fd1b13 100644 --- a/.github/workflows/packaged-browser-e2e.yml +++ b/.github/workflows/packaged-browser-e2e.yml @@ -39,7 +39,11 @@ jobs: root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca' package=root/'orca-ide_1.4.188_amd64.deb' expected='uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g==' - assert base64.b64encode(hashlib.sha512(package.read_bytes()).digest()).decode()==expected + digest=hashlib.sha512() + with package.open('rb') as archive: + for block in iter(lambda: archive.read(1024*1024), b''): + digest.update(block) + assert base64.b64encode(digest.digest()).decode()==expected extracted=root/'extracted' subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True) executable=extracted/'opt'/'Orca'/'orca-ide' diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml index 9f9df4f9b0b5..592bc75bb5e8 100644 --- a/.github/workflows/pi-owner-runtime.yml +++ b/.github/workflows/pi-owner-runtime.yml @@ -9,6 +9,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: pi-owner-runtime-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: runtime: strategy: diff --git a/.github/workflows/pi-provider-runtime.yml b/.github/workflows/pi-provider-runtime.yml index 837c38baf9a8..310467374994 100644 --- a/.github/workflows/pi-provider-runtime.yml +++ b/.github/workflows/pi-provider-runtime.yml @@ -7,6 +7,9 @@ on: - '.github/workflows/pi-provider-runtime.yml' permissions: contents: read +concurrency: + group: pi-provider-runtime-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: runtime: strategy: diff --git a/.github/workflows/pr-test-loc.yml b/.github/workflows/pr-test-loc.yml index a884aedc6361..340c6c7b70d1 100644 --- a/.github/workflows/pr-test-loc.yml +++ b/.github/workflows/pr-test-loc.yml @@ -6,7 +6,6 @@ on: - opened - synchronize - reopened - - ready_for_review concurrency: group: pr-test-loc-${{ github.event.pull_request.number }} @@ -19,7 +18,8 @@ permissions: jobs: loc: name: test vs non-test LoC - runs-on: ubuntu-latest + # API calls and a small Node script fit the free single-CPU container runner. + runs-on: ubuntu-slim timeout-minutes: 2 steps: # Why no checkout: the Files API already has per-file additions/deletions. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1b52aac7571d..27fc5215044d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,4 +1,5 @@ name: PR Checks +run-name: "PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}" on: pull_request: @@ -23,25 +24,32 @@ jobs: # Per-job outputs also skip git-compat/xterm/packaging/shell when those # inputs are unchanged; empty diffs fail closed and run everything. code_paths: - name: detect code-relevant changes - runs-on: ubuntu-latest + name: detect changes and check repository guards + # Reuse one lightweight checkout for detection and the always-required guards. + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + contents: read + actions: read outputs: should_run: ${{ steps.filter.outputs.should_run }} - native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} + reused_run_id: ${{ steps.readiness.outputs.run_id }} + # A proven success masks required work only; advisory routing still uses the full diff. + native_cache_changed: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.native_cache_changed }} mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} - mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }} - static_analysis: ${{ steps.filter.outputs.static_analysis }} - typecheck: ${{ steps.filter.outputs.typecheck }} - git_compatibility: ${{ steps.filter.outputs.git_compatibility }} - codex_index_heal_contract: ${{ steps.filter.outputs.codex_index_heal_contract }} - xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }} - shell_contracts: ${{ steps.filter.outputs.shell_contracts }} - test: ${{ steps.filter.outputs.test }} - orcad_browser: ${{ steps.filter.outputs.orcad_browser }} - cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }} - managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }} - package: ${{ steps.filter.outputs.package }} - package_windows: ${{ steps.filter.outputs.package_windows }} + mobile_web_app: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_web_app }} + static_analysis: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.static_analysis }} + typecheck: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.typecheck }} + git_compatibility: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.git_compatibility }} + codex_index_heal_contract: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.codex_index_heal_contract }} + xterm_patch_sync: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.xterm_patch_sync }} + shell_contracts: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.shell_contracts }} + test: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.test }} + orcad_browser: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.orcad_browser }} + cross-version-wire: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.cross-version-wire }} + managed_hook_node18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }} + package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }} + package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }} e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }} test_files: ${{ steps.e2e_filter.outputs.test_files }} ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }} @@ -51,23 +59,59 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - # Why blob:none: full history is needed for the merge-base diff, but historical - # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the - # few this job actually reads on demand. - fetch-depth: 0 + # Why depth 50 and not 0: every diff below resolves to HEAD^1, so only the merge commit + # and a little slack are needed. Fetching all 8127 refs' commit graph cost ~20s here and + # is charged to the start of all 22 jobs, since they all need this one. + # Why blob:none stays: the sparse tree below is ~7 files, so there are no blobs to + # materialize and no promisor refetch. Measured 9.5s -> 1.6s against 20.7s today. + fetch-depth: 50 filter: blob:none + sparse-checkout: | + /config/scripts/git-pull-request-diff-base.mjs + /package.json + /README.md + /docs/readme/ + /.github/scripts/check-root-directory-entries.mjs + /config/scripts/check-readme-local-links.mjs + /config/scripts/pr-code-change-scope.mjs + /config/scripts/pr-e2e-source-routing.mjs + /config/scripts/pr-ready-check-reuse.mjs + sparse-checkout-cone-mode: false persist-credentials: false + - name: Reject new root-level files and folders + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + node .github/scripts/check-root-directory-entries.mjs "$DIFF_BASE" HEAD + + # The full Git index retains link targets outside the sparse working tree. + - name: Check README local links + run: node config/scripts/check-readme-local-links.mjs + + # Readiness changes eligibility for advisory tests, not the already-tested source. + - name: Find identical successful required checks + id: readiness + if: github.event.action == 'ready_for_review' + env: + GH_TOKEN: ${{ github.token }} + PR_CHECK_WORKFLOW_SHA: ${{ github.workflow_sha }} + run: node config/scripts/pr-ready-check-reuse.mjs + - name: Classify changed paths id: filter env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail + # Why HEAD^1 and not --merge-base: HEAD is the pull request merge commit, so its first + # parent is the base side already. Computing a merge base instead would require the + # payload base SHA to be in the graph, which is what forced a full-history checkout. + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" # Why --no-renames: name-only rename detection can report only the destination. # A code file moved under docs/ must still expose its code-side deletion. - CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")" + CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)" echo "Changed paths:" printf '%s\n' "$CHANGED" printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT" @@ -79,8 +123,8 @@ jobs: run: | set -euo pipefail BASE="${{ github.event.pull_request.base.sha }}" - HEAD="${{ github.event.pull_request.head.sha }}" - CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")" + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE")" + CHANGED="$(git diff --name-only --diff-filter=AMCR "$DIFF_BASE" HEAD)" # Source routes are executable contracts so a test can prove exact # authorities, exclusions, and sentinels without evaluating workflow shell. TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)" @@ -94,7 +138,7 @@ jobs: # trigger on IME source rather than on a spec name in some route's list. NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)" echo "native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" - WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE" "$HEAD")" + WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)" WSL_SOURCE_CHANGED="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source)" echo "wsl_source_changed=$WSL_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED" @@ -111,38 +155,63 @@ jobs: name: static analysis needs: [code_paths] if: needs.code_paths.outputs.static_analysis == 'true' - runs-on: ubuntu-latest + # Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster -- + # type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke + # 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so + # the whole toolchain resolves. Free for public repositories, same as the typecheck job. + runs-on: ubuntu-24.04-arm steps: - name: Checkout uses: actions/checkout@v6 with: - # Why blob:none: full history is needed for the merge-base diff, but historical - # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the - # few this job actually reads on demand. - fetch-depth: 0 - filter: blob:none + # Why depth 50: the gates below diff against HEAD^1, so no merge base is computed and + # the payload base SHA need not be in the graph. + # Why no blob:none here, unlike code_paths: this job checks out all 30,226 files, and + # the filter then forces a second promisor fetch of nearly every blob. Measured 23s + # blobless against ~11s without it. + fetch-depth: 50 persist-credentials: false + # Why two guarded installs: the mixed root+mobile store entry is 537 MB against + # 321 MB for root alone, and restoring it costs 8.6s against 4.6s. Most PRs skip the + # mobile install below, so they were paying 216 MB for packages they never link. The + # root-only key is also the one the hourly warmer reseeds. Only one of these runs. - uses: ./.github/actions/install-node-dependencies + if: needs.code_paths.outputs.mobile_dependencies != 'true' + with: + native-runtime: node + + - uses: ./.github/actions/install-node-dependencies + if: needs.code_paths.outputs.mobile_dependencies == 'true' with: native-runtime: node cache-dependency-path: | pnpm-lock.yaml mobile/pnpm-lock.yaml + # Keep each check in its own log while sharing this runner. - name: Lint + id: root-lint + background: true run: pnpm exec oxlint --format github - name: Reject low-evidence patterns run: pnpm run audit:anti-slop + - wait: root-lint + - name: Enforce focused code-quality plugins + id: native-code-quality + background: true run: pnpm run audit:code-quality:native - name: Enforce type-aware code-quality baseline run: pnpm run audit:code-quality:type-aware + # Mobile installation changes import resolution for the native cycle check. + - wait: native-code-quality + # Why here: the changed-code gate lints mobile files too, and its type-aware pass # resolves types from mobile/node_modules. Without the install every mobile type # degrades to an `error` type — reported as phantom findings against the changed lines. @@ -150,11 +219,15 @@ jobs: if: needs.code_paths.outputs.mobile_dependencies == 'true' - name: Enforce changed-code quality + id: changed-code-quality + background: true run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" - name: Enforce React Doctor on changed lines run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}" + - wait: changed-code-quality + - name: Check Zustand selector fan-out budget run: pnpm run check:zustand-selector-fanout @@ -167,9 +240,9 @@ jobs: - name: Check VM runtime rollback compatibility env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \ + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + if git diff --quiet "$DIFF_BASE" HEAD -- \ src/shared/ephemeral-vm-runtime-store.ts \ src/shared/ephemeral-vm-runtime-feature-store.ts \ src/shared/ephemeral-vm-runtime-rollback-projection.ts \ @@ -194,11 +267,39 @@ jobs: - name: Enforce runtime Electron-import ratchet run: pnpm run check:runtime-electron-ratchet + # Why: extraction writes sorted evidence to an isolated temporary path, + # so feature PRs need one normalized AST pass rather than a three-OS matrix. + - name: Verify localization extraction + id: localization-extraction + background: true + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + # Detection failures run the full check; renames retain the removed input path. + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" && + scope="$(node config/scripts/localization-extraction-change-scope.mjs "$RUNNER_TEMP/localization-changes")" && [ "$scope" = false ]; then + echo "Localization extraction inputs are unchanged." + else + pnpm run verify:localization-extraction + fi + # Why both: the ratchet proves nothing reachable from the runtime imports electron, # which is a property of the import graph. This proves the Node artifact it enables # actually boots, pairs, creates a worktree and round-trips a real PTY. - name: Boot orcad and round-trip a terminal - run: pnpm run smoke:orcad-terminal + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + ORCA_BACKGROUND_LAUNCH: '1' + run: | + # Detection failures run the smoke; renames retain the removed input path. + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/orcad-smoke-changes" && + scope="$(node config/scripts/orcad-terminal-smoke-change-scope.mjs "$RUNNER_TEMP/orcad-smoke-changes")" && [ "$scope" = false ]; then + echo "Orcad terminal smoke inputs are unchanged." + else + pnpm run smoke:orcad-terminal + fi - name: Verify the generated RPC params catalog run: pnpm run verify:rpc-params-catalog @@ -209,23 +310,16 @@ jobs: - name: Verify skill freshness manifest run: pnpm run verify:skill-bundle-manifest - - name: Verify localization catalog - run: pnpm run verify:localization-catalog - - # Why: the renderer ships only the English entries i18next cannot rebuild - # from each call site's inline default, so the generated subset has to - # track en.json and those defaults. - - name: Verify runtime-required localization catalog - run: pnpm run verify:localization-runtime-catalog - - # Why: extraction writes sorted evidence to an isolated temporary path, - # so feature PRs need one normalized AST pass rather than a three-OS matrix. - - name: Verify localization extraction - run: pnpm run verify:localization-extraction + - name: Verify localization catalogs + id: localization-catalogs + background: true + run: pnpm run verify:localization-catalogs - name: Verify localization coverage run: pnpm run verify:localization-coverage + - wait: [localization-catalogs, localization-extraction] + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names @@ -249,37 +343,11 @@ jobs: - name: Verify macOS entitlements run: pnpm verify:macos-entitlements - root_directory_guard: - name: root directory guard - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - # Why blob:none: full history is needed for the merge-base diff, but historical - # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the - # few this job actually reads on demand. - fetch-depth: 0 - filter: blob:none - persist-credentials: false - - - name: Reject new root-level files and folders - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" - - # Why here: the READMEs embed media owned by docs/site and resources/onboarding, - # and the classifier skips static_analysis for docs-only diffs. This job runs - # on every PR and needs no install. - - name: Check README local links - run: node config/scripts/check-readme-local-links.mjs - typecheck: needs: [code_paths] if: needs.code_paths.outputs.typecheck == 'true' - runs-on: ubuntu-latest + # Typechecking uses no native runtime, so it can use the free public ARM runner. + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -317,39 +385,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies - # Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the - # same binary on every PR for minutes of runner time. The key carries the version - # because that is the only input; the sha256 assertion below still guards the - # tarball on the miss path that actually builds. Only this PR's own later pushes - # can restore it — GitHub scopes a cache written from a pull_request run to that - # ref — so a first push always takes the build path below. - - name: Cache baseline Git build - uses: actions/cache@v5 - with: - path: ~/.cache/orca-git-compat/git-2.25.5 - key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 - - # Why its own step: this is `make -j$(nproc)` on every core, and the lanes below - # spend their wall clock waiting on container starts, not on Git. Sharing a runner - # with the build stretched one ~1.5s boundary case past Vitest's 30s timeout, so - # the build has to finish before anything timed starts. - - name: Build the baseline Git binary - run: | - archive="$RUNNER_TEMP/git-2.25.5.tar.gz" - source="$HOME/.cache/orca-git-compat/git-2.25.5" - if [ -x "$source/git" ]; then - exit 0 - fi - curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" - echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ - | sha256sum --check - mkdir -p "$source" - tar -xzf "$archive" -C "$source" --strip-components=1 - make -C "$source" -j"$(nproc)" \ - NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git - # Why: the linked binaries are what the next run needs; the objects that - # produced them are most of the tree and would bloat the cache entry. - find "$source" -name '*.o' -delete + - uses: ./.github/actions/prepare-git-compatibility - name: Verify Git binary compatibility matrix run: | @@ -399,7 +435,8 @@ jobs: name: Codex index-heal contract needs: [code_paths] if: needs.code_paths.outputs.codex_index_heal_contract == 'true' - runs-on: ubuntu-latest + # Why ARM: @openai/codex ships @openai/codex-linux-arm64. + runs-on: ubuntu-24.04-arm env: CODEX_CLI_VERSION: '0.150.1' @@ -434,7 +471,10 @@ jobs: name: xterm patch sync needs: [code_paths] if: needs.code_paths.outputs.xterm_patch_sync == 'true' - runs-on: ubuntu-latest + # Why ARM: the patch check rebuilds 4 packages x 2 builds and byte-compares against the + # checked-in bundles. Those were generated on Linux x64 and reproduce byte-for-byte on + # darwin-arm64, so the output is neither host-arch nor host-OS dependent. + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -442,21 +482,14 @@ jobs: with: persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - - # Why: the check rebuilds every package in the manifest from a pinned upstream - # commit — @xterm/xterm and its three addons, each built twice (once unmodified to - # prove the toolchain still reproduces the published bundles, once patched). Caching - # the npm metadata and the shallow clone keeps the repeated cost to the builds - # themselves; the key is the manifest, so a commit, package or toolchain bump - # invalidates it. - - name: Restore upstream xterm build inputs - uses: actions/cache@v5 + # The generator uses Node built-ins and installs its own upstream toolchain. + - uses: actions/setup-node@v6 with: - path: | - ~/.npm - ${{ runner.temp }}/xterm-patch-build/upstream/.git - key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }} + node-version-file: package.json + package-manager-cache: false + + # Rebuild both pristine and patched bundles; reuse only the pinned toolchain. + - uses: ./.github/actions/prepare-xterm-dependencies - name: Verify xterm patches match the pinned upstream build env: @@ -467,7 +500,8 @@ jobs: name: shell contracts needs: [code_paths] if: needs.code_paths.outputs.shell_contracts == 'true' - runs-on: ubuntu-latest + # Why ARM: fish 4.x is published for noble/arm64 and zsh is in the arm64 archive. + runs-on: ubuntu-24.04-arm # Why: this job's cost is almost entirely package download, and a stalled mirror has # no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this # is generous; it exists so a wedge fails the job instead of holding the whole run @@ -493,6 +527,8 @@ jobs: # fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships # 3.7, so the PPA is what makes that lane real. - name: Install zsh and fish + id: shells + background: true run: | # Why the update/PPA/fish steps are tolerant: a repo the runner image already # ships can lack a Release file for this suite, and a failed add-apt-repository @@ -541,6 +577,12 @@ jobs: # first install command in this step to prove the lane really installs them. timeout 300 sudo apt-get install -y zsh fish + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + + - wait: shells + # Separate from the install so the failure names the contract, not an apt error. # ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds # instead of after a full dependency install. @@ -556,10 +598,6 @@ jobs: exit 1 fi - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: node - - name: Test real shell contracts run: | pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \ @@ -588,7 +626,7 @@ jobs: name: prepare test native cache node 24 needs: [code_paths] if: needs.code_paths.outputs.native_cache_changed == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -602,14 +640,19 @@ jobs: node-version: '24' test: - needs: [code_paths, test_native_cache] + needs: [code_paths, test_native_cache, static_analysis, typecheck] + # Honor cancellation while allowing the optional native-cache primer to skip. if: >- - always() && + !cancelled() && needs.code_paths.outputs.test == 'true' && + needs.static_analysis.result == 'success' && + needs.typecheck.result == 'success' && (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24"]' + runner: ubuntu-24.04-arm + selection_mode: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix # would pay for it on every shard to run one file in whichever shard it landed in. @@ -673,9 +716,22 @@ jobs: pnpm-lock.yaml mobile/pnpm-lock.yaml + # The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is + # WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from + # the runner: Ubuntu ships no WebKit build to point at. + - name: Install WebKit for the drawer check + id: webkit + background: true + run: pnpm exec playwright install --with-deps webkit + # The entry lives in mobile/ so one React resolves; without this every RN import is nothing. - uses: ./.github/actions/install-mobile-dependencies + - name: Prepare mobile route snapshot + id: mobile-routes + background: true + run: node config/scripts/run-mobile-web-app-checks.mjs --prepare-route-snapshot "$RUNNER_TEMP/mobile-routes.json" + # Why the runner's Google Chrome and not a downloaded chromium: same reason as the orcad # browser job -- Ubuntu 24.04 only ships an AppArmor userns profile for the Chrome .deb. # Why fail instead of skip: a silently skipped render check is the failure this job exists @@ -691,15 +747,12 @@ jobs: "$chrome" --version echo "ORCA_MOBILE_WEB_RENDER_BROWSER=$chrome" >> "$GITHUB_ENV" - # The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is - # WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from - # the runner: Ubuntu ships no WebKit build to point at. - - name: Install WebKit for the drawer check - run: pnpm exec playwright install --with-deps webkit - - name: Build and verify the app bundle run: pnpm run build:mobile-web + # Browser checks must observe both a finished install and the built bundle. + - wait: [webkit, mobile-routes] + # The bundling tests skip themselves where mobile dependencies are absent, which is how they # stay green in the sharded `test` job. This is the job that installs them, so here a missing # install has to fail rather than skip everything the job exists to run. @@ -709,23 +762,20 @@ jobs: # one `pr-code-change-scope.mjs` fires this job on, so naming a test into the family is all # it takes to have it run. Quoted because these are vitest filename filters, matched as # substrings against the discovered files, and the shell must not touch them. - # - # Cost: 18 files in 25-30s wall, of which the frame-budget sweep is 2.5s. That sweep encodes - # 111 noise JPEGs in Chromium, so it is the one step here whose cost grows with its viewport - # set; adding rows to that set is a decision about this job's runtime. + # Route censuses share fresh dependency lists for this invocation; scratch builds stay independent. - name: Builder, override census and render checks env: ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1' + ORCA_MOBILE_WEB_PREPARED_ROUTE_SNAPSHOT: ${{ runner.temp }}/mobile-routes.json run: | - pnpm exec vitest run --config config/vitest.config.ts \ - 'config/scripts/mobile-web-app-' \ - 'config/scripts/build-mobile-web-app-bundle.test.mjs' + node config/scripts/run-mobile-web-app-checks.mjs cross-version-wire: name: cross-version wire compatibility needs: [code_paths] if: needs.code_paths.outputs.cross-version-wire == 'true' - runs-on: ubuntu-latest + # Why ARM: source-only: tagged checkout plus in-process vitest, no docker or browser. + runs-on: ubuntu-24.04-arm steps: # Why fetch-depth 0: the harness extracts the newest release tag to skew @@ -757,12 +807,16 @@ jobs: tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts + tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts + tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts + tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 needs: [code_paths] if: needs.code_paths.outputs.managed_hook_node18 == 'true' - runs-on: ubuntu-latest + # Why ARM: Node 18 publishes linux-arm64; the per-platform runtime files are read as data. + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -785,7 +839,7 @@ jobs: package: name: package - needs: [code_paths] + needs: [code_paths, static_analysis, typecheck] if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest # Let the serial Docker gates reach their own deadlines and report cleanup failures. @@ -798,7 +852,7 @@ jobs: persist-credentials: false - name: Cache electron-builder downloads - uses: actions/cache@v5 + uses: actions/cache/restore@v5 with: path: ~/.cache/electron-builder key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }} @@ -835,6 +889,24 @@ jobs: src/main/browser/browser-route-h3-egress.electron.test.ts src/main/browser/browser-route-dns-prefetch.electron.test.ts + # Restore independent fixtures during builds; keep native lifecycle probes serial. + - uses: ./.github/actions/prepare-linux-package-fixture + id: shutdown-fixture-cache + background: true + with: + fixture: headless-serve-shutdown + + - uses: ./.github/actions/prepare-linux-package-fixture + id: cli-fixture-cache + background: true + with: + fixture: cli-launch-contract + + - name: Install Linux package tooling + id: linux-package-tools + background: true + run: sudo apt-get update && sudo apt-get install -y cpio rpm + - name: Build package inputs run: | status=0 @@ -853,6 +925,8 @@ jobs: exit "$status" - name: Project web client from renderer build + id: web-client + background: true run: pnpm run build:web-from-renderer # Why here and not inside "Build package inputs": this job assembles packaging inputs step by @@ -864,17 +938,16 @@ jobs: - name: Build native components run: pnpm run build:native - - name: Install Linux package tooling - run: sudo apt-get update && sudo apt-get install -y cpio rpm + - wait: [linux-package-tools, web-client] - name: Package unpacked app env: + ORCA_BACKGROUND_LAUNCH: '1' ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' - # PR artifacts are only inspected locally; gzip avoids release-size xz compression. - run: >- - pnpm exec electron-builder --config config/electron-builder.config.cjs - --linux AppImage deb rpm --x64 --publish never - --config.deb.compression=gz --config.rpm.compression=gzip + # Prepare once with every hook, then isolate the format-specific mutations. + run: | + pnpm exec electron-builder --config config/electron-builder.config.cjs --linux dir --x64 --publish never + node config/scripts/package-linux-formats.mjs - name: Verify root-package marker payloads run: | @@ -889,13 +962,21 @@ jobs: [[ "$deb_marker" == deb ]] || { echo "Expected deb marker, got: $deb_marker"; exit 1; } [[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; } + - wait: shutdown-fixture-cache + - name: Verify headless serve signal shutdown + env: + ORCA_SHUTDOWN_FIXTURE_CACHE_IMAGE: ${{ steps.shutdown-fixture-cache.outputs.image }} run: >- node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --all-entrypoints # A default container reproduces the hostile AppImage launch environment. + - wait: cli-fixture-cache + - name: Verify Linux CLI launch contract + env: + ORCA_CLI_FIXTURE_CACHE_IMAGE: ${{ steps.cli-fixture-cache.outputs.image }} run: node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage - name: Smoke packaged CLI @@ -906,7 +987,7 @@ jobs: package_windows: name: package (windows) - needs: [code_paths] + needs: [code_paths, static_analysis, typecheck] if: needs.code_paths.outputs.package_windows == 'true' runs-on: windows-2022 timeout-minutes: 30 @@ -918,14 +999,15 @@ jobs: persist-credentials: false - name: Cache electron-builder downloads - uses: actions/cache@v5 + uses: actions/cache/restore@v5 with: path: | ~\AppData\Local\electron\Cache ~\AppData\Local\electron-builder\Cache - key: electron-builder-windows-${{ hashFiles('pnpm-lock.yaml') }} + # Release builds seed this exact path set; PR-local copies cannot serve other PRs. + key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | - electron-builder-windows- + electron-builder-win- # Why persist-native-cache false: this job later rebuilds the same path for # Electron. A post-job save would store the Electron ABI under the Node key. @@ -954,6 +1036,12 @@ jobs: # vitest runs here directly rather than through `pnpm test`, so the addon # assertions only hold once install-node-dependencies has rebuilt natives. + - name: Test Windows installer process probe + # Keep cold CIM startup out of the concurrent Electron/native process workload. + run: >- + pnpm exec vitest run --config config/vitest.config.ts + config/scripts/nsis-process-check.test.mjs + - name: Test Windows-specific boundaries run: >- pnpm exec vitest run --config config/vitest.config.ts @@ -989,6 +1077,7 @@ jobs: src/main/windows/windows-host-job.win32.test.ts src/main/windows/windows-process-tree-command-line-patch.test.ts src/main/windows/windows-process-table-native-addon.win32.test.ts + src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts src/main/windows-live-tree-kill.win32.test.ts src/main/wsl/wsl-runner.test.ts src/main/wsl/wsl-guest-environment.test.ts @@ -1088,11 +1177,10 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} verify: - if: always() + if: ${{ !cancelled() }} needs: - code_paths - static_analysis - - root_directory_guard - typecheck - git_compatibility - codex_index_heal_contract @@ -1105,7 +1193,9 @@ jobs: - managed_hook_node18 - package - package_windows - runs-on: ubuntu-latest + # Evaluating job results only needs the lightweight container runner. + runs-on: ubuntu-slim + timeout-minutes: 5 steps: # Why: e2e is deliberately absent from needs. The suite is currently red on @@ -1122,7 +1212,6 @@ jobs: SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} STATIC_ANALYSIS: ${{ needs.static_analysis.result }} STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} - ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} @@ -1151,9 +1240,6 @@ jobs: if [ "$CODE_PATHS" != "success" ]; then exit 1 fi - if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then - exit 1 - fi if [ "$SHOULD_RUN" != "true" ]; then echo "Docs-only change; expensive PR checks skipped." fi diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index d5030252f88d..2fc81c887bba 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -1,6 +1,6 @@ -# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED +# Explicit review identities share workflow concurrency; legacy names use ordered cancellation. name: Pullfrog -run-name: ${{ inputs.name || github.workflow }} +run-name: ${{ inputs.name || github.workflow }}${{ inputs.pull_request_number && format(' | PR {0}', inputs.pull_request_number) || '' }} on: workflow_dispatch: inputs: @@ -11,21 +11,71 @@ on: type: string description: Run name + pull_request_number: + type: string + description: Optional PR identity for cancelling superseded reviews + head_sha: + type: string + description: Optional expected PR head; stale reviews are skipped + permissions: contents: read + pull-requests: read + +concurrency: + group: ${{ inputs.pull_request_number && format('pullfrog-pr-{0}', inputs.pull_request_number) || format('pullfrog-run-{0}', github.run_id) }} + cancel-in-progress: true jobs: + review_scope: + runs-on: ubuntu-slim + permissions: + contents: read + pull-requests: read + actions: write + outputs: + current: ${{ steps.scope.outputs.current }} + number: ${{ steps.scope.outputs.number }} + head: ${{ steps.scope.outputs.head }} + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts/pullfrog-review-scope.cjs + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/github-script@v8 + id: scope + with: + script: | + const { reviewScope } = require('./config/scripts/pullfrog-review-scope.cjs') + await reviewScope({ github, context, core }) + pullfrog: + needs: review_scope + if: needs.review_scope.outputs.current == 'true' runs-on: ubuntu-latest permissions: id-token: write contents: read + pull-requests: read steps: - name: Checkout code uses: actions/checkout@v6 with: fetch-depth: 1 + - name: Recheck review head before starting agent + id: freshness + if: needs.review_scope.outputs.number != '' + uses: actions/github-script@v8 + env: + REVIEW_NUMBER: ${{ needs.review_scope.outputs.number }} + REVIEW_HEAD: ${{ needs.review_scope.outputs.head }} + with: + script: | + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: Number(process.env.REVIEW_NUMBER) }) + core.setOutput('current', pr.state === 'open' && pr.head.sha === process.env.REVIEW_HEAD) - name: Run agent + if: needs.review_scope.outputs.number == '' || steps.freshness.outputs.current == 'true' uses: pullfrog/pullfrog@v0 with: prompt: ${{ inputs.prompt }} diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index b2ac03e2b085..2490d39af39b 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1314,6 +1314,16 @@ jobs: restore-keys: | electron-builder-${{ matrix.platform }}- + # PRs cache tools separately from Electron; identical paths preserve their cache version. + - name: Seed shared Linux packaging downloads + if: matrix.platform == 'linux-x64' && github.ref == 'refs/heads/main' + uses: actions/cache@v5 + with: + path: ~/.cache/electron-builder + key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }} + # The release cache above restores the same tools, so this only needs to save on a miss. + lookup-only: true + # Why: pnpm install triggers electron's postinstall, which downloads the # Electron binary from GitHub release assets. GitHub's download CDN # occasionally returns 504s that fail the whole release. Retry on diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml index 8014145939ba..e6ab98f0d0d2 100644 --- a/.github/workflows/release-policy.yml +++ b/.github/workflows/release-policy.yml @@ -16,7 +16,7 @@ concurrency: jobs: enforce: if: github.repository == 'stablyai/orca' - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Enforce release policy diff --git a/.github/workflows/terminal-ime-e2e.yml b/.github/workflows/terminal-ime-e2e.yml index bd6be26bd272..168280f9ac2f 100644 --- a/.github/workflows/terminal-ime-e2e.yml +++ b/.github/workflows/terminal-ime-e2e.yml @@ -49,7 +49,7 @@ jobs: run: >- xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 - pnpm run test:e2e -- + pnpm run test:e2e tests/e2e/terminal-ime-exact-byte.spec.ts --workers=1 diff --git a/.github/workflows/track-community-prs.yaml b/.github/workflows/track-community-prs.yaml index 5a5dcb670c2d..7a588663fb20 100644 --- a/.github/workflows/track-community-prs.yaml +++ b/.github/workflows/track-community-prs.yaml @@ -15,7 +15,8 @@ permissions: jobs: track-community-pr: - runs-on: ubuntu-latest + # API-only bookkeeping fits the free single-CPU container runner. + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Generate bufo-bot token diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4ed5063e318a..2691f4907bcc 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -8,19 +8,54 @@ on: required: true type: string + runner: + description: Hosted runner for the unit shards; relay integration keeps its x86 host. + required: false + default: ubuntu-latest + type: string + + selection_mode: + description: Shadow validates selection; selected applies it only to draft PRs. + required: false + default: shadow + type: string + permissions: contents: read jobs: - test: - name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} + plan: runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + shards: ${{ steps.plan.outputs.shards }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Plan unit selection + id: plan + env: + ORCA_UNIT_SELECTION_MODE: ${{ inputs.selection_mode }} + run: node config/scripts/ci-unit-plan.mjs + - uses: actions/upload-artifact@v7 + continue-on-error: true + with: + name: unit-selection-attempt-${{ github.run_attempt }} + path: ci-shards/unit-selection.json + retention-days: 14 + + test: + needs: plan + name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }} + runs-on: ${{ inputs.runner }} strategy: fail-fast: false matrix: node: ${{ fromJSON(inputs.node_versions) }} - shard: [1, 2, 3, 4, 5, 6, 7, 8] - shard_total: [8] + shard: ${{ fromJSON(needs.plan.outputs.shards) }} steps: - name: Checkout @@ -33,13 +68,16 @@ jobs: native-runtime: node node-version: ${{ matrix.node }} cache-electron-package: 'true' - cache-dependency-path: | - pnpm-lock.yaml - cloud/pnpm-lock.yaml - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs + - uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: unit-selection-attempt-${{ github.run_attempt }} + path: ci-shards/ + - name: Test shard env: ORCA_BALANCE_UNIT_SHARDS: '1' @@ -47,27 +85,7 @@ jobs: 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 \ - --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ - --exclude=src/main/shell-startup-feature-channel.test.ts \ - --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ - --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ - --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ - --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ - --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ - --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ - --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ - --exclude=src/shared/posix-command-path-lookup.test.ts \ - --exclude=tests/e2e/relay-region-compatibility.unit.test.ts \ - --exclude=tests/e2e/relay-region-correction.unit.test.ts \ - --exclude=tests/e2e/cross-version-wire/** \ - --shard=${{ matrix.shard }}/${{ matrix.shard_total }} + --shard=${{ matrix.shard.index }}/${{ matrix.shard.count }} - name: Upload unit shard assignment if: always() @@ -75,13 +93,46 @@ jobs: continue-on-error: true uses: actions/upload-artifact@v7 with: - name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }} + name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard.index }}-attempt-${{ github.run_attempt }} path: ci-shards/ retention-days: 14 if-no-files-found: warn + selection_evidence: + needs: test + if: ${{ !cancelled() }} + continue-on-error: true + runs-on: ubuntu-slim + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts/ci-unit-selection-review.mjs + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '24' + - uses: actions/download-artifact@v8 + with: + pattern: unit-shard-node-*-attempt-${{ github.run_attempt }} + path: unit-evidence/ + - name: Compare selection with full results + continue-on-error: true + run: node config/scripts/ci-unit-selection-review.mjs unit-evidence + - uses: actions/upload-artifact@v7 + if: always() + continue-on-error: true + with: + name: unit-selection-review-attempt-${{ github.run_attempt }} + path: unit-evidence/selection-review.json + retention-days: 30 + relay_integration: - name: relay integration node ${{ fromJSON(inputs.node_versions)[0] }} + name: relay integration node ${{ matrix.node }} + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(inputs.node_versions) }} runs-on: ubuntu-latest steps: @@ -93,7 +144,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node - node-version: ${{ fromJSON(inputs.node_versions)[0] }} + node-version: ${{ matrix.node }} cache-electron-package: 'true' cache-dependency-path: | pnpm-lock.yaml diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index e0d7b80d0473..53c231111dbf 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -35,6 +35,7 @@ jobs: timeout-minutes: 50 env: EXPECT: ${{ inputs.expect || 'survival' }} + ORCA_BACKGROUND_LAUNCH: '1' steps: - name: Checkout diff --git a/.github/workflows/win-update-survival-e2e.yml b/.github/workflows/win-update-survival-e2e.yml index 50c38f2e3ad5..b709f82b7885 100644 --- a/.github/workflows/win-update-survival-e2e.yml +++ b/.github/workflows/win-update-survival-e2e.yml @@ -29,6 +29,21 @@ on: options: - survival - cold-restore + trace_installer: + description: Trace installer process checks in this disposable build + type: boolean + default: false + policy: + description: Execution policy inherited by harness child processes + type: choice + default: inherited + options: + - inherited + - Restricted + from_release: + description: Optional base release tag; blank uses this branch build + type: string + default: '' permissions: contents: read @@ -39,7 +54,7 @@ concurrency: jobs: survival: - name: survival (branch build over itself) + name: update ${{ inputs.from_release || 'branch' }} to branch (${{ inputs.expect || 'survival' }}, policy=${{ inputs.policy || 'inherited' }}) runs-on: windows-2022 timeout-minutes: 50 env: @@ -62,18 +77,23 @@ jobs: with: install: false - - name: Install dependencies - run: pnpm install --frozen-lockfile - # Why: cache the built installer keyed on the inputs that affect it, so a # harness-only edit skips the ~20 min electron-builder build. The daemon # relocation code lives under src/, so changing it correctly rebuilds. + # Hash before installation creates native build artifacts under native/. - name: Cache branch installer id: cache-installer - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: dist/orca-windows-setup.exe - key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }} + key: branch-installer-${{ inputs.trace_installer && format('trace-{0}-', hashFiles('tests/tools/win-update-e2e/trace-installer-branches.mjs')) || '' }}${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/**', 'mobile/**', 'patches/**', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml') }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Instrument disposable installer and uninstaller + if: inputs.trace_installer && steps.cache-installer.outputs.cache-hit != 'true' + run: node tests/tools/win-update-e2e/trace-installer-branches.mjs # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle # build resolves React Native and Expo from mobile/node_modules. Gated with the @@ -88,26 +108,46 @@ jobs: pnpm run build:desktop pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never - # Why: install the branch build, open a terminal, update the SAME build - # over it, and assert the relocated daemon survives. Same build on both - # sides isolates the survival mechanism (NSIS kill sweep + userData daemon - # + adoption) from cross-version staleness policy. No --install-dir: a CI - # runner has no real Orca to protect. + - name: Cache successfully built installer before survival verification + if: steps.cache-installer.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist/orca-windows-setup.exe + key: ${{ steps.cache-installer.outputs.cache-primary-key }} + + # A released base also exercises its old uninstaller; default runs isolate this branch. - name: Run survival harness id: harness shell: pwsh env: ORCA_E2E_DIAG_DIR: artifacts/diag + ORCA_BACKGROUND_LAUNCH: '1' + DEBUG: 'pw:browser' + ORCA_E2E_NSIS_TRACE: ${{ github.workspace }}\artifacts\nsis-process-trace.log + ORCA_E2E_PROCESS_POLICY: ${{ inputs.policy || 'inherited' }} + FROM_RELEASE: ${{ inputs.from_release }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | New-Item -ItemType Directory -Force artifacts | Out-Null $exe = "dist/orca-windows-setup.exe" if (-not (Test-Path $exe)) { throw "Installer not found at $exe" } $log = "artifacts/survival-output.log" - node tests/tools/win-update-e2e/run.mjs ` - --from "$exe" ` - --to "$exe" ` - --expect "$env:EXPECT" ` - --soak-seconds 60 2>&1 | Tee-Object -FilePath $log + $harnessArgs = @('--to', $exe, '--expect', $env:EXPECT, '--soak-seconds', '60') + if ($env:FROM_RELEASE) { + $harnessArgs += @('--from-release', $env:FROM_RELEASE) + } else { + $harnessArgs += @('--from', $exe) + } + # Only the harness and its children inherit the test policy. + $launch = @' + if (process.env.ORCA_E2E_PROCESS_POLICY === 'Restricted') { + process.env.PSExecutionPolicyPreference = 'Restricted' + } + console.log('Harness child process policy: ' + (process.env.PSExecutionPolicyPreference || 'inherited')) + process.argv.splice(1, 0, 'tests/tools/win-update-e2e/run.mjs') + await import('./tests/tools/win-update-e2e/run.mjs') + '@ + node --input-type=module -e $launch -- @harnessArgs 2>&1 | Tee-Object -FilePath $log exit $LASTEXITCODE - name: Upload survival output @@ -117,6 +157,7 @@ jobs: name: win-update-survival-output path: | artifacts/survival-output.log + artifacts/nsis-process-trace.log artifacts/diag/** retention-days: 7 if-no-files-found: warn diff --git a/.github/workflows/windows-terminal-restart-e2e.yml b/.github/workflows/windows-terminal-restart-e2e.yml index abb8dc89aa81..d576ee650499 100644 --- a/.github/workflows/windows-terminal-restart-e2e.yml +++ b/.github/workflows/windows-terminal-restart-e2e.yml @@ -22,6 +22,7 @@ jobs: timeout-minutes: 30 env: NODE_OPTIONS: --max-old-space-size=4096 + ORCA_BACKGROUND_LAUNCH: '1' steps: - name: Checkout diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 86931f1f9ec6..0a6625f6fe7e 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -6,6 +6,7 @@ "trailingComma": "none", "ignorePatterns": [ "cloud/**", + "resources/licenses/**", ".github/actions/cloud-sql-rollout-lease/**", ".anti-slop-plugin/**" ] diff --git a/.oxlintrc.json b/.oxlintrc.json index 7de99d4461ef..7a0753c06e27 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -143,7 +143,26 @@ { "files": ["src/renderer/src/**/*.{ts,tsx}"], "rules": { - "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error" + "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error", + "no-restricted-properties": [ + "error", + { + "property": "randomUUID", + "message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'." + } + ] + } + }, + { + "files": ["src/shared/**/*.ts"], + "rules": { + "no-restricted-properties": [ + "error", + { + "property": "randomUUID", + "message": "src/shared is compiled into the web bundle, where crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP). Use createNonSecureContextUuid() from './non-secure-context-uuid', or import randomUUID from 'node:crypto' in main-only code." + } + ] } }, { diff --git a/Casks/orca.rb b/Casks/orca.rb index 6b97b27d52e6..1abc9b3a9124 100644 --- a/Casks/orca.rb +++ b/Casks/orca.rb @@ -5,8 +5,7 @@ sha256 arm: "fc707f290ff3b631b7b7947bf339885b61a43d2e89475997c125b61268ed4966", intel: "5f677c13a08f7a5740442e29d388285a86488c8c1f7aa5f10a8721a2c6ede8e4" - url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg", - verified: "github.com/stablyai/orca/" + url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg" name "Orca" desc "IDE for orchestrating AI coding agents across terminals and worktrees" homepage "https://onorca.dev/" diff --git a/Casks/orca@rc.rb b/Casks/orca@rc.rb index 63bc843b6407..08eaeeca459f 100644 --- a/Casks/orca@rc.rb +++ b/Casks/orca@rc.rb @@ -5,8 +5,7 @@ sha256 arm: "563b6b14323fc9d5489299c82442d514bc12cabffc9d06d3964ed572af4b3955", intel: "457088c7021f07de1a419197f7b2bd00092741ad4727d4fef3d86af38a6831e7" - url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg", - verified: "github.com/stablyai/orca/" + url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg" name "Orca RC" desc "IDE for orchestrating AI coding agents across terminals and worktrees" homepage "https://onorca.dev/" diff --git a/README.md b/README.md index 98efc301c2f9..e8722108c200 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   MiMo Code logo MiMo Code   @@ -195,6 +196,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Charm logo Charm   Cline logo Cline   Codebuff logo Codebuff   + Freebuff logo Freebuff   Command Code logo Command Code   Continue logo Continue   Droid logo Droid   diff --git a/cloud/apps/push/src/durable-push-claim.test.ts b/cloud/apps/push/src/durable-push-claim.test.ts index f43588bd2c3d..c7a228a05595 100644 --- a/cloud/apps/push/src/durable-push-claim.test.ts +++ b/cloud/apps/push/src/durable-push-claim.test.ts @@ -157,7 +157,7 @@ it('keeps claim, exclusion and prune correct without the queue index', async () for (const claim of leased) await store.finish(claim) expect((await store.claim())?.notification.notificationSeq).toBe(2) advance(10 * 60_000) - expect(await store.prune()).toBe(1) + expect(await store.prune()).toEqual({ deleted: 1, saturated: false }) expect(await batchCount(db)).toBe(0) }) @@ -211,11 +211,11 @@ it('prunes a large backlog in bounded calls without touching live or leased work [JSON.stringify(notification(9)), now - 1, now - 1, now + 1000, now - 1] ) await store.accept('host', 'phone-live', notification(1)) - expect(await store.prune()).toBe(perCall) - expect(await store.prune()).toBe(500) - expect(await store.prune()).toBe(0) + expect(await store.prune()).toEqual({ deleted: perCall, saturated: true }) + expect(await store.prune()).toEqual({ deleted: 500, saturated: false }) + expect(await store.prune()).toEqual({ deleted: 0, saturated: false }) advance(1000) - expect(await store.prune()).toBe(1) + expect(await store.prune()).toEqual({ deleted: 1, saturated: false }) expect(await batchCount(db)).toBe(1) expect(await store.pendingCount('phone-live')).toBe(1) }) diff --git a/cloud/apps/push/src/durable-push-store.ts b/cloud/apps/push/src/durable-push-store.ts index 61fed2350184..02d26f20b3e1 100644 --- a/cloud/apps/push/src/durable-push-store.ts +++ b/cloud/apps/push/src/durable-push-store.ts @@ -12,6 +12,8 @@ const CLAIM_CANDIDATE_ATTEMPTS = 4 export const PRUNE_BATCH_ROWS = 2_000 export const PRUNE_MAX_BATCHES = 50 export const DELIVERY_LEASE_MS = 30_000 +// `saturated` means the batch budget ran out with rows still matching, so a backlog remains. +export type PushPruneSweep = { deleted: number; saturated: boolean } export type QueuedPushDelivery = { id: string registrationId: string @@ -200,10 +202,10 @@ export class DurablePushStore { } // Bounded per call and per statement, so it drains any backlog on its own without holding locks. - async prune(): Promise { + async prune(): Promise { const now = this.now() // Also clears terminal rows older revisions kept, since each carries a past expires_at. - let deleted = await this.deleteInBatches( + let { deleted, saturated } = await this.deleteInBatches( 'push_delivery_batches', 'batch_id', 'expires_at <= ? AND lease_until <= ?', @@ -215,9 +217,11 @@ export class DurablePushStore { ['push_event_recipients', 'event_id, registration_id'], ['push_events', 'event_id'] ] as const) { - deleted += await this.deleteInBatches(table, key, 'created_at < ?', [now - RETENTION_MS]) + const sweep = await this.deleteInBatches(table, key, 'created_at < ?', [now - RETENTION_MS]) + deleted += sweep.deleted + saturated ||= sweep.saturated } - return deleted + return { deleted, saturated } } private async deleteInBatches( @@ -225,7 +229,7 @@ export class DurablePushStore { key: string, where: string, params: unknown[] - ): Promise { + ): Promise { const lockRows = this.background.dialect === 'postgres' ? ' FOR UPDATE SKIP LOCKED' : '' let total = 0 for (let batch = 0; batch < PRUNE_MAX_BATCHES; batch++) { @@ -235,8 +239,9 @@ export class DurablePushStore { ) const changes = Number(result?.changes ?? 0) total += changes - if (changes < PRUNE_BATCH_ROWS) break + // A short batch drained the predicate; only a full last batch leaves rows behind. + if (changes < PRUNE_BATCH_ROWS) return { deleted: total, saturated: false } } - return total + return { deleted: total, saturated: true } } } diff --git a/cloud/apps/push/src/push-background.test.ts b/cloud/apps/push/src/push-background.test.ts new file mode 100644 index 000000000000..21598ef67a21 --- /dev/null +++ b/cloud/apps/push/src/push-background.test.ts @@ -0,0 +1,174 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { startPushBackground } from './push-background.js' +import { createPushServerHarness } from './push-server-harness.test-fixture.js' +import { reserveRequestConnection } from './push-background-database.js' +import { DurablePushStore, PRUNE_BATCH_ROWS, type PushPruneSweep } from './durable-push-store.js' +import type { PushDatabase } from './push-database.js' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +async function fixture(mode: 'active' | 'validation' = 'active') { + const harness = await createPushServerHarness() + const runtime = harness.server + const challenges = vi.spyOn(runtime.challenges, 'pruneExpired').mockResolvedValue(0) + const sessions = vi.spyOn(runtime.sessions, 'pruneExpired').mockResolvedValue(0) + const deliveries = vi + .spyOn(runtime.deliveryStore, 'prune') + .mockResolvedValue({ deleted: 0, saturated: false }) + vi.spyOn(runtime.worker, 'start').mockImplementation(() => {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() + const stop = startPushBackground({ mode }, runtime) + cleanups.push(async () => { + await stop() + await harness.close() + }) + return { stop, challenges, sessions, deliveries, warn } +} + +it('keeps one slow sweep per store while other stores keep their cadence', async () => { + const h = await fixture() + let finish!: (sweep: PushPruneSweep) => void + h.deliveries.mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)) + ) + try { + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(h.deliveries).toHaveBeenCalledTimes(1) + expect(h.challenges).toHaveBeenCalledTimes(10) + expect(h.sessions).toHaveBeenCalledTimes(1) + } finally { + finish({ deleted: 100_000, saturated: false }) + } + await vi.advanceTimersByTimeAsync(60_000) + expect(h.deliveries).toHaveBeenCalledTimes(2) + await h.stop() + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(h.deliveries).toHaveBeenCalledTimes(2) + expect(h.challenges).toHaveBeenCalledTimes(11) + expect(h.sessions).toHaveBeenCalledTimes(1) +}) + +it('reports a sweep that is still running a full interval after it started', async () => { + const h = await fixture() + let finish!: (sweep: PushPruneSweep) => void + h.deliveries.mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)) + ) + try { + await vi.advanceTimersByTimeAsync(119_999) + expect(h.warn).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(h.warn).toHaveBeenCalledWith( + JSON.stringify({ event: 'orca_push_prune_overdue', target: 'deliveries' }) + ) + } finally { + finish({ deleted: 0, saturated: false }) + } + // One report per sweep: the settled sweep clears its own watchdog. + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(h.warn).toHaveBeenCalledTimes(1) +}) + +it('releases a failed sweep so the next scheduled sweep can recover', async () => { + const h = await fixture() + h.deliveries.mockRejectedValueOnce(new Error('database unavailable')) + await vi.advanceTimersByTimeAsync(60_000) + expect(h.deliveries).toHaveBeenCalledTimes(1) + expect(h.warn).toHaveBeenCalledWith( + JSON.stringify({ event: 'orca_push_prune_failed', target: 'deliveries', error: 'Error' }) + ) + await vi.advanceTimersByTimeAsync(60_000) + expect(h.deliveries).toHaveBeenCalledTimes(2) + expect(h.warn).toHaveBeenCalledTimes(1) +}) + +it('resumes a saturated sweep at once rather than waiting out the interval', async () => { + const h = await fixture() + let backlogSweeps = 3 + h.deliveries.mockImplementation(async () => { + const saturated = backlogSweeps-- > 0 + return { deleted: saturated ? PRUNE_BATCH_ROWS : 0, saturated } + }) + await vi.advanceTimersByTimeAsync(60_000) + expect(h.deliveries).toHaveBeenCalledTimes(1) + // Three saturated sweeps resume within milliseconds instead of costing an interval each. + await vi.advanceTimersByTimeAsync(10) + expect(h.deliveries).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(59_000) + expect(h.deliveries).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(1_000) + expect(h.deliveries).toHaveBeenCalledTimes(5) +}) + +it('keeps a delivery claim behind one statement while a backlog drains back to back', async () => { + const h = await fixture() + let backlog = true + let finishedDeletes = 0 + const database: PushDatabase = { + dialect: 'postgres', + query: async (sql) => { + if (!sql.startsWith('DELETE')) return [] + await new Promise((resolve) => setTimeout(resolve, 4_000)) + finishedDeletes++ + // Only deliveries hold a backlog, so each sweep spends its whole budget there and returns. + if (!backlog || !sql.includes('push_delivery_batches')) return [{ changes: 0 }] + return [{ changes: PRUNE_BATCH_ROWS }] + }, + transaction: (operation) => operation(database), + lockQuotaScope: async () => {}, + tryLockScope: async () => true, + tryLockSharedScope: async () => true, + close: async () => {} + } + const store = new DurablePushStore(database, Date.now, reserveRequestConnection(database, 2)) + const sweeps: Promise[] = [] + let inFlight = 0 + let concurrentSweeps = 0 + h.deliveries.mockImplementation(() => { + concurrentSweeps = Math.max(concurrentSweeps, ++inFlight) + const sweep = store.prune().finally(() => void inFlight--) + sweeps.push(sweep) + return sweep + }) + try { + await vi.advanceTimersByTimeAsync(10 * 60_000) + const queuedAt = finishedDeletes + const startedAt = Date.now() + let statementsAhead: number | undefined + let claimDelay: number | undefined + const claim = store.claim().then(() => { + statementsAhead = finishedDeletes - queuedAt + claimDelay = Date.now() - startedAt + }) + await vi.advanceTimersByTimeAsync(44_000) + await claim + // Sweeping serially parks one statement ahead of the claim no matter how long the drain runs. + expect({ statementsAhead, concurrentSweeps }).toEqual({ + statementsAhead: 1, + concurrentSweeps: 1 + }) + expect(claimDelay).toBeLessThanOrEqual(4_000) + // Each sweep exhausts its 50-batch budget, so the drain continues instead of idling out the tick. + expect(sweeps.length).toBeGreaterThan(1) + } finally { + await h.stop() + backlog = false + await vi.advanceTimersByTimeAsync(10 * 60_000) + await Promise.all(sweeps) + } +}) + +it('keeps validation mode free of sweeps and timers', async () => { + const h = await fixture('validation') + await vi.advanceTimersByTimeAsync(20 * 60_000) + expect(h.challenges).not.toHaveBeenCalled() + expect(h.sessions).not.toHaveBeenCalled() + expect(h.deliveries).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/cloud/apps/push/src/push-background.ts b/cloud/apps/push/src/push-background.ts index 8925fd41fedd..9fad4700e7b5 100644 --- a/cloud/apps/push/src/push-background.ts +++ b/cloud/apps/push/src/push-background.ts @@ -1,26 +1,55 @@ import type { PushConfig } from './config.js' +import type { PushPruneSweep } from './durable-push-store.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 +// Chained rather than periodic, so a sweep spanning many bounded statements never overlaps itself and +// one that exhausted its batch budget resumes at once instead of idling out the rest of the interval. +function prune(label: string, run: () => Promise, intervalMs: number): () => void { + let timer: NodeJS.Timeout | undefined + let overdue: NodeJS.Timeout | undefined + let stopped = false + function schedule(delayMs: number): void { + if (stopped) return + timer = setTimeout(tick, delayMs) + timer.unref() + } + function tick(): void { + // Admission waits are untimed, so a lost slot release would otherwise stall retention silently. + overdue = setTimeout(() => { + console.warn(JSON.stringify({ event: 'orca_push_prune_overdue', target: label })) + }, intervalMs) + overdue.unref() + void run() + .then((sweep) => schedule(sweep.saturated ? 0 : intervalMs)) + .catch((error: unknown) => { + console.warn( + JSON.stringify({ + event: 'orca_push_prune_failed', + target: label, + error: error instanceof Error ? error.name : 'unknown' + }) + ) + schedule(intervalMs) + }) + .finally(() => clearTimeout(overdue)) + } + schedule(intervalMs) + return () => { + stopped = true + clearTimeout(timer) + clearTimeout(overdue) + } } +// One DELETE under a statement timeout: there is no batch budget for it to exhaust. +const unbatchedSweep = + (run: () => Promise) => + async (): Promise => ({ deleted: await run(), saturated: false }) + export function startPushBackground( config: Pick, runtime: Pick< @@ -30,14 +59,22 @@ export function startPushBackground( ): () => 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), + const stops = [ + prune( + 'challenges', + unbatchedSweep(() => challenges.pruneExpired()), + CHALLENGE_PRUNE_INTERVAL_MS + ), + prune( + 'sessions', + unbatchedSweep(() => 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) + for (const stop of stops) stop() await worker.stop() } } diff --git a/cloud/apps/push/src/push-database.ts b/cloud/apps/push/src/push-database.ts index b1a7415297b1..9957a7c28b52 100644 --- a/cloud/apps/push/src/push-database.ts +++ b/cloud/apps/push/src/push-database.ts @@ -85,15 +85,17 @@ class SqliteDatabase extends SqliteTransaction { 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 + 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() } diff --git a/cloud/apps/push/src/sqlite-transaction-queue.test.ts b/cloud/apps/push/src/sqlite-transaction-queue.test.ts new file mode 100644 index 000000000000..1ee5e8b3d52a --- /dev/null +++ b/cloud/apps/push/src/sqlite-transaction-queue.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { setImmediate } from 'node:timers/promises' +import { expect, it, vi } from 'vitest' +import { openPushDatabase } from './push-database.js' + +it('releases queued work and close after a SQLite transaction cannot acquire its lock', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-push-sqlite-queue-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + // Keep the native handle reachable for cleanup even if a queue regression strands close(). + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openPushDatabase({ dataDir }) + capture.mockRestore() + const blocker = new DatabaseSync(join(dataDir, 'orca-push.sqlite')) + try { + await database.query('CREATE TABLE queue_progress (value INTEGER)') + await database.query('INSERT INTO queue_progress VALUES (0)') + blocker.exec('BEGIN IMMEDIATE') + const operation = vi.fn(async () => undefined) + await expect(database.transaction(operation)).rejects.toThrow('database is locked') + let lockFailures = 0 + const blocked = Array.from({ length: 5 }, () => + database.transaction(operation).catch(() => { + lockFailures += 1 + }) + ) + await setImmediate() + expect(lockFailures).toBe(5) + await Promise.all(blocked) + expect(operation).not.toHaveBeenCalled() + blocker.exec('ROLLBACK') + let completed = 0 + const pending = Array.from({ length: 100 }, () => + database.transaction(async (transaction) => { + await transaction.query('UPDATE queue_progress SET value = value + 1') + completed += 1 + }) + ) + for (const request of pending) void request.catch(() => undefined) + await setImmediate() + expect(completed).toBe(100) + await Promise.all(pending) + expect(await database.query('SELECT value FROM queue_progress')).toEqual([{ value: 100 }]) + let closed = false + const closing = database.close().then(() => { + closed = true + }) + await setImmediate() + expect(closed).toBe(true) + await closing + } finally { + capture.mockRestore() + blocker.close() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) + +it('does not roll back a transaction when BEGIN failed before taking ownership', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-push-sqlite-owner-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openPushDatabase({ dataDir }) + capture.mockRestore() + try { + await database.query('CREATE TABLE queue_owner (value INTEGER)') + await database.query('BEGIN IMMEDIATE') + await database.query('INSERT INTO queue_owner VALUES (7)') + await expect(database.transaction(async () => undefined)).rejects.toThrow( + 'cannot start a transaction within a transaction' + ) + expect(connection?.isTransaction).toBe(true) + let rows: unknown + void database.query('SELECT value FROM queue_owner').then((result) => { + rows = result + }) + await setImmediate() + expect(rows).toEqual([{ value: 7 }]) + await database.query('ROLLBACK') + expect(await database.query('SELECT value FROM queue_owner')).toEqual([]) + await database.close() + } finally { + capture.mockRestore() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) diff --git a/cloud/apps/relay/src/assignment-headroom-scope.test.ts b/cloud/apps/relay/src/assignment-headroom-scope.test.ts new file mode 100644 index 000000000000..9862849d8e0e --- /dev/null +++ b/cloud/apps/relay/src/assignment-headroom-scope.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js' + +const NOW = 100_000 +const CELL_COUNT = 32 +const identity = { userId: 'headroom-user', relayHostId: 'headroomhost0001' } + +function observeHeadroom(database: RelayDatabase, rowCounts: number[]): RelayDatabase { + return { + dialect: database.dialect, + async query(sql, params) { + const rows = await database.query(sql, params) + if (sql.includes('FROM relay_cell_connection_limits limits')) rowCounts.push(rows.length) + return rows + }, + queryLocked: (sql, params, options) => database.queryLocked(sql, params, options), + transaction: (operation, options) => + database.transaction((transaction) => operation(observeHeadroom(transaction, rowCounts)), options), + close: () => database.close() + } +} + +describe('assignment headroom query scope', () => { + let database: RelayDatabase | undefined + afterEach(async () => await database?.close()) + + async function setup() { + database = await openInMemoryRelayDatabase() + const rowCounts: number[] = [] + const store = new RelayAssignmentStore(observeHeadroom(database, rowCounts), () => NOW, { + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + const cells = Array.from({ length: CELL_COUNT }, (_, index) => ({ + id: `cell-${String(index).padStart(2, '0')}`, + url: `https://cell-${index}.example.com`, + capacityRequests: 1_000, + connectionHardCap: 600 as const, + connectionUnobservedBound: 50 + })) + await store.reconcileCells(cells) + for (const cell of cells) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + startedAt: NOW - 10, + ready: true, + observedRequests: 0, + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0, + connectionHardCap: 600, + connectionUnobservedBound: 50 + }) + } + await database.query( + `INSERT INTO relay_assignments + (user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, + last_activity_at, reserved_controls, reserved_splices, reserved_invites, + pending_installs, pending_confirmations, migration_leases) + VALUES (?, ?, 'cell-00', 1, ?, ?, 0, 0, 0, 0, 0, 0)`, + [identity.userId, identity.relayHostId, NOW + 90_000, NOW] + ) + await store.acquireActivity(identity, { + activityId: 'existing-splice', + kind: 'splice', + cellId: 'cell-00' + }) + rowCounts.length = 0 + return { store, rowCounts, database } + } + + it('reads one cell for a sticky assignment even when the capped fleet grows', async () => { + const { store, rowCounts } = await setup() + await expect(store.assign(identity)).resolves.toMatchObject({ + ...identity, + cellId: 'cell-00', + assignmentEpoch: 1 + }) + console.info('sticky headroom rows', { fleetCells: CELL_COUNT, rowCounts }) + expect(rowCounts).toEqual([1]) + }) + + it('still considers the full fleet for a new placement', async () => { + const { store, rowCounts } = await setup() + await expect( + store.assign({ userId: 'new-user', relayHostId: 'newheadroomhost1' }) + ).resolves.toMatchObject({ cellId: 'cell-01', assignmentEpoch: 1 }) + expect(rowCounts).toEqual([CELL_COUNT]) + }) + + it.each([ + ['missing snapshot', `DELETE FROM relay_cell_connection_snapshots WHERE cell_id = 'cell-00'`], + ['expired snapshot', `UPDATE relay_cell_connection_snapshots SET snapshot_at = ${NOW - 45_000} WHERE cell_id = 'cell-00'`], + ['old incarnation', `UPDATE relay_cell_connection_snapshots SET cell_incarnation = 'old' WHERE cell_id = 'cell-00'`], + ['capacity boundary', `UPDATE relay_cell_connection_snapshots SET enforced_connection_units = 450 WHERE cell_id = 'cell-00'`] + ])('rejects the pinned active host with %s', async (_name, sql) => { + const { store, database } = await setup() + await database.query(sql) + await expect(store.assign(identity)).rejects.toThrow('relay_connection_headroom_exhausted') + }) + + it('preserves admission for cells without a connection limit', async () => { + const { store, rowCounts, database } = await setup() + await database.query(`DELETE FROM relay_cell_connection_limits WHERE cell_id = 'cell-00'`) + await expect(store.assign(identity)).resolves.toMatchObject({ cellId: 'cell-00' }) + expect(rowCounts).toEqual([0]) + }) + + it('counts outstanding reservations at the admission boundary', async () => { + const { store, database } = await setup() + await database.query( + `UPDATE relay_cell_connection_snapshots SET enforced_connection_units = 449 + WHERE cell_id = 'cell-00'` + ) + await database.query( + `INSERT INTO relay_control_connection_reservations + (reservation_id, idempotency_key, user_id, relay_host_id, assignment_epoch, + cell_id, state, created_at, timeout_at, updated_at) + VALUES ('pending', 'pending', 'other-user', 'other-host', 1, + 'cell-00', 'reserved', ?, ?, ?)`, + [NOW, NOW + 90_000, NOW] + ) + await expect(store.assign(identity)).rejects.toThrow('relay_connection_headroom_exhausted') + }) +}) diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 3cb5dcfeee9f..3572a69299cc 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -7466,10 +7466,16 @@ export class RelayAssignmentStore { } private async connectionHeadroomByCell( - database: RelayDatabase + database: RelayDatabase, + cellId?: string ): Promise> { const now = this.now() - const rows = await database.query(ASSIGNMENT_CONNECTION_HEADROOM_QUERY) + const rows = await database.query( + cellId === undefined + ? ASSIGNMENT_CONNECTION_HEADROOM_QUERY + : `${ASSIGNMENT_CONNECTION_HEADROOM_QUERY} WHERE limits.cell_id = ?`, + cellId === undefined ? [] : [cellId] + ) return new Map( rows.map((row) => { const heartbeat = optionalInteger(row, 'last_heartbeat_at') @@ -7503,7 +7509,7 @@ export class RelayAssignmentStore { database: RelayDatabase, cellId: string ): Promise { - return (await this.connectionHeadroomByCell(database)).get(cellId) !== false + return (await this.connectionHeadroomByCell(database, cellId)).get(cellId) !== false } private async cellIsLive( diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index db1203bc45cb..6a8a1fc202ab 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -852,16 +852,18 @@ class SqliteDatabase extends SqliteTransaction { 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') - recordMeasuredHold(this.holds, transaction) - return result - } catch (error) { - this.database.exec('ROLLBACK') - throw error + this.database.exec('BEGIN IMMEDIATE') + const transaction = new SqliteTransaction(this.database) + try { + const result = await operation(transaction) + this.database.exec('COMMIT') + recordMeasuredHold(this.holds, transaction) + return result + } catch (error) { + this.database.exec('ROLLBACK') + throw error + } } finally { release() } diff --git a/cloud/apps/relay/src/host-session-registry.test.ts b/cloud/apps/relay/src/host-session-registry.test.ts index 69697cce60e4..fcf73695b40a 100644 --- a/cloud/apps/relay/src/host-session-registry.test.ts +++ b/cloud/apps/relay/src/host-session-registry.test.ts @@ -535,6 +535,8 @@ describe('host session cleanup races', () => { const socket = new FakeSocket() const activation = activate(socket as unknown as WebSocket, identity, null, 1, false, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledOnce() socket.close() blocked.resolve('control:production-gce-c3:1') await activation @@ -560,6 +562,8 @@ describe('host session cleanup races', () => { const rebindSocket = new FakeSocket() const rebinding = activate(rebindSocket as unknown as WebSocket, identity, original, 1, true, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledTimes(2) rebindSocket.close() blocked.resolve('control:production-gce-c3:1') await rebinding @@ -574,6 +578,49 @@ describe('host session cleanup races', () => { ) }) + it('skips a closed queued control so its live retry avoids abandoned database work', async () => { + const stalled = deferred() + const activateControl = vi + .fn() + .mockReturnValueOnce(stalled.promise) + .mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 4_000)) + return 'control:production-gce-c3:1' + }) + const { registry, activate, releaseActivity } = createRegistry(activateControl) + const firstSocket = new FakeSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const first = activate(firstSocket as unknown as WebSocket, identity, null, 1, false, 1) + await vi.advanceTimersByTimeAsync(0) + expect(activateControl).toHaveBeenCalledOnce() + const abandonedSocket = new FakeSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const abandoned = activate(abandonedSocket as unknown as WebSocket, identity, null, 1, false, 1) + const liveSocket = new FakeSocket() + const startedAt = Date.now() + let liveCompletedAt: number | undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. + const live = activate(liveSocket as unknown as WebSocket, identity, null, 1, false, 1) + .then(() => { liveCompletedAt = Date.now() }) + firstSocket.close() + abandonedSocket.close() + stalled.resolve('control:production-gce-c3:1') + await vi.advanceTimersByTimeAsync(8_000) + await Promise.all([first, abandoned, live]) + + console.log(JSON.stringify({ + scenario: 'closed queued control before a live retry', + activationCalls: activateControl.mock.calls.length, + activityReleases: releaseActivity.mock.calls.length, + liveReadyMs: liveCompletedAt === undefined ? null : liveCompletedAt - startedAt + })) + expect(activateControl).toHaveBeenCalledTimes(2) + expect(releaseActivity).toHaveBeenCalledOnce() + expect(liveCompletedAt! - startedAt).toBe(4_000) + expect(registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })?.socket) + .toBe(liveSocket) + }) + it('rejects client lookup when the indexed control socket is not open', async () => { const reservation = { userId: identity.sub, diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 380ccfbc9c65..4e3372ce6923 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -1103,7 +1103,7 @@ export class HostSessionRegistry { .catch(() => undefined) .then(async () => { clearTimeout(queueWaitTimer) - if (queueWaitExpired) return + if (queueWaitExpired || socket.readyState !== socket.OPEN) return if ((this.sessions.get(key) ?? null) !== existing) { socket.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded') return diff --git a/cloud/apps/relay/src/relay-first-frame-close.blackbox.test.ts b/cloud/apps/relay/src/relay-first-frame-close.blackbox.test.ts new file mode 100644 index 000000000000..8a3d797cdfb9 --- /dev/null +++ b/cloud/apps/relay/src/relay-first-frame-close.blackbox.test.ts @@ -0,0 +1,272 @@ +import { connect, type Socket } from 'node:net' +import { afterEach, expect, it, vi } from 'vitest' +import { RELAY_CLOSE_CODE, RELAY_PROTOCOL_LIMITS } from '@orca-cloud/relay-contract' +import { loadRelayConfig } from './config.js' +import type { RelayDatabase } from './database.js' +import { createRelayServer } from './relay-server.js' + +const cleanups: (() => Promise | void)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() + vi.restoreAllMocks() +}) + +const IDLE_LEDGER = { + physicalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0 +} + +async function fixture( + options: { role?: 'cell' | 'director'; hardCap?: number } = {} +) { + const role = options.role ?? 'cell' + const database: RelayDatabase = { + query: vi.fn(async () => []), + queryLocked: vi.fn(async () => []), + transaction: (operation) => operation(database), + close: async () => {} + } + const config = loadRelayConfig({ + ORCA_RELAY_PUBLIC_URL: 'http://127.0.0.1', + ORCA_RELAY_CELL_URL: 'http://127.0.0.1', + ORCA_RELAY_AUTH_ISSUER: 'https://auth.example.test', + ORCA_RELAY_JWKS_URL: 'https://auth.example.test/jwks', + ORCA_RELAY_ASSIGNMENT_SIGNING_KEY: 'synthetic-assignment-key-for-test-only', + ORCA_RELAY_ROLE: role, + ORCA_RELAY_ADMIN_AUDIENCE: 'https://auth.example.test/admin', + ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'deploy@example.test', + ORCA_RELAY_CELL_CONNECTION_HARD_CAP: '600', + ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND: '60', + ...(role === 'director' + ? { + ORCA_RELAY_CELLS_JSON: JSON.stringify([ + { id: 'cell-1', url: 'https://cell-1.example.test', capacityRequests: 900 } + ]) + } + : {}) + }) + const relay = createRelayServer(config, database, { + connectionLedgerLimits: { hardCap: options.hardCap ?? 3, controlReserve: 1 } + }) + await new Promise((resolve) => relay.server.listen(0, '127.0.0.1', resolve)) + cleanups.push(() => new Promise((resolve) => relay.server.close(() => resolve()))) + const address = relay.server.address() + if (!address || typeof address === 'string') throw new Error('missing test port') + return { relay, port: address.port, database } +} + +type RawPeer = { + socket: Socket + received: () => Buffer + transport: () => { ended: boolean; error: string | null } +} + +async function silentUpgrade(port: number, target: string): Promise { + const socket = connect(port, '127.0.0.1') + cleanups.push(() => { + socket.destroy() + }) + await new Promise((resolve, reject) => { + let header = '' + socket.once('error', reject) + socket.once('connect', () => { + socket.write( + `GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\n` + + 'Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\n' + + // RFC 6455 example nonce, matching the existing raw-upgrade fixture. + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n' + ) + }) + const readHeader = (chunk: Buffer): void => { + header += chunk.toString() + if (!header.includes('\r\n\r\n')) return + socket.off('data', readHeader) + if (header.startsWith('HTTP/1.1 101 ')) resolve() + else reject(new Error(header.split('\r\n')[0])) + } + socket.on('data', readHeader) + }) + socket.removeAllListeners('error') + // This raw peer reads frames without answering the server's close handshake. + const chunks: Buffer[] = [] + let ended = false + let error: string | null = null + socket.on('data', (chunk: Buffer) => chunks.push(chunk)) + socket.on('end', () => { + ended = true + }) + socket.on('error', (caught: Error) => { + error = caught.message + }) + return { + socket, + received: () => Buffer.concat(chunks), + transport: () => ({ ended, error }) + } +} + +// A 43-character base64url credential is the shortest value RelayAuthSchema accepts. +const WELL_FORMED_CREDENTIAL = 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678' + +function maskedTextFrame(payload: string): Buffer { + const body = Buffer.from(payload) + const mask = Buffer.from([1, 2, 3, 4]) + const masked = Buffer.from(body.map((byte, index) => byte ^ mask[index % 4]!)) + return Buffer.concat([Buffer.from([0x81, 0x80 | body.length]), mask, masked]) +} + +function relayAuthFrame(): Buffer { + return maskedTextFrame( + JSON.stringify({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: WELL_FORMED_CREDENTIAL + }) + ) +} + +// The close frame is the last unmasked frame a rejected peer receives: 0x88, length, +// then a big-endian status code followed by the UTF-8 reason. +function readCloseFrame(received: Buffer): { code: number; reason: string } | null { + const start = received.lastIndexOf(0x88) + if (start < 0 || received.length < start + 4) return null + const length = received[start + 1]! + return { + code: received.readUInt16BE(start + 2), + reason: received.subarray(start + 4, start + 2 + length).toString('utf8') + } +} + +async function expectIdleLedger( + relay: Awaited>['relay'], + timeout: number +): Promise { + await vi.waitFor(() => expect(relay.connectionSnapshot()).toMatchObject(IDLE_LEDGER), { timeout }) +} + +const PHONE_TARGET = '/v1/connect/abcdefghijklmnop' + +it.each([ + { label: 'first-frame timeout', target: PHONE_TARGET, opcode: undefined }, + { label: 'binary first frame', target: PHONE_TARGET, opcode: 0x82 }, + { label: 'invalid phone auth', target: PHONE_TARGET, opcode: 0x81 }, + { label: 'invalid host-data auth', target: '/v1/host/data/connection-1', opcode: 0x81 } +])( + 'releases admission after $label even when the peer ignores close', + async ({ target, opcode }) => { + const { relay, port, database } = await fixture() + const peer = await silentUpgrade(port, target) + const firstFrameDeadline = opcode === undefined ? RELAY_PROTOCOL_LIMITS.firstFrameDeadlineMs : 0 + if (opcode !== undefined) peer.socket.write(Buffer.from([opcode, 0x80, 0, 0, 0, 0])) + await expectIdleLedger(relay, firstFrameDeadline + 2_000) + expect(relay.runtimeCounts().preAuthConnections).toBe(0) + expect(database.query).not.toHaveBeenCalled() + expect(database.queryLocked).not.toHaveBeenCalled() + await silentUpgrade(port, PHONE_TARGET) + expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2) + } +) + +it('releases admission after a rejected director invite when the peer ignores close', async () => { + const { relay, port } = await fixture({ role: 'director' }) + const peer = await silentUpgrade(port, PHONE_TARGET) + peer.socket.write(relayAuthFrame()) + await expectIdleLedger(relay, 2_000) + expect(readCloseFrame(peer.received())).toEqual({ + code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + reason: 'invalid invite' + }) + expect(relay.runtimeCounts().preAuthConnections).toBe(0) + await silentUpgrade(port, PHONE_TARGET) + expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2) +}) + +it('releases admission after a director move redirect when the peer ignores close', async () => { + const { relay, port } = await fixture({ role: 'director' }) + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + vi.spyOn(relay.store, 'resolveInviteForMove').mockResolvedValue({ + userId: identity.userId, + relayDeviceId: 'device-1' + }) + vi.spyOn(relay.assignments, 'resolve').mockResolvedValue({ + ...identity, + cellId: 'cell-1', + cellUrl: 'https://cell-1.example.test', + assignmentEpoch: 1, + leaseExpiresAt: Date.now() + 60_000 + }) + const peer = await silentUpgrade(port, PHONE_TARGET) + peer.socket.write(relayAuthFrame()) + await expectIdleLedger(relay, 2_000) + expect(peer.received().toString('utf8')).toContain('"type":"relay-moved"') + expect(readCloseFrame(peer.received())).toEqual({ + code: RELAY_CLOSE_CODE.DRAINING, + reason: 'connect to assigned cell' + }) + await silentUpgrade(port, PHONE_TARGET) + expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2) +}) + +// A peer that keeps draining its socket does get the rejection: the close frame is written +// before the force-close timer can fire, and TCP delivers those bytes ahead of the FIN. +// +// Scope, deliberately narrow: this peer reads every byte as it arrives, so the assertion below +// speaks only for a responsive peer. It is not evidence that delivery survives backpressure — +// `terminate()` destroys the socket a second later, and a frame still queued in the kernel or in +// `ws`'s own buffer goes unsent. Treat the rejection as best effort; the bound on the close is +// what the trade-off actually buys. +it('delivers the rejection code and a graceful FIN to a peer that keeps reading', async () => { + const { relay, port } = await fixture() + const peer = await silentUpgrade(port, PHONE_TARGET) + peer.socket.write(maskedTextFrame(JSON.stringify({ type: 'relay-auth', v: 1, mode: 'wrong' }))) + await vi.waitFor(() => expect(peer.transport().ended).toBe(true), { timeout: 3_000 }) + expect(peer.received().toString('utf8')).toContain('"code":4401') + expect(readCloseFrame(peer.received())).toEqual({ + code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + reason: 'invalid relay auth' + }) + expect(peer.transport().error).toBeNull() + await expectIdleLedger(relay, 2_000) +}) + +const BINARY_FIRST_FRAME = Buffer.from([0x82, 0x80, 0, 0, 0, 0]) +// maxPreAuthPerSource caps how many unauthenticated sockets one source may hold at once. +const CONCURRENT_PEERS_PER_SOURCE = 4 + +it('returns capacity to its exact baseline after repeated bursts of rejections', async () => { + const { relay, port } = await fixture({ hardCap: 2 * CONCURRENT_PEERS_PER_SOURCE + 1 }) + for (let wave = 0; wave < 3; wave++) { + const peers = await Promise.all( + Array.from({ length: CONCURRENT_PEERS_PER_SOURCE }, () => + silentUpgrade(port, PHONE_TARGET) + ) + ) + expect(relay.connectionSnapshot()).toMatchObject({ + enforcedConnectionUnits: 2 * CONCURRENT_PEERS_PER_SOURCE + }) + for (const peer of peers) peer.socket.write(BINARY_FIRST_FRAME) + await expectIdleLedger(relay, 3_000) + expect(relay.runtimeCounts().preAuthConnections).toBe(0) + } +}) + +// A rejection racing the peer's own disconnect must release once, not twice: the ledger +// does not clamp at zero, so a double release shows up as a negative count here. +it('releases exactly once when a rejected peer disconnects at the same moment', async () => { + const { relay, port } = await fixture({ hardCap: 2 * CONCURRENT_PEERS_PER_SOURCE + 1 }) + const peers = await Promise.all( + Array.from({ length: CONCURRENT_PEERS_PER_SOURCE }, () => silentUpgrade(port, PHONE_TARGET)) + ) + for (const peer of peers) { + peer.socket.write(BINARY_FIRST_FRAME) + peer.socket.destroy() + } + await expectIdleLedger(relay, 3_000) + expect(relay.connectionSnapshot()).toMatchObject(IDLE_LEDGER) + expect(relay.runtimeCounts().preAuthConnections).toBe(0) + await silentUpgrade(port, PHONE_TARGET) + expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2) +}) diff --git a/cloud/apps/relay/src/relay-readiness-coalescing.test.ts b/cloud/apps/relay/src/relay-readiness-coalescing.test.ts new file mode 100644 index 000000000000..2dc8ca0af224 --- /dev/null +++ b/cloud/apps/relay/src/relay-readiness-coalescing.test.ts @@ -0,0 +1,106 @@ +import { expect, it, vi } from 'vitest' +import type { RelayDatabase } from './database.js' +import { createRelayReadiness } from './relay-readiness.js' + +function database(query: RelayDatabase['query']): RelayDatabase { + return { + query, + queryLocked: query, + transaction: (operation) => operation(database(query)), + close: async () => {} + } +} + +function gate() { + let release!: () => void + const wait = new Promise((resolve) => (release = resolve)) + return { wait, release } +} + +it('shares both dependency probes and caches from completion for concurrent callers', async () => { + const sql = gate() + let now = 1_000 + const query = vi.fn(async () => { + await sql.wait + return [{ ready: 1 }] + }) + const fetchImpl = vi.fn(async () => new Response('{}')) + const observe = vi.fn() + const readiness = createRelayReadiness(database(query), 'https://jwks.example.test', { + fetch: fetchImpl, + now: () => now, + observe + }) + const checks = Array.from({ length: 100 }, () => readiness.check()) + try { + expect({ sql: query.mock.calls.length, jwks: fetchImpl.mock.calls.length }).toEqual({ + sql: 1, + jwks: 1 + }) + expect(observe).not.toHaveBeenCalled() + now = 5_000 + } finally { + sql.release() + } + expect(await Promise.all(checks)).toEqual(Array(100).fill(true)) + expect(observe).toHaveBeenCalledTimes(1) + now = 14_999 + expect(await readiness.check()).toBe(true) + expect(query).toHaveBeenCalledTimes(1) + now = 15_000 + expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual( + Array(100).fill(true) + ) + expect(query).toHaveBeenCalledTimes(2) + expect(fetchImpl).toHaveBeenCalledTimes(2) +}) + +it('shares failures, retains the failure cache, and retries after expiry', async () => { + let healthy = false + let now = 1_000 + const query = vi.fn(async () => { + if (!healthy) throw new Error('offline') + return [{ ready: 1 }] + }) + const fetchImpl = vi.fn(async () => new Response('{}', { status: healthy ? 200 : 503 })) + const observe = vi.fn() + const readiness = createRelayReadiness(database(query), 'https://jwks.example.test', { + fetch: fetchImpl, + now: () => now, + observe + }) + expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual( + Array(100).fill(false) + ) + expect(query).toHaveBeenCalledTimes(1) + expect(fetchImpl).toHaveBeenCalledTimes(1) + expect(observe).toHaveBeenCalledTimes(1) + expect(readiness.degradedDependencies()).toEqual([]) + healthy = true + now = 10_999 + expect(await readiness.check()).toBe(false) + expect(query).toHaveBeenCalledTimes(1) + now = 11_000 + expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual( + Array(100).fill(true) + ) + expect(query).toHaveBeenCalledTimes(2) + expect(fetchImpl).toHaveBeenCalledTimes(2) + expect(observe).toHaveBeenCalledTimes(2) +}) + +it('keeps separate readiness owners independent', async () => { + const query = vi.fn(async () => [{ ready: 1 }]) + const fetchImpl = vi.fn(async () => new Response('{}')) + const first = createRelayReadiness(database(query), 'https://one.example.test', { + fetch: fetchImpl + }) + const second = createRelayReadiness(database(query), 'https://two.example.test', { + fetch: fetchImpl + }) + expect(await Promise.all([first.check(), first.check(), second.check(), second.check()])).toEqual( + [true, true, true, true] + ) + expect(query).toHaveBeenCalledTimes(2) + expect(fetchImpl).toHaveBeenCalledTimes(2) +}) diff --git a/cloud/apps/relay/src/relay-readiness.ts b/cloud/apps/relay/src/relay-readiness.ts index 973a827e1048..72d3cd5c4713 100644 --- a/cloud/apps/relay/src/relay-readiness.ts +++ b/cloud/apps/relay/src/relay-readiness.ts @@ -131,6 +131,7 @@ export function createRelayReadiness( const settleSql = createDependencyGrace('sql', options.sqlGraceMs ?? RELAY_READINESS_SQL_GRACE_MS) let cachedAt = Number.NEGATIVE_INFINITY let cached = false + let pending: Promise | null = null let lastObservedReady: boolean | undefined let degraded: RelayReadinessDependency[] = [] @@ -153,8 +154,7 @@ export function createRelayReadiness( } } - const check = async (): Promise => { - if (now() - cachedAt < cacheMs) return cached + const probe = async (): Promise => { const startedAt = now() const [jwks, sql] = await Promise.all([timed(now, probeJwks), timed(now, probeSql)]) const completedAt = now() @@ -185,5 +185,13 @@ export function createRelayReadiness( return cached } + const check = async (): Promise => { + if (now() - cachedAt < cacheMs) return cached + pending ??= probe().finally(() => { + pending = null + }) + return pending + } + return { check, degradedDependencies: () => [...degraded] } } diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index e9a08398da9f..b90c322d1c24 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -276,7 +276,7 @@ export function createRelayServer( finished = true authenticated(source) observability.recordAuth(false) - socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'first frame timeout') + closeRelayWebSocket(socket, RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'first frame timeout') }, RELAY_PROTOCOL_LIMITS.firstFrameDeadlineMs) socket.once('message', (raw, binary) => { if (finished) return @@ -285,7 +285,11 @@ export function createRelayServer( authenticated(source) if (binary) { observability.recordAuth(false) - socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'first frame must be text') + closeRelayWebSocket( + socket, + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'first frame must be text' + ) return } void callback(raw).catch((error: unknown) => { @@ -356,7 +360,11 @@ export function createRelayServer( webSocket.send( JSON.stringify({ type: 'relay-hello', ok: false, code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL }) ) - webSocket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'invalid relay auth') + closeRelayWebSocket( + webSocket, + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid relay auth' + ) return } if (config.role === 'director') { @@ -376,7 +384,11 @@ export function createRelayServer( code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL }) ) - webSocket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'invalid invite') + closeRelayWebSocket( + webSocket, + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid invite' + ) return } phoneAdmission?.hostData.release() @@ -389,7 +401,7 @@ export function createRelayServer( assignmentEpoch: assignment.assignmentEpoch }) ) - webSocket.close(RELAY_CLOSE_CODE.DRAINING, 'connect to assigned cell') + closeRelayWebSocket(webSocket, RELAY_CLOSE_CODE.DRAINING, 'connect to assigned cell') return } await sessions.acceptClient( @@ -442,7 +454,11 @@ export function createRelayServer( const auth = HostDataAuthSchema.safeParse(firstPayload(raw, 'host-data-auth')) if (!auth.success) { observability.recordAuth(false) - webSocket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'invalid host data auth') + closeRelayWebSocket( + webSocket, + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid host data auth' + ) return } const accepted = await sessions.acceptHostData( @@ -566,8 +582,3 @@ export function createRelayServer( cellIncarnation } } - -export function closeWithDrain(socket: WebSocket, graceMs: number): void { - socket.send(JSON.stringify({ type: 'drain', graceMs, recovery: 'resolve-director' })) - socket.close(RELAY_CLOSE_CODE.DRAINING, 'resolve configured director') -} diff --git a/cloud/apps/relay/src/relay-websocket-close.ts b/cloud/apps/relay/src/relay-websocket-close.ts index d4a7f61a3ee2..4b0517c3e531 100644 --- a/cloud/apps/relay/src/relay-websocket-close.ts +++ b/cloud/apps/relay/src/relay-websocket-close.ts @@ -3,6 +3,14 @@ import type WebSocket from 'ws' const RELAY_WEBSOCKET_FORCE_CLOSE_MS = 1_000 const forceCloseTimers = new WeakMap>() +// Delivering the rejection is best effort, and deliberately so. The close frame carrying the +// code and reason is written before the timer can fire, so a peer reading normally gets its +// rejection ahead of the FIN — that much is asserted in relay-first-frame-close.blackbox.test.ts. +// It is not a delivery guarantee: `ws` writes the frame to the socket, and `terminate()` destroys +// the socket a second later, so under backpressure the frame (and any `relay-moved` message queued +// before it) can still be dropped unsent even though the peer never stopped reading. Bounding the +// close is what keeps a stalled peer from holding admission, and no finite grace makes delivery +// certain. Keep the close write ahead of any new wait added here. export function closeRelayWebSocket(socket: WebSocket, code: number, reason: string): void { if (socket.readyState === socket.CLOSED) return if (!forceCloseTimers.has(socket)) { diff --git a/cloud/apps/relay/src/sqlite-transaction-queue.test.ts b/cloud/apps/relay/src/sqlite-transaction-queue.test.ts new file mode 100644 index 000000000000..99fb311ae25e --- /dev/null +++ b/cloud/apps/relay/src/sqlite-transaction-queue.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { setImmediate } from 'node:timers/promises' +import { expect, it, vi } from 'vitest' +import { openRelayDatabase } from './database.js' + +it('releases queued work and close after a SQLite transaction cannot acquire its lock', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-relay-sqlite-queue-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + // Keep the native handle reachable for cleanup even if a queue regression strands close(). + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openRelayDatabase({ dataDir }) + capture.mockRestore() + const blocker = new DatabaseSync(join(dataDir, 'orca-relay.sqlite')) + try { + await database.query('CREATE TABLE queue_progress (value INTEGER)') + await database.query('INSERT INTO queue_progress VALUES (0)') + blocker.exec('BEGIN IMMEDIATE') + const operation = vi.fn(async () => undefined) + await expect(database.transaction(operation)).rejects.toThrow('database is locked') + let lockFailures = 0 + const blocked = Array.from({ length: 5 }, () => + database.transaction(operation).catch(() => { + lockFailures += 1 + }) + ) + await setImmediate() + expect(lockFailures).toBe(5) + await Promise.all(blocked) + expect(operation).not.toHaveBeenCalled() + blocker.exec('ROLLBACK') + let completed = 0 + const pending = Array.from({ length: 100 }, () => + database.transaction(async (transaction) => { + await transaction.query('UPDATE queue_progress SET value = value + 1') + completed += 1 + }) + ) + for (const request of pending) void request.catch(() => undefined) + await setImmediate() + expect(completed).toBe(100) + await Promise.all(pending) + expect(await database.query('SELECT value FROM queue_progress')).toEqual([{ value: 100 }]) + let closed = false + const closing = database.close().then(() => { + closed = true + }) + await setImmediate() + expect(closed).toBe(true) + await closing + } finally { + capture.mockRestore() + blocker.close() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) + +it('does not roll back a transaction when BEGIN failed before taking ownership', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-relay-sqlite-owner-')) + let connection: DatabaseSync | undefined + const prepare = DatabaseSync.prototype.prepare + const capture = vi + .spyOn(DatabaseSync.prototype, 'prepare') + .mockImplementation(function (this: DatabaseSync, sql) { + connection = this + return prepare.call(this, sql) + }) + const database = await openRelayDatabase({ dataDir }) + capture.mockRestore() + try { + await database.query('CREATE TABLE queue_owner (value INTEGER)') + await database.query('BEGIN IMMEDIATE') + await database.query('INSERT INTO queue_owner VALUES (7)') + await expect(database.transaction(async () => undefined)).rejects.toThrow( + 'cannot start a transaction within a transaction' + ) + expect(connection?.isTransaction).toBe(true) + let rows: unknown + void database.query('SELECT value FROM queue_owner').then((result) => { + rows = result + }) + await setImmediate() + expect(rows).toEqual([{ value: 7 }]) + await database.query('ROLLBACK') + expect(await database.query('SELECT value FROM queue_owner')).toEqual([]) + await database.close() + } finally { + capture.mockRestore() + if (connection?.isOpen) connection.close() + rmSync(dataDir, { recursive: true, force: true }) + } +}) diff --git a/cloud/dev/scripts/relay-load-control-peer.mjs b/cloud/dev/scripts/relay-load-control-peer.mjs index a075d6bf4e70..26d0ca6a69b0 100644 --- a/cloud/dev/scripts/relay-load-control-peer.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.mjs @@ -860,11 +860,12 @@ export class RelayLoadControlPeer { scheduleRefresh(delayMs) { if (this.stopped) return + const socket = this.socket + const reschedule = () => { + if (this.socket === socket) this.scheduleRefresh(this.phase.refreshIntervalMs) + } this.refreshTimer = setTimeout(() => { - void this.refresh().then( - () => this.scheduleRefresh(this.phase.refreshIntervalMs), - () => this.scheduleRefresh(this.phase.refreshIntervalMs) - ) + void this.refresh().then(reschedule, reschedule) }, delayMs) } diff --git a/cloud/dev/scripts/relay-load-control-peer.test.mjs b/cloud/dev/scripts/relay-load-control-peer.test.mjs index 95ebd89e1ef6..08bb3291a11f 100644 --- a/cloud/dev/scripts/relay-load-control-peer.test.mjs +++ b/cloud/dev/scripts/relay-load-control-peer.test.mjs @@ -901,3 +901,105 @@ test('shutdown closes both splice legs and waits for the in-flight splice', asyn assert.equal(observations.at(-1).type, 'shutdown') assert.equal(observations.at(-1).detail.activeSpliceSockets, 0) }) + +function trackRefreshTimers(context) { + const schedule = global.setTimeout + const cancel = global.clearTimeout + const timers = new Map() + context.mock.method(global, 'setTimeout', (callback, milliseconds, ...args) => { + const timer = schedule(() => { + timers.delete(timer) + callback(...args) + }, milliseconds) + timers.set(timer, milliseconds) + return timer + }) + context.mock.method(global, 'clearTimeout', (timer) => { + timers.delete(timer) + cancel(timer) + }) + context.after(() => { + for (const timer of timers.keys()) cancel(timer) + }) + return () => [...timers.values()].filter((milliseconds) => milliseconds >= 180_000).length +} + +function reconnectingPeer(context, observations) { + const peer = new RelayLoadControlPeer( + 0, + peerOptions({ + directorOrigin: undefined, + targetOrigin: 'https://cell.test', + accessToken: 'test-access-token' + }), + (type) => observations.push(type) + ) + peer.phase.refreshOffsetMs = 180_000 + peer.phase.refreshIntervalMs = 200_000 + peer.createSocket = () => { + const socket = fakeHandshakeSocket() + socket.send = (raw) => { + const message = JSON.parse(raw) + if (message.type === 'host-hello') { + queueMicrotask(() => socket.message(validChallenge(peer))) + } else if (message.type === 'host-challenge-ack') { + queueMicrotask(() => + socket.message({ + type: 'host-hello-ack', + generation: 1, + controlResumeSecret: 'test-secret' + }) + ) + } + } + return openOnNextTurn(socket) + } + context.after(() => peer.shutdown()) + return peer +} + +for (const outcome of ['success', 'failure']) { + for (const owner of ['current connection', 'replacement connection', 'shutdown']) { + test(`refresh ${outcome} respects its ${owner} ownership`, async (context) => { + const refreshTimers = trackRefreshTimers(context) + const observations = [] + const peer = reconnectingPeer(context, observations) + const pendingResponse = deferred() + const requestStarted = deferred() + let delayResponse = false + context.mock.method(global, 'fetch', async () => { + if (delayResponse) { + requestStarted.resolve() + return await pendingResponse.promise + } + return response({ relayToken: 'test-relay-token' }) + }) + await peer.connect() + assert.equal(refreshTimers(), 1) + clearTimeout(peer.refreshTimer) + delayResponse = true + peer.scheduleRefresh(0) + await requestStarted.promise + delayResponse = false + if (owner !== 'current connection') { + peer.socket.close(1006) + await new Promise((resolve) => setImmediate(resolve)) + await peer.connect() + assert.equal(refreshTimers(), 1) + } + const shutdown = owner === 'shutdown' ? peer.shutdown() : undefined + if (outcome === 'success') pendingResponse.resolve(response({ relayToken: 'fresh-token' })) + else pendingResponse.reject(new Error('token request failed')) + await new Promise((resolve) => setImmediate(resolve)) + const timersAfterCompletion = refreshTimers() + await (shutdown ?? peer.shutdown()) + + assert.equal(timersAfterCompletion, owner === 'shutdown' ? 0 : 1) + assert.equal(refreshTimers(), 0) + assert.equal( + observations.filter((type) => type === 'refresh').length, + owner === 'current connection' && outcome === 'success' ? 1 : 0 + ) + }) + } +} diff --git a/cloud/package.json b/cloud/package.json index 545f5387bcfc..0dd2a5466999 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -26,7 +26,7 @@ }, "devDependencies": { "@types/node": "^24.10.0", - "tsx": "^4.21.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/pnpm-lock.yaml b/cloud/pnpm-lock.yaml index 4849a4ecea66..6042ee85cd45 100644 --- a/cloud/pnpm-lock.yaml +++ b/cloud/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^24.10.0 version: 24.13.2 tsx: - specifier: ^4.21.0 - version: 4.22.4 + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) apps/push: dependencies: @@ -65,7 +65,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay: dependencies: @@ -114,7 +114,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay-fence-broker: dependencies: @@ -139,7 +139,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) apps/relay-ops: dependencies: @@ -164,7 +164,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) packages/postgres-schema: devDependencies: @@ -176,7 +176,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages/push-contract: dependencies: @@ -192,7 +192,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages/relay-contract: dependencies: @@ -208,7 +208,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) packages: @@ -227,156 +227,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.17': resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} @@ -593,6 +749,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -871,6 +1032,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + tweetnacl@1.0.3: resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} @@ -1015,81 +1181,159 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: hono: 4.13.7 @@ -1195,13 +1439,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))': + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4) + + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15) '@vitest/pretty-format@4.1.11': dependencies: @@ -1284,6 +1536,35 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1542,13 +1823,19 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tweetnacl@1.0.3: {} typescript@5.9.3: {} undici-types@7.18.2: {} - vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4): + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1557,14 +1844,54 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.2 - esbuild: 0.28.1 + esbuild: 0.28.2 fsevents: 2.3.3 tsx: 4.22.4 - vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)): + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.2 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.15 + + vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.2 + transitivePeerDependencies: + - msw + + vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1581,7 +1908,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 diff --git a/config/build-plugins/pdfjs-viewer-assets.ts b/config/build-plugins/pdfjs-viewer-assets.ts index c212194ec71e..a632a7aa2d61 100644 --- a/config/build-plugins/pdfjs-viewer-assets.ts +++ b/config/build-plugins/pdfjs-viewer-assets.ts @@ -76,6 +76,9 @@ export function createPdfjsViewerAssetsPlugin(root = pdfjsRoot()): Plugin { next() return } + if (response.destroyed) { + return + } response.statusCode = 200 response.setHeader('Content-Length', size) response.setHeader( @@ -86,7 +89,18 @@ export function createPdfjsViewerAssetsPlugin(root = pdfjsRoot()): Plugin { response.end() return } - createReadStream(filePath).pipe(response) + const stream = createReadStream(filePath) + const stopReading = (): void => { + stream.destroy() + } + response.once('close', stopReading) + response.once('error', stopReading) + stream.once('close', () => { + response.off('close', stopReading) + response.off('error', stopReading) + }) + stream.once('error', () => response.destroy()) + stream.pipe(response) }) }, writeBundle(options) { diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 7dc017b9c981..c4f58fa521ce 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -16,15 +16,34 @@ type OutputChunk = Rollup.OutputChunk // electron, and smoke-loads daemon-entry under plain Node to prove its module // graph still resolves. -// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime): -// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run -// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them. +// The CLI loads these paths after electron-vite replaces out/main. +export const CLI_MAIN_ENTRY_NAMES = [ + 'agent-hooks/managed-agent-hook-controls', + 'orca-profiles/profile-index-store', + 'codex/managed-home-shell-preflight', + 'claude-accounts/keychain', + ...[ + 'access', + 'active-location', + 'storage-classification', + 'offline-settings', + 'backup-path', + 'database-recovery', + 'domain-reader', + 'recovery-command' + ].map((module) => `persistence/profile-state/profile-state-${module}`), + 'persistence/profile-state/legacy-json/profile-state-export-path', + 'persistence/profile-state/legacy-json/profile-state-recovery', + 'startup/http1-compatibility-marker' +] as const + +// Plain-Node processes and CLI modules cannot load Electron's API. const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', 'computer-sidecar', 'wsl-transcript-fs-process-entry', - 'agent-hooks/managed-agent-hook-controls' + ...CLI_MAIN_ENTRY_NAMES ] as const // Entries executed as worker threads of the main process. Electron's module is @@ -41,7 +60,9 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'session-scanner-worker-entry', 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry', - 'usage-scan-worker-entry' + 'usage-scan-worker-entry', + 'profile-state-backup-worker-entry', + 'profile-state-writer-worker-entry' ] as const export const GUARDED_ENTRY_NAMES = [ diff --git a/config/docker/cli-launch-contract/Dockerfile b/config/docker/cli-launch-contract/Dockerfile index c90cbcd979c7..bc9ba2947f4d 100644 --- a/config/docker/cli-launch-contract/Dockerfile +++ b/config/docker/cli-launch-contract/Dockerfile @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=ubuntu:24.04 +ARG BASE_IMAGE=ubuntu@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 FROM ${BASE_IMAGE} ARG LIBASOUND_PACKAGE=libasound2t64 diff --git a/config/e2e-failure-tracking.json b/config/e2e-failure-tracking.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/config/e2e-failure-tracking.json @@ -0,0 +1 @@ +[] diff --git a/config/electron-builder-pr-linux.config.cjs b/config/electron-builder-pr-linux.config.cjs new file mode 100644 index 000000000000..54056e809965 --- /dev/null +++ b/config/electron-builder-pr-linux.config.cjs @@ -0,0 +1,8 @@ +const release = require('./electron-builder.config.cjs') + +// CI discards these packages after smoke tests; keep release compression unchanged. +module.exports = { + ...release, + deb: { ...release.deb, fpm: [...(release.deb.fpm ?? []), '--deb-compression-level=1'] }, + rpm: { ...release.rpm, fpm: [...(release.rpm.fpm ?? []), '--rpm-compression-level=1'] } +} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 5703faf96894..01527ad8693f 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -188,6 +188,9 @@ module.exports = { // Why: these repo-only inputs are either bundled into out/ or copied via // extraResources. Shipping them in app.asar bloats the desktop bundle. '!src{,/**/*}', + '!out/orcad{,/**/*}', + '!out/orcad-template{,/**/*}', + '!out/.orcad-*{,/**/*}', '!config{,/**/*}', '!docs{,/**/*}', '!mobile{,/**/*}', @@ -207,6 +210,8 @@ module.exports = { // it is gitignored, but exclude it defensively so a stray local capture at // package time never bloats app.asar. '!pr-evidence{,/**/*}', + // Local build logs and rollback copies are never application resources. + '!notes{,/**/*}', // Why: local agent/tooling directories may contain worktree symlink loops; // they are never runtime inputs and must not be traversed by electron-builder. '!{.claude,.grok,.agents,.codex}{,/**/*}', @@ -281,6 +286,9 @@ module.exports = { 'out/main/gemini/**', 'out/main/grok/**', 'out/main/hermes/**', + 'out/main/orca-profiles/profile-index-store.js', + 'out/main/persistence/profile-state/**', + 'out/main/startup/http1-compatibility-marker.js', 'out/main/daemon-entry.js', 'out/main/session-scanner-service-entry.js', 'out/main/wsl-transcript-fs-process-entry.js', diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh index d89439073abd..a6fb31f9aec9 100644 --- a/config/nsis/orca-installer-hooks.nsh +++ b/config/nsis/orca-installer-hooks.nsh @@ -3,6 +3,8 @@ ; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall / ; customUnInstall hook Orca needs lives here. +!include "${__FILEDIR__}\orca-process-check.nsh" + ; --------------------------------------------------------------------------- ; Markdown "Open with Orca" (issue #10138) ; diff --git a/config/nsis/orca-process-check.nsh b/config/nsis/orca-process-check.nsh new file mode 100644 index 000000000000..4acedf5afc8e --- /dev/null +++ b/config/nsis/orca-process-check.nsh @@ -0,0 +1,17 @@ +; Defining the hook suppresses electron-builder's process-info declarations. +!include "getProcessInfo.nsh" +Var pid +Var /GLOBAL IsPowerShellAvailable + +!macro customCheckAppRunning + ; Restricted permits inline commands; test the process query rather than script-file policy. + ; Match upstream FIND/KILL's profile behavior so the probe cannot skip a failing profile. + nsExec::Exec `"$PowerShellPath" -Command "try { Get-CimInstance -ClassName Win32_Process -ErrorAction Stop | Out-Null; exit 0 } catch { exit 1 }"` + Pop $0 + ; Launch errors, timeouts, and failed queries retain upstream's image-name fallback. + StrCpy $IsPowerShellAvailable 1 + ${if} $0 == 0 + StrCpy $IsPowerShellAvailable 0 + ${endIf} + !insertmacro _CHECK_APP_RUNNING +!macroend diff --git a/config/patches/@vscode__windows-process-tree@0.8.0.patch b/config/patches/@vscode__windows-process-tree@0.8.0.patch index 7c930a5fca56..3855e187ec68 100644 --- a/config/patches/@vscode__windows-process-tree@0.8.0.patch +++ b/config/patches/@vscode__windows-process-tree@0.8.0.patch @@ -28,13 +28,14 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..0bb2af7923b6e6f1f0da40cae8067304 "/guard:cf", "/sdl", diff --git a/lib/index.js b/lib/index.js -index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba20906615 100644 +index e586cd6ead522a4ff060f5338201f45e3467d639..0ee62c35558ff40e2298c464fdf6e9485f9d181d 100644 --- a/lib/index.js +++ b/lib/index.js -@@ -7,11 +7,13 @@ Object.defineProperty(exports, "__esModule", { value: true }); +@@ -7,11 +7,14 @@ exports.getAllProcesses = exports.getProcessTree = exports.getProcessCpuUsage = exports.getProcessList = exports.filterProcessList = exports.buildProcessTree = exports.ProcessDataFlag = void 0; const util_1 = require("util"); const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined; ++exports.getProcessCreationTime = native === undefined ? undefined : native.getProcessCreationTime; +exports.supportedProcessDataFlags = native === undefined ? undefined : native.supportedProcessDataFlags; var ProcessDataFlag; (function (ProcessDataFlag) { @@ -45,7 +46,7 @@ index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba })(ProcessDataFlag = exports.ProcessDataFlag || (exports.ProcessDataFlag = {})); // requestInProgress is used for any function that uses CreateToolhelp32Snapshot, as multiple calls // to this cannot be done at the same time. -@@ -66,11 +68,12 @@ function buildProcessTree(rootPid, processList, maxDepth = MAX_FILTER_DEPTH) { +@@ -66,11 +69,12 @@ // • the properties are inlined/splatted // • the 'ppid' field is omitted // • the depth of the tree is limited by `maxDepth` @@ -60,13 +61,14 @@ index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba }); return buildNode(root, maxDepth); diff --git a/lib/index.ts b/lib/index.ts -index f9aa005d9ced9e42885b8a976de5eb5bd61899ee..1b509af0b9065918bcb5cb75f2d7f23821d4a56a 100644 +index 7b53aad05c3682a5c7d814d81a39c3f391ae97e3..284dae185b56241244b4d6bcab9e08f7530b8cf3 100644 --- a/lib/index.ts +++ b/lib/index.ts -@@ -6,12 +6,15 @@ +@@ -6,12 +6,16 @@ import { promisify } from 'util'; const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined; ++export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined = native?.getProcessCreationTime; +/** The flag bits this compiled addon reports; undefined off win32. */ +export const supportedProcessDataFlags: number | undefined = native?.supportedProcessDataFlags; import { IProcessInfo, IProcessTreeNode, IProcessCpuInfo } from '@vscode/windows-process-tree'; @@ -80,7 +82,7 @@ index f9aa005d9ced9e42885b8a976de5eb5bd61899ee..1b509af0b9065918bcb5cb75f2d7f238 } type RequestCallback = (processList: IProcessInfo[]) => void; -@@ -81,11 +84,12 @@ export function buildProcessTree(rootPid: number, processList: IterableQueue(); + } + ++Napi::Value ReadProcessCreationTime(const Napi::CallbackInfo& args) { ++ Napi::Env env(args.Env()); ++ if (args.Length() != 1 || !args[0].IsNumber()) { ++ return env.Undefined(); ++ } ++ const double pid = args[0].As().DoubleValue(); ++ if (!(pid >= 1 && pid <= MAXDWORD) || pid != static_cast(pid)) { ++ return env.Undefined(); ++ } ++ ProcessInfo pinfo{}; ++ pinfo.pid = static_cast(pid); ++ GetProcessCreationTime(pinfo); ++ if (pinfo.creationTimeMs == 0) { ++ return env.Undefined(); ++ } ++ return Napi::Number::New(env, static_cast(pinfo.creationTimeMs)); ++} ++ Napi::Object Init(Napi::Env env, Napi::Object exports) { ++ exports.Set("getProcessCreationTime", Napi::Function::New(env, ReadProcessCreationTime)); exports.Set("getProcessList", Napi::Function::New(env, GetProcessList)); exports.Set("getProcessCpuUsage", Napi::Function::New(env, GetProcessCpuUsage)); + // Lets a caller prove THIS BINARY understands CREATIONTIME. The JS enum is @@ -398,10 +422,10 @@ index c9e3457a759c1acaa2644231a4917d45aed951f8..3f26a354477f062b34bd31fbd17be529 } diff --git a/typings/windows-process-tree.d.ts b/typings/windows-process-tree.d.ts -index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e3791ec1b629 100644 +index 70e242b123e76d43c452007be1ce92a6d224c8bb..b1d0a53c0c18cc16531b394c19bb256bdbaf782f 100644 --- a/typings/windows-process-tree.d.ts +++ b/typings/windows-process-tree.d.ts -@@ -7,9 +7,17 @@ declare module '@vscode/windows-process-tree' { +@@ -7,8 +7,17 @@ export enum ProcessDataFlag { None = 0, Memory = 1, @@ -409,18 +433,18 @@ index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e379 + CommandLine = 2, + CreationTime = 4 } - ++ + /** + * The flag bits the compiled addon actually understands, or undefined off + * win32. `ProcessDataFlag` above is source; this is what the binary reports, + * so it is the only way to tell a patched build from a stale prebuilt. + */ + export const supportedProcessDataFlags: number | undefined; -+ ++ export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined; + export interface IProcessInfo { pid: number; - ppid: number; -@@ -24,6 +32,9 @@ declare module '@vscode/windows-process-tree' { +@@ -24,6 +33,9 @@ * The string returned is at most 512 chars, strings exceeding this length are truncated. */ commandLine?: string; @@ -430,7 +454,7 @@ index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e379 } export interface IProcessCpuInfo extends IProcessInfo { -@@ -35,6 +46,7 @@ declare module '@vscode/windows-process-tree' { +@@ -35,6 +47,7 @@ name: string; memory?: number; commandLine?: string; diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index d36bf36b63f5..40b240f3296c 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -707,7 +707,7 @@ index 98733dc0cd752b554bd94e45904ca341ad141bba..3dd5ad9b3124dbd5ba7f76679b26650d // Stop processing immediately on unexpected error and log this._writeQueue.length = 0; diff --git a/src/win/conpty.cc b/src/win/conpty.cc -index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c6678cf86 100644 +index 7b286d3d64..5239ba4e40 100644 --- a/src/win/conpty.cc +++ b/src/win/conpty.cc @@ -18,6 +18,7 @@ @@ -718,7 +718,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c #include #include #include -@@ -44,12 +45,40 @@ struct pty_baton { +@@ -44,15 +45,37 @@ struct pty_baton { HANDLE hOut; HPCON hpc; @@ -749,18 +749,25 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c }; static std::vector> ptyHandles; -+// Orca: guards the job accessors below, and PtyKill, against the exit watcher -+// thread. It does NOT make the whole table safe -- PtyResize and PtyClear still -+// read it unlocked, as they always have -- but it closes the window this patch -+// opened, where the watcher can close hShell/hJob and free the baton between a -+// lookup and its use. -+// Handle VALUES are recycled aggressively, so an unguarded read could pass the -+// shell-pid check against an unrelated process and terminate the wrong job. ++// Orca: every table access shares the exit watcher's lock; handles can be recycled. +static std::mutex ptyJobMutex; static volatile LONG ptyCounter; - static pty_baton* get_pty_baton(int id) { -@@ -102,8 +131,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) { +-static pty_baton* get_pty_baton(int id) { ++static pty_baton* get_pty_baton_locked(int id) { + auto it = std::find_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) { + return ptyHandle->id == id; + }); +@@ -62,7 +85,7 @@ static pty_baton* get_pty_baton(int id) { + return nullptr; + } + +-static bool remove_pty_baton(int id) { ++static bool remove_pty_baton_locked(int id) { + auto it = std::remove_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) { + return ptyHandle->id == id; + }); +@@ -102,8 +125,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) { // Get process exit code. GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code)); // Clean up handles @@ -782,7 +789,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + // NDEBUG would compile the call away and leak every baton. + baton->shellExited = true; + if (baton->consoleClosed) { -+ const bool removed = remove_pty_baton(baton->id); ++ const bool removed = remove_pty_baton_locked(baton->id); + assert(removed); + (void)removed; + } @@ -794,7 +801,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c auto status = tsfn.BlockingCall(exit_event, callback); // In main thread switch (status) { -@@ -242,6 +294,20 @@ +@@ -242,6 +288,20 @@ HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info, return HRESULT_FROM_WIN32(GetLastError()); } @@ -815,15 +822,40 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) { Napi::Env env(info.Env()); Napi::HandleScope scope(env); -@@ -303,6 +369,7 @@ +@@ -301,8 +361,10 @@ static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) { + // We were able to instantiate a conpty + const int ptyId = InterlockedIncrement(&ptyCounter); marshal.Set("pty", Napi::Number::New(env, ptyId)); - ptyHandles.emplace_back( - std::make_unique(ptyId, hIn, hOut, hpc)); -+ ptyHandles.back()->allowJobBreakaway = !usesCygwinRuntime(shellpath); +- ptyHandles.emplace_back( +- std::make_unique(ptyId, hIn, hOut, hpc)); ++ auto baton = std::make_unique(ptyId, hIn, hOut, hpc); ++ baton->allowJobBreakaway = !usesCygwinRuntime(shellpath); ++ std::lock_guard guard(ptyJobMutex); ++ ptyHandles.emplace_back(std::move(baton)); } else { throw Napi::Error::New(env, "Cannot launch conpty"); } -@@ -409,6 +476,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -350,11 +412,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { + const bool useConptyDll = info[4].As().Value(); + Napi::Function exitCallback = info[5].As(); + +- // Fetch pty handle from ID and start process +- pty_baton* handle = get_pty_baton(id); +- if (!handle) { +- throw Napi::Error::New(env, "Invalid pty handle"); ++ pty_baton* handle; ++ { ++ std::lock_guard guard(ptyJobMutex); ++ handle = get_pty_baton_locked(id); ++ if (!handle || handle->consoleClosed) { ++ throw Napi::Error::New(env, "Invalid pty handle"); ++ } + } ++ // No watcher exists for this baton until SetupExitCallback below; pipe waits stay unlocked. + + // Prepare command line + std::unique_ptr mutableCommandline = std::make_unique(cmdline.length() + 1); +@@ -409,6 +475,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { throw errorWithCode(info, "UpdateProcThreadAttribute failed"); } @@ -839,7 +871,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c PROCESS_INFORMATION piClient{}; fSuccess = !!CreateProcessW( nullptr, -@@ -416,7 +492,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -416,7 +491,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { nullptr, // lpProcessAttributes nullptr, // lpThreadAttributes false, // bInheritHandles VERY IMPORTANT that this is false @@ -851,7 +883,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c envArg, // lpEnvironment mutableCwd.get(), // lpCurrentDirectory &siEx.StartupInfo, // lpStartupInfo -@@ -426,8 +505,48 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -426,8 +504,48 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { throw errorWithCode(info, "Cannot create process"); } @@ -902,25 +934,52 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c if (useConptyDll && fLoadedDll) { PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress( -@@ -440,6 +559,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { +@@ -438,8 +556,12 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) { + } + } - // Update handle - handle->hShell = piClient.hProcess; -+ handle->shellPid = piClient.dwProcessId; -+ handle->hJob = hJob; +- // Update handle +- handle->hShell = piClient.hProcess; ++ { ++ std::lock_guard guard(ptyJobMutex); ++ handle->hShell = piClient.hProcess; ++ handle->shellPid = piClient.dwProcessId; ++ handle->hJob = hJob; ++ } // Close the thread handle to avoid resource leak CloseHandle(piClient.hThread); -@@ -544,27 +665,213 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) { +@@ -472,9 +594,10 @@ static Napi::Value PtyResize(const Napi::CallbackInfo& info) { + SHORT rows = static_cast(info[2].As().Uint32Value()); + const bool useConptyDll = info[3].As().Value(); + +- const pty_baton* handle = get_pty_baton(id); ++ std::lock_guard guard(ptyJobMutex); ++ const pty_baton* handle = get_pty_baton_locked(id); + +- if (handle != nullptr) { ++ if (handle != nullptr && !handle->consoleClosed) { + HANDLE hLibrary = LoadConptyDll(info, useConptyDll); + bool fLoadedDll = hLibrary != nullptr; + if (fLoadedDll) +@@ -513,9 +636,10 @@ static Napi::Value PtyClear(const Napi::CallbackInfo& info) { + return env.Undefined(); + } + +- const pty_baton* handle = get_pty_baton(id); ++ std::lock_guard guard(ptyJobMutex); ++ const pty_baton* handle = get_pty_baton_locked(id); + +- if (handle != nullptr) { ++ if (handle != nullptr && !handle->consoleClosed) { + HANDLE hLibrary = LoadConptyDll(info, useConptyDll); + bool fLoadedDll = hLibrary != nullptr; + if (fLoadedDll) +@@ -544,29 +668,215 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) { int id = info[0].As().Int32Value(); const bool useConptyDll = info[1].As().Value(); - const pty_baton* handle = get_pty_baton(id); -- -- if (handle != nullptr) { -- HANDLE hLibrary = LoadConptyDll(info, useConptyDll); -- bool fLoadedDll = hLibrary != nullptr; -- if (fLoadedDll) + // Orca: resolve the DLL BEFORE touching any baton state, for the same reason + // PtyConnect does it before creating anything. LoadConptyDll throws when + // conpty.dll is missing, and a throw after consoleClosed was set would strand @@ -934,7 +993,18 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + (HMODULE)hLibrary, + useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole"); + } -+ + +- if (handle != nullptr) { +- HANDLE hLibrary = LoadConptyDll(info, useConptyDll); +- bool fLoadedDll = hLibrary != nullptr; +- if (fLoadedDll) +- { +- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress( +- (HMODULE)hLibrary, +- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole"); +- if (pfnClosePseudoConsole) +- { +- pfnClosePseudoConsole(handle->hpc); + // Orca: the baton now outlives the shell, so this runs on a self-exited pty + // too -- that is the whole point. Take what we need under the lock: the + // watcher thread nulls hShell the moment the shell dies, and TerminateProcess @@ -945,7 +1015,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + bool owed = false; + { + std::lock_guard guard(ptyJobMutex); -+ pty_baton* handle = get_pty_baton(id); ++ pty_baton* handle = get_pty_baton_locked(id); + // Why the consoleClosed check: a second kill() would otherwise close the + // same pseudoconsole twice. Upstream relied on the baton being gone. + if (handle != nullptr && !handle->consoleClosed) { @@ -965,9 +1035,9 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + hShellDup = nullptr; + TerminateProcess(handle->hShell, 1); + } -+ } + } + if (handle->shellExited) { -+ const bool removed = remove_pty_baton(id); ++ const bool removed = remove_pty_baton_locked(id); + assert(removed); + (void)removed; + } @@ -979,19 +1049,11 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + // drained, and the watcher must be able to take the lock while it does. + if (owed) { + if (pfnClosePseudoConsole) - { -- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress( -- (HMODULE)hLibrary, -- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole"); -- if (pfnClosePseudoConsole) -- { -- pfnClosePseudoConsole(handle->hpc); -- } -- } ++ { ++ pfnClosePseudoConsole(hpc); + } - if (useConptyDll) { - TerminateProcess(handle->hShell, 1); -+ pfnClosePseudoConsole(hpc); -+ } + if (hShellDup != nullptr) { + TerminateProcess(hShellDup, 1); + CloseHandle(hShellDup); @@ -999,8 +1061,8 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c } return env.Undefined(); -+} -+ + } + +/** + * Orca: confirm a baton really is the pty the caller means. + * @@ -1032,7 +1094,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + // Held across the lookup AND the Win32 call: the watcher thread can otherwise + // close these handles and free the baton in between. + std::lock_guard guard(ptyJobMutex); -+ const pty_baton* handle = get_pty_baton(info[0].As().Int32Value()); ++ const pty_baton* handle = get_pty_baton_locked(info[0].As().Int32Value()); + if (!ownsShell(handle, info[1].As().Uint32Value())) { + return Napi::Boolean::New(env, false); + } @@ -1064,7 +1126,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + // Held across the lookup AND the Win32 call: the watcher thread can otherwise + // close these handles and free the baton in between. + std::lock_guard guard(ptyJobMutex); -+ const pty_baton* handle = get_pty_baton(info[0].As().Int32Value()); ++ const pty_baton* handle = get_pty_baton_locked(info[0].As().Int32Value()); + if (!ownsShell(handle, info[1].As().Uint32Value())) { + return env.Null(); + } @@ -1138,10 +1200,12 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c + } + hHostJob = job; + return Napi::Boolean::New(env, true); - } - ++} ++ /** -@@ -577,6 +884,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) { + * Init + */ +@@ -577,6 +887,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) { exports.Set("resize", Napi::Function::New(env, PtyResize)); exports.Set("clear", Napi::Function::New(env, PtyClear)); exports.Set("kill", Napi::Function::New(env, PtyKill)); diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index a236145dd943..e100d54e17a7 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,623 @@ } }, "gates": [ + { + "id": "terminal-preview.connection-settings-ownership", + "title": "Terminal preview connections survive unrelated settings updates", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-unit", + "surfaces": [ + "dashboard terminal preview connection", + "preview settings updates", + "preview box fit and grid claim" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "remote-runtime"], + "coverageNotes": "Mounted production component and real settings action with cloned IPC replies, inert xterm/input adapters, and simulated metrics. Local/remote-qualified renderer identities covered; no physical provider, native geometry, focus, IME or rendered app claim. Subsequent hidden/offscreen Electron component proof uses real xterm and synthetic snapshots, preserving the rendered terminal across unrelated updates and measuring equivalent fallback scale/fit requests after a font-size change.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx", + "https://github.com/stablyai/orca/pull/23099" + ], + "invariant": "Equal composed theme with unchanged PTY, mode, contrast and font/shaping settings keeps the current preview owner. Relevant changes replace it once, preserve explicit fit/grid negotiation, and reject retired pending completions.", + "oracle": "Ten unrelated settings replies produce one connect and xterm construction, no pre-unmount teardown, then one final cleanup. Changed colors/mode/contrast/font/shaping replace owners; pending retired connections never construct and obsolete grid claims are canceled.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx" + ], + "testFiles": [ + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx", + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx", + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx", + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx", + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx", + "assertions": [ + "keeps one preview connection across ten unrelated settings snapshots", + "ignores an old pending connection after a relevant theme change", + "does not install a terminal after unmounting a pending connection" + ] + }, + { + "file": "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx", + "assertions": [ + "ignores a pending connection retired by a metric change", + "cancels an obsolete metric owner grid claim before the next change" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-geometry.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx", + "result": "passed", + "durationSeconds": 2.23, + "summary": "63 tests /5 suites pass. Lifetime regressions: original 7 fail/7 pass, candidate14 pass; all8 metric/shaping cases pass both original and corrected candidate." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "Focused deterministic suites; local elapsed recorded, p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local checks and independent adverse review only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Original reconnects/reconstructs11 times for10 unrelated updates; candidate1. Seven of14 lifetime cases fail original. Original and corrected candidate both pass all8 geometry preservation cases." + }, + "performanceBudget": { + "required": true, + "evidence": "Ten unrelated settings replies: connect/constructor11 to1, pre-unmount unsubscribe/dispose10 to0. One previous-theme ref and shallow comparison added; CPU/heap overhead unmeasured. No timer/polling/subprocess/subscriber added; broad settings render/composition work remains." + }, + "knownGaps": [ + "No full-dashboard interaction, native focus/input/IME, ligature shaping or physical PTY validation. Hidden Electron component rendering and one font-size transition were checked; fit endpoint was inert.", + "No live SSH/WSL/relay or Linux/Windows run. Provider/wire ownership and folder/git workspace paths unchanged.", + "Selection/buffer/input state now persists across unrelated and live cursor/scroll updates instead of incidental resets. Small comparison/ref overhead unmeasured.", + "No CI soak, heap, main snapshot serialization or frame latency claim." + ], + "promotionCriteria": [ + "Retain count, stale-owner and explicit metric fit/grid oracles; add rendered native and provider/platform evidence plus CI soak." + ], + "demotionRule": "Keep experimental until soak; investigate failures without weakening owner counts, changed-theme behavior, fit/grid claims or late-disposal assertions." + }, + { + "id": "workspace-hooks.execution-host-ownership", + "title": "Hook reads and writes use the stored repository execution owner", + "maturity": "experimental", + "protection": "partial", + "owner": "workspace-runtime", + "layer": "cross-layer-unit", + "surfaces": [ + "desktop worktree hook IPC", + "runtime repository hooks", + "issue-command read and write", + "hosted-review own-store host resolution" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Actual hydration and authoritative desktop owner selection with mocked filesystem, Git, inspection and provider boundaries; synthetic local/SSH/runtime rows. No physical provider or native platform certification.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts" + ], + "invariant": "Hook file operations follow the authoritative repository row in this process Store: canonical SSH selects its target, explicit local and self-addressed runtime rows use registered local files, and missing SSH providers never substitute local I/O.", + "oracle": "Nine desktop/runtime operations never use local I/O for canonical SSH or dial a stale legacy target; legacy SSH/local/runtime controls retain behavior, folder skips remain, missing providers do not gain local fallback.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/hooks/worktree-hook-execution-host.test.ts" + ], + "testFiles": ["src/main/ipc/hooks/worktree-hook-execution-host.test.ts"], + "assertionRefs": [ + { + "file": "src/main/ipc/hooks/worktree-hook-execution-host.test.ts", + "assertions": [ + "routes %s to the stored owner", + "refuses an unreachable canonical SSH write without touching local files" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/hooks/worktree-hook-execution-host.test.ts", + "result": "passed", + "durationSeconds": 0.739, + "summary": "Independent68pass/original37fail31controls. Eleven additional authority/unavailable-provider/encoded-ID/folder controls pass; author102unique permanent tests across four suites." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Focused renderer-unit suites, p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Author and independent local evidence only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Frozen permanent suite68pass; original sources37fail31controls; independently reproduced with eleven extra controls." + }, + "performanceBudget": { + "required": true, + "evidence": "Incorrect local/stale-target routes9to0 for each of four changed identity shapes. This is correctness evidence only, no latency/CPU/heap gain. Existing host resolver reused, no polling, cache or inventory/subprocess fanout added." + }, + "knownGaps": [ + "CI soak pending.", + "Physical SSH/WSL/remote-runtime, native Linux/Windows, mobile and mixed-version integration untested. Local/daemon PTY data, identity and liveness unchanged.", + "Runtime absent-provider issue-command write still returns ok:true without a write; this preexisting convention is not a completion claim.", + "Only own-store rows use the generalized helper; generic runtime dispatch still rejects hosts this process does not execute.", + "No actual filesystem writes, provider operations, native UI or packaged app validation in routing fixtures." + ], + "promotionCriteria": [ + "Retain exact route, no-local-fallback and authority oracles; obtain CI soak and physical host evidence before promotion." + ], + "demotionRule": "Keep experimental until soak; investigate authority/routing failures without replacing exact-target or no-local-I/O assertions." + }, + { + "id": "runtime-files.renderer-document-watch-ownership", + "title": "Filesystem watches stay owned by their renderer document", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "ipc-contract", + "surfaces": [ + "desktop file explorer watchers", + "desktop editor file watchers", + "direct SSH file watchers" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "wsl"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh", "wsl"], + "coverageNotes": "Actual desktop local/WSL subscription and SSH watcher controller logic under mocked filesystem/provider handles on macOS. Document events use an EventEmitter sender. Live remote/native Windows/Linux/WSL and rendered reload reattachment are unproved; PTY/process liveness and wire formats are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/ipc/filesystem-watcher-listener-lifecycle.ts" + ], + "invariant": "A replaced or crashed renderer document retains no file watches or pending ownership and cannot recreate them from an old asynchronous continuation. Shared roots retain live sibling owners; blocked and same-document navigation retain the current document. Shutdown removes registered lifecycle listeners.", + "oracle": "Run repeated reloads/crash, pending local/SSH installation cancellation with late success, aborted-install joiners, reconnect retry snapshots, failed-removal restoration and shutdown/reopen. Require zero obsolete roots/intent/listeners, no stale setup/retry, physical handle disposal and preservation of sibling notifications and same-tick remote handoff.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher-document-lifetime.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-remote-cancellation.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/ipc/filesystem-watcher-document-lifetime.test.ts", + "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts", + "src/main/ipc/filesystem-watcher-remote-cancellation.test.ts", + "src/main/ipc/filesystem-watcher-native-capacity.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/filesystem-watcher-document-lifetime.test.ts", + "assertions": [ + "retains zero roots and listeners across 15 reloads and a renderer crash", + "does not revive a cancelled %s setup for a joiner whose document reloaded", + "does not re-arm an old provider snapshot into the same WebContents after replacement", + "does not restore a replaced sibling document after an awaited %s removal recovery", + "keeps same-document and blocked navigations alive, and removes lifecycle listeners at shutdown" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-watcher-document-lifetime.test.ts src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts src/main/ipc/filesystem-watcher-remote-cancellation.test.ts src/main/ipc/filesystem-watcher-native-capacity.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 1.08, + "summary": "45 tests across four focused suites pass. Broader watcher suite passes 146 plus one existing skipped; isolated publication without sibling #22995 passes 145 plus one existing skipped." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "Focused deterministic IPC-contract suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Seven permanent regressions fail with HEAD source substituted: cancelled joiner resurrection for local and SSH, restoration into replaced local/SSH documents, stale provider retry, shutdown listener retention, and root retention. All 13 document ownership cases pass after the change." + }, + "performanceBudget": { + "required": true, + "evidence": "15 reloads plus crash: retained local/SSH/desired roots 16→0 each; pending setup aborted; sender lifecycle listeners 1→0. Actual ownership modules with synthetic handles; no native process/heap/latency claim. No new timer, polling, process scan or provider fanout; existing root cleanup runs at document end." + }, + "knownGaps": [ + "No native Windows/Linux/WSL or live SSH execution.", + "No rendered Electron reload-reattachment/paint run or CI soak; broader watcher process isolation is a separate gate." + ], + "promotionCriteria": [ + "Retain lifecycle, count and sibling-delivery assertions and add cross-platform and rendered reload evidence before promotion." + ], + "demotionRule": "Keep experimental until soak evidence; investigate lifecycle failures without suppressing assertions or retrying unexplained failures." + }, + { + "id": "ephemeral-vm-recipe.output-tail-parity", + "title": "Recipe output capture preserves UTF-8 tails and releases settled storage", + "maturity": "experimental", + "protection": "partial", + "owner": "ephemeral-vm-runtime-store", + "layer": "shared-node-process-contract", + "surfaces": ["VM recipe stdout and stderr", "recipe cancellation and process errors"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Actual recipe capture with synthetic child streams and real forced GC, plus real macOS shell output and descendant cancellation. Linux and Windows native execution, live VM providers and remote lifecycle journeys remain untested. Local/daemon PTY, SSH ownership, WSL launch, mobile and relay wire behavior are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/01ce5edf1aeec8a4b5cf06f531434be7d7c0cbcd/src/shared/ephemeral-vm-recipe-process.ts" + ], + "invariant": "Decoded stdout and stderr callback contents and order remain unchanged, final tails are the last capped run of raw output bytes with any leading partial UTF-8 sequence dropped, and success or failure releases capture storage without recapturing late output.", + "oracle": "Compare byte-bounded tails against the old per-chunk model and an independent code-point model across compaction, growth, oversized chunks and mid-character splits. Feed split and malformed UTF-8 through actual stream decoding. Require at most 5 MiB moved through the capture for 4 MiB of output with zero encoding, collectible backing buffers after close and error, zero late capture work with preserved callbacks, one decode per stream, unchanged tails on forced cancellation or synchronous kill-close, and every capture limit clamped to a bounded byte count.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/ephemeral-vm-recipe-process.test.ts src/shared/ephemeral-vm-recipe-output-capture.test.ts src/shared/growing-byte-buffer.test.ts src/main/ephemeral-vm-recipe-runner.test.ts" + ], + "testFiles": [ + "src/shared/ephemeral-vm-recipe-process.test.ts", + "src/shared/ephemeral-vm-recipe-output-capture.test.ts", + "src/shared/growing-byte-buffer.test.ts", + "src/main/ephemeral-vm-recipe-runner.test.ts" + ], + "assertionRefs": [ + { + "file": "src/shared/ephemeral-vm-recipe-output-capture.test.ts", + "assertions": [ + "matches the old per-chunk UTF-8 tail for limit %s across varied chunk boundaries", + "retains the same code-point tail for limit %s wherever byte boundaries fall", + "preserves stream decoding, callback boundaries and independent tails", + "moves at most 5 MiB through the capture for 4 MiB of output", + "releases capture storage after %s while preserving late callbacks", + "decodes once per stream when force-kill closes synchronously", + "clamps a $limit capture limit to a bounded byte count" + ] + }, + { + "file": "src/shared/growing-byte-buffer.test.ts", + "assertions": [ + "reads and writes correctly after the head offset has advanced", + "keeps a bounded suffix stable across many small appends without unbounded growth" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/ephemeral-vm-recipe-process.test.ts src/shared/ephemeral-vm-recipe-output-capture.test.ts src/shared/growing-byte-buffer.test.ts src/main/ephemeral-vm-recipe-runner.test.ts", + "result": "passed", + "durationSeconds": 0.48, + "summary": "60 tests across four suites passed, including forced-GC lifetime, synchronous SIGKILL-close and the shared byte buffer's head-offset cases." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "focused Node process and capture contracts with forced GC" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Author and independent local runs pass; no CI or soak history collected." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline moves 943,194,112 bytes for 4 MiB output and fails the permanent 5 MiB budget. An earlier ring prototype retained two backing buffers after error and encoded late output after close; both permanent lifetime cases fail there and pass after cleanup correction. Dropping the head offset from GrowingByteBuffer keeps the byte budget but takes 12.36 ms instead of 0.25 ms." + }, + "performanceBudget": { + "required": true, + "evidence": "4 MiB in 4 KiB chunks, seven paired samples, macOS: the old per-chunk re-encode moves 943,194,112 bytes in 265.78 ms median; raw-byte capture into GrowingByteBuffer moves 4,194,304 appended plus 1,048,576 decoded bytes, encodes nothing, and takes 0.25 ms. End to end through runRecipeCommand is 0.26 ms with no output callback and 0.78 ms with one (the callback decoder is the work setEncoding already did). A 64-byte one-shot is 0.075 to 0.206 microseconds; retained storage reserves spare capacity and can exceed a string. No new polling, timers, subprocesses, wire fields or deadline changes." + }, + "promotionCriteria": [ + "Collect 100 consecutive CI passes or 14 days of soak history.", + "Exercise native Windows and Linux recipe output and cancellation." + ], + "knownGaps": [ + "Synthetic output timings do not establish typical recipe prevalence, end-to-end provisioning gains or UI latency.", + "Retained storage reserves spare capacity and can use more memory than a string; CJK bytes can exceed UTF-16 storage. Tiny output is slower, and an oversized single chunk still copies its capped tail.", + "Malformed UTF-8 now yields a different tail than the old policy: replacement characters no longer inflate the byte count, so a malformed tail keeps more real output. No production override of the cap was found, and odd caps are clamped rather than coerced per chunk.", + "Live VM providers, SSH lifecycle journeys, and native Windows or Linux termination were not run." + ], + "demotionRule": "Keep experimental or demote if output differs, discarded bytes return, settled capture remains reachable, cancellation loses its tail, or the deterministic encoding budget regresses. Never weaken the oracle or increase retries to obtain a pass." + }, + { + "id": "agent-session.trust-preflight-completion", + "title": "Agent launch preflight waits for its trust owner to settle", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session-runtime", + "layer": "cross-layer-unit", + "surfaces": [ + "agent trust IPC", + "remote (SSH) agent workspace trust writer", + "renderer agent preflight", + "Codex continuation launch admission", + "main-process worktree startup trust", + "Codex launch and resume preparation" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh"], + "coverageNotes": "Actual main IPC handler, renderer preflight, session-continuation caller and config mutation queue with fake Electron bridge, config writer, launch and remote owner, plus the actual main-process worktree-startup, Codex quick-launch-prep and Codex resume-prep modules with mocked trust writer, runtime home and hook service. Counts prove admission ordering and the per-call trust-write bound only; no native PTY/CLI, actual config mutation or live provider evidence. Because codexHookService and ensureRealHomeCodexHookState are both mocked and isHostSystemDefaultRealHomeSelected defaults to false, no suite drives either config.toml lane the two launch-prep sites re-enter after the trust write, so nothing here speaks to whether a launch as a whole is bounded. The remote-preset suite calls markRemoteAgentWorkspaceTrusted directly and asserts its artifact shapes plus its unresolvable-home bail-out; nothing exercises markRemoteWorktreeTrusted, the unbounded caller that wraps it on the launch paths, so the SSH side of the bound is unmeasured here.", + "motivatingLinks": ["https://github.com/stablyai/orca/blob/main/src/main/ipc/agent-trust.ts"], + "invariant": "Every local Codex trust write a launch waits on - the five markCodexProjectTrusted call sites - settles before that launch proceeds, or is abandoned at a bounded deadline; rejected, abandoned and SSH-unreachable writes leave the workspace untrusted and never fall back to local trust. Two limits sit outside that claim and are not asserted. First, the deadline bounds the write, not the whole launch: a caller whose next step re-enters the same config.toml lane can still wait on that lane after its own write is abandoned. Second, one Codex trust write a launch waits on is not bounded at all - the remote write reached through markRemoteWorktreeTrusted in runtime/runtime-worktree-agent-startup.ts, which derives preset 'codex' from TUI_AGENT_CONFIG the same way the local path does and is awaited ahead of the agent spawn on every SSH launch path. Only the agentTrust:markTrusted handler's remote write is capped.", + "oracle": "Held local write or actual queue predecessor yields zero continuation launch calls until release, then exactly one; delayed local rejection settles successfully afterward. SSH awaits its remote writer and never invokes local trust. A never-settling local or SSH write resolves the handler at the deadline with a named warning, no leaked timer and no local fallback, and a rejection arriving after the deadline stays handled. An already-complete write admits the launch on microtasks with zero timers. Two concurrent launches for one workspace settle independently. In the main-process worktree-startup, Codex quick-launch-prep and Codex resume-prep modules a never-settling write resolves the caller at the deadline, still yields the launch home or resume outcome, leaks no timer, and writes no other preset artifact; the write stays ordered before the runtime-home and hook-repair steps the spawn waits on. Those three module cases mock codexHookService, so they establish that the write is abandoned at the deadline, not that the launch around it is bounded. No case drives markRemoteWorktreeTrusted, so nothing here bounds the remote Codex write that fronts an SSH launch; the remote-preset suite checks only what markRemoteAgentWorkspaceTrusted writes and when it bails out, never how long it may take.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts tests/e2e/agent-trust-completion.unit.test.ts src/renderer/src/lib/agent-trust-preflight.test.ts src/renderer/src/lib/launch-agent-session-continuation.test.ts src/main/remote-agent-trust-presets.test.ts src/main/startup/codex-launch-trust-write-deadline.test.ts src/main/runtime/runtime-worktree-agent-startup.test.ts" + ], + "testFiles": [ + "tests/e2e/agent-trust-completion.unit.test.ts", + "src/renderer/src/lib/agent-trust-preflight.test.ts", + "src/renderer/src/lib/launch-agent-session-continuation.test.ts", + "src/main/remote-agent-trust-presets.test.ts", + "src/main/startup/codex-launch-trust-write-deadline.test.ts", + "src/main/runtime/runtime-worktree-agent-startup.test.ts" + ], + "assertionRefs": [ + { + "file": "tests/e2e/agent-trust-completion.unit.test.ts", + "assertions": [ + "holds the actual session-continuation launch call until the local write settles", + "keeps renderer preflight pending behind the actual config mutation queue", + "settles best-effort only after a queued local trust failure", + "bounds a never-settling local write and continues untrusted", + "admits an already-complete write without waiting for any timer", + "absorbs a local rejection that arrives after the deadline", + "bounds a never-settling SSH trust write without a local fallback", + "settles two concurrent launches for the same workspace independently", + "keeps remote failures best-effort without a local fallback" + ] + }, + { + "file": "src/main/startup/codex-launch-trust-write-deadline.test.ts", + "assertions": [ + "bounds a never-settling write in quick-launch prep and still returns the launch home", + "keeps the quick-launch trust write ahead of the home the spawn waits on", + "bounds a never-settling write in resume prep and still resolves the resume home", + "keeps the resume trust write ahead of the hook repair that precedes the spawn", + "contains a rejected resume trust write without trusting the workspace elsewhere" + ] + }, + { + "file": "src/main/runtime/runtime-worktree-agent-startup.test.ts", + "assertions": [ + "waits for the Codex trust write before resolving", + "bounds a never-settling Codex trust write instead of holding the launch open" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-27", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts tests/e2e/agent-trust-completion.unit.test.ts src/renderer/src/lib/agent-trust-preflight.test.ts src/renderer/src/lib/launch-agent-session-continuation.test.ts src/main/remote-agent-trust-presets.test.ts src/main/startup/codex-launch-trust-write-deadline.test.ts src/main/runtime/runtime-worktree-agent-startup.test.ts", + "result": "passed", + "durationSeconds": 0.413, + "summary": "53 tests across six suites pass: 15 trust-completion (four deadline cases), 8 renderer preflight, 7 continuation-launch, 6 remote-preset, 5 Codex launch/resume prep deadline and 12 worktree-startup tests. Six of those are the bound and ordering cases this candidate adds. No native launch, PTY or actual config write." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Focused mocked preflight/continuation/remote trust suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Author and independent local mocked evidence; CI soak and native platform evidence pending." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Trust-completion suite, re-run at the earlier head. Pre-await implementation: 5 fail/10 pass - the three launch-admission orderings plus the never-settling local and SSH cases, the SSH one only by exhausting the test timeout. Unbounded-await implementation: 3 fail/12 pass - never-settling local write, post-deadline rejection and never-settling SSH write all hang to the test timeout. The already-complete-write case passes unbounded too, so it is a timer control, not a red. Launch-prep and worktree-startup suites, this head: with the three call sites reverted to a bare await, 3 fail/14 pass - the quick-launch, resume and worktree-startup never-settling cases each hang to the 30s vitest timeout; restored, 17 pass. Whole gate at this head: 53 pass across six suites." + }, + "performanceBudget": { + "required": true, + "evidence": "No new subprocess, polling, retry or trust operation. Each of the five markCodexProjectTrusted call sites a launch awaits is capped by one per-call deadline timer, cleared on settlement, so none of those five can hold its caller indefinitely; the five share that one timer shape and no site gained a second write or a retry. The cap covers the local Codex writes only - the remote write through markRemoteWorktreeTrusted is still awaited with no timer, so an SSH launch can still be held open by a trust write. It also bounds the trust write, not the launch. At startup/codex-launch-preparation.ts the first awaited step after the write is ensureRealHomeHooksIfSelected, not codexHookService.prepareRuntimeHomeForLaunch: when the host system-default real home is selected it calls ensureRealHomeCodexHookState, which acquires runExclusivelyForCodexTrustConfig directly on ~/.codex/config.toml, the same lane the abandoned write still holds a queue slot for. When the real home is not selected that call returns without awaiting the lane and runtimeHome.prepareForCodexLaunchAsync is synchronous on this non-WSL path, so the first re-entry is prepareRuntimeHomeForLaunch through runExclusivelyForRuntimeAndSystemTrustConfig. At startup/codex-session-resume-launch.ts the first awaited step is the hook branch: ensureRealHomeCodexHookState when the resume home is ~/.codex, otherwise installForLaunchPrep or refreshRuntimeUserHooksForLaunchPrep through runExclusivelyForRuntimeAndSystemTrustConfig, whose inner acquire is that same ~/.codex/config.toml. Either way a wedged lane still stalls those two launches one step after the write is abandoned, and the earliest such step is the real-home ensure, not the hook-service call the gate used to name. Only markLocalWorktreeTrusted in runtime/runtime-worktree-agent-startup.ts has nothing after its Codex branch, so only it has an end-to-end bounded launch path. Reliability fix, no performance gain claim." + }, + "knownGaps": [ + "CI soak pending.", + "No live Codex CLI, PTY, trust prompt, native Electron app or actual trust-config mutation was exercised.", + "Windows/Linux/WSL/daemon/remote-runtime behavior was not executed; remote owner is mocked. No routing/wire/PTY ownership change.", + "Abandoning a write at the deadline does not cancel it; a legitimate slow write (WSL grant sessions run to 30s) can land after the launch already started, restoring the pre-fix ordering for that case only.", + "The deadline value is a reasoned cap over observed lane holders, not a measured p95.", + "All five awaited local Codex trust writes (markCodexProjectTrusted) are now bounded by the same helper: the agentTrust:markTrusted IPC handler, ipc/worktree-remote.ts, runtime/runtime-worktree-agent-startup.ts, startup/codex-launch-preparation.ts and startup/codex-session-resume-launch.ts. Four have a test in these suites; the ipc/worktree-remote.ts site is bounded but still has no test here. A sixth awaited Codex trust write - the remote one through markRemoteWorktreeTrusted - is not bounded at all; see the remote gap below.", + "Bounding the trust write does not bound the launch at two of the three new sites, and on both the first unbounded re-entry is earlier than the hook-service call. In startup/codex-launch-preparation.ts the next awaited call after the abandoned write is ensureRealHomeHooksIfSelected, which is conditional: only when the target is not WSL and runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv) is true does it call ensureRealHomeCodexHookState, which chains behind that module's own serial ensure promise and then acquires runExclusivelyForCodexTrustConfig on ~/.codex/config.toml (it skips the lane only when hooks are enabled, the lane is already unavailable and its retry backoff has not elapsed). When the real home is not selected that call returns without awaiting the lane, runtimeHome.prepareForCodexLaunchAsync is synchronous on the non-WSL path, and the first re-entry is codexHookService.prepareRuntimeHomeForLaunch. In startup/codex-session-resume-launch.ts the next awaited call is the hook branch: ensureRealHomeCodexHookState when the resume home is ~/.codex, otherwise installForLaunchPrep or refreshRuntimeUserHooksForLaunchPrep. All of those reach the same unbounded per-config.toml serial queue (runExclusivelyForCodexTrustConfig, FIFO with no depth cap and no timeout) on ~/.codex/config.toml; the runExclusivelyForRuntimeAndSystemTrustConfig wrapper takes the runtime home's config.toml first, and markCodexProjectTrusted itself takes both. A wedged lane therefore still stalls a quick-launch or a resume one step after the write is abandoned, and the abandoned write keeps its queue slot ahead of that step. The new launch-prep tests mock codexHookService and ensureRealHomeCodexHookState wholesale and default the real-home selection to false, so no test in these suites can catch this residual stall. Only markLocalWorktreeTrusted in runtime/runtime-worktree-agent-startup.ts, which has nothing after its Codex branch, is bounded end to end.", + "The cap remains per call site, not global. Two launches racing the same wedged lane each wait their own deadline, so the worst case a user can see is one deadline per launch, not one shared cap.", + "The remote (SSH) trust write reached through markRemoteWorktreeTrusted in runtime/runtime-worktree-agent-startup.ts is still awaited unbounded; only the agentTrust:markTrusted handler's remote write is capped. It is a Codex trust write a launch waits on, not merely an adjacent one: it reads TUI_AGENT_CONFIG[agent].preflightTrust, which is 'codex' for the codex agent, and markRemoteAgentWorkspaceTrusted then branches into markRemoteCodexProjectTrusted. Its only production caller is markRemoteWorkspaceTrustedForAgent in runtime/orca-runtime-activate-managed-worktree.ts, reached with a connectionId from seven launch entry points. On orca-runtime-create-agent-session.ts, orca-runtime-get-agent-session-execution-namespace.ts, orca-runtime-terminal-create-deduplication.ts, runtime-remote-managed-worktree-create.ts and runtime-folder-worktree-create.ts (wired in by orca-runtime-create-managed-worktree.ts) the await sits directly before the createTerminal that spawns the agent; on orca-runtime-resolve-mobile-session-terminal-command.ts and orca-runtime-resolve-worktree-removal-target.ts it instead gates the launch command those functions return for their caller to spawn, so the launch still waits on it. It chains a session.resolveHome multiplexer round trip, an SFTP realpath, a read, a createDir and a write, none with a timeout, over a link that may be half-open - so an SSH Codex launch can still hang with nothing happening, the same failure this branch bounds locally. Deliberately left out of scope here, and deliberately left out of the invariant." + ], + "promotionCriteria": [ + "Retain pending-write/queue/launch-boundary red-green evidence and remote owner assertions; obtain actual platform/session evidence and CI soak before promotion." + ], + "demotionRule": "Remain experimental until repeated independent/CI evidence; investigate ordering or unbounded-wait regressions without weakening completion or deadline assertions." + }, + { + "id": "settings.general-section-lifetime", + "title": "General settings search preserves matching section state", + "maturity": "experimental", + "protection": "partial", + "owner": "renderer-settings", + "layer": "renderer-unit", + "surfaces": [ + "General settings search", + "app version and remote update display", + "autosave settings draft" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "remote-runtime", + "ssh", + "wsl" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Actual GeneralPane, update/version and remote-status components with mocked store/API; actual autosave form. Windows runtime on/off simulated. No physical updater, paired host, settings write or native input.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/renderer/src/components/settings/GeneralPane.tsx" + ], + "invariant": "A matching General settings section retains its owner and state as preceding search results disappear; an actually removed section releases its owner and reloads when shown again.", + "oracle": "Across 14 matching searches the Updates section keeps the same DOM node and the version-read and remote-refresh counts do not grow, instead of six each. Store-published remote update state still reaches the retained section. Explicit refresh and genuine hide/reopen still read; drafts yield to externally changed saved settings; a removed owner cannot publish its late version.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx" + ], + "testFiles": [ + "src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx", + "assertions": [ + "keeps showing store-published remote update state without a remount", + "keeps explicit remote refresh and true hide/reopen reads", + "preserves an autosave draft until external settings change or the section hides", + "does not publish a version result from a genuinely unmounted section" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/GeneralPane.section-lifetime.test.tsx", + "result": "passed", + "durationSeconds": 1.89, + "summary": "7 tests/1 suite pass after the shared SettingsSectionStack adoption. Read-count oracles restated as node identity plus non-growing counts; original main fails both retained-section cases, the draft, the release-picker reveal and the late-version publish." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Focused renderer-unit suites, p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Author and independent local evidence only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Candidate permanent6pass versus original4fail2controls. Additional independent pending-version/error-context/store-refresh3pass versus original2fail1control." + }, + "performanceBudget": { + "required": true, + "evidence": "Actual version and remote-refresh admissions 6 to 1 across 14 matching queries, which also stops one paired-host update probe per keystroke. Section map now lives in the shared SettingsSectionStack; no added poll, cache, listener or subprocess. No packaged CPU/frame/heap claim." + }, + "knownGaps": [ + "CI soak pending.", + "Physical updater/install, local/daemon PTYs and mobile are unaffected; no execution-host routing, process authority or wire change.", + "Native Linux/Windows, SSH/WSL and paired remote-runtime behavior not exercised; synthetic runtime flags cover renderer branches only.", + "Matching search no longer incidentally refreshes version/remote-status/CLI discovery; explicit checks and real hide/reopen remain. The installed version cannot change in-process and remote update entries are store-published, so neither goes stale without a signal.", + "Retained drafts, release reveal and pending/error context last until a real hide or their own transition; programmatic-query draft test does not reproduce ordinary mouse-search blur/commit.", + "Hidden Electron actual-form proof is separate; no packaged latency, heap or native focus evidence." + ], + "promotionCriteria": [ + "Retain count, draft, owner cleanup and refresh controls; collect CI soak and relevant full-app/provider evidence before promotion." + ], + "demotionRule": "Keep experimental until soak; investigate state or cleanup regressions without weakening owner, read-count or draft assertions." + }, + { + "id": "settings.accounts-section-lifetime", + "title": "Accounts search preserves matching account section owners", + "maturity": "experimental", + "protection": "partial", + "owner": "renderer-settings", + "layer": "renderer-unit", + "surfaces": [ + "Accounts settings search", + "Grok and Cursor account status", + "Codex pending sign-in link" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "remote-runtime", + "ssh", + "wsl" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Actual AccountsPane, Grok, Cursor and Codex login components; synthetic APIs/store with Windows account support enabled/disabled. No real account, keychain or provider action.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/renderer/src/components/settings/AccountsPane.tsx" + ], + "invariant": "Matching sections retain their owners as earlier search results disappear; genuine removal releases the Codex subscription and reopens fresh status reads.", + "oracle": "Across nine matching Grok, ten matching Cursor and eleven matching Codex queries each matched section keeps the same DOM node and its status-read, watcher and subscription counts do not grow, against four to six reads each before. Explicit refresh, store-published usage updates, pushed login URLs and genuine removal all still reach the retained section.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx" + ], + "testFiles": [ + "src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx", + "assertions": [ + "preserves explicit Grok refresh and real hide/reopen status reads", + "keeps the login subscription until Codex genuinely hides", + "keeps Cursor refresh driven by usage updates and genuine reopen" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/settings/AccountsPane.section-lifetime.test.tsx", + "result": "passed", + "durationSeconds": 1.84, + "summary": "10 tests/1 suite pass after the shared SettingsSectionStack adoption. Read-count oracles restated as node identity plus non-growing counts; original main fails the three retained-section cases and the retained Codex subscription, and passes the explicit-refresh, usage-driven and genuine-removal controls." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Focused renderer-unit suites, p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Author and independent local evidence only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Candidate ten pass; current-main original seven fail and three controls pass." + }, + "performanceBudget": { + "required": true, + "evidence": "Actual renderer status-read/subscription admissions counted; the section map now lives in the shared SettingsSectionStack and no cache, poll, listener or process is added. No CPU, latency or heap claim. Matching sections retain local state and lose only the incidental search-driven refresh that duplicated an existing subscription." + }, + "knownGaps": [ + "CI soak pending.", + "No native Linux/Windows or live SSH/WSL/paired provider behavior; platform flags are simulated.", + "Hidden Electron Grok proof uses the original audit baseline and synthetic status; current-main Cursor addition has unit evidence only.", + "Matching search no longer incidentally refreshes Grok/Cursor status or pending Codex URLs. Every one of those surfaces is store-subscribed or push-subscribed, so the retained section still tracks usage updates, pushed login URLs, explicit refresh and true hide/reopen; there is no bounded automatic freshness deadline and none of them relied on one before.", + "Child drafts, errors, copied feedback and timers remain until their own transition or real removal." + ], + "promotionCriteria": [ + "Retain read/subscription counts and genuine-removal controls; collect CI soak and relevant full-app provider evidence." + ], + "demotionRule": "Keep experimental until soak; investigate owner and refresh regressions without weakening assertions or adding retries." + }, { "id": "agent-session.journal-streaming-replay", "title": "Journal replay bounds obsolete revision memory without changing recovery", @@ -79,8 +696,83 @@ "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." + "promotionCriteria": ["Retain red/green heap and value assertions and complete CI soak."], + "demotionRule": "Keep experimental until cross-platform and soak evidence; investigate failures without weakening content or memory assertions." + }, + { + "id": "relay-performance.ai-vault-ready-ownership", + "title": "AI Vault readiness releases canceled request owners", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-runtime", + "layer": "relay-service-client-contract", + "surfaces": ["AI Vault sidecar startup", "SSH title lookup cancellation"], + "platforms": ["macos", "linux", "windows"], + "providers": ["ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["ssh"], + "coverageNotes": "Production relay client with synthetic child events and real forced GC. Native remote process execution and live SSH remain untested. Local/daemon PTY, WSL shell selection and mobile terminal stream are unaffected.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/23024"], + "invariant": "Canceled unsent calls release request payloads before sidecar readiness, disposal releases all pending readiness owners, and live calls still start exactly once through cancellation and startup failures.", + "oracle": "Hold ready and cancel 1,000 title calls; require zero retained request arrays after GC and one child. Dispose before ready and require uncanceled payload collection and zero timers. Race ready with abort and spawn failure with a second lane; require only live calls delivered and successful results. Preserve existing restart and cancel-ack behavior.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/relay/ai-vault-service-ready-retention.test.ts src/relay/ai-vault-service-client.test.ts src/relay/ai-vault-service-restart-policy.test.ts src/relay/ai-vault-service-spawn.test.ts src/relay/ai-vault-handler.test.ts src/shared/promise-settlement-waiters.test.ts" + ], + "testFiles": [ + "src/relay/ai-vault-service-ready-retention.test.ts", + "src/relay/ai-vault-service-client.test.ts", + "src/relay/ai-vault-service-restart-policy.test.ts", + "src/relay/ai-vault-service-spawn.test.ts", + "src/relay/ai-vault-handler.test.ts", + "src/shared/promise-settlement-waiters.test.ts" + ], + "assertionRefs": [ + { + "file": "src/relay/ai-vault-service-ready-retention.test.ts", + "assertions": [ + "releases canceled request payloads while readiness remains pending", + "releases uncanceled readiness payloads when the client is disposed", + "does not send a call canceled in the turn that readiness arrives", + "retries an unsent lane after spawn fails while the other lane starts a child" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/relay/ai-vault-service-ready-retention.test.ts src/relay/ai-vault-service-client.test.ts src/relay/ai-vault-service-restart-policy.test.ts src/relay/ai-vault-service-spawn.test.ts src/relay/ai-vault-handler.test.ts src/shared/promise-settlement-waiters.test.ts", + "result": "passed", + "durationSeconds": 0.616, + "summary": "38 tests across six suites passed, including real forced-GC retention and startup/cancel races." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "focused Node contract tests with forced GC" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Local runs passed; CI/soak history not collected." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline retains all 1,000 canceled arrays and the disposed uncanceled payload; both permanent regressions fail there and pass after the fix. Independent spawn-failure race failed on the initial prototype, passed on baseline and passes after correction." + }, + "performanceBudget": { + "required": true, + "evidence": "Synthetic cold-start churn retains 1,000 to zero canceled arrays; incremental heap 34,771,464 to 492,504 bytes. One shared startup reaction, no new polling, timers, process starts, timeout changes or wire fields." + }, + "promotionCriteria": [ + "Collect 100 consecutive CI passes or 14 days of soak history.", + "Exercise real SSH sidecar cancellation and restart on Linux and Windows." + ], + "knownGaps": [ + "Synthetic churn does not measure typical user frequency or UI latency.", + "Physical sidecar/native Windows/Linux and live SSH paths were not exercised." + ], + "demotionRule": "Keep experimental or demote if canceled payloads survive GC, disposal retains startup owners, or live lanes stall/duplicate after startup failure." }, { "id": "runtime.connection-owned-host-status", @@ -8925,6 +9617,77 @@ ], "demotionRule": "Demote if discovery and launch can resolve different authorities, a scoped Floating probe overwrites the active-project inventory, or local context selection adds recurring scans or retained in-flight entries." }, + { + "id": "agent-session.opencode2-hook-cleanup", + "title": "OpenCode 2 setup releases acquired hook owners after failure", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-integrations", + "layer": "main-unit", + "surfaces": ["OpenCode 2 generated plugin setup and unload"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Actual generated wrapper with synthetic factories, prompt registrations and event iterators. Supporting generated-module tests cover both OpenCode variants; no native provider execution.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/opencode2/status-plugin-setup-source.ts" + ], + "invariant": "After a hook factory is acquired, prompt registration failure or a throwing/rejecting prompt disposer cannot skip hook disposal. Unload waits for in-flight event delivery before disposing the hook factory and remains fail-open.", + "oracle": "Fault-injected setup and unload each call hook disposal once. The ordered control completes delivery and subscription before hook cleanup. Normal unload disposes both owners once.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/opencode2/status-plugin-setup-lifetime.test.ts" + ], + "testFiles": ["src/main/opencode2/status-plugin-setup-lifetime.test.ts"], + "assertionRefs": [ + { + "file": "src/main/opencode2/status-plugin-setup-lifetime.test.ts", + "assertions": [ + "disposes the factory after prompt registration %s", + "disposes the factory after prompt cleanup %s", + "waits for in-flight delivery before factory cleanup after a prompt cleanup failure" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/opencode2/status-plugin-setup-lifetime.test.ts", + "result": "passed", + "durationSeconds": 0.125, + "summary": "10 permanent cases pass; baseline seven fail and three controls pass. Fresh-main run including existing setup/lineage suites: 59 pass." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Synthetic wrapper unit suite; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local deterministic validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline seven failures and three controls; candidate ten passed. Hook disposal after prompt registration/cleanup failure changes from zero to one." + }, + "performanceBudget": { + "required": true, + "evidence": "No new polling, scans, subprocesses or successful-startup awaits. Failure paths now await the existing hook disposer; teardown retains event-delivery ordering. No latency or heap improvement claimed." + }, + "knownGaps": [ + "Native Linux/Windows, SSH/WSL and remote-runtime plugin execution not run.", + "Local/daemon PTY and mobile/relay transport behavior is unaffected.", + "Pending non-abortable registration, event delivery or disposal promises can still delay cleanup; no timeout or cancellation redesign.", + "Malformed form normalization remains a separate defect.", + "CI soak pending." + ], + "promotionCriteria": [ + "Retain cleanup-count and delivery-order oracles; add provider/platform execution and CI soak." + ], + "demotionRule": "Keep experimental until soak; investigate failures without weakening cleanup or delivery-order assertions." + }, { "id": "agent-session.provider-ownership", "title": "Provider sessions are resumed once per workspace ownership claim", @@ -9937,6 +10700,83 @@ ], "demotionRule": "Cannot promote without deterministic oracle and runtime history." }, + { + "id": "worktree-listing.execution-host-ownership", + "title": "Worktree listings keep canonical SSH ownership and mutation fencing", + "maturity": "experimental", + "protection": "partial", + "owner": "worktree-listing", + "layer": "main-unit", + "surfaces": [ + "detected worktree listing", + "known worktree catalog", + "worktree list and listAll" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Actual handlers, hydration, ownership, scan cache and provider authority with mocked providers/Git/store/IPC. Independent controls use real lineage pruning and an in-memory map. No physical SSH or native Git.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts" + ], + "invariant": "Canonical SSH listings use the owning provider and cannot register remote roots locally or reuse local cache authority. A listing overtaken by a mutation or root revision cannot prune newer lineage.", + "oracle": "Both SSH owner spellings reach five read forms without local Git/root registration; disconnection and authority rotation invalidate detected answers. Both catalog channels suppress side effects after mutation/root revision. Two direct canonical scans require two provider listings instead of one cached listing.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktrees-canonical-ssh-listing.test.ts" + ], + "testFiles": ["src/main/ipc/worktrees-canonical-ssh-listing.test.ts"], + "assertionRefs": [ + { + "file": "src/main/ipc/worktrees-canonical-ssh-listing.test.ts", + "assertions": [ + "keeps %s on the direct SSH boundary", + "reports disconnection without replaying an authoritative local-cache answer", + "preserves lineage when %s is overtaken by a %s" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktrees-canonical-ssh-listing.test.ts", + "result": "passed", + "durationSeconds": 0.884, + "summary": "26 permanent cases pass on isolated current main. Separate independent run40pass;8current-main related suites61pass. Author64scoped pass on shared checkout. Original permanent13fail/13controls. Initial candidate lineage regression repaired and revalidated." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Focused mocked-provider unit suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local author/independent/current-main validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Original permanent13fail13controls; repaired26pass. Initial candidate lost actual lineage in2independent races; repaired40pass includes these controls." + }, + "performanceBudget": { + "required": true, + "evidence": "No added polling or concurrency; listAll remains bounded at8. Canonical SSH roots avoid local cache/registration. Trade-off:2connected direct scans use2provider listings instead of1cached result. No CPU/latency/heap improvement claim." + }, + "knownGaps": [ + "CI soak pending.", + "Local and daemon PTY, terminal output and mobile wire are unaffected.", + "Native Linux/Windows, live SSH/WSL and paired runtime-host integrations not executed. Generic runtime rows retain refusal.", + "Catalog list/listAll may still return a superseded plain-array snapshot; this gate prevents stale lineage side effects, not all response staleness.", + "Canonical forgetRemovedForExecutionHost remains outside scope; no new destructive authorization.", + "Filesystem classifier and registry defects are separate repairs; this gate only prevents listing-induced local-root admission.", + "Canonical SSH bypasses the improper5second local cache, increasing provider calls. No replacement cache or physical cancellation claim." + ], + "promotionCriteria": [ + "Retain exact-owner, no-local-I/O, cache-admission and overtaken-lineage controls; collect CI soak and real provider coverage." + ], + "demotionRule": "Keep experimental until soak; investigate host/lineage regressions without weakening ownership assertions or adding blind retries." + }, { "id": "xterm-addon.boundary-containment", "title": "xterm addon failures stay pane-scoped and input survives", @@ -17477,6 +18317,86 @@ ], "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." }, + { + "id": "runtime-events.renderer-subscription-ownership", + "title": "Runtime events stay owned by their renderer subscription", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "ipc-contract", + "surfaces": [ + "renderer runtime events", + "remote runtime reconnect replay", + "renderer Linear refresh" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Actual renderer manager, bridge, store actions, adapter and preload dispatcher with synthetic IPC boundaries on macOS. No rendered/native lifecycle, live peer or real provider network claim.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/renderer/src/hooks/runtime-client-events-sync.ts" + ], + "invariant": "Only the current subscription attempt can cause renderer event work or reconnect recovery. Current early frames and other hosts survive; canceled late setup handles dispose.", + "oracle": "Hold setup, clean up the bridge, deliver100 events and a replay, and require zero provider read dispatches/cache publications/recovery/re-subscriptions. Require initial/live delivery, same-ID replacement, rekey, stop during synchronous setup, sibling host survival and late disposal.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/runtime-client-events-sync-ownership.test.ts src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts src/renderer/src/hooks/runtime-client-events-sync.test.ts src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/preload/runtime-environment-subscriptions.test.ts" + ], + "testFiles": [ + "src/renderer/src/hooks/runtime-client-events-sync-ownership.test.ts", + "src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts", + "src/renderer/src/hooks/runtime-client-events-sync.test.ts", + "src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts", + "src/renderer/src/runtime/runtime-client-events.test.ts", + "src/preload/runtime-environment-subscriptions.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts", + "assertions": [ + "does no Linear read dispatch or cache publication after cleanup while setup is pending", + "does no replay recovery or resubscription after cleanup", + "accepts a fresh bridge early frame while rejecting the previous bridge frame" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/runtime-client-events-sync-ownership.test.ts src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge-ownership.test.ts src/renderer/src/hooks/runtime-client-events-sync.test.ts src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/preload/runtime-environment-subscriptions.test.ts", + "result": "passed", + "durationSeconds": 2.12, + "summary": "41 tests /6 suites pass;13 new regressions have12 failures /1 pass against original production source." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "Focused deterministic renderer/preload suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "12/13 new tests fail original source; candidate41 tests pass.100 stale dispatches/publications become0 each." + }, + "performanceBudget": { + "required": true, + "evidence": "No new timers, scans or IPC. Per-event O(1) owner guard adds paired median0.026ms/10k pending events and0.062ms/10k settled events. Each benchmark delivers2.2million events with no per-event desired/key reads. Synthetic timing, no rendered latency claim." + }, + "knownGaps": [ + "No native Windows/Linux/WSL, live SSH/remote or rendered app lifecycle test.", + "Already-admitted asynchronous handlers are not canceled; physical listener cleanup still waits for setup settlement.", + "No CI soak or mounted render/network/heap measurement." + ], + "promotionCriteria": [ + "Retain actual composed dispatch/publication and late-disposal oracles; add CI soak and cross-platform lifecycle evidence before promotion." + ], + "demotionRule": "Keep experimental until soak; investigate failures without suppressing ownership assertions or unexplained retries." + }, { "id": "runtime-files.watcher-process-isolation", "title": "Runtime and SSH relay watcher faults stay process-isolated without disrupting host services", @@ -20275,6 +21195,236 @@ "Default config paths are shared with GNOME on a disposable hosted CI runner; nested mode refuses non-GitHub-Actions execution." ], "demotionRule": "Keep experimental on unexplained failures; retain exact bytes and participation checks without retries, skips, or longer deadlines." + }, + { + "id": "relay.control-activation-ownership", + "title": "Closed queued controls leave persistence for the live retry", + "maturity": "experimental", + "protection": "partial", + "owner": "relay", + "layer": "registry-unit-and-local-websocket", + "surfaces": ["relay host control activation"], + "platforms": ["macos", "linux", "windows"], + "providers": ["relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["relay"], + "coverageNotes": "Actual registry queue with controlled store latency plus local WebSocket proof and connection-capacity suites on macOS. No PostgreSQL or live remote host run. PTY, daemon, SSH process state, WSL execution and folder/git workspace behavior are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/cloud/apps/relay/src/host-session-registry.ts" + ], + "invariant": "A closed queued transport starts no durable activation; an already-started activation retains cleanup, and a live successor becomes the indexed control without concurrent same-host activation.", + "oracle": "Queue a closed control before a live retry behind stalled persistence. With controlled 4s store calls, require two total activations, one release for the already-started closed control, and live readiness at 4s. Existing tests retain cleanup when closure happens during I/O.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/host-session-registry.test.ts ../../../cloud/apps/relay/src/host-session-client-accept.test.ts ../../../cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts ../../../cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts --silent=false" + ], + "testFiles": [ + "cloud/apps/relay/src/host-session-registry.test.ts", + "cloud/apps/relay/src/host-session-client-accept.test.ts", + "cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts", + "cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts" + ], + "assertionRefs": [ + { + "file": "cloud/apps/relay/src/host-session-registry.test.ts", + "assertions": [ + "skips a closed queued control so its live retry avoids abandoned database work", + "does not publish a control that closes during activation", + "does not rebind a control that closes during activation", + "fails a control waiting behind a stalled activation without breaking serialization" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/host-session-registry.test.ts ../../../cloud/apps/relay/src/host-session-client-accept.test.ts ../../../cloud/apps/relay/src/relay-host-proof-failure.blackbox.test.ts ../../../cloud/apps/relay/src/relay-connection-hard-cap.blackbox.test.ts --silent=false", + "result": "passed", + "durationSeconds": 0.407, + "summary": "88 tests across four files passed; relay typecheck passed separately." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused unit and local socket suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline regression failed: three activation calls, two releases, live ready at 8s. Guarded registry passed with two calls, one release, live ready at 4s under controlled store latency." + }, + "performanceBudget": { + "required": true, + "evidence": "Deletes abandoned persistence before it starts. No new timer, polling, queue, provider call or wire change; existing FIFO and post-I/O cleanup remain." + }, + "knownGaps": [ + "No real PostgreSQL contention benchmark or live remote host/physical mobile run.", + "Windows/Linux execution and CI soak are not recorded; queue cleanup after a never-settling predecessor remains unchanged." + ], + "promotionCriteria": [ + "Retain red/green count and latency assertions; collect cross-platform and CI soak evidence." + ], + "demotionRule": "Keep experimental until soak evidence; investigate lifecycle failures without retries or weakening count, identity or cleanup assertions." + }, + { + "id": "relay.assignment-headroom-scope", + "title": "Single-cell admission preserves capacity with bounded headroom reads", + "maturity": "experimental", + "protection": "partial", + "owner": "relay", + "layer": "sqlite-store-integration", + "surfaces": ["relay assignment admission", "relay migration target capacity"], + "platforms": ["macos", "linux", "windows"], + "providers": ["relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["relay"], + "coverageNotes": "Actual assignment store and SQLite tests on macOS. No real PostgreSQL or live remote host/physical phone run. Local/daemon PTY, SSH execution ownership, WSL, folder/git workspace behavior and transport framing are unaffected.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/23035"], + "invariant": "A single-cell capacity check reads only that cell while retaining snapshot freshness, incarnation, outstanding reservation and missing-limit policy; new placement retains the full fleet inventory.", + "oracle": "With 32 capped cells, an existing active host stays on its pinned cell and reads one headroom row; new placement reads all 32. Missing, expired or prior-incarnation snapshots, capacity exhaustion and outstanding debt still refuse the active pinned host; an uncapped cell remains admissible.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/assignment-headroom-scope.test.ts ../../../cloud/apps/relay/src/assignment-connection-headroom.test.ts ../../../cloud/apps/relay/src/assignment-store.test.ts ../../../cloud/apps/relay/src/assignment-store-lock-order.test.ts ../../../cloud/apps/relay/src/cell-inventory-lock-contention.test.ts ../../../cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts ../../../cloud/apps/relay/src/regional-rehome-target-selection.test.ts --silent=false" + ], + "testFiles": [ + "cloud/apps/relay/src/assignment-headroom-scope.test.ts", + "cloud/apps/relay/src/assignment-connection-headroom.test.ts", + "cloud/apps/relay/src/assignment-store.test.ts", + "cloud/apps/relay/src/assignment-store-lock-order.test.ts", + "cloud/apps/relay/src/cell-inventory-lock-contention.test.ts", + "cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts", + "cloud/apps/relay/src/regional-rehome-target-selection.test.ts" + ], + "assertionRefs": [ + { + "file": "cloud/apps/relay/src/assignment-headroom-scope.test.ts", + "assertions": [ + "reads one cell for a sticky assignment even when the capped fleet grows", + "still considers the full fleet for a new placement", + "rejects the pinned active host with %s", + "preserves admission for cells without a connection limit", + "counts outstanding reservations at the admission boundary" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-25", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir cloud/apps/relay exec vitest run ../../../cloud/apps/relay/src/assignment-headroom-scope.test.ts ../../../cloud/apps/relay/src/assignment-connection-headroom.test.ts ../../../cloud/apps/relay/src/assignment-store.test.ts ../../../cloud/apps/relay/src/assignment-store-lock-order.test.ts ../../../cloud/apps/relay/src/cell-inventory-lock-contention.test.ts ../../../cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts ../../../cloud/apps/relay/src/regional-rehome-target-selection.test.ts --silent=false", + "result": "passed", + "durationSeconds": 0.513, + "summary": "189 tests across seven files passed; independent root rerun also passed189 tests. Relay typecheck passed separately." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused SQLite store/capacity/lock-order suites; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline two count regressions failed: sticky headroom 32 rows instead of 1 and uncapped target 31 unrelated rows instead of 0. Narrowed query passes all eight regression cases." + }, + "performanceBudget": { + "required": true, + "evidence": "Actual sticky path result rows 32 to 1; no new timer, polling, queue, cache or provider call. Supplemental SQLite query benchmark with 32 cells and 400 active reservations each measured median per-query 0.285ms to 0.022ms; not a production PostgreSQL latency claim." + }, + "knownGaps": [ + "Real PostgreSQL query plan and concurrent admission not rerun for the scoped variant.", + "Windows/Linux execution, live remote host/mobile flows and CI soak are not recorded." + ], + "promotionCriteria": [ + "Retain red/green row budget and semantic admission cases; collect PostgreSQL, cross-platform and CI soak evidence." + ], + "demotionRule": "Keep experimental until soak evidence; investigate admission/ownership regressions without weakening row or capacity assertions." + }, + { + "id": "terminal-history.tombstone-rescan-ownership", + "title": "Shell-history cleanup reuses one directory listing while preserving deletion ownership", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-runtime", + "layer": "main-filesystem-contract", + "surfaces": ["shell-history tombstone cleanup", "native and WSL history deletion"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "wsl"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "wsl"], + "coverageNotes": "Actual macOS temp-filesystem deletion and fake WSL cleanup ownership. Live Windows, Linux and WSL execution untested; SSH and daemon replay unaffected because every path here is host-local.", + "motivatingLinks": ["https://github.com/stablyai/orca/issues/23030"], + "invariant": "Only renamed tombstones are recursively removed, active plus retry admissions stay at 64, retries retain their delays and exhaustion behavior, a root displaced at the cap is admitted from another root's freed slot, and no tombstone is handed to removal twice per listing.", + "oracle": "Drain 1,024 real directory entries in controlled batches using at most 8 directory listings and 64 active removals. Exercise late arrivals discovered after the listing, both roots draining under one shared cap, a failing listing mid-drain, delayed failure retries, exhausted attempt budgets, and cancellation of an in-flight listing.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/terminal-history-tombstone-scan-cost.test.ts src/main/terminal-history-async-delete.test.ts src/main/terminal-history-tombstone-retry.test.ts src/main/terminal-history-gc-fs-call-count.test.ts src/main/terminal-history-gc.test.ts src/main/terminal-history.test.ts" + ], + "testFiles": [ + "src/main/terminal-history-tombstone-scan-cost.test.ts", + "src/main/terminal-history-async-delete.test.ts", + "src/main/terminal-history-tombstone-retry.test.ts", + "src/main/terminal-history-gc-fs-call-count.test.ts", + "src/main/terminal-history-gc.test.ts", + "src/main/terminal-history.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/terminal-history-tombstone-scan-cost.test.ts", + "assertions": [ + "drains 1024 tombstones without re-reading the directory per completion", + "picks up a tombstone created after the queued names were read", + "drains both roots within the shared admission cap without per-completion reads", + "keeps draining after a directory read fails mid-drain", + "drops an in-flight refill during fixture cleanup", + "preserves delayed failure retries while successful removals replenish the queue", + "does not retry a tombstone past its attempt budget when another root frees slots" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-26", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/terminal-history-tombstone-scan-cost.test.ts src/main/terminal-history-async-delete.test.ts src/main/terminal-history-tombstone-retry.test.ts src/main/terminal-history-gc-fs-call-count.test.ts src/main/terminal-history-gc.test.ts src/main/terminal-history.test.ts", + "result": "passed", + "durationSeconds": 1.95, + "summary": "86 tests across six suites passed, repeated three times with no flakes." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "focused temp-filesystem history tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Local author and independent runs pass; CI/soak history not collected." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Pre-change main lists the tombstone directory 1,026 times for 1,024 tombstones and fails the bounded-listing oracle. Reusing one listing reduces it to 6 while still removing every entry." + }, + "performanceBudget": { + "required": true, + "evidence": "Five real-filesystem samples per version, 1,024 tombstone directories each holding a meta.json, macOS arm64 / Node 24, identical harness. Median directory listings 1,026 to 6, names enumerated 494,348 to 1,077, blocking main-thread time in the rescan path 820 to 7 ms (the listings themselves 266 to 2.7 ms, and now off-thread), total drain 886 to 75 ms. The remaining ~70 ms is the recursive rm itself and is unchanged. Average concurrent removals 62 of 64, restored from the 46 an event-loop-deferred refill left idle. No polling, no new timers, and no work deferred past process exit." + }, + "promotionCriteria": [ + "Collect 100 consecutive CI passes or 14 days of soak history.", + "Run native Windows and WSL history cleanup on real filesystems." + ], + "knownGaps": [ + "No rendered UI latency measurement or physical Windows/Linux/WSL run.", + "One listing is retained per root while it drains, about 160 KB per 1,024 tombstones, shrinking as names are consumed.", + "A small backlog still costs roughly one listing per completion batch, of a directory that is nearly empty by then.", + "A tombstone that exhausts its retries stays on disk until the next startup, unchanged from before." + ], + "demotionRule": "Keep experimental or demote if the reused listing strands a displaced root, crosses the admission cap, rearms an exhausted retry through another root, hands one tombstone to removal twice, or touches a recreated live history path." } ] } diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs index e25a7bc222e1..c5f27a664228 100644 --- a/config/scripts/audit-localization-coverage.mjs +++ b/config/scripts/audit-localization-coverage.mjs @@ -7,9 +7,9 @@ import process from 'node:process' import ts from 'typescript-api' const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) -// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-fixtures.ts` here, not under `__tests__/`. +// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-test-rig.ts` / `*-fixtures.ts` here, not under `__tests__/`. const TEST_SUPPORT_FILE_PATTERN = - /[.-](?:test-harness|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/ + /[.-](?:test-harness|test-rig|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/ const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate']) const USER_VISIBLE_JSX_ATTRIBUTES = new Set([ diff --git a/config/scripts/audit-localization-coverage.test.mjs b/config/scripts/audit-localization-coverage.test.mjs index 477054f2e8fa..6460dc9f36d3 100644 --- a/config/scripts/audit-localization-coverage.test.mjs +++ b/config/scripts/audit-localization-coverage.test.mjs @@ -50,6 +50,7 @@ describe('localization coverage file skipping', () => { it('skips test-only modules that sit beside their spec', () => { expect(skipped('src/renderer/src/components/browser-pane/stream-test-harness.ts')).toBe(true) + expect(skipped('src/renderer/src/runtime/browser-tab-creation-test-rig.ts')).toBe(true) expect(skipped('src/renderer/src/hooks/ipc-events-test-fixtures.ts')).toBe(true) expect(skipped('src/renderer/src/lib/session-test-state.ts')).toBe(true) expect(skipped('src/renderer/src/store/slices/routing-fixture.ts')).toBe(true) diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 51178e9bed5f..1bfad89b6e73 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises' +import { readRouteSnapshot } from './mobile-web-app-route-snapshot.mjs' import { realpathSync } from 'node:fs' import { basename, extname, join, resolve } from 'node:path' import { createRequire } from 'node:module' @@ -466,7 +467,10 @@ const isScriptOutput = (path) => path.endsWith('.js') * wrap every route, and their imports are part of the page as surely as the route module's. */ export async function mobileWebAppRouteClosure(routeModule) { - return await mobileWebAppModuleClosure(['app/_layout', 'app/h/_layout', routeModule]) + return ( + readRouteSnapshot(routeModule) ?? + (await mobileWebAppModuleClosure(['app/_layout', 'app/h/_layout', routeModule])) + ) } /** diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index cd56aa98c0ab..baa18b6b39d2 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -1,8 +1,13 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { + withScratch, + readAppBundle, + readWrittenBundle, + copyWrittenBundle +} from './mobile-web-app-bundle-test-fixture.mjs' import { MOBILE_WEB_APP_NATIVE_PARITY_STYLE, MOBILE_WEB_APP_ROOT_RESET, @@ -65,15 +70,6 @@ function allScriptSource({ script, chunks }) { return [script, ...chunks.map((chunk) => chunk.bytes)].map((bytes) => bytes.toString('utf8')) } -async function withScratch(run) { - const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-test-')) - try { - return await run(scratch) - } finally { - await rm(scratch, { recursive: true, force: true }) - } -} - describe('the CRLF pin', () => { it('exempts the same extensions in .gitattributes as the CRLF scan skips', async () => { const attributes = await readFile(join(projectDir, '.gitattributes'), 'utf8') @@ -91,8 +87,36 @@ describe('the CRLF pin', () => { }) describeBundling('the app bundle', () => { + it('isolates read-only fixture consumers from mutations in another assertion', async () => { + const first = await readAppBundle() + const original = first.script[0] + first.script[0] ^= 255 + first.chunks.length = 0 + first.routeKeys.length = 0 + const next = await readAppBundle() + expect(next.script[0]).toBe(original) + expect(next.chunks.length).toBeGreaterThan(0) + expect(next.routeKeys.length).toBeGreaterThan(0) + const written = await readWrittenBundle() + written.manifest.assets.length = 0 + expect((await readWrittenBundle()).manifest.assets.length).toBeGreaterThan(0) + const originalByte = written.files[0].bytes[0] + written.files[0].bytes[0] ^= 255 + expect((await readWrittenBundle()).files[0].bytes[0]).toBe(originalByte) + await withScratch(async (scratch) => { + const firstDir = join(scratch, 'first') + await copyWrittenBundle(firstDir) + await writeFile(join(firstDir, 'manifest.json'), 'corrupted') + const secondDir = join(scratch, 'second') + const second = await copyWrittenBundle(secondDir) + for (const { file, bytes } of second.files) { + expect((await readFile(join(secondDir, file))).equals(bytes), file).toBe(true) + } + }) + }, 120_000) + it('resolves react-native to react-native-web and leaves no require.context', async () => { - const sources = allScriptSource(await bundleMobileWebApp()) + const sources = allScriptSource(await readAppBundle()) for (const source of sources) { expect(source).not.toContain('require.context') } @@ -101,7 +125,7 @@ describeBundling('the app bundle', () => { }, 120_000) it('cuts the routes into chunks the entry does not load', async () => { - const { script, chunks, entryStaticBytes } = await bundleMobileWebApp() + const { script, chunks, entryStaticBytes } = await readAppBundle() expect(chunks.length).toBeGreaterThan(1) // The entry's own bytes plus the chunks it imports statically, which is what the browser // parses before any route paints. Every route chunk is outside it. @@ -112,7 +136,7 @@ describeBundling('the app bundle', () => { }, 120_000) it('names the chunk each route lands in', async () => { - const { chunks, routeChunks, routeKeys } = await bundleMobileWebApp() + const { chunks, routeChunks, routeKeys } = await readAppBundle() expect(Object.keys(routeChunks).sort()).toEqual([...routeKeys].sort()) const emitted = new Set(chunks.map((chunk) => chunk.name)) for (const [key, name] of Object.entries(routeChunks)) { @@ -202,7 +226,7 @@ describeBundling('the app bundle', () => { ) it('bundles every route module', async () => { - const { routeKeys } = await bundleMobileWebApp() + const { routeKeys } = await readAppBundle() expect(routeKeys).toEqual(await collectMobileWebAppRouteKeys(appDir)) }, 120_000) @@ -274,7 +298,7 @@ describeBundling('the app bundle', () => { }, 240_000) it("names an output the same way the manifest's own asset hash does", async () => { - const { script, chunks } = await bundleMobileWebApp() + const { script, chunks } = await readAppBundle() // The name is embedded in the importer, so it cannot be recomputed later; this is what says // the name inside the bytes and the manifest's sha256 of those bytes are the same string. expect(hashedAsset(script, 'js').path).toBe(`assets/${sha256Hex(script)}.js`) @@ -330,7 +354,7 @@ describeBundling('the app bundle', () => { // once per call. The file explorer calls triggerSelection on every row tap, and C1.9 already // traced a swallowed long press on the worktree list to that stray click. `haptics.web.ts` is // what keeps the whole shim out of the bundle, so this reads the bytes rather than the import. - for (const source of allScriptSource(await bundleMobileWebApp())) { + for (const source of allScriptSource(await readAppBundle())) { // The shim's own fingerprint, not `navigator.vibrate`: react-native-web's Vibration export // calls that too, and it touches no DOM until something invokes it. expect(source).not.toContain('ariaHidden') @@ -340,7 +364,7 @@ describeBundling('the app bundle', () => { }, 120_000) it("ships react-native-web's hairline at one device pixel, whichever of its builds resolves", async () => { - const sources = allScriptSource(await bundleMobileWebApp()) + const sources = allScriptSource(await readAppBundle()) // Minified, so the assignment reads `.hairlineWidth=`; RNW's own value is the literal 1. const assignments = sources.flatMap( (source) => source.match(/\.hairlineWidth=[^;]{0,120}/g) ?? [] @@ -354,7 +378,7 @@ describeBundling('the app bundle', () => { it('embeds no absolute path from this checkout', async () => { // Every chunk, not only the entry: the route manifest names each route by absolute path, and // the chunk that import resolves to is where such a path would survive. - for (const source of allScriptSource(await bundleMobileWebApp())) { + for (const source of allScriptSource(await readAppBundle())) { expect(source).not.toContain(projectDir) } }, 120_000) @@ -370,76 +394,56 @@ describeBundling('the app bundle', () => { }, 120_000) it('loads the entry as a module, so its route imports resolve', async () => { - await withScratch(async (scratch) => { - const outDir = join(scratch, 'module-tag') - const { manifest } = await buildMobileWebAppBundle({ outDir }) - const html = await readFile(join(outDir, 'index.html'), 'utf8') - // import() in a classic script is a syntax error, so the tag and the format are one fact. - expect(html).toContain('