diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 456fa8f2831..322481ae8b3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -63,9 +63,10 @@ CI enforces this for `src/preload/` and `src/shared/`. Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular: +- if you are an outside contributor, link the issue your PR addresses - open with an ELI5 of the change (plain language paragraph; the PR title is the one-liner) - explain what changed and why, and stay focused on a single topic when possible -- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual change, say `No visual change` and why +- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual or interaction change, write `N/A` and briefly explain why - include high-quality tests when behavior changes or bug fixes warrant them - include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk - mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 053fcdbe0fb..4163150f47e 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -2,6 +2,14 @@ name: Install Node dependencies description: Installs the Node toolchain and repository dependencies for CI jobs, with optional Electron archive caching. inputs: + cache-pnpm-store: + description: Restore or save the pnpm download store; verification and native caches are independent. + required: false + default: 'true' + cache-pnpm-store-lookup-only: + description: Auto uses measured hosted Node 24 root installs; true forces lookup, false retains archive restoration. + required: false + default: auto cache-pnpm-verification: description: Restore pnpm's policy-checked lockfile verification record. required: false @@ -28,9 +36,12 @@ inputs: default: 'false' outputs: + pnpm-store-cache-hit: + description: Whether the requested download store matched an existing cache. + value: ${{ steps.pnpm-store-lookup.outputs.cache-hit || steps.pnpm-store-restore.outputs.cache-hit || steps.requested-node.outputs.cache-hit || steps.default-node.outputs.cache-hit }} verification-cache-hit: description: Whether pnpm's verification record was restored. - value: ${{ steps.verification-cache-restore.outputs.cache-hit }} + value: ${{ steps.verification-cache.outputs.cache-hit }} verification-cache-path: description: The small pnpm-owned verification record, without registry metadata. value: ${{ steps.verification-cache.outputs.path }} @@ -56,6 +67,30 @@ outputs: runs: using: composite steps: + - name: Resolve pnpm store mode + id: pnpm-store-mode + if: >- + github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && + (inputs.cache-pnpm-store-lookup-only == 'true' || + (inputs.cache-pnpm-store-lookup-only == 'auto' && + inputs.cache-dependency-path == 'pnpm-lock.yaml' && + runner.environment == 'github-hosted' && job.container.id == '' && + (runner.os == 'Linux' || runner.os == 'macOS' || runner.os == 'Windows') && + (runner.arch == 'X64' || runner.arch == 'ARM64') && + (inputs.node-version == '' || inputs.node-version == '24'))) + shell: bash + env: + LOOKUP_REQUEST: ${{ inputs.cache-pnpm-store-lookup-only }} + run: | + lookup_only=true + case "$LOOKUP_REQUEST" in + [aA][uU][tT][oO]) + # Hosted runners have Node for this manifest-only check before toolchain setup. + lookup_only="$(node -p 'const p = require("./package.json"); p.engines?.node === "24" && typeof p.packageManager === "string" && p.packageManager.split("+")[0] === "pnpm@12.8.1"')" + ;; + esac + printf 'lookup-only=%s\n' "$lookup_only" >> "$GITHUB_OUTPUT" + # setup-node needs pnpm on PATH to locate and restore its store. - name: Setup pnpm uses: pnpm/setup@v2 @@ -69,7 +104,7 @@ runs: uses: actions/setup-node@v6 with: node-version-file: package.json - cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }} + cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} package-manager-cache: false @@ -79,7 +114,7 @@ runs: uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }} + cache: ${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} package-manager-cache: false @@ -87,51 +122,58 @@ runs: - name: Resolve pnpm download store id: pnpm-store if: >- - github.event_name == 'pull_request' && - (runner.os != 'Windows' || runner.arch != 'X64' || !contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) + github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && + !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && + (runner.os != 'Windows' || + !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && + !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml')) || + (github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && + steps.pnpm-store-mode.outputs.lookup-only == 'true') shell: bash env: LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }} + STORE_LOOKUP_ONLY: ${{ steps.pnpm-store-mode.outputs.lookup-only == 'true' }} 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" + if [ "$STORE_LOOKUP_ONLY" = 'true' ]; then + printf 'ORCA_PNPM_STORE_CACHE_PATH=%s\n' "$cache_path" >> "$GITHUB_ENV" + fi # Match setup-node's key and path so existing default-branch stores remain reusable. - # Hosted Windows x64 mixed installs cost less than restoring their root/mobile store. + # Direct downloads beat store restoration for the measured Linux, macOS and Windows installs. - name: Restore pnpm download store without saving + id: pnpm-store-restore if: >- - github.event_name == 'pull_request' && - (runner.os != 'Windows' || runner.arch != 'X64' || !contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) + github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && + !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && + (runner.os != 'Windows' || + !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && + !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml')) 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: >- - inputs.cache-pnpm-verification == 'true' && - (runner.os == 'Linux' || - (runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64')) || - (runner.os == 'macOS' && runner.arch == 'X64')) - 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" + # Producers can refresh access and publish misses without downloading existing archives. + - name: Keep pnpm download store without restoring + id: pnpm-store-lookup + if: steps.pnpm-store-mode.outputs.lookup-only == 'true' + uses: actions/cache@v5 + with: + # Twice-nested composite cleanup loses internal step outputs. + path: ${{ env.ORCA_PNPM_STORE_CACHE_PATH }} + key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }} + lookup-only: true - name: Restore pnpm verification record - id: verification-cache-restore - if: steps.verification-cache.outputs.key != '' - continue-on-error: true - uses: actions/cache/restore@v5 + id: verification-cache + uses: ./.github/actions/restore-pnpm-verification with: - path: ${{ steps.verification-cache.outputs.path }} - key: ${{ steps.verification-cache.outputs.key }} + enabled: ${{ inputs.cache-pnpm-verification }} - name: Validate native runtime shell: bash @@ -169,7 +211,7 @@ runs: # 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' + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache.outputs.cache-hit != 'true' continue-on-error: true uses: actions/cache/save@v5 with: diff --git a/.github/actions/prepare-git-compatibility/action.yml b/.github/actions/prepare-git-compatibility/action.yml index 058d31a064c..96aa4ef9e15 100644 --- a/.github/actions/prepare-git-compatibility/action.yml +++ b/.github/actions/prepare-git-compatibility/action.yml @@ -10,7 +10,7 @@ runs: 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 + key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5-submodule # Finish the CPU-heavy build before any timed compatibility lanes start. - name: Build the baseline Git binary @@ -18,7 +18,9 @@ runs: 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 + if [ -x "$source/git" ] && [ -x "$source/git-submodule" ] \ + && [ -f "$source/git-sh-setup" ] && [ -f "$source/git-sh-i18n" ] \ + && [ -f "$source/git-parse-remote" ] && [ -x "$source/git-sh-i18n--envsubst" ]; then exit 0 fi curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" @@ -27,6 +29,7 @@ runs: 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 + NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease \ + git git-submodule git-sh-setup git-sh-i18n git-parse-remote git-sh-i18n--envsubst # Object files are no longer needed after linking the cached binary. find "$source" -name '*.o' -delete diff --git a/.github/actions/prepare-headless-compiler/action.yml b/.github/actions/prepare-headless-compiler/action.yml new file mode 100644 index 00000000000..9e71e6df1b0 --- /dev/null +++ b/.github/actions/prepare-headless-compiler/action.yml @@ -0,0 +1,45 @@ +name: Prepare headless detector compiler +description: Reuse the policy-checked compiler from main; callers install normally on a miss. +inputs: + seed: + description: Pack an already installed compiler instead of activating a cached compiler. + default: 'false' +outputs: + available: + description: Whether the cached compiler was validated and activated. + value: ${{ steps.activate.outputs.available }} +runs: + using: composite + steps: + - id: identity + shell: bash + env: + COMPILER_POLICY_HASH: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', 'config/patches/**', '.github/actions/install-node-dependencies/**', '.github/actions/restore-pnpm-verification/**', 'config/scripts/headless-detector-compiler-cache.mjs', '.github/actions/prepare-headless-compiler/action.yml') }} + run: node config/scripts/headless-detector-compiler-cache.mjs identity + - id: cache + uses: actions/cache/restore@v5 + continue-on-error: true + with: + path: ${{ steps.identity.outputs.path }} + key: ${{ steps.identity.outputs.key }} + - id: activate + if: inputs.seed != 'true' && steps.cache.outputs.cache-hit == 'true' + shell: bash + env: + COMPILER_CACHE_KEY: ${{ steps.identity.outputs.key }} + COMPILER_CACHE_PATH: ${{ steps.identity.outputs.path }} + run: node config/scripts/headless-detector-compiler-cache.mjs activate + - name: Pack installed compiler + if: inputs.seed == 'true' && steps.cache.outputs.cache-hit != 'true' + shell: bash + env: + COMPILER_CACHE_KEY: ${{ steps.identity.outputs.key }} + COMPILER_CACHE_PATH: ${{ steps.identity.outputs.path }} + run: node config/scripts/headless-detector-compiler-cache.mjs pack + - name: Save compiler only from main + if: inputs.seed == 'true' && steps.cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/cache/save@v5 + continue-on-error: true + with: + path: ${{ steps.identity.outputs.path }} + key: ${{ steps.identity.outputs.key }} diff --git a/.github/actions/restore-pnpm-verification/action.yml b/.github/actions/restore-pnpm-verification/action.yml new file mode 100644 index 00000000000..fcd428f7c80 --- /dev/null +++ b/.github/actions/restore-pnpm-verification/action.yml @@ -0,0 +1,58 @@ +name: Restore pnpm verification +description: Restore the pnpm-owned lockfile verdict without installing dependencies. + +inputs: + enabled: + description: Restore a verification record on supported runners. + default: 'true' + container-toolchain: + description: Use the manifest's pnpm version and default Linux cache path without installing host pnpm. + default: 'false' + +outputs: + path: + description: The pnpm-owned verification record. + value: ${{ steps.verification-cache.outputs.path }} + key: + description: Exact OS, architecture, pnpm version and policy key. + value: ${{ steps.verification-cache.outputs.key }} + cache-hit: + description: Whether the exact record was restored. + value: ${{ steps.verification-cache-restore.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Resolve pnpm verification cache + id: verification-cache + if: >- + inputs.enabled == 'true' && + (runner.os == 'Linux' || + (runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64')) || + (runner.os == 'macOS' && runner.arch == 'X64')) + shell: bash + env: + POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }} + CONTAINER_TOOLCHAIN: ${{ inputs.container-toolchain }} + run: | + if [ "$CONTAINER_TOOLCHAIN" = true ]; then + test "$RUNNER_OS" = Linux + # Cache versions include paths, so match the native Linux producer. + cache_path="$(node -p "require('node:path').join(process.env.XDG_CACHE_HOME || require('node:path').join(require('node:os').homedir(), '.cache'), 'pnpm')")" + pnpm_version="$(node -p "require('./package.json').packageManager.match(/^pnpm@([^+]+)/)[1]")" + mkdir -p "$cache_path" + else + cache_path="$(pnpm cache path)" + pnpm_version="$(pnpm --version)" + fi + printf 'path=%s/lockfile-verified.jsonl\n' "$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 }} diff --git a/.github/scripts/check-root-directory-entries.mjs b/.github/scripts/check-root-directory-entries.mjs index 5b63326691d..a428c8cc090 100644 --- a/.github/scripts/check-root-directory-entries.mjs +++ b/.github/scripts/check-root-directory-entries.mjs @@ -13,9 +13,8 @@ function readRootEntries(sha) { return stdout.split('\0').filter(Boolean) } -// Why: the Cloud workspace import is the one reviewed root addition; it stays -// listed until it lands on main, after which the base tree carries it. -const REVIEWED_ROOT_ENTRIES = new Set(['cloud']) +// These reviewed additions stay listed until the base tree carries them. +const REVIEWED_ROOT_ENTRIES = new Set(['cloud', 'opencode.json']) function checkRootDirectoryEntries(argv) { if (argv.length !== 2) { diff --git a/.github/scripts/render-readme-downloads-badge.mjs b/.github/scripts/render-readme-downloads-badge.mjs index efe0d670cf5..9c98bf9229e 100644 --- a/.github/scripts/render-readme-downloads-badge.mjs +++ b/.github/scripts/render-readme-downloads-badge.mjs @@ -1,5 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' +import { pathToFileURL } from 'node:url' +import { isAgentStateRulesTag } from '../../config/scripts/release-tag-patterns.mjs' const repository = process.env.GITHUB_REPOSITORY ?? 'stablyai/orca' const token = process.env.GITHUB_TOKEN @@ -24,6 +26,22 @@ async function fetchJson(url) { return response.json() } +// Why rules releases are excluded: every running app fetches them every few hours, so they +// count fetches, not installs. +export function countReleaseDownloads(releases) { + let total = 0 + for (const release of releases) { + if (release.draft || isAgentStateRulesTag(release.tag_name ?? '')) { + continue + } + + for (const asset of release.assets ?? []) { + total += asset.download_count ?? 0 + } + } + return total +} + async function getTotalReleaseDownloads() { let page = 1 let total = 0 @@ -37,16 +55,7 @@ async function getTotalReleaseDownloads() { return total } - for (const release of releases) { - if (release.draft) { - continue - } - - for (const asset of release.assets ?? []) { - total += asset.download_count ?? 0 - } - } - + total += countReleaseDownloads(releases) page += 1 } } @@ -104,9 +113,11 @@ function renderBadge(value) { ` } -const total = await getTotalReleaseDownloads() -const badge = renderBadge(formatDownloads(total)) +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const total = await getTotalReleaseDownloads() + const badge = renderBadge(formatDownloads(total)) -await mkdir(dirname(outputPath), { recursive: true }) -await writeFile(outputPath, badge) -console.log(`Rendered ${outputPath} from ${total} downloads.`) + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, badge) + console.log(`Rendered ${outputPath} from ${total} downloads.`) +} diff --git a/.github/workflows/agent-state-rules-publish.yml b/.github/workflows/agent-state-rules-publish.yml new file mode 100644 index 00000000000..8a24adbc73f --- /dev/null +++ b/.github/workflows/agent-state-rules-publish.yml @@ -0,0 +1,94 @@ +name: Publish agent state rules + +# The only publisher of agent state rules releases. Merging never publishes: a maintainer runs +# this from main, first to `next` (RC and dev builds), then promotes the identical file to +# `stable` after a soak. Every job runs on the dispatched commit, so the publish job runs exactly +# the script and rules the gate tested. +# +# Why GITHUB_TOKEN: release-policy.yml deletes an agent-state-rules-* release any other author +# publishes. Its releases start no workflow, so release-policy.yml sees them only when someone +# edits one by hand, and then accepts them as bot-authored prereleases. +on: + workflow_dispatch: + inputs: + action: + description: 'publish-next builds from main and publishes to next; promote-stable copies the next file to stable' + required: true + type: choice + options: + - publish-next + - promote-stable + bundled_only: + description: 'publish-next: tell apps to fall back to the rules they shipped with; promote-stable then carries it to stable' + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: agent-state-rules-publish + cancel-in-progress: false + +jobs: + gate: + if: github.ref == 'refs/heads/main' && inputs.action == 'publish-next' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Run the rules gate (schema, regex safety, the bundle, every transcript replay) + shell: bash + run: | + set -euo pipefail + mapfile -t gate_files < <(git ls-files \ + ':(glob)src/main/runtime/agent-state-rules/**/*.test.ts' \ + ':(glob)src/main/runtime/readiness-census*.test.ts' \ + ':(glob)src/main/runtime/*transcript.test.ts' \ + ':(glob)src/main/runtime/*transcripts.test.ts' \ + config/scripts/agent-state-rules-bundle.test.mjs) + pnpm exec vitest run --config config/vitest.config.ts "${gate_files[@]}" + + publish-next: + needs: gate + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: agent-state-rules + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Publish to next + env: + GH_TOKEN: ${{ github.token }} + BUNDLED_ONLY: ${{ inputs.bundled_only }} + shell: bash + run: | + set -euo pipefail + flags=() + [[ "$BUNDLED_ONLY" == "true" ]] && flags+=(--bundled-only) + node config/scripts/agent-state-rules-bundle.mjs publish-next "${flags[@]}" + + promote-stable: + if: github.ref == 'refs/heads/main' && inputs.action == 'promote-stable' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: agent-state-rules + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Copy the next file to stable + env: + GH_TOKEN: ${{ github.token }} + run: node config/scripts/agent-state-rules-bundle.mjs promote-stable diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 84ed72cc68b..39f1bb4b888 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -1,16 +1,20 @@ name: Warm shared CI caches on: + # Cache-input pushes seed immediately; this schedule repairs eviction and image changes. schedule: - - cron: '41 * * * *' + - cron: '41 */6 * * *' workflow_dispatch: push: branches: [main] paths: - '.github/workflows/ci-cache-warmup.yml' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-git-compatibility/**' + - '.github/actions/prepare-headless-compiler/**' + - 'config/scripts/headless-detector-compiler-cache*' - '.github/actions/prepare-linux-package-fixture/**' - 'config/docker/headless-serve-shutdown/**' - 'config/docker/cli-launch-contract/**' @@ -33,13 +37,14 @@ on: - 'src/shared/zip-extractor-command.ts' - 'config/scripts/shared-electron-dist-cache.mjs' - 'config/scripts/space-sharing-copy.mjs' - - 'config/patches/node-pty@1.1.0.patch' - - 'config/patches/@vscode__windows-process-tree@0.8.0.patch' + - 'config/patches/**' - 'native/windows-registry/**' pull_request: paths: - '.github/workflows/ci-cache-warmup.yml' - '.github/actions/prepare-git-compatibility/**' + - '.github/actions/prepare-headless-compiler/**' + - 'config/scripts/headless-detector-compiler-cache*' - '.github/actions/prepare-linux-package-fixture/**' - 'config/docker/headless-serve-shutdown/**' - 'config/docker/cli-launch-contract/**' @@ -51,7 +56,7 @@ permissions: concurrency: group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }} - # Hourly retries must let an active warmer finish publishing its caches. + # Scheduled retries must let an active warmer finish publishing its caches. cancel-in-progress: ${{ github.event_name != 'schedule' }} jobs: @@ -71,6 +76,11 @@ jobs: native-runtime: node node-version: '24' cache-electron-package: 'true' + cache-pnpm-store-lookup-only: 'true' + + - uses: ./.github/actions/prepare-headless-compiler + with: + seed: 'true' - name: Populate shared Electron archive run: node config/scripts/install-electron-package-binary.mjs @@ -104,6 +114,7 @@ jobs: native-runtime: node node-version: '24' cache-electron-package: 'true' + cache-pnpm-store-lookup-only: 'true' - name: Populate shared Electron archive run: node config/scripts/install-electron-package-binary.mjs - name: Verify native cache is usable @@ -125,6 +136,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node + cache-pnpm-store-lookup-only: 'true' - name: Verify native cache is usable run: node config/scripts/ensure-native-runtime.mjs --check-only diff --git a/.github/workflows/cloud-deploy-relay-asia-topology.yml b/.github/workflows/cloud-deploy-relay-asia-topology.yml index 07fbc68056e..3e8929376be 100644 --- a/.github/workflows/cloud-deploy-relay-asia-topology.yml +++ b/.github/workflows/cloud-deploy-relay-asia-topology.yml @@ -78,6 +78,7 @@ jobs: production:production-gce-c30) ;; production:production-gce-c31) ;; production:production-gce-c32,production-gce-c33) target_region=us-central1 ;; + production:production-gce-c34) ;; *) echo "cell-ids do not match the reviewed environment topology" >&2; exit 1 ;; esac echo "TARGET_REGION=${target_region}" >> "${GITHUB_ENV}" diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index af66e918386..913b6a24b17 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -194,7 +194,7 @@ jobs: EXPECTED_REGION=us-central1 EXPECTED_DATABASE_POOL_MAX= ;; - c27|c28|c29|c30|c31) + c27|c28|c29|c30|c31|c34) EXPECTED_HARD_CAP=3000 EXPECTED_REGION=asia-east2 EXPECTED_DATABASE_POOL_MAX=16 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 69a487607b0..7830df9eacf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -49,7 +49,7 @@ jobs: # v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10. uses: pnpm/action-setup@v5 with: - version: 10.24.0 + version: 10.34.6 package_json_file: docs/site/package.json run_install: false @@ -224,7 +224,7 @@ jobs: # v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10. uses: pnpm/action-setup@v5 with: - version: 10.24.0 + version: 10.34.6 package_json_file: docs/site/package.json run_install: false diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 517c77c9ddd..2957c8eaa6e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -119,7 +119,22 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 + env: + ORCA_E2E_APT_PACKAGES: build-essential python3 + run: &install_e2e_tools | + read -r -a packages <<< "$ORCA_E2E_APT_PACKAGES" + for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + if [ -f "$source" ]; then + sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source" + fi + done + sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF' + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + Acquire::Retries "1"; + APTCONF + timeout 120 sudo apt-get update + timeout 300 sudo apt-get install -y "${packages[@]}" - uses: ./.github/actions/install-node-dependencies with: @@ -178,19 +193,9 @@ jobs: - name: Install native build and headless UI tools # The Azure archive took 16 minutes for one font package; bound setup # separately so a slow mirror cannot consume the shard's test budget. - run: &install_e2e_tools | - for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do - if [ -f "$source" ]; then - sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source" - fi - done - sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF' - Acquire::http::Timeout "15"; - Acquire::https::Timeout "15"; - Acquire::Retries "1"; - APTCONF - timeout 120 sudo apt-get update - timeout 300 sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils + env: &e2e_tool_packages + ORCA_E2E_APT_PACKAGES: build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils + run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies with: @@ -281,6 +286,7 @@ jobs: # unbounded inventory fallback; the paired fixture exercises that real boundary. # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this # lane now receives those specs from pr.yml's SSH source mapping. + env: *e2e_tool_packages run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies @@ -417,6 +423,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools + env: *e2e_tool_packages run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies diff --git a/.github/workflows/macos-updater-tests.yml b/.github/workflows/macos-updater-tests.yml new file mode 100644 index 00000000000..056a40204b4 --- /dev/null +++ b/.github/workflows/macos-updater-tests.yml @@ -0,0 +1,49 @@ +name: macOS updater regression tests + +on: + pull_request: + paths: + - '.github/workflows/macos-updater-tests.yml' + - 'src/main/macos-update-running-instances*' + - 'src/main/updater*' + - 'src/main/updater/**' + - 'src/main/startup/main-process-quit*' + - 'src/main/window/main-window-state-lifecycle*' + - 'src/main/window/dashboard-popout-window*' + - 'src/shared/child-process/**' + - 'src/shared/update-status-types.ts' + - 'pnpm-lock.yaml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: macos-updater-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + updater: + runs-on: macos-15 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Exercise native application registry and update shutdown + env: + ORCA_BACKGROUND_LAUNCH: '1' + run: >- + pnpm exec vitest run --config config/vitest.config.ts + src/main/macos-update-running-instances.test.ts + src/main/macos-update-running-instances.integration.test.ts + src/main/updater.mac-install.test.ts + src/main/updater.headless-serve-install.test.ts + src/main/updater-mac-quit-guard.test.ts + src/main/startup/desktop-startup-ordering.test.ts + src/main/startup/main-process-quit-update-veto.test.ts + src/main/window/main-window-state-lifecycle.test.ts + src/main/window/dashboard-popout-window.test.ts diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 8835c6876b3..78047f09d2e 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -26,6 +26,7 @@ on: # it or to the release workflow it guards must re-run them. - '.github/workflows/mobile.yml' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' - '.github/workflows/mobile-ios-release.yml' - 'config/scripts/mobile-release-check-scope*' - 'config/scripts/mobile-test-change-scope*' @@ -113,18 +114,18 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # Both compilers are read-only; finish them before starting the test workers. + # Call installed tools so pnpm's dependency refresh cannot race between checks. - name: Typecheck id: production-types background: true - run: pnpm typecheck + run: node node_modules/typescript/bin/tsc --noEmit # Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until # tsconfig.test.json existed nothing checked them, and at introduction 127 of the 632 had # drifted. This fails when a test file that checks today stops checking, when a test leaves # the program, and on @ts-nocheck; the baseline may only shrink. - name: Typecheck tests (ratchet) - run: pnpm run check:tests-typecheck + run: node scripts/check-tests-typecheck-ratchet.mjs - wait: production-types diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index d8d6802576c..90c3fc68473 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -16,6 +16,8 @@ on: - '.npmrc' - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' + - '.github/actions/prepare-headless-compiler/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-orcad-prebuilds/**' - '.github/workflows/node-server-tests.yml' @@ -35,6 +37,8 @@ on: - '.npmrc' - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' + - '.github/actions/prepare-headless-compiler/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-orcad-prebuilds/**' - '.github/workflows/node-server-tests.yml' @@ -74,15 +78,18 @@ jobs: 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 }} + should_run: ${{ steps.scope.outputs.should_run || steps.graph.outputs.should_run }} + qualification: ${{ steps.scope.outputs.qualification || steps.graph.outputs.qualification }} + runners: ${{ steps.scope.outputs.runners || steps.graph.outputs.runners }} steps: - uses: actions/checkout@v6 with: fetch-depth: 2 persist-credentials: false - - uses: ./.github/actions/install-node-dependencies + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + package-manager-cache: false - name: Detect headless-server build and test inputs id: scope shell: bash @@ -94,7 +101,7 @@ jobs: # Compare the entire push, including multi-commit pushes and removed files. if git fetch --no-tags --depth=1 origin "$PUSH_BASE" && git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes"; then - node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --full-qualification else echo 'should_run=true' >> "$GITHUB_OUTPUT" fi @@ -102,11 +109,30 @@ jobs: fi # Compare the tested merge with its base, retaining both sides of renames. if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes"; then - node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph else echo 'should_run=true' >> "$GITHUB_OUTPUT" fi + - uses: ./.github/actions/prepare-headless-compiler + id: compiler + if: steps.scope.outputs.graph_required == 'true' + continue-on-error: true + - uses: ./.github/actions/install-node-dependencies + if: steps.scope.outputs.graph_required == 'true' && steps.compiler.outputs.available != 'true' + - name: Check the headless import graph + id: graph + if: steps.scope.outputs.graph_required == 'true' + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" = push ]; then + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification + else + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" + fi + persistence: needs: changes concurrency: @@ -121,7 +147,7 @@ jobs: 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"]') }} + os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-15","macos-15-intel","windows-2022","windows-11-arm"]') }} runs-on: ${{ matrix.os }} timeout-minutes: 20 env: @@ -134,6 +160,29 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }} + cache-pnpm-store: ${{ runner.os != 'Windows' }} + cache-pnpm-store-lookup-only: 'true' + # Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped + # it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION + # moves past the Bun daemon's. Install its pinned dependencies independently of this checkout. + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + if: runner.os == 'Linux' + with: + bun-version: 1.4.2 + - name: Build the last Bun orcad for the cross-runtime tests + id: bun-orcad + background: true + shell: bash + env: + BUN_ORCAD_COMMIT: f4092c06d639ee13ad446261dcabc78b27a21fbc + run: | + if [ "$RUNNER_OS" != Linux ]; then exit 0; fi + git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT" + git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT" + pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts + node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad" + echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT" + echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT" # Linux release slots come from the floor and Alpine lanes; this slot serves local tests. - uses: ./.github/actions/prepare-orcad-prebuilds id: orcad-prebuild @@ -144,26 +193,11 @@ jobs: (github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name))) }} restore-windows-cache: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} - run: pnpm build:orcad - # Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped - # it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION - # moves past the Bun daemon's. Same lockfile, so its build reuses this checkout's node_modules. - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - if: runner.os == 'Linux' - with: - bun-version: 1.4.2 - - name: Build the last Bun orcad for the cross-runtime tests - if: runner.os == 'Linux' - shell: bash - env: - BUN_ORCAD_COMMIT: f4092c06d639ee13ad446261dcabc78b27a21fbc - run: | - git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT" - git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT" - ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules" - node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad" - echo "ORCA_BUN_ORCAD_SLOT=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_ENV" - echo "BUN_EXECUTABLE=$(command -v bun)" >> "$GITHUB_ENV" + - wait: bun-orcad - run: pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }} + env: + ORCA_BUN_ORCAD_SLOT: ${{ steps.bun-orcad.outputs.slot }} + BUN_EXECUTABLE: ${{ steps.bun-orcad.outputs.executable }} # Only a Windows runner compiles it; arm64 cross-compiles here, as release-cut does for the relay. # Before the Node 18 check below: the build script imports TypeScript, which Node 18 cannot load. - name: Build the Windows process-table addons for the desktop template @@ -363,11 +397,19 @@ jobs: with: ref: ${{ inputs.ref }} persist-credentials: false + - uses: ./.github/actions/restore-pnpm-verification + id: pnpm-verification + with: + container-toolchain: 'true' - name: Verify native Alpine artifact and persistence + env: + VERIFICATION_CACHE_PATH: ${{ steps.pnpm-verification.outputs.path }} run: | + touch "$VERIFICATION_CACHE_PATH" # Multi-arch index digest of the tag; re-resolve it whenever NODE_RUNTIME_PIN moves. docker run --rm --init -i \ -e ORCA_BACKGROUND_LAUNCH=1 \ + -v "$VERIFICATION_CACHE_PATH:/root/.cache/pnpm/lockfile-verified.jsonl" \ -v "$GITHUB_WORKSPACE:/work" -w /work \ node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 sh -s <<'NODE_SERVER_QUALIFICATION' set -eu diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 32a167cf071..ed9a25732bb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -154,15 +154,17 @@ jobs: echo "No specs requiring the reusable E2E workflow" fi - static_analysis: - name: static analysis + preflight: + name: static analysis and typecheck needs: [code_paths] - if: needs.code_paths.outputs.static_analysis == 'true' + if: needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' # 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. + # the whole toolchain resolves. Free for public repositories. runs-on: ubuntu-24.04-arm + outputs: + shards: ${{ steps.unit-plan.outputs.shards }} steps: - name: Checkout @@ -197,21 +199,35 @@ jobs: # Keep each check in its own log while sharing this runner. - name: Lint + if: '!cancelled()' id: root-lint background: true - run: pnpm exec oxlint --format github + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm exec oxlint --format github - name: Reject low-evidence patterns + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run audit:anti-slop - wait: root-lint - name: Enforce focused code-quality plugins + if: '!cancelled()' id: native-code-quality background: true - run: pnpm run audit:code-quality:native + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run audit:code-quality:native - name: Enforce type-aware code-quality baseline + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run audit:code-quality:type-aware # Mobile installation changes import resolution for the native cycle check. @@ -221,28 +237,39 @@ jobs: # 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. - uses: ./.github/actions/install-mobile-dependencies - if: needs.code_paths.outputs.mobile_dependencies == 'true' + if: needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true' - name: Enforce changed-code quality + if: '!cancelled()' id: changed-code-quality background: true - run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" - name: Enforce React Doctor on changed lines + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}" - wait: changed-code-quality - name: Check Zustand selector fan-out budget + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:zustand-selector-fanout - name: Check reliability gate manifest + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:reliability-gates - name: Enforce dead design-system classes + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:dead-classes - name: Check VM runtime rollback compatibility + if: needs.code_paths.outputs.static_analysis == 'true' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | @@ -264,25 +291,33 @@ jobs: config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts - name: Enforce max-lines ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:max-lines-ratchet - name: Enforce ts-nocheck ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:ts-nocheck-ratchet - name: Enforce runtime Electron-import ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:runtime-electron-ratchet - name: Check Node runtime pin + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:node-runtime-pin # 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 + if: '!cancelled()' id: localization-extraction background: true env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi # 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" && @@ -296,6 +331,7 @@ jobs: # 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 + if: needs.code_paths.outputs.static_analysis == 'true' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} ORCA_BACKGROUND_LAUNCH: '1' @@ -310,20 +346,30 @@ jobs: fi - name: Verify the generated RPC params catalog + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:rpc-params-catalog - name: Verify bundled skill guides + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:bundled-skill-guides - name: Verify skill freshness manifest + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:skill-bundle-manifest - name: Verify localization catalogs + if: '!cancelled()' id: localization-catalogs background: true - run: pnpm run verify:localization-catalogs + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run verify:localization-catalogs - name: Verify localization coverage + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:localization-coverage - wait: [localization-catalogs, localization-extraction] @@ -334,6 +380,7 @@ jobs: # in .d.ts to `any`, which is how #1186 shipped a broken IPC signature # past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts. - name: Guard against project-owned .d.ts in preload/shared + if: needs.code_paths.outputs.static_analysis == 'true' run: | matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true) if [ -n "$matches" ]; then @@ -346,33 +393,19 @@ jobs: fi - name: Check feature wall asset budget + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm check:feature-wall-assets - name: Verify macOS entitlements + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm verify:macos-entitlements - typecheck: - needs: [code_paths] - if: needs.code_paths.outputs.typecheck == 'true' - # Typechecking uses no native runtime, so it can use the free public ARM runner. - runs-on: ubuntu-24.04-arm - outputs: - shards: ${{ steps.unit-plan.outputs.shards }} - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 2 - persist-credentials: false - - - uses: ./.github/actions/install-node-dependencies - # Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets # the next run skip unchanged files. Share one cache entry across commits while the # PR base stays stable; actions/cache keeps the first successful graph and the # compiler still invalidates stale files from its content hashes. - name: Cache TypeScript incremental state + if: needs.code_paths.outputs.typecheck == 'true' uses: actions/cache@v5 with: path: config/*.tsbuildinfo @@ -382,17 +415,24 @@ jobs: # Planning shares setup and stays off the compiler's critical path. - name: Plan unit selection + if: '!cancelled()' id: unit-plan background: true env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} - run: node config/scripts/ci-unit-plan.mjs + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + node config/scripts/ci-unit-plan.mjs - run: pnpm run typecheck + if: needs.code_paths.outputs.typecheck == 'true' - wait: unit-plan - uses: actions/upload-artifact@v7 + if: needs.code_paths.outputs.typecheck == 'true' with: name: unit-selection-attempt-${{ github.run_attempt }} path: ci-shards/unit-selection.json @@ -400,8 +440,12 @@ jobs: git_compatibility: name: Git compatibility - needs: [code_paths] - if: needs.code_paths.outputs.git_compatibility == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.git_compatibility == 'true' && + needs.preflight.result == 'success' runs-on: ubuntu-latest steps: @@ -415,6 +459,8 @@ jobs: - uses: ./.github/actions/prepare-git-compatibility - name: Verify Git binary compatibility matrix + env: + ORCA_BACKGROUND_LAUNCH: '1' run: | specs=( "alpine/git:edge-2.38.1|2.38.1" @@ -429,9 +475,13 @@ jobs: pids=() ( ORCA_GIT_COMPAT_BINARY="$HOME/.cache/orca-git-compat/git-2.25.5/git" \ + GIT_EXEC_PATH="$HOME/.cache/orca-git-compat/git-2.25.5" \ ORCA_GIT_COMPAT_VERSION="2.25.5" \ pnpm exec vitest run --config config/vitest.config.ts \ - src/shared/git-binary-compatibility.test.ts + src/shared/git-binary-compatibility.test.ts \ + src/main/git/worktree-safety-real-git.test.ts \ + src/main/git/worktree-rebase-update-refs-real-git.test.ts \ + src/relay/git-review-draft-binary-compatibility.test.ts ) & pids+=("$!") @@ -441,7 +491,10 @@ jobs: version="${spec#*|}" ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \ pnpm exec vitest run --config config/vitest.config.ts \ - src/shared/git-binary-compatibility.test.ts + src/shared/git-binary-compatibility.test.ts \ + src/main/git/worktree-safety-real-git.test.ts \ + src/main/git/worktree-rebase-update-refs-real-git.test.ts \ + src/relay/git-review-draft-binary-compatibility.test.ts ) & pids+=("$!") done @@ -460,8 +513,12 @@ jobs: # repair stops happening. Pinned because the binary is the thing expected to drift. codex_index_heal_contract: name: Codex index-heal contract - needs: [code_paths] - if: needs.code_paths.outputs.codex_index_heal_contract == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.codex_index_heal_contract == 'true' && + needs.preflight.result == 'success' # Why ARM: @openai/codex ships @openai/codex-linux-arm64. runs-on: ubuntu-24.04-arm env: @@ -526,8 +583,12 @@ jobs: xterm_patch_sync: name: xterm patch sync - needs: [code_paths] - if: needs.code_paths.outputs.xterm_patch_sync == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.xterm_patch_sync == 'true' && + needs.preflight.result == 'success' # 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. @@ -555,8 +616,12 @@ jobs: shell_contracts: name: shell contracts - needs: [code_paths] - if: needs.code_paths.outputs.shell_contracts == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.shell_contracts == 'true' && + needs.preflight.result == 'success' # 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 @@ -671,6 +736,7 @@ jobs: src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/fish-xdg-data-dirs-handoff.test.ts \ src/main/shell-startup-feature-channel.test.ts \ + src/main/zsh-deferred-startup-line-init.live-shell.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ @@ -682,35 +748,38 @@ jobs: src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts - # Static analysis saves the Node cache; typecheck publishes the plan before tests fan out. + # Preflight saves the Node cache and publishes the plan before tests fan out. test: - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] # Cancellation and failed prerequisites stop the expensive matrix. if: >- !cancelled() && needs.code_paths.outputs.test == 'true' && - needs.static_analysis.result == 'success' && - needs.typecheck.result == 'success' + needs.preflight.result == 'success' uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24"]' runner: ubuntu-24.04-arm - shards: ${{ needs.typecheck.outputs.shards }} + shards: ${{ needs.preflight.outputs.shards }} # Why a sibling and not part of the test workflow: it is advisory, so it must not delay the # gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s # after the last shard. Deliberately absent from verify's needs for the same reason. unit_selection_evidence: needs: [test] - if: ${{ !cancelled() && needs.test.result == 'success' }} + if: ${{ !cancelled() && (needs.test.result == 'success' || needs.test.result == 'failure') }} uses: ./.github/workflows/unit-selection-evidence.yml # 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. orcad_browser: name: orcad browser provider - needs: [code_paths] - if: needs.code_paths.outputs.orcad_browser == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.orcad_browser == 'true' && + needs.preflight.result == 'success' runs-on: ubuntu-latest steps: @@ -762,7 +831,7 @@ jobs: # in this job loads node-pty. - uses: ./.github/actions/install-node-dependencies with: - native-runtime: node + native-runtime: none cache-dependency-path: | pnpm-lock.yaml mobile/pnpm-lock.yaml @@ -823,8 +892,12 @@ jobs: cross-version-wire: name: cross-version wire compatibility - needs: [code_paths] - if: needs.code_paths.outputs.cross-version-wire == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.cross-version-wire == 'true' && + needs.preflight.result == 'success' # Why ARM: source-only: tagged checkout plus in-process vitest, no docker or browser. runs-on: ubuntu-24.04-arm @@ -867,8 +940,12 @@ jobs: managed_hook_node18: name: managed hooks on Node 18 - needs: [code_paths] - if: needs.code_paths.outputs.managed_hook_node18 == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.managed_hook_node18 == 'true' && + needs.preflight.result == 'success' # Why ARM: Node 18 publishes linux-arm64; the per-platform runtime files are read as data. runs-on: ubuntu-24.04-arm @@ -893,7 +970,7 @@ jobs: package: name: package - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest # Let the serial Docker gates reach their own deadlines and report cleanup failures. @@ -1050,7 +1127,7 @@ jobs: package_windows: name: package (windows) - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] if: needs.code_paths.outputs.package_windows == 'true' runs-on: windows-2022 timeout-minutes: 30 @@ -1128,6 +1205,7 @@ jobs: src/shared/child-process/windows-cmd-shim-resolution.test.ts src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts src/main/agent-hooks/windows-hook-payload-delivery.test.ts + src/main/jcode/hook-gate-script.test.ts src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts src/main/codex/windows-hook-command.test.ts src/main/codex/windows-hook-upgrade.test.ts @@ -1158,6 +1236,8 @@ jobs: src/main/runtime/unreadable-secret-store-preservation.win32.test.ts src/main/ipc/pty-codex-account-attribution.test.ts src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts + src/main/ipc/preflight-provider-command-selection.test.ts + src/main/ipc/preflight-runnable-local-cli.test.ts src/relay/windows-port-scan.win32.test.ts src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts src/main/ssh/remote-node-runtime-store-windows.test.ts @@ -1211,8 +1291,19 @@ jobs: e2e: name: e2e - needs: code_paths - if: needs.code_paths.outputs.e2e_should_run == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.e2e_should_run == 'true' && + ( + needs.preflight.result == 'success' || + ( + needs.preflight.result == 'skipped' && + needs.code_paths.outputs.static_analysis == 'false' && + needs.code_paths.outputs.typecheck == 'false' + ) + ) # Why: reusable e2e.yml only checkouts, builds, and uploads artifacts. permissions: contents: read @@ -1255,8 +1346,7 @@ jobs: if: ${{ !cancelled() }} needs: - code_paths - - static_analysis - - typecheck + - preflight - git_compatibility - codex_index_heal_contract - xterm_patch_sync @@ -1285,10 +1375,8 @@ jobs: env: CODE_PATHS: ${{ needs.code_paths.result }} SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} - STATIC_ANALYSIS: ${{ needs.static_analysis.result }} - STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} - TYPECHECK: ${{ needs.typecheck.result }} - TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} + PREFLIGHT: ${{ needs.preflight.result }} + PREFLIGHT_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }} CODEX_INDEX_HEAL_CONTRACT: ${{ needs.codex_index_heal_contract.result }} @@ -1334,8 +1422,7 @@ jobs: fi } # Require success when the PR has code-relevant changes - check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN" - check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN" + check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN" check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN" check_job codex_index_heal_contract "$CODEX_INDEX_HEAL_CONTRACT" "$CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN" check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN" diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 4228b31fd21..3073d160d11 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -79,6 +79,7 @@ jobs: source_ref: ${{ steps.resolve.outputs.ref }} source_sha: ${{ steps.resolve.outputs.sha }} source_short_sha: ${{ steps.resolve.outputs.short_sha }} + ships_orcad_template: ${{ steps.orcad-template-support.outputs.ships }} steps: # Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs): # this job runs with contents:write and secret scope, so avoid executing @@ -783,6 +784,21 @@ jobs: git push origin "$TAG" fi + # Why: a patch cut from a base older than #24155 has no orcad template to build or ship. + - name: Detect whether the tag ships the orcad template + id: orcad-template-support + if: steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' + env: + TAG: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }} + run: | + set -euo pipefail + if git cat-file -e "refs/tags/${TAG}^{commit}:config/scripts/packaged-orcad-template.cjs" 2>/dev/null; then + echo "ships=true" >>"$GITHUB_OUTPUT" + else + echo "ships=false" >>"$GITHUB_OUTPUT" + echo "::notice::$TAG predates the orcad template; skipping its build and download." + fi + - name: Release E2E signal summary if: always() run: | @@ -1154,7 +1170,7 @@ jobs: # signing quota, so it runs beside the release gates instead of behind them. orcad-template: needs: cut - if: needs.cut.outputs.should_release == 'true' + if: needs.cut.outputs.should_release == 'true' && needs.cut.outputs.ships_orcad_template == 'true' permissions: contents: read uses: ./.github/workflows/node-server-tests.yml @@ -1203,7 +1219,16 @@ jobs: - orcad-template - release-preflight - relay-windows-process-tree - if: needs.cut.outputs.should_release == 'true' + # Why not the implicit success(): a tag without the orcad template skips that job on purpose. + if: >- + !cancelled() && + needs.cut.result == 'success' && + needs.cut.outputs.should_release == 'true' && + needs.create-release.result == 'success' && + needs.release-preflight.result == 'success' && + needs.relay-windows-process-tree.result == 'success' && + (needs.orcad-template.result == 'success' || + (needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')) env: # beforePack and afterPack fail the package when the template is absent. ORCA_REQUIRE_ORCAD_TEMPLATE: '1' @@ -1463,6 +1488,7 @@ jobs: # After the app build so nothing that cleans out/ can drop it; electron-builder ships it. - name: Download the orcad deployment template + if: needs.cut.outputs.ships_orcad_template == 'true' uses: actions/download-artifact@v8 with: name: orcad-template @@ -1806,7 +1832,7 @@ jobs: # each file against the template manifest, so it must record the signed bytes. - name: Reseal the orcad template over its signed binaries id: reseal-orcad-template - if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' + if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' && needs.cut.outputs.ships_orcad_template == 'true' run: node config/scripts/packaged-orcad-template.cjs --reseal-signed dist/win-unpacked inner-signing-list.txt # The uninstaller must return signed before rebuilding the installer. @@ -2306,7 +2332,16 @@ jobs: - release-preflight # release-mac-build.yml downloads the relay addons from this run. - relay-windows-process-tree - if: needs.cut.outputs.should_release == 'true' + # Why not the implicit success(): a tag without the orcad template skips that job on purpose. + if: >- + !cancelled() && + needs.cut.result == 'success' && + needs.cut.outputs.should_release == 'true' && + needs.create-release.result == 'success' && + needs.release-preflight.result == 'success' && + needs.relay-windows-process-tree.result == 'success' && + (needs.orcad-template.result == 'success' || + (needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')) # Why: SignPath requires every job in this signing workflow to be # GitHub-hosted. The actual mac build runs in release-mac-build.yml so # Blacksmith stays outside Windows artifact provenance. @@ -2340,6 +2375,15 @@ jobs: - skill-sharing-linux-floor-release-gate - skill-sharing-release-gate - terminal-rendering-golden + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: >- + !cancelled() && + needs.cut.result == 'success' && + needs.build.result == 'success' && + needs.build-mac.result == 'success' && + needs.skill-sharing-linux-floor-release-gate.result == 'success' && + needs.skill-sharing-release-gate.result == 'success' && + needs.terminal-rendering-golden.result == 'success' runs-on: ubuntu-latest permissions: contents: write @@ -2408,7 +2452,8 @@ jobs: needs: - cut - publish-release - if: ${{ needs.cut.outputs.tag != '' }} + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }} runs-on: ubuntu-latest permissions: actions: write @@ -2437,7 +2482,8 @@ jobs: # A release created with GITHUB_TOKEN does not reliably emit a release # event to other workflows. Dispatch the trusted default-branch workflow; # it validates and checks out the released tag before deploying. - if: ${{ needs.cut.outputs.tag != '' }} + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }} runs-on: ubuntu-latest permissions: actions: write @@ -2481,7 +2527,8 @@ jobs: needs: - cut - publish-release - if: ${{ needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }} + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }} uses: ./.github/workflows/homebrew-bump.yml with: tag: ${{ needs.cut.outputs.tag }} diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index b8555ffbdeb..80694364bae 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -158,6 +158,8 @@ jobs: # Design D2: the parent release-cut run merged it from every node-server lane at this tag. - name: Download the orcad deployment template from the release run + # Why: release-cut skips the template for a tag that predates it. + if: hashFiles('config/scripts/packaged-orcad-template.cjs') != '' uses: actions/download-artifact@v8 with: name: orcad-template diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml index 53decc5e7e3..512a5fb8ea8 100644 --- a/.github/workflows/release-policy.yml +++ b/.github/workflows/release-policy.yml @@ -22,7 +22,9 @@ jobs: # Why: release events run this file from the tagged commit, so load the module from the same commit. - uses: actions/checkout@v6 with: - sparse-checkout: config/scripts/release-policy.mjs + sparse-checkout: | + config/scripts/release-policy.mjs + config/scripts/release-tag-patterns.mjs sparse-checkout-cone-mode: false persist-credentials: false - name: Enforce release policy diff --git a/.github/workflows/ssh-hostile-hosts.yml b/.github/workflows/ssh-hostile-hosts.yml index a77f66a14b1..c96382dbb40 100644 --- a/.github/workflows/ssh-hostile-hosts.yml +++ b/.github/workflows/ssh-hostile-hosts.yml @@ -27,6 +27,7 @@ on: - '!src/**/*.test.ts' - 'src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts' - '.github/workflows/ssh-hostile-hosts.yml' + - '.github/actions/restore-pnpm-verification/**' workflow_dispatch: inputs: cells: @@ -56,7 +57,17 @@ jobs: PYTHON: /opt/python/cp312-cp312/bin/python3 steps: - name: Install glibc 2.28 prerequisites - run: dnf install -y git procps-ng unzip which xz + run: | + missing_tool=false + for tool in git ps unzip which xz; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing_tool=true + fi + done + if [ "$missing_tool" = true ]; then + # The image's source-built Git needs no RPM; missing tools come from AlmaLinux. + dnf --disablerepo='epel*' install -y git procps-ng unzip which xz + fi - uses: actions/checkout@v6 with: persist-credentials: false @@ -89,11 +100,19 @@ jobs: with: name: hostile-hosts-glibc-slot path: out/orcad-prebuilds + - uses: ./.github/actions/restore-pnpm-verification + id: pnpm-verification + with: + container-toolchain: 'true' - name: Build and smoke the linux-x64-musl slot on Alpine + env: + VERIFICATION_CACHE_PATH: ${{ steps.pnpm-verification.outputs.path }} run: | + touch "$VERIFICATION_CACHE_PATH" # Same digest as the headless-server musl lane; re-resolve it whenever NODE_RUNTIME_PIN moves. docker run --rm --init -i \ -e ORCA_BACKGROUND_LAUNCH=1 \ + -v "$VERIFICATION_CACHE_PATH:/root/.cache/pnpm/lockfile-verified.jsonl" \ -v "$GITHUB_WORKSPACE:/work" -w /work \ node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 sh -s <<'MUSL_SLOT' set -eu @@ -198,7 +217,7 @@ jobs: fail-fast: false matrix: include: - - os: macos-14 + - os: macos-15 target: darwin-arm64 cell: macos-arm64-local-sshd - os: macos-15-intel diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index a677bd8589e..f822dd9a53d 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -36,6 +36,7 @@ on: - 'config/ci/windows-ssh-provider/**' - '.github/workflows/ssh-windows-hosts.yml' - '.github/actions/prepare-orcad-prebuilds/**' + - '.github/actions/restore-pnpm-verification/**' - 'config/scripts/orcad-windows-prebuild-cache.mjs' workflow_dispatch: inputs: @@ -83,10 +84,17 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - id: dependencies - with: - native-runtime: node + # Keep complete server/test trees; missing future imports fail the unchanged builds. + sparse-checkout: | + .github + config + native + resources + tests + src/main + src/shared + src/relay + src/types - name: Self-test the provisioning scripts before touching the machine shell: pwsh run: | @@ -96,6 +104,23 @@ jobs: if($errors.Count){throw "PowerShell parse failed: $($file.FullName)"} } & config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 + # ARM inbox servicing can finish while the independent Node artifacts are prepared. + - name: Prepare the Windows inbox SSH capability + id: inbox-capability + background: true + shell: pwsh + run: | + if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){ + $receipts=Join-Path $pwd '.build/ssh-windows-host-receipts' + New-Item -ItemType Directory -Force -Path $receipts | Out-Null + . config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 + Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') + } + - uses: ./.github/actions/install-node-dependencies + id: dependencies + with: + native-runtime: node + cache-pnpm-store: 'false' # The deploy materializes rung A from this template; only this runner's slot exists here. # The process-tree addon carries the launcher that starts the relay outside sshd's job; the # orcad slot and the relay both stage it, and a standard-user host has no other launch route. @@ -121,6 +146,7 @@ jobs: pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}" node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}" pnpm run build:relay + - wait: inbox-capability - name: Run the Windows host cells against a private ${{ matrix.server }} sshd shell: pwsh timeout-minutes: 50 @@ -134,6 +160,8 @@ jobs: $cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_}) if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out')} $archive='' + $preparation='' + if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){$preparation=Join-Path $receipts 'inbox-capability-preparation.json'} if('${{ matrix.server }}' -eq 'preview'){ $archive=Join-Path $env:RUNNER_TEMP 'preview-${{ matrix.archive }}' # The provisioning script refuses the archive unless its sha256 and every binary's match the pin. @@ -143,7 +171,7 @@ jobs: $callback={param($context) & (Join-Path $tools 'invoke-pinned-relay-cells.ps1') -SourceRoot $sourceRoot -Context $context -Target 'win32-${{ matrix.arch }}' -ReceiptRoot $receipts -Cells $cells }.GetNewClosure() - & (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -Accounts $cells.Count -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log') + & (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -InboxPreparationReceipt $preparation -Accounts $cells.Count -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log') - uses: actions/upload-artifact@v7 if: always() with: diff --git a/.github/workflows/terminal-ime-e2e.yml b/.github/workflows/terminal-ime-e2e.yml index 168280f9ac2..a99e045e4b9 100644 --- a/.github/workflows/terminal-ime-e2e.yml +++ b/.github/workflows/terminal-ime-e2e.yml @@ -5,12 +5,21 @@ on: # change is worth a real ibus session. A pull_request trigger here would run it on every PR. workflow_call: workflow_dispatch: + inputs: + diagnose_wayland_input: + description: Capture passive Wayland input diagnostics while running both native lanes + required: false + type: boolean + default: false schedule: - cron: '30 9 * * *' permissions: contents: read +env: + ORCA_BACKGROUND_LAUNCH: '1' + jobs: linux-x11: name: Linux X11 terminal IME @@ -98,6 +107,7 @@ jobs: - name: Run native Wayland Hangul terminating digit env: SKIP_BUILD: '1' + ORCA_E2E_WAYLAND_INPUT_DIAGNOSTICS: ${{ inputs.diagnose_wayland_input && '1' || '' }} run: node config/scripts/run-terminal-ibus-hangul-e2e.mjs --nested-wayland - name: Upload Wayland terminal IME evidence if: always() diff --git a/.github/workflows/terminal-perf.yml b/.github/workflows/terminal-perf.yml index 83ada43d2cf..2f3a37524eb 100644 --- a/.github/workflows/terminal-perf.yml +++ b/.github/workflows/terminal-perf.yml @@ -68,25 +68,60 @@ jobs: - name: Install native build tools and xvfb run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh + - name: Select dependency preparation + id: install-mode + shell: bash + env: + RUNNER_KIND: ${{ runner.environment }} + JOB_CONTAINER: ${{ job.container.id }} + run: | + node <<'NODE' + const fs = require('node:fs') + const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8')) + const actionPath = '.github/actions/install-node-dependencies/action.yml' + const action = fs.existsSync(actionPath) ? fs.readFileSync(actionPath, 'utf8') : '' + const inputs = action.split(/^inputs:[ \t]*\r?$/m)[1]?.split(/^\S/m)[0] ?? '' + const shared = process.env.RUNNER_KIND === 'github-hosted' && !process.env.JOB_CONTAINER && + process.env.RUNNER_OS === 'Linux' && process.env.RUNNER_ARCH === 'X64' && + manifest.engines?.node === '24' && typeof manifest.packageManager === 'string' && + manifest.packageManager.split('+')[0] === 'pnpm@12.8.1' && + manifest.scripts?.postinstall === 'node config/scripts/rebuild-native-deps.mjs' && + /^ native-runtime:/m.test(inputs) && /^ cache-pnpm-store-lookup-only:/m.test(inputs) && + fs.existsSync('.github/actions/prepare-native-runtime/action.yml') && + fs.existsSync('config/scripts/ensure-native-runtime.mjs') + fs.appendFileSync(process.env.GITHUB_OUTPUT, `shared=${shared}\n`) + NODE + + - name: Prepare current dependencies + if: steps.install-mode.outputs.shared == 'true' + uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + cache-electron-package: 'true' + cache-pnpm-store-lookup-only: 'true' + - name: Setup pnpm + if: steps.install-mode.outputs.shared != 'true' uses: pnpm/setup@v2 with: install: false - name: Setup Node.js + if: steps.install-mode.outputs.shared != 'true' uses: actions/setup-node@v6 with: node-version-file: package.json cache: pnpm - # Why: this scheduled/manual workflow uses the same native install path as - # PR and E2E CI, which needs pnpm to bypass its bundled gyp_main.py. + # Historical refs can lack the shared action; retain their original install path. - name: Use external node-gyp to avoid pnpm's bundled copy + if: steps.install-mode.outputs.shared != 'true' run: | npm install -g node-gyp@11.5.0 echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - name: Install dependencies + if: steps.install-mode.outputs.shared != 'true' run: pnpm install --frozen-lockfile - name: Build Electron app for terminal perf diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index abe9cf28a41..fed0c4c31d1 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -26,6 +26,7 @@ jobs: test: name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }} runs-on: ${{ inputs.runner }} + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -82,6 +83,7 @@ jobs: matrix: node: ${{ fromJSON(inputs.node_versions) }} runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout @@ -103,8 +105,8 @@ jobs: - name: Install relay integration dependencies working-directory: cloud run: | - npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts - npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build + npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts + npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay^...' build - name: Test relay integration contracts env: diff --git a/.gitignore b/.gitignore index dbb74b9c950..ffa4bc4f600 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,7 @@ docs/** !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/ime-regression-checklist.md +!docs/reference/jcode-hook-events.md !docs/reference/linux-glibc-compatibility.md !docs/reference/macos-press-and-hold.md !docs/reference/orcad-operations.md diff --git a/README.md b/README.md index b9e441a3ab4..4c215157da9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -235,7 +235,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Download APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk) +- **Android:** [Download APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk) --- diff --git a/cloud/apps/push/package.json b/cloud/apps/push/package.json index b47964587ac..a4ba2462f5b 100644 --- a/cloud/apps/push/package.json +++ b/cloud/apps/push/package.json @@ -15,20 +15,20 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@hono/node-server": "^1.19.17", + "@hono/node-server": "^2.1.2", "@orca-cloud/postgres-schema": "workspace:*", "@orca-cloud/push-contract": "workspace:*", "google-auth-library": "^10.5.0", - "hono": "^4.13.7", + "hono": "^4.13.10", "pg": "^8.22.0", "pg-connection-string": "2.14.0", "tweetnacl": "^1.0.3", "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "@types/pg": "^8.20.0", - "tsx": "^4.21.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/apps/push/src/durable-push-payload-parse-budget.test.ts b/cloud/apps/push/src/durable-push-payload-parse-budget.test.ts new file mode 100644 index 00000000000..f04c6daadcd --- /dev/null +++ b/cloud/apps/push/src/durable-push-payload-parse-budget.test.ts @@ -0,0 +1,244 @@ +import { createHash } from 'node:crypto' +import { afterEach, expect, it, vi } from 'vitest' +import { DurablePushStore } from './durable-push-store.js' +import { buildPushDelivery } from './push-delivery-message.js' +import type { PushDatabase } from './push-database.js' +import { + cleanupDurablePushFixtures, + fixture, + notification +} from './durable-push-store.test-fixture.js' + +type QueryCall = { sql: string; params?: unknown[] } + +afterEach(async () => { + vi.restoreAllMocks() + await cleanupDurablePushFixtures() +}) + +function traceDatabase( + database: PushDatabase, + calls: QueryCall[], + errors: unknown[] +): PushDatabase { + return { + dialect: database.dialect, + query: async (sql, params) => { + calls.push({ sql, params }) + try { + return await database.query(sql, params) + } catch (error) { + errors.push(error) + throw error + } + }, + transaction: (run) => database.transaction((tx) => run(traceDatabase(tx, calls, errors))), + lockQuotaScope: (key) => database.lockQuotaScope(key), + tryLockScope: (key) => database.tryLockScope(key), + tryLockSharedScope: (key) => database.tryLockSharedScope(key), + close: () => database.close() + } +} + +function payloadHash(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +it('parses each leased row once with exact complete payload, serialized key order and SQL sequence', async () => { + const { db, store, clock } = await fixture() + const input = { + ...notification(1), + body: 'Unicode: 🐋\ud800', + extra: { first: [null, false, 3], next: { z: 'last', a: 'first' } } + } + await store.accept('host', 'phone', input) + const [row] = await db.query('SELECT * FROM push_delivery_batches') + if (!row) { + throw new Error('Missing delivery row') + } + const payload = String(row.payload_json) + const calls: QueryCall[] = [] + const errors: unknown[] = [] + const owner = new DurablePushStore(traceDatabase(db, calls, errors), clock) + const parse = vi.spyOn(JSON, 'parse') + const delivery = await owner.claim() + expect(delivery).toEqual({ + id: row.batch_id, + registrationId: 'phone', + hostFingerprint: 'host', + notification: input, + expiresAt: 1_300_000, + lease: expect.any(String), + attempts: 1 + }) + expect(JSON.stringify(delivery?.notification)).toBe(payload) + expect(payloadHash(delivery?.notification)).toBe(payloadHash(input)) + if (!delivery) { + throw new Error('Missing delivery') + } + const published = buildPushDelivery(delivery) + const expected = buildPushDelivery({ ...delivery, notification: input }) + expect(JSON.stringify(published)).toBe(JSON.stringify(expected)) + expect(payloadHash(published)).toBe(payloadHash(expected)) + expect(calls.map(({ sql }) => sql.replace(/\s+/g, ' ').trim())).toEqual([ + `SELECT * FROM push_delivery_batches WHERE state = 'pending' AND lease_until <= ? AND expires_at > ? AND due_at <= ? AND due_at > ? AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.state = 'pending' AND busy.lease_until > 0 AND busy.lease_until > ?) ORDER BY due_at, created_at, batch_id LIMIT 1${db.dialect === 'postgres' ? ' FOR UPDATE SKIP LOCKED' : ''}`, + "SELECT (SELECT batch_id FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' AND expires_at > ? AND due_at > ? ORDER BY due_at, created_at, batch_id LIMIT 1) AS head, EXISTS (SELECT 1 FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' AND lease_until > 0 AND lease_until > ?) AS busy", + 'SELECT notification_seq FROM push_dismissed_events WHERE host_fingerprint = ? AND notification_epoch = ? AND notification_id = ?', + 'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?' + ]) + expect(calls[1]?.params).toEqual(['phone', clock(), clock() - 300_000, 'phone', clock()]) + expect(calls[2]?.params).toEqual(['host', 'epoch', 'notification-1']) + expect(calls[3]?.params).toEqual([delivery?.lease, clock() + 30_000, row.batch_id]) + expect(errors).toEqual([]) + expect(parse.mock.calls.filter(([value]) => value === payload)).toHaveLength(1) +}) + +it('keeps concurrent device claims separate and returns fresh payload objects', async () => { + const { store } = await fixture() + const input = notification(1) + await store.accept('host', 'phone-a', input) + await store.accept('host', 'phone-b', input) + const payload = JSON.stringify(input) + const parse = vi.spyOn(JSON, 'parse') + const claims = await Promise.all(Array.from({ length: 4 }, () => store.claim())) + const delivered = claims.filter((claim) => claim !== null) + expect(delivered).toHaveLength(2) + expect(delivered.map((claim) => claim.registrationId).sort()).toEqual(['phone-a', 'phone-b']) + expect(delivered.every((claim) => JSON.stringify(claim.notification) === payload)).toBe(true) + expect(delivered[0]?.notification).not.toBe(delivered[1]?.notification) + expect(parse.mock.calls.filter(([value]) => value === payload)).toHaveLength(2) +}) + +it('reads changed retry bytes and a later writer update without carrying a parsed result across calls', async () => { + const { db, store, advance } = await fixture() + await store.accept('host', 'phone', notification(1)) + const first = await store.claim() + if (!first) { + throw new Error('Missing first delivery') + } + first.notification.body = 'provider changed this retry' + await store.finish(first, 1000) + advance(1000) + const retriedPayload = JSON.stringify(first.notification) + const parse = vi.spyOn(JSON, 'parse') + const retry = await store.claim() + expect(retry).toEqual({ ...first, lease: expect.any(String), attempts: 2 }) + expect(retry?.lease).not.toBe(first.lease) + expect(retry?.notification).not.toBe(first.notification) + expect(JSON.stringify(retry?.notification)).toBe(retriedPayload) + expect(payloadHash(retry?.notification)).toBe(payloadHash(first.notification)) + const retryParses = parse.mock.calls.filter(([value]) => value === retriedPayload).length + if (!retry) { + throw new Error('Missing retry delivery') + } + await store.finish(retry, 1000) + const changed = { ...notification(1), body: 'fresh database row', title: 'Changed' } + const changedPayload = JSON.stringify(changed) + await db.query('UPDATE push_delivery_batches SET payload_json = ? WHERE batch_id = ?', [ + changedPayload, + first.id + ]) + advance(1000) + const fresh = await store.claim() + expect(fresh).toEqual({ ...retry, notification: changed, lease: expect.any(String), attempts: 3 }) + expect(JSON.stringify(fresh?.notification)).toBe(changedPayload) + expect(payloadHash(fresh?.notification)).toBe(payloadHash(changed)) + expect(retry.notification.body).toBe('provider changed this retry') + expect(retryParses).toBe(1) + expect(parse.mock.calls.filter(([value]) => value === changedPayload)).toHaveLength(1) +}) + +it('keeps dismissed alerts on the original single-parse delete path without leasing', async () => { + const { db, store, clock } = await fixture() + const input = notification(1) + await store.accept('host', 'phone', input) + await db.query( + 'INSERT INTO push_dismissed_events(host_fingerprint, notification_epoch, notification_id, notification_seq, created_at) VALUES (?, ?, ?, ?, ?)', + ['host', 'epoch', input.notificationId, 1, clock()] + ) + const calls: QueryCall[] = [] + const parse = vi.spyOn(JSON, 'parse') + expect(await new DurablePushStore(traceDatabase(db, calls, []), clock).claim()).toBeNull() + expect(await store.pendingCount('phone')).toBe(0) + expect(calls.at(-1)?.sql).toBe('DELETE FROM push_delivery_batches WHERE batch_id = ?') + expect(calls.some(({ sql }) => sql.startsWith('UPDATE'))).toBe(false) + expect(parse.mock.calls.filter(([value]) => value === JSON.stringify(input))).toHaveLength(1) +}) + +it('preserves the existing trust boundary for an object missing notification fields', async () => { + const { db, store } = await fixture() + await store.accept('host', 'phone', notification(1)) + await db.query('UPDATE push_delivery_batches SET payload_json = ?', ['{}']) + const parse = vi.spyOn(JSON, 'parse') + const delivery = await store.claim() + expect(delivery?.notification).toEqual({}) + expect(JSON.stringify(delivery?.notification)).toBe('{}') + expect(parse.mock.calls.filter(([value]) => value === '{}')).toHaveLength(1) +}) + +it.each(['not JSON', 'undefined', 'null', '[]'])( + 'preserves invalid payload rejection and rolls back the lease for %s', + async (payload) => { + const { db, store } = await fixture() + await store.accept('host', 'phone', notification(1)) + await db.query('UPDATE push_delivery_batches SET payload_json = ?', [payload]) + const [before] = await db.query('SELECT * FROM push_delivery_batches') + const parse = vi.spyOn(JSON, 'parse') + let caught: unknown + try { + await store.claim() + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Error) + const index = parse.mock.calls.findIndex(([value]) => value === payload) + expect(index).toBeGreaterThanOrEqual(0) + if (payload === 'not JSON' || payload === 'undefined') { + expect(caught).toBe(parse.mock.results[index]?.value) + expect(caught).toBeInstanceOf(SyntaxError) + } else { + expect(caught).toMatchObject({ message: 'invalid_push_delivery_payload' }) + } + expect(await db.query('SELECT * FROM push_delivery_batches')).toEqual([before]) + expect(parse.mock.calls.filter(([value]) => value === payload)).toHaveLength(1) + } +) + +it('preserves the exact database UPDATE error and retries with a fresh payload after rollback', async () => { + const { db, store, clock } = await fixture() + await store.accept('host', 'phone', notification(1)) + const [before] = await db.query('SELECT * FROM push_delivery_batches') + if (!before) { + throw new Error('Missing delivery row') + } + const originalQuery = db.query.bind(db) + const errors: unknown[] = [] + // SQLite raises a native error in the real transaction; PostgreSQL uses its real constraint. + await originalQuery( + db.dialect === 'sqlite' + ? "CREATE TRIGGER deny_lease BEFORE UPDATE ON push_delivery_batches BEGIN SELECT RAISE(FAIL, 'deny_lease'); END" + : 'ALTER TABLE push_delivery_batches ADD CONSTRAINT deny_lease CHECK (lease_until = 0)' + ) + const owner = new DurablePushStore(traceDatabase(db, [], errors), clock) + const parse = vi.spyOn(JSON, 'parse') + let caught: unknown + try { + await owner.claim() + } catch (error) { + caught = error + } + expect(errors).toHaveLength(1) + expect(caught).toBe(errors[0]) + expect(await originalQuery('SELECT * FROM push_delivery_batches')).toEqual([before]) + expect(parse.mock.calls.filter(([value]) => value === String(before.payload_json))).toHaveLength( + 1 + ) + await originalQuery( + db.dialect === 'sqlite' + ? 'DROP TRIGGER deny_lease' + : 'ALTER TABLE push_delivery_batches DROP CONSTRAINT deny_lease' + ) + const fresh = await owner.claim() + expect(fresh?.notification).toEqual(notification(1)) + expect(fresh?.attempts).toBe(1) +}) diff --git a/cloud/apps/push/src/durable-push-store.ts b/cloud/apps/push/src/durable-push-store.ts index b84ebb17893..9456e25743e 100644 --- a/cloud/apps/push/src/durable-push-store.ts +++ b/cloud/apps/push/src/durable-push-store.ts @@ -153,15 +153,15 @@ export class DurablePushStore { 'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?', [lease, now + DELIVERY_LEASE_MS, row.batch_id] ) - return this.delivery(row, lease) + return this.delivery(row, lease, notification) } - private delivery(row: SqlRow, lease: string): QueuedPushDelivery { + private delivery(row: SqlRow, lease: string, notification: PushNotification): QueuedPushDelivery { return { id: String(row.batch_id), registrationId: String(row.registration_id), hostFingerprint: String(row.host_fingerprint), - notification: parsePushDeliveryPayload(String(row.payload_json)), + notification, expiresAt: Number(row.expires_at), lease, attempts: Number(row.attempts) + 1 diff --git a/cloud/apps/relay-fence-broker/package.json b/cloud/apps/relay-fence-broker/package.json index b14f162d77d..6e95ece0581 100644 --- a/cloud/apps/relay-fence-broker/package.json +++ b/cloud/apps/relay-fence-broker/package.json @@ -14,13 +14,13 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@hono/node-server": "^1.19.17", - "hono": "^4.13.7", + "@hono/node-server": "^2.1.2", + "hono": "^4.13.10", "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", - "tsx": "^4.21.0", + "@types/node": "^24.19.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/apps/relay-ops/package.json b/cloud/apps/relay-ops/package.json index 5865d6c441d..d3961198665 100644 --- a/cloud/apps/relay-ops/package.json +++ b/cloud/apps/relay-ops/package.json @@ -17,13 +17,13 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@hono/node-server": "^1.19.17", - "hono": "^4.13.7", + "@hono/node-server": "^2.1.2", + "hono": "^4.13.10", "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", - "tsx": "^4.21.0", + "@types/node": "^24.19.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/apps/relay/package.json b/cloud/apps/relay/package.json index e9624bc3080..0a85bc9343e 100644 --- a/cloud/apps/relay/package.json +++ b/cloud/apps/relay/package.json @@ -15,21 +15,21 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@hono/node-server": "^1.19.17", + "@hono/node-server": "^2.1.2", "@orca-cloud/postgres-schema": "workspace:*", "@orca-cloud/relay-contract": "workspace:*", - "hono": "^4.13.7", - "jose": "^6.1.3", + "hono": "^4.13.10", + "jose": "^6.2.12", "pg": "^8.22.0", "tweetnacl": "^1.0.3", - "ws": "^8.21.3", + "ws": "^8.22.0", "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "@types/pg": "^8.20.0", "@types/ws": "^8.18.1", - "tsx": "^4.21.0", + "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/apps/relay/src/assignment-cleanup-steps.test.ts b/cloud/apps/relay/src/assignment-cleanup-steps.test.ts index 0e5eac13e31..38fbb105580 100644 --- a/cloud/apps/relay/src/assignment-cleanup-steps.test.ts +++ b/cloud/apps/relay/src/assignment-cleanup-steps.test.ts @@ -16,6 +16,7 @@ function stubStore(overrides: Partial = {}) { abortExpiredRegionalRehomes: method('abortExpiredRegionalRehomes'), reapRegionalRehomeAttempts: method('reapRegionalRehomeAttempts'), releaseExpiredActivityLeases: method('releaseExpiredActivityLeases'), + pruneReleasedControlReservations: method('pruneReleasedControlReservations'), releaseExpiredActivity: method('releaseExpiredActivity'), releaseExpiredRegionPreferences: method('releaseExpiredRegionPreferences'), evacuateDeadCells: method('evacuateDeadCells'), @@ -43,6 +44,7 @@ describe('assignment cleanup steps', () => { 'abortExpiredRegionalRehomes', 'reapRegionalRehomeAttempts', 'releaseExpiredActivityLeases', + 'pruneReleasedControlReservations', 'releaseExpiredActivity', 'releaseExpiredRegionPreferences', 'evacuateDeadCells' diff --git a/cloud/apps/relay/src/assignment-cleanup-steps.ts b/cloud/apps/relay/src/assignment-cleanup-steps.ts index 31e25be3688..9edd7044313 100644 --- a/cloud/apps/relay/src/assignment-cleanup-steps.ts +++ b/cloud/apps/relay/src/assignment-cleanup-steps.ts @@ -1,9 +1,9 @@ import { runRelayBackgroundOperation } from './relay-background-operation.js' -// The eleven periodic assignment sweeps the director runs every 30s. Each step +// The twelve periodic assignment sweeps the director runs every 30s. Each step // re-derives its state from the database and is idempotent, so they carry no // intra-tick ordering dependency — which is what makes per-step isolation -// sound: one failing sweep costs one tick of itself, never the other ten. +// sound: one failing sweep costs one tick of itself, never the other eleven. // (A single poisoned rehome row once silenced the whole chained form // fleet-wide.) Sweep failures are logged, never fed into the rehome worker's // dispatch-failure budget: a sweep exception is not a dispatch failure and @@ -17,6 +17,7 @@ export type AssignmentCleanupStore = { abortExpiredRegionalRehomes(): Promise reapRegionalRehomeAttempts(): Promise releaseExpiredActivityLeases(): Promise + pruneReleasedControlReservations(): Promise releaseExpiredActivity(): Promise releaseExpiredRegionPreferences(): Promise evacuateDeadCells(): Promise @@ -34,6 +35,10 @@ function assignmentCleanupSteps( ['abort-expired-regional-rehomes', () => assignments.abortExpiredRegionalRehomes()], ['reap-regional-rehome-attempts', () => assignments.reapRegionalRehomeAttempts()], ['release-expired-activity-leases', () => assignments.releaseExpiredActivityLeases()], + [ + 'prune-released-control-reservations', + () => assignments.pruneReleasedControlReservations() + ], ['release-expired-activity', () => assignments.releaseExpiredActivity()], ['release-expired-region-preferences', () => assignments.releaseExpiredRegionPreferences()], ['evacuate-dead-cells', () => assignments.evacuateDeadCells()] diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index e9e9d7b507c..14c3e50a0dc 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -1,4 +1,5 @@ import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js' +import { HeapWindowReaper } from './heap-window-reaper.js' import { selectIdleRegionalRehomes, type IdleRegionalRehomeCandidate, @@ -498,6 +499,19 @@ export class RelayHomeCellUnavailableError extends Error { // never return otherwise starves connection headroom fleet-wide and turns // every placement into relay_capacity_exhausted. const LATE_ARRIVAL_DEBT_RETENTION_MS = 10 * 60 * 1_000 +// A released reservation is read by nothing: every reader filters it out by state, and the only +// statement that still touches it is the per-host lock, which just makes that lock set longer. A day +// is margin for forensics, not for reads. +export const RELEASED_CONTROL_RESERVATION_RETENTION_MS = 24 * 60 * 60 * 1_000 +// ~27 rows per page, so a statement deletes a few hundred rows at most. With ~9 director ticks a +// minute the row cap is ~5M rows a day: the 13.7M-row backlog drains over about three days, and a +// walk that finds nothing reads ~1 MB a tick. +const RELEASED_CONTROL_RESERVATION_REAP_BUDGET = { + pagesPerStatement: 16, + maxPagesPerTick: 128, + maxRowsPerTick: 400, + budgetMs: 250 +} const CELL_FENCE_TTL_MS = 5 * 60 * 1_000 const CELL_FENCE_ATTEMPT_TTL_MS = 60 * 60 * 1_000 const CELL_DRAIN_SEND_PERMIT_MS = 30_000 @@ -541,6 +555,11 @@ export class RelayAssignmentStore { private readonly admissionSelector: RelayCellAdmissionSelector private readonly migrationCellRegistrar: RelayMigrationCellRegistrar private readonly activityQueue = new AssignmentIdentityQueue() + private readonly releasedReservationReaper = new HeapWindowReaper( + 'relay_control_connection_reservations', + `state = 'released' AND released_at <= ?`, + RELEASED_CONTROL_RESERVATION_REAP_BUDGET + ) private assignmentTail: Promise = Promise.resolve() constructor( @@ -2944,6 +2963,10 @@ export class RelayAssignmentStore { async evacuateDeadCells(limit = 100): Promise { if (!this.requireLiveCells) return 0 const cutoff = this.now() - this.heartbeatTtlMs + const cellIds = await this.deadCellEvacuationCandidates(cutoff) + // Why: without a candidate cell the host query below walks every assignment by primary key to + // return nothing (574 ms per call in production, from stale existing-only cells it can never act on). + if (cellIds.length === 0) return 0 const rows = await this.database.query( `SELECT assignment.user_id, assignment.relay_host_id, assignment.cell_id FROM relay_assignments assignment @@ -3006,8 +3029,9 @@ export class RelayAssignmentStore { AND fence.expires_at > ? ) ) + AND assignment.cell_id IN (${cellIds.map(() => '?').join(', ')}) ORDER BY assignment.user_id, assignment.relay_host_id LIMIT ?`, - [1, cutoff, this.now(), this.now(), limit] + [1, cutoff, this.now(), this.now(), ...cellIds, limit] ) let moved = 0 for (const row of rows) { @@ -3034,6 +3058,43 @@ export class RelayAssignmentStore { return moved } + // The cell-level half of evacuateDeadCells' predicate, so it is a superset: every cell the host + // query could act on is here, and only the per-host pin checks are left out. + private async deadCellEvacuationCandidates(cutoff: number): Promise { + const now = this.now() + const rows = await this.database.query( + `SELECT cell.cell_id + FROM relay_cells cell + LEFT JOIN relay_cell_committed_fences committed ON committed.cell_id = cell.cell_id + LEFT JOIN relay_cell_fence_attempts attempt ON attempt.attempt_id = committed.attempt_id + LEFT JOIN relay_cell_fences fence ON fence.cell_id = cell.cell_id + LEFT JOIN relay_cell_runtime runtime ON runtime.cell_id = cell.cell_id + WHERE (runtime.cell_id IS NULL OR runtime.ready != ? OR runtime.last_heartbeat_at <= ?) + AND ( + ( + cell.enabled = 1 + AND NOT EXISTS ( + SELECT 1 FROM relay_cell_connection_limits limits + WHERE limits.cell_id = cell.cell_id + ) + ) + OR ( + cell.enabled = 0 + AND attempt.completed_at IS NOT NULL + AND attempt.aborted_at IS NULL + AND committed.cell_incarnation = runtime.cell_incarnation + AND fence.cell_incarnation = committed.cell_incarnation + AND committed.attested_at >= runtime.last_heartbeat_at + AND committed.expires_at > ? + AND fence.expires_at > ? + ) + ) + ORDER BY cell.cell_id`, + [1, cutoff, now, now] + ) + return rows.map((row) => text(row, 'cell_id')) + } + async configureCell( cell: RelayCellConfig, admission: boolean | CellAdmissionState @@ -7158,6 +7219,12 @@ export class RelayAssignmentStore { return aborted } + async pruneReleasedControlReservations(): Promise { + return await this.releasedReservationReaper.reap(this.database, [ + this.now() - RELEASED_CONTROL_RESERVATION_RETENTION_MS + ]) + } + async releaseExpiredActivityLeases(): Promise { const now = this.now() await this.database.query( diff --git a/cloud/apps/relay/src/credential-cleanup-sweep-postgres.test.ts b/cloud/apps/relay/src/credential-cleanup-sweep-postgres.test.ts index 45f38326d3a..1736959bd3f 100644 --- a/cloud/apps/relay/src/credential-cleanup-sweep-postgres.test.ts +++ b/cloud/apps/relay/src/credential-cleanup-sweep-postgres.test.ts @@ -1,6 +1,11 @@ import pg from 'pg' import { afterAll, beforeEach, describe, expect, it } from 'vitest' -import { RelayCredentialStore, type RelayIdentity } from './credential-store.js' +import { + AUDIT_EVENT_RETENTION_MS, + CONFIRM_RESULT_RETENTION_MS, + RelayCredentialStore, + type RelayIdentity +} from './credential-store.js' import { openRelayDatabase, type RelayDatabase } from './database.js' // The outage this guards against: the credential cleanup ran every 30s in all 23 cells and both @@ -169,9 +174,9 @@ describePostgres('credential cleanup against PostgreSQL', () => { await database.query(`ANALYZE relay_connection_bases`) const reaper = await plan( - `DELETE FROM relay_connection_bases WHERE ctid IN ( + `DELETE FROM relay_connection_bases WHERE ctid = ANY(ARRAY( SELECT ctid FROM relay_connection_bases WHERE active = ? AND deadline <= ? LIMIT 5000 - )`, + ))`, [0, NOW - DAY_MS] ) @@ -280,4 +285,55 @@ describePostgres('credential cleanup against PostgreSQL', () => { { state: 'invalidated', total: '3' } ]) }) + + it('plans the audit reaper off relay_audit_events_at when most rows are past retention', async () => { + // Unordered, a LIMIT with this many matches is cheapest as a sequential scan from page 0, which + // in production would reread every retained row once the oldest pages are reaped. + await database.query( + `INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json) + SELECT 'old-' || n, ?, 'resume-confirmed', ?, ?, '{}' FROM generate_series(1, 20000) AS n`, + [NOW - AUDIT_EVENT_RETENTION_MS - DAY_MS, identity.userId, identity.relayHostId] + ) + await database.query(`ANALYZE relay_audit_events`) + + const reaper = await plan( + `DELETE FROM relay_audit_events WHERE ctid = ANY(ARRAY( + SELECT ctid FROM relay_audit_events WHERE at <= ? ORDER BY at LIMIT 5000 + ))`, + [NOW - AUDIT_EVENT_RETENTION_MS] + ) + + expect(reaper).toContain('relay_audit_events_at') + expect(reaper).not.toContain('Seq Scan on relay_audit_events') + }) + + it('reaps old confirm results and audit events and keeps the ones inside retention', async () => { + await database.query( + `INSERT INTO relay_confirm_results + (user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at) + SELECT ?, ?, 'old-' || n, 'basis-1', '{}', '{}', ? FROM generate_series(1, 200) AS n`, + [identity.userId, identity.relayHostId, NOW - CONFIRM_RESULT_RETENTION_MS - 1] + ) + await database.query( + `INSERT INTO relay_confirm_results + (user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at) + VALUES (?, ?, 'recent', 'basis-1', '{}', '{}', ?)`, + [identity.userId, identity.relayHostId, NOW - DAY_MS] + ) + await database.query( + `INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json) + VALUES ('old', ?, 'resume-confirmed', ?, ?, '{}'), ('recent', ?, 'resume-confirmed', ?, ?, '{}')`, + [ + NOW - AUDIT_EVENT_RETENTION_MS - 1, identity.userId, identity.relayHostId, + NOW - DAY_MS, identity.userId, identity.relayHostId + ] + ) + + await store.cleanup() + + expect(await database.query(`SELECT req_id FROM relay_confirm_results`)).toEqual([ + { req_id: 'recent' } + ]) + expect(await database.query(`SELECT id FROM relay_audit_events`)).toEqual([{ id: 'recent' }]) + }) }) diff --git a/cloud/apps/relay/src/credential-store-cleanup.test.ts b/cloud/apps/relay/src/credential-store-cleanup.test.ts index ba9e7be7475..7d0e70a41dd 100644 --- a/cloud/apps/relay/src/credential-store-cleanup.test.ts +++ b/cloud/apps/relay/src/credential-store-cleanup.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { RelayCredentialStore, type RelayIdentity } from './credential-store.js' +import { + AUDIT_EVENT_RETENTION_MS, + CONFIRM_RESULT_RETENTION_MS, + RelayCredentialStore, + type RelayIdentity +} from './credential-store.js' import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js' const identity: RelayIdentity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } @@ -71,6 +76,29 @@ async function insertDirectAuthorization( ) } +async function insertConfirmResult( + database: RelayDatabase, + result: { reqId: string; committedAt: number } +): Promise { + await database.query( + `INSERT INTO relay_confirm_results + (user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at) + VALUES (?, ?, ?, 'basis-1', '{}', '{}', ?)`, + [identity.userId, identity.relayHostId, result.reqId, result.committedAt] + ) +} + +async function insertAuditEvent( + database: RelayDatabase, + event: { id: string; at: number } +): Promise { + await database.query( + `INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json) + VALUES (?, ?, 'resume-confirmed', ?, ?, '{}')`, + [event.id, event.at, identity.userId, identity.relayHostId] + ) +} + async function remainingIds(database: RelayDatabase, table: string, column: string): Promise { const rows = await database.query(`SELECT ${column} FROM ${table} ORDER BY ${column}`) return rows.map((row) => String(row[column])) @@ -294,4 +322,46 @@ describe('credential cleanup invite reaper', () => { expect(await remainingIds(database, 'relay_direct_authorizations', 'direct_auth_id')).toEqual([]) await database.close() }) + + it('reaps confirm results and audit events only past their retention windows', async () => { + const database = await openInMemoryRelayDatabase() + const store = new RelayCredentialStore(database, () => NOW) + await insertConfirmResult(database, { reqId: 'confirm-old', committedAt: NOW - CONFIRM_RESULT_RETENTION_MS }) + await insertConfirmResult(database, { + reqId: 'confirm-recent', + committedAt: NOW - CONFIRM_RESULT_RETENTION_MS + 1 + }) + await insertAuditEvent(database, { id: 'audit-old', at: NOW - AUDIT_EVENT_RETENTION_MS }) + await insertAuditEvent(database, { id: 'audit-recent', at: NOW - AUDIT_EVENT_RETENTION_MS + 1 }) + + await store.cleanup() + + expect(await remainingIds(database, 'relay_confirm_results', 'req_id')).toEqual(['confirm-recent']) + expect(await remainingIds(database, 'relay_audit_events', 'id')).toEqual(['audit-recent']) + await database.close() + }) + + it('still replays a confirm result inside retention', async () => { + // The one reader: a retried confirm on the same basis gets the stored answer back. + const database = await openInMemoryRelayDatabase() + const store = new RelayCredentialStore(database, () => NOW) + await database.query( + `INSERT INTO relay_confirm_results + (user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at) + VALUES (?, ?, 'confirm-1', 'basis-1', '{}', ?, ?)`, + [identity.userId, identity.relayHostId, JSON.stringify({ v: 1, reqId: 'confirm-1' }), NOW - DAY_MS] + ) + + await store.cleanup() + + expect( + await store.confirmResume({ + ...identity, + reqId: 'confirm-1', + basisConnId: 'basis-1', + owningControlGeneration: 1 + }) + ).toEqual({ v: 1, reqId: 'confirm-1' }) + await database.close() + }) }) diff --git a/cloud/apps/relay/src/credential-store.ts b/cloud/apps/relay/src/credential-store.ts index 8d1281bb3c5..8d17ba623f3 100644 --- a/cloud/apps/relay/src/credential-store.ts +++ b/cloud/apps/relay/src/credential-store.ts @@ -6,6 +6,7 @@ import { type DeviceResumeConfirmed } from '@orca-cloud/relay-contract' import type { RelayDatabase, SqlRow } from './database.js' +import { HeapWindowReaper } from './heap-window-reaper.js' const CREDENTIAL_GRACE_MS = 24 * 60 * 60 * 1000 // Released desktops validate invite expiry against their own clock with zero @@ -20,9 +21,23 @@ const TERMINAL_INVITE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // active/unconsumed AND inside its deadline, and every deadline is set at most 30s past insert, so // a settled row can never authorize anything again. A day is margin for forensics, not for reads. const INACTIVE_AUTHORIZATION_RETENTION_MS = 24 * 60 * 60 * 1000 +// A stored confirm result only answers a retry of the same request on the same connection basis, +// and a different basis is refused as a tuple mismatch; a basis lives for one phone connection. A +// week matches the pairing support window above. +export const CONFIRM_RESULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 +// Nothing in the relay reads audit events back; 90 days covers support and incident questions. +export const AUDIT_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000 // Bounded so one cycle cannot hold row locks or grow WAL without limit; the backlog drains over // however many cycles it takes. const REAP_BATCH_ROWS = 5000 +// ~14 rows per page. With ~9 director ticks a minute the row cap is ~3M rows a day, so the 7.4M-row +// backlog drains over two to three days; a walk that finds nothing reads ~1 MB a tick. +const CONFIRM_RESULT_REAP_BUDGET = { + pagesPerStatement: 16, + maxPagesPerTick: 128, + maxRowsPerTick: 250, + budgetMs: 250 +} export type RelayIdentity = { userId: string; relayHostId: string } export type CredentialReservation = RelayIdentity & { @@ -81,6 +96,13 @@ function string(row: SqlRow, field: string): string { } export class RelayCredentialStore { + // committed_at has no index, so a LIMIT delete would plan as a sequential scan of the whole table. + private readonly confirmResultReaper = new HeapWindowReaper( + 'relay_confirm_results', + 'committed_at <= ?', + CONFIRM_RESULT_REAP_BUDGET + ) + constructor( private readonly database: RelayDatabase, private readonly now: () => number = Date.now @@ -680,16 +702,29 @@ export class RelayCredentialStore { 'consumed_at IS NOT NULL AND consumed_at <= ?', [now - INACTIVE_AUTHORIZATION_RETENTION_MS] ) + await this.confirmResultReaper.reap(this.database, [now - CONFIRM_RESULT_RETENTION_MS]) + // Ordered so the plan walks relay_audit_events_at from its oldest entry: unordered, a LIMIT + // can plan as a sequential scan that rereads the retained rows before reaching the old ones. + await this.reapBatch('relay_audit_events', 'at <= ?', [now - AUDIT_EVENT_RETENTION_MS], 'at') } // ctid/rowid, not the primary key: the physical address lets the delete re-find exactly the batch - // the subquery located instead of re-matching the predicate per row. - private async reapBatch(table: string, predicate: string, params: unknown[]): Promise { - const address = this.database.dialect === 'sqlite' ? 'rowid' : 'ctid' + // the subquery located instead of re-matching the predicate per row. On Postgres an array of TIDs, + // not `IN`: IN can plan as a hash join over a sequential scan of the whole table. + private async reapBatch( + table: string, + predicate: string, + params: unknown[], + orderBy?: string + ): Promise { + const sqlite = this.database.dialect === 'sqlite' + const address = sqlite ? 'rowid' : 'ctid' + const order = orderBy ? `ORDER BY ${orderBy} ` : '' + const batch = `SELECT ${address} FROM ${table} WHERE ${predicate} ${order}LIMIT ${REAP_BATCH_ROWS}` await this.database.query( - `DELETE FROM ${table} WHERE ${address} IN ( - SELECT ${address} FROM ${table} WHERE ${predicate} LIMIT ${REAP_BATCH_ROWS} - )`, + sqlite + ? `DELETE FROM ${table} WHERE rowid IN (${batch})` + : `DELETE FROM ${table} WHERE ctid = ANY(ARRAY(${batch}))`, params ) } diff --git a/cloud/apps/relay/src/database.test.ts b/cloud/apps/relay/src/database.test.ts index 0c987f95d40..bfeff59c69f 100644 --- a/cloud/apps/relay/src/database.test.ts +++ b/cloud/apps/relay/src/database.test.ts @@ -68,7 +68,6 @@ describe('relay database', () => { 'relay_cell_connection_runtime', 'relay_cell_connection_snapshots', 'relay_cell_drain_attempt_states', - 'relay_cell_drain_attempts', 'relay_cell_drain_recovery_attempts', 'relay_cell_fence_apply_invocations', 'relay_cell_fence_attempts', @@ -80,7 +79,6 @@ describe('relay database', () => { 'relay_cell_runtime', 'relay_cells', 'relay_confirm_results', - 'relay_confirmable_splices', 'relay_connection_bases', 'relay_control_capabilities', 'relay_control_connection_reservations', @@ -88,7 +86,6 @@ describe('relay database', () => { 'relay_direct_authorizations', 'relay_install_results', 'relay_invites', - 'relay_migration_leases', 'relay_post_drain_migration_pins', 'relay_rate_windows', 'relay_region_decisions', diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index 6a8a1fc202a..dbc5e88df8c 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -94,6 +94,10 @@ export interface RelayDatabase { // Constraint swaps are matched by NAME in pg_constraint, never by body, because the CHECK list is // generated from REGION_LIST. Changing a constraint's definition under the same name therefore does // nothing on boot: an operator drops it, and the next boot adds the current definition back. +// relay_confirmable_splices, relay_cell_drain_attempts and relay_migration_leases are no longer +// created; nothing ever wrote them. Databases that have them keep them empty until a drop is safe: +// an older image still creates them at boot, and a drop racing that CREATE can fail its schema step. +// Account erasure in orca-cloud must be deployed with retired-table support (orca-cloud#493) first. const SCHEMA = ` CREATE TABLE IF NOT EXISTS relay_invites ( user_id TEXT NOT NULL, @@ -154,19 +158,6 @@ CREATE TABLE IF NOT EXISTS relay_install_results ( PRIMARY KEY (user_id, relay_host_id, relay_device_id, req_id) ); -CREATE TABLE IF NOT EXISTS relay_confirmable_splices ( - basis_conn_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - relay_host_id TEXT NOT NULL, - owning_control_generation BIGINT NOT NULL, - relay_device_id TEXT NOT NULL, - accepted_credential_version BIGINT NOT NULL, - accepted_as TEXT NOT NULL, - confirm_deadline BIGINT NOT NULL, - active BIGINT NOT NULL, - created_at BIGINT NOT NULL -); - CREATE TABLE IF NOT EXISTS relay_connection_bases ( basis_conn_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -507,15 +498,6 @@ CREATE TABLE IF NOT EXISTS relay_cell_fence_apply_invocations ( CREATE INDEX IF NOT EXISTS relay_cell_fence_apply_invocations_attempt ON relay_cell_fence_apply_invocations(attempt_id, started_at); -CREATE TABLE IF NOT EXISTS relay_cell_drain_attempts ( - cell_id TEXT PRIMARY KEY, - cell_incarnation TEXT NOT NULL, - planned_grace_ms BIGINT NOT NULL, - attempted_at BIGINT NOT NULL, - retry_after BIGINT NOT NULL, - recover_forward_attempted_at BIGINT -); - CREATE TABLE IF NOT EXISTS relay_cell_drain_attempt_states ( attempt_id TEXT PRIMARY KEY, cell_id TEXT NOT NULL, @@ -605,17 +587,6 @@ CREATE TABLE IF NOT EXISTS relay_rate_windows ( CREATE INDEX IF NOT EXISTS relay_rate_windows_started ON relay_rate_windows(window_started_at); -CREATE TABLE IF NOT EXISTS relay_migration_leases ( - user_id TEXT NOT NULL, - relay_host_id TEXT NOT NULL, - source_cell_id TEXT NOT NULL, - target_cell_id TEXT NOT NULL, - assignment_epoch BIGINT NOT NULL, - expires_at BIGINT NOT NULL, - completed_at BIGINT, - PRIMARY KEY (user_id, relay_host_id, assignment_epoch) -); - CREATE TABLE IF NOT EXISTS relay_assignment_migrations ( user_id TEXT NOT NULL, relay_host_id TEXT NOT NULL, @@ -741,7 +712,6 @@ const POSTGRES_TRANSACTION_PHASES = [ ['relay_region_rehome_', 'regional-rehome'], ['relay_assignment_activity_leases', 'activity-lease'], ['relay_assignment_migration', 'migration'], - ['relay_migration_leases', 'migration'], ['relay_post_drain_migration_pins', 'migration'], ['relay_cell_connection_runtime', 'cell-runtime'], ['relay_cell_connection_snapshots', 'cell-runtime'], @@ -753,7 +723,6 @@ const POSTGRES_TRANSACTION_PHASES = [ ['relay_admission_selector', 'admission'], ['relay_cell_admission', 'admission'], ['relay_control_connection_reservations', 'connection'], - ['relay_confirmable_splices', 'connection'], ['relay_connection_bases', 'connection'], ['relay_direct_authorizations', 'connection'], ['relay_confirm_results', 'connection'], diff --git a/cloud/apps/relay/src/dead-cell-evacuation-precheck.test.ts b/cloud/apps/relay/src/dead-cell-evacuation-precheck.test.ts new file mode 100644 index 00000000000..c7958a5b5a7 --- /dev/null +++ b/cloud/apps/relay/src/dead-cell-evacuation-precheck.test.ts @@ -0,0 +1,154 @@ +import pg from 'pg' +import { afterAll, afterEach, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import type { RelayCellConfig } from './config.js' +import { + openInMemoryRelayDatabase, + openRelayDatabase, + type RelayDatabase, + type SqlRow +} from './database.js' + +// The sweep's host query walked every assignment by primary key to return nothing while stale +// existing-only cells sat in the fleet. These pin that a fleet with nothing to evacuate never +// reaches it, and that a cell the sweep can act on still does, on both dialects. +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const schema = 'relay_dead_cell_precheck_test' +const HOST_QUERY = 'FROM relay_assignments assignment' +const CELLS: RelayCellConfig[] = [ + { id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 }, + { id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 } +] + +class RecordingDatabase implements RelayDatabase { + readonly statements: string[] = [] + + constructor(private readonly delegate: RelayDatabase) {} + + get dialect() { + return this.delegate.dialect + } + + async query(sql: string, params: unknown[] = []): Promise { + this.statements.push(sql) + return await this.delegate.query(sql, params) + } + + async queryLocked(...args: Parameters): Promise { + return await this.delegate.queryLocked(...args) + } + + async transaction(operation: (transaction: RelayDatabase) => Promise): Promise { + return await this.delegate.transaction(operation) + } + + async close(): Promise { + await this.delegate.close() + } +} + +function scopedUrl(): string { + const url = new URL(databaseUrl!) + url.searchParams.set('options', `-c search_path=${schema}`) + return url.toString() +} + +async function onAdmin(sql: string): Promise { + const client = new pg.Client({ connectionString: databaseUrl }) + await client.connect() + try { + await client.query(sql) + } finally { + await client.end() + } +} + +const dialects: [string, () => Promise][] = [ + ['sqlite', openInMemoryRelayDatabase], + ...(databaseUrl + ? [ + [ + 'postgres', + async () => { + await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + await onAdmin(`CREATE SCHEMA ${schema}`) + return await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' }) + } + ] as [string, () => Promise] + ] + : []) +] + +describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) => { + let database: RelayDatabase | undefined + + afterEach(async () => { + await database?.close() + database = undefined + }) + + afterAll(async () => { + if (databaseUrl) await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + }) + + async function setup(now: () => number) { + database = await open() + const recording = new RecordingDatabase(database) + const store = new RelayAssignmentStore(recording, now, { + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + await store.reconcileCells(CELLS) + return { store, recording } + } + + async function heartbeat(store: RelayAssignmentStore, cell: RelayCellConfig): Promise { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + startedAt: 50, + ready: true, + observedRequests: 0 + }) + } + + it('skips the host query when the only dead cell is unfenced and existing-only', async () => { + let now = 100 + const { store, recording } = await setup(() => now) + for (const cell of CELLS) await heartbeat(store, cell) + const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + await store.setCellEnabled('cell-b', false) + expect(await store.assign(identity)).toMatchObject({ cellId: 'cell-a' }) + await store.setCellEnabled('cell-b', true) + await store.setCellEnabled('cell-a', false) + now += 45_001 + await heartbeat(store, CELLS[1]!) + recording.statements.length = 0 + + expect(await store.evacuateDeadCells()).toBe(0) + expect(recording.statements.some((sql) => sql.includes(HOST_QUERY))).toBe(false) + expect( + await database!.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ?`, [ + identity.userId + ]) + ).toEqual([{ cell_id: 'cell-a' }]) + }) + + it('still evacuates hosts from a dead uncapped cell that admits', async () => { + let now = 100 + const { store, recording } = await setup(() => now) + for (const cell of CELLS) await heartbeat(store, cell) + const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + await store.setCellEnabled('cell-b', false) + expect(await store.assign(identity)).toMatchObject({ cellId: 'cell-a' }) + await store.setCellEnabled('cell-b', true) + now += 45_001 + await heartbeat(store, CELLS[1]!) + recording.statements.length = 0 + + expect(await store.evacuateDeadCells()).toBe(1) + expect(recording.statements.some((sql) => sql.includes(HOST_QUERY))).toBe(true) + expect((await store.resolve(identity))?.cellId).toBe('cell-b') + }) +}) diff --git a/cloud/apps/relay/src/dead-relay-tables-postgres.test.ts b/cloud/apps/relay/src/dead-relay-tables-postgres.test.ts new file mode 100644 index 00000000000..101570c782e --- /dev/null +++ b/cloud/apps/relay/src/dead-relay-tables-postgres.test.ts @@ -0,0 +1,83 @@ +import pg from 'pg' +import { afterAll, describe, expect, it } from 'vitest' +import { openRelayDatabase } from './database.js' + +// Three tables were created at every boot and never written. The schema stops creating them, and +// a database that already has them must still boot, because they stay until a separate drop. +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip +const schema = 'relay_dead_tables_test' +const DEAD_TABLES = [ + 'relay_confirmable_splices', + 'relay_cell_drain_attempts', + 'relay_migration_leases' +] + +function scopedUrl(): string { + const url = new URL(databaseUrl!) + url.searchParams.set('options', `-c search_path=${schema}`) + return url.toString() +} + +async function onScoped(operation: (client: pg.Client) => Promise): Promise { + const client = new pg.Client({ connectionString: scopedUrl() }) + await client.connect() + try { + return await operation(client) + } finally { + await client.end() + } +} + +async function resetSchema(): Promise { + const client = new pg.Client({ connectionString: databaseUrl }) + await client.connect() + try { + await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + await client.query(`CREATE SCHEMA ${schema}`) + } finally { + await client.end() + } +} + +async function existingDeadTables(): Promise { + return await onScoped(async (client) => { + const rows = await client.query( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = $1 AND table_name = ANY($2) ORDER BY table_name`, + [schema, DEAD_TABLES] + ) + return rows.rows.map((row) => String(row.table_name)) + }) +} + +describePostgres('removed relay tables against PostgreSQL', () => { + afterAll(async () => { + const client = new pg.Client({ connectionString: databaseUrl }) + await client.connect() + await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + await client.end() + }) + + it('does not create the removed tables on a fresh database', async () => { + await resetSchema() + const database = await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' }) + await database.close() + + expect(await existingDeadTables()).toEqual([]) + }) + + it('boots on a database an older image created, leaving those tables alone', async () => { + await resetSchema() + await onScoped(async (client) => { + await client.query(`CREATE TABLE relay_confirmable_splices (basis_conn_id TEXT PRIMARY KEY)`) + await client.query(`CREATE TABLE relay_cell_drain_attempts (cell_id TEXT PRIMARY KEY)`) + await client.query(`CREATE TABLE relay_migration_leases (user_id TEXT NOT NULL)`) + }) + + const database = await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' }) + await database.close() + + expect(await existingDeadTables()).toEqual([...DEAD_TABLES].sort()) + }) +}) diff --git a/cloud/apps/relay/src/heap-window-reaper.ts b/cloud/apps/relay/src/heap-window-reaper.ts new file mode 100644 index 00000000000..3e7a1dd74cf --- /dev/null +++ b/cloud/apps/relay/src/heap-window-reaper.ts @@ -0,0 +1,90 @@ +import { performance } from 'node:perf_hooks' +import type { RelayDatabase, SqlRow } from './database.js' + +export type HeapWindowReapBudget = { + // Pages one DELETE may visit, so its row locks and WAL stay a few hundred rows. + pagesPerStatement: number + // Pages one tick may visit while it finds nothing to delete. + maxPagesPerTick: number + // Rows one tick deletes before it stops; this is what paces a backlog over days. + maxRowsPerTick: number + budgetMs: number +} + +// Why a TID range and not `WHERE LIMIT n`: these tables have no index on their +// retention column, so the planner answers LIMIT with a sequential scan from page 0 (production +// EXPLAIN, 2026-10-04). That scan gets longer every tick as the reaped head of the heap empties. +// A TID range bounds each statement to its own pages whatever the table holds. +export class HeapWindowReaper { + private nextPage: number | undefined + + constructor( + private readonly table: string, + private readonly predicate: string, + private readonly budget: HeapWindowReapBudget, + private readonly random: () => number = Math.random, + private readonly clock: () => number = () => performance.now() + ) {} + + async reap(database: RelayDatabase, params: unknown[]): Promise { + if (database.dialect !== 'postgres') { + return changes( + await database.query( + `DELETE FROM ${this.table} WHERE rowid IN ( + SELECT rowid FROM ${this.table} WHERE ${this.predicate} + LIMIT ${this.budget.maxRowsPerTick} + )`, + params + ) + ) + } + const pages = await heapPages(database, this.table) + if (pages === 0) return 0 + // A random first page: every director runs this sweep, and walks that all start at page 0 + // after a rollout would read the same pages in lockstep. + let page = this.nextPage ?? Math.floor(this.random() * pages) + const startedAt = this.clock() + let scanned = 0 + let deleted = 0 + while ( + scanned < this.budget.maxPagesPerTick && + deleted < this.budget.maxRowsPerTick && + this.clock() - startedAt < this.budget.budgetMs + ) { + if (page >= pages) page = 0 + const end = Math.min(page + this.budget.pagesPerStatement, pages) + // SKIP LOCKED: a row some request holds is left for a later pass rather than waited on. + // `= ANY(ARRAY(...))`, not `IN (...)`: IN can plan as a hash join over a sequential scan of + // the whole table; an array of TIDs is always a TID scan. + deleted += changes( + await database.query( + `DELETE FROM ${this.table} WHERE ctid = ANY(ARRAY( + SELECT ctid FROM ${this.table} + WHERE ctid >= CAST(? AS tid) AND ctid < CAST(? AS tid) AND ${this.predicate} + FOR UPDATE SKIP LOCKED + ))`, + [`(${page},0)`, `(${end},0)`, ...params] + ) + ) + scanned += end - page + page = end + } + this.nextPage = page + return deleted + } +} + +async function heapPages(database: RelayDatabase, table: string): Promise { + const row = ( + await database.query( + `SELECT pg_relation_size(CAST(? AS regclass)) / current_setting('block_size')::bigint + AS pages`, + [table] + ) + )[0] + return Number(row?.pages ?? 0) +} + +function changes(rows: SqlRow[]): number { + return Number(rows[0]?.changes ?? 0) +} diff --git a/cloud/apps/relay/src/host-control-proof-cleanup.test.ts b/cloud/apps/relay/src/host-control-proof-cleanup.test.ts new file mode 100644 index 00000000000..ce7a25fa530 --- /dev/null +++ b/cloud/apps/relay/src/host-control-proof-cleanup.test.ts @@ -0,0 +1,366 @@ +import { createHash, createHmac } from 'node:crypto' +import { EventEmitter } from 'node:events' +import { + buildHostProofMacInput, + HostChallengeSchema, + HOST_CHALLENGE_PLAINTEXT_DOMAIN, + RELAY_CLOSE_CODE +} from '@orca-cloud/relay-contract' +import nacl from 'tweetnacl' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type WebSocket from 'ws' +import { RelayAssignmentStore } from './assignment-store.js' +import { loadRelayConfig } from './config.js' +import { RelayCredentialStore } from './credential-store.js' +import type { RelayDatabase } from './database.js' +import { HostSessionRegistry } from './host-session-registry.js' +import type { RelayTokenClaims } from './relay-token-verifier.js' +import { ProcessQueuedByteBudget } from './splice-forwarder.js' + +class ProofSocket extends EventEmitter { + readonly OPEN = 1 + readonly CLOSING = 2 + readonly CLOSED = 3 + readyState = this.OPEN + readonly send = vi.fn<(frame: string) => void>() + readonly close = vi.fn((code?: number, reason?: string) => { + this.readyState = this.CLOSED + this.emit('close', code, Buffer.from(reason ?? '')) + }) + + peerClose(): void { + this.readyState = this.CLOSED + this.emit('close', 1000, Buffer.alloc(0)) + } + + registrySocket(): WebSocket { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fake implements the registry's send, state, close and EventEmitter surface; no actual networking is invoked. + return this as unknown as WebSocket + } +} + +function fixture() { + const database: RelayDatabase = { + query: vi.fn(async () => []), + queryLocked: vi.fn(async () => []), + transaction: (operation) => operation(database), + close: async () => undefined + } + 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: 'cell', + 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' + }) + const assignments = new RelayAssignmentStore(database) + const verify = vi.spyOn(assignments, 'verifyCellAssignment').mockResolvedValue(true) + const activate = vi.spyOn(assignments, 'activateControl').mockResolvedValue('control:1') + vi.spyOn(assignments, 'markMigrationTargetRegistered').mockResolvedValue(true) + const recordAuth = vi.fn() + const registry = new HostSessionRegistry( + config, + async () => null, + new RelayCredentialStore(database), + assignments, + new ProcessQueuedByteBudget(), + { + recordAuth, + recordForwardedBytes: vi.fn(), + recordHttp: vi.fn(), + recordReconnect: vi.fn(), + recordSql: vi.fn() + } + ) + const keyPair = nacl.box.keyPair() + const identity = { + sub: 'user-proof', + prof: 'profile-proof', + relayHostId: createHash('sha256').update(keyPair.publicKey).digest('base64url').slice(0, 16), + purpose: 'host-control', + exp: Math.floor(Date.now() / 1000) + 3600 + } satisfies RelayTokenClaims + const hello = JSON.stringify({ + type: 'host-hello', + v: 1, + relayHostId: identity.relayHostId, + assignmentEpoch: 1, + hostPublicKeyB64: Buffer.from(keyPair.publicKey).toString('base64'), + appVersion: 'test' + }) + return { registry, verify, activate, recordAuth, database, identity, keyPair, hello } +} + +async function openProof(h: ReturnType, socket = new ProofSocket()) { + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from(h.hello), false) + await vi.advanceTimersByTimeAsync(0) + expect(h.verify).toHaveBeenCalled() + expect(socket.send).toHaveBeenCalledOnce() + return socket +} + +function answerProof(socket: ProofSocket, keyPair: nacl.BoxKeyPair): void { + const frame = socket.send.mock.calls[0]?.[0] + if (frame === undefined) { + throw new Error('missing challenge') + } + const parsed: unknown = JSON.parse(frame) + if (parsed === null || typeof parsed !== 'object' || !('type' in parsed)) { + throw new Error('invalid challenge frame') + } + const { type, ...fields } = parsed + expect(type).toBe('host-challenge') + const challenge = HostChallengeSchema.parse(fields) + const plaintext = nacl.box.open( + Buffer.from(challenge.ciphertextB64, 'base64'), + Buffer.from(challenge.nonceB64, 'base64'), + Buffer.from(challenge.relayEphemeralPublicKeyB64, 'base64'), + keyPair.secretKey + ) + if (plaintext === null) { + throw new Error('challenge did not decrypt') + } + const domain = new TextEncoder().encode(`${HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`) + expect(plaintext.subarray(0, domain.length)).toEqual(domain) + const transcriptLength = new DataView( + plaintext.buffer, + plaintext.byteOffset + domain.length, + 4 + ).getUint32(0, false) + const transcriptStart = domain.length + 4 + const transcript = plaintext.subarray(transcriptStart, transcriptStart + transcriptLength) + const secret = plaintext.subarray(transcriptStart + transcriptLength) + const proofB64 = createHmac('sha256', secret) + .update(buildHostProofMacInput(transcript)) + .digest('base64') + socket.emit( + 'message', + Buffer.from( + JSON.stringify({ type: 'host-challenge-ack', challengeId: challenge.challengeId, proofB64 }) + ), + false + ) +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('host control proof cleanup', () => { + it('allocates no hello stage for an already closed peer', () => { + const h = fixture() + const socket = new ProofSocket() + socket.peerClose() + h.registry.acceptControl(socket.registrySocket(), h.identity) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('message')).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.verify).not.toHaveBeenCalled() + }) + + it('releases the host hello timer and listeners when its peer closes early', () => { + const h = fixture() + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + expect(vi.getTimerCount()).toBe(1) + expect(socket.listenerCount('message')).toBe(1) + socket.peerClose() + expect({ + timers: vi.getTimerCount(), + message: socket.listenerCount('message'), + close: socket.listenerCount('close') + }).toEqual({ timers: 0, message: 0, close: 0 }) + vi.advanceTimersByTime(2000) + expect(socket.close).not.toHaveBeenCalled() + expect(h.verify).not.toHaveBeenCalled() + expect(h.database.query).not.toHaveBeenCalled() + }) + + it('preserves the exact hello deadline and refusal while releasing its message listener', () => { + const h = fixture() + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + vi.advanceTimersByTime(1999) + expect(socket.close).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'host hello timeout' + ) + expect(socket.listenerCount('message')).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('releases the challenge timer and listeners when its peer closes before proof', async () => { + const h = fixture() + const socket = await openProof(h) + expect(vi.getTimerCount()).toBe(1) + expect(socket.listenerCount('message')).toBe(1) + socket.peerClose() + expect({ + timers: vi.getTimerCount(), + message: socket.listenerCount('message'), + close: socket.listenerCount('close') + }).toEqual({ timers: 0, message: 0, close: 0 }) + await vi.advanceTimersByTimeAsync(10_000) + expect(socket.close).not.toHaveBeenCalled() + expect(h.activate).not.toHaveBeenCalled() + expect(h.database.query).not.toHaveBeenCalled() + }) + + it('preserves the exact proof deadline and refusal with no leftover listener', async () => { + const h = fixture() + const socket = await openProof(h) + await vi.advanceTimersByTimeAsync(9999) + expect(socket.close).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'host proof timeout' + ) + expect(socket.listenerCount('message')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + expect(h.recordAuth).not.toHaveBeenCalled() + }) + + it('does no challenge crypto, send, timer or registration after a closed peer finishes verification', async () => { + const h = fixture() + let finish!: (valid: boolean) => void + h.verify.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve + }) + ) + const generateKey = vi.spyOn(nacl.box, 'keyPair') + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from(h.hello), false) + expect(h.verify).toHaveBeenCalledOnce() + socket.peerClose() + finish(true) + await vi.advanceTimersByTimeAsync(0) + expect(socket.send).not.toHaveBeenCalled() + expect(generateKey).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('message')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + expect(h.database.query).not.toHaveBeenCalled() + expect( + h.registry.get({ userId: h.identity.sub, relayHostId: h.identity.relayHostId }) + ).toBeNull() + }) + + it.each([false, true])('preserves invalid first-frame refusal (binary=%s)', (binary) => { + const h = fixture() + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from('{}'), binary) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + binary ? 'host hello must be text' : 'invalid host hello' + ) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + }) + + it.each([false, true])( + 'preserves invalid proof authentication failure (binary=%s)', + async (binary) => { + const h = fixture() + const socket = await openProof(h) + socket.emit('message', Buffer.from('{}'), binary) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid host proof' + ) + expect(h.recordAuth).toHaveBeenCalledExactlyOnceWith(false) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + } + ) + + it('allocates no proof wait when sending the challenge closes its peer', async () => { + const h = fixture() + const socket = new ProofSocket() + socket.send.mockImplementation(() => socket.peerClose()) + await openProof(h, socket) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('message')).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + }) + + it('keeps the existing diagnostic and refusal when challenge send throws', async () => { + const h = fixture() + const socket = new ProofSocket() + socket.send.mockImplementation(() => { + throw new Error('synthetic send failure') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await openProof(h, socket) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.LIMIT_EXCEEDED, + 'relay temporarily unavailable' + ) + expect(warn).toHaveBeenCalledExactlyOnceWith( + '[orca-relay] host hello proof failed: synthetic send failure' + ) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + }) + + it('contains assignment lookup rejection with its existing close and diagnostic', async () => { + const h = fixture() + h.verify.mockRejectedValueOnce(new Error('synthetic lookup failure')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from(h.hello), false) + await vi.advanceTimersByTimeAsync(0) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.LIMIT_EXCEEDED, + 'relay temporarily unavailable' + ) + expect(warn).toHaveBeenCalledExactlyOnceWith( + '[orca-relay] host hello proof failed: synthetic lookup failure' + ) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + }) + + it('keeps a newer same-host peer live when the old proof peer closes', async () => { + const h = fixture() + const oldPeer = await openProof(h) + const replacement = await openProof(h) + oldPeer.peerClose() + expect(vi.getTimerCount()).toBe(1) + answerProof(replacement, h.keyPair) + await vi.advanceTimersByTimeAsync(0) + expect(h.activate).toHaveBeenCalledOnce() + expect(h.recordAuth).toHaveBeenCalledExactlyOnceWith(true) + expect( + h.registry.get({ userId: h.identity.sub, relayHostId: h.identity.relayHostId })?.socket + ).toBe(replacement) + expect(replacement.send).toHaveBeenCalledTimes(2) + expect(replacement.listenerCount('message')).toBe(1) + expect(replacement.listenerCount('close')).toBe(2) + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(10_000) + expect(oldPeer.close).not.toHaveBeenCalled() + expect(replacement.close).not.toHaveBeenCalled() + h.registry.drain(0) + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 4e3372ce692..06cfb2d57ac 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -160,6 +160,30 @@ function send(socket: WebSocket, type: string, message: object): void { socket.send(JSON.stringify({ type, ...message })) } +function readControlFrame( + socket: WebSocket, + timeoutMs: number, + timeoutReason: string, + receive: (raw: RawData, isBinary: boolean) => void +): void { + if (socket.readyState !== socket.OPEN) return + const timer = setTimeout(() => { + finish() + socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, timeoutReason) + }, timeoutMs) + function finish(): void { + clearTimeout(timer) + socket.off('message', onMessage) + socket.off('close', finish) + } + function onMessage(raw: RawData, isBinary: boolean): void { + finish() + receive(raw, isBinary) + } + socket.once('message', onMessage) + socket.once('close', finish) +} + // Hosts abandon connects after 15s; waiting much longer than that behind a // stalled predecessor only accumulates doomed sockets. const ACTIVATION_QUEUE_WAIT_MS = 30_000 @@ -794,12 +818,7 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.DRAINING, 'relay draining') return } - let firstFrameTimer: ReturnType | null = setTimeout(() => { - socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host hello timeout') - }, 2_000) - socket.once('message', (raw, isBinary) => { - if (firstFrameTimer) clearTimeout(firstFrameTimer) - firstFrameTimer = null + readControlFrame(socket, 2_000, 'host hello timeout', (raw, isBinary) => { if (isBinary) { socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host hello must be text') return @@ -991,6 +1010,7 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'wrong assignment epoch') return } + if (socket.readyState !== socket.OPEN) return const key = this.key(identity.sub, identity.relayHostId) const existing = this.sessions.get(key) @@ -1035,11 +1055,7 @@ export class HostSessionRegistry { ciphertextB64: Buffer.from(ciphertext).toString('base64'), expiresAt }) - const proofTimer = setTimeout(() => { - socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host proof timeout') - }, 10_000) - socket.once('message', (raw, isBinary) => { - clearTimeout(proofTimer) + readControlFrame(socket, 10_000, 'host proof timeout', (raw, isBinary) => { const ack = isBinary ? null : HostChallengeAckSchema.safeParse(payload(raw, 'host-challenge-ack')) diff --git a/cloud/apps/relay/src/postgres-drain-send-locking.test.ts b/cloud/apps/relay/src/postgres-drain-send-locking.test.ts index 024bf46db5e..26eec5013b0 100644 --- a/cloud/apps/relay/src/postgres-drain-send-locking.test.ts +++ b/cloud/apps/relay/src/postgres-drain-send-locking.test.ts @@ -42,9 +42,6 @@ describePostgres('PostgreSQL drain-send locking', () => { `DELETE FROM relay_control_connection_reservations WHERE user_id = ?`, [identity.userId] ) - await database.query(`DELETE FROM relay_migration_leases WHERE user_id = ?`, [ - identity.userId - ]) await database.query(`DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`, [ identity.userId ]) diff --git a/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts b/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts index 314b758b17f..c61c6b3ff0f 100644 --- a/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts +++ b/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts @@ -32,7 +32,7 @@ const CELL_TABLES = [ // The target-row statement locks exactly these, held from it to COMMIT. const TARGET_LOCKED = ['target:relay_cells', 'target:relay_cell_admission'] -type Trip = { sql: string; lockable: Record } +type Trip = { sql: string; lockable: Record; probeMs: number } type DelayControl = StatementDelay & { beforeTrip: (sql: string) => Promise } @@ -159,11 +159,12 @@ describePostgres('PostgreSQL regional rehome target-row lock', () => { bystander: context.bystander.id } control.beforeTrip = async (sql) => { + const probeStartedAt = performance.now() const state: Record = {} for (const [role, cellId] of Object.entries(probed)) { for (const table of CELL_TABLES) state[`${role}:${table}`] = await lockable(table, cellId) } - trips.push({ sql, lockable: state }) + trips.push({ sql, lockable: state, probeMs: performance.now() - probeStartedAt }) } consumeRelayCellInventoryHold(delayed) control.enabled = true @@ -188,13 +189,22 @@ describePostgres('PostgreSQL regional rehome target-row lock', () => { ]) } const counts = consumeRelayCellInventoryHold(delayed) + const commitProbeMs = trips.at(-1)!.probeMs console.info( - JSON.stringify({ event: 'rehome_target_row_hold', trips: trips.length, ...counts }) + JSON.stringify({ + event: 'rehome_target_row_hold', + trips: trips.length, + commitProbeMs, + ...counts + }) ) expect(counts.rehomeTargetRowHolds).toBe(1) expect(counts.cellInventoryHoldMaxSite).toBe('rehome-target-row') expect(counts.rehomeTargetRowHoldMsMax).toBeGreaterThanOrEqual(STATEMENT_DELAY_MS) - expect(counts.rehomeTargetRowHoldMsMax).toBeLessThanOrEqual(2 * STATEMENT_DELAY_MS) + // The COMMIT observer probes run under the lock, but are absent in production. + expect(counts.rehomeTargetRowHoldMsMax - commitProbeMs).toBeLessThanOrEqual( + 2 * STATEMENT_DELAY_MS + ) expect(await reservedRequests(context.target.id)).toBe(context.targetReservedBefore + 2) }) diff --git a/cloud/apps/relay/src/released-reservation-prune.test.ts b/cloud/apps/relay/src/released-reservation-prune.test.ts new file mode 100644 index 00000000000..9dc81c32391 --- /dev/null +++ b/cloud/apps/relay/src/released-reservation-prune.test.ts @@ -0,0 +1,236 @@ +import pg from 'pg' +import { afterAll, afterEach, describe, expect, it } from 'vitest' +import { + RelayAssignmentStore, + RELEASED_CONTROL_RESERVATION_RETENTION_MS +} from './assignment-store.js' +import { + openInMemoryRelayDatabase, + openRelayDatabase, + type RelayDatabase +} from './database.js' +import { HeapWindowReaper } from './heap-window-reaper.js' + +// Released reservations were never deleted: 13.7M rows and 9 GB in production, every one of them +// still locked by each placement of its host. These pin what the prune may take and how it walks. +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip +const schema = 'relay_released_reservation_prune_test' +const NOW = 100 * 24 * 60 * 60 * 1_000 +const STALE = NOW - RELEASED_CONTROL_RESERVATION_RETENTION_MS +const PREDICATE = `state = 'released' AND released_at <= ?` + +function scopedUrl(): string { + const url = new URL(databaseUrl!) + url.searchParams.set('options', `-c search_path=${schema}`) + return url.toString() +} + +async function onAdmin(sql: string): Promise { + const client = new pg.Client({ connectionString: databaseUrl }) + await client.connect() + try { + await client.query(sql) + } finally { + await client.end() + } +} + +async function openPostgres(): Promise { + await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + await onAdmin(`CREATE SCHEMA ${schema}`) + return await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' }) +} + +async function insertReservation( + database: RelayDatabase, + id: string, + state: string, + releasedAt: number | null +): Promise { + 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, released_at, updated_at) + VALUES (?, ?, 'user-1', 'host000000000001', 1, 'cell-a', ?, 1, 1, ?, 1)`, + [id, id, state, releasedAt] + ) +} + +async function remainingIds(database: RelayDatabase): Promise { + const rows = await database.query( + `SELECT reservation_id FROM relay_control_connection_reservations ORDER BY reservation_id` + ) + return rows.map((row) => String(row.reservation_id)) +} + +const dialects: [string, () => Promise][] = [ + ['sqlite', openInMemoryRelayDatabase], + ...(databaseUrl ? [['postgres', openPostgres] as [string, () => Promise]] : []) +] + +describe.each(dialects)('released reservation prune (%s)', (_dialect, open) => { + let database: RelayDatabase | undefined + + afterEach(async () => { + await database?.close() + database = undefined + }) + + afterAll(async () => { + if (databaseUrl) await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + }) + + it('deletes only released rows older than the retention window', async () => { + database = await open() + await insertReservation(database, 'a-old-released', 'released', STALE - 1) + await insertReservation(database, 'b-edge-released', 'released', STALE) + await insertReservation(database, 'c-fresh-released', 'released', STALE + 1) + await insertReservation(database, 'd-reserved', 'reserved', null) + await insertReservation(database, 'e-debt', 'late-arrival-debt', null) + await insertReservation(database, 'f-claimed', 'claimed', null) + // A row that once was released and came back is judged by its state, not its timestamp. + await insertReservation(database, 'g-claimed-old-release', 'claimed', STALE - 1) + const store = new RelayAssignmentStore(database, () => NOW) + + expect(await store.pruneReleasedControlReservations()).toBe(2) + expect(await remainingIds(database)).toEqual([ + 'c-fresh-released', + 'd-reserved', + 'e-debt', + 'f-claimed', + 'g-claimed-old-release' + ]) + }) +}) + +describePostgres('heap window reaper against PostgreSQL', () => { + let database: RelayDatabase + // 200 rows of this shape fill about two pages; 6,000 spread the table over ~60 pages. + const ROWS = 6_000 + + afterEach(async () => { + await database?.close() + }) + + afterAll(async () => { + await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + }) + + async function seed(): Promise { + database = await openPostgres() + // Every third row is still live, so each page holds rows the walk must keep. + 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, released_at, updated_at) + SELECT 'r-' || lpad(n::text, 6, '0'), 'r-' || n, 'user-1', 'host000000000001', 1, + 'cell-a', CASE WHEN n % 3 = 0 THEN 'claimed' ELSE 'released' END, + 1, 1, CASE WHEN n % 3 = 0 THEN NULL ELSE 1 END, 1 + FROM generate_series(1, ?) AS n`, + [ROWS] + ) + const pages = ( + await database.query( + `SELECT pg_relation_size('relay_control_connection_reservations') / 8192 AS pages` + ) + )[0]! + return Number(pages.pages) + } + + it('stops each tick at its row cap and drains the backlog over later ticks', async () => { + const pages = await seed() + expect(pages).toBeGreaterThan(20) + const reaper = new HeapWindowReaper( + 'relay_control_connection_reservations', + PREDICATE, + { pagesPerStatement: 2, maxPagesPerTick: 1_000, maxRowsPerTick: 300, budgetMs: 60_000 }, + () => 0.5 + ) + + const first = await reaper.reap(database, [NOW]) + // One statement past the cap at most: two pages of this shape hold well under 300 rows. + expect(first).toBeGreaterThanOrEqual(300) + expect(first).toBeLessThan(600) + + let ticks = 1 + while ((await reaper.reap(database, [NOW])) > 0) ticks += 1 + expect(ticks).toBeGreaterThan(5) + const left = await database.query( + `SELECT state, COUNT(*) AS rows FROM relay_control_connection_reservations GROUP BY state` + ) + expect(left).toEqual([{ state: 'claimed', rows: String(ROWS / 3) }]) + }) + + it('wraps from the end of the heap back to its first page', async () => { + const pages = await seed() + // Starts on the last page, so every other page is reached only after the wrap. + const reaper = new HeapWindowReaper( + 'relay_control_connection_reservations', + PREDICATE, + { pagesPerStatement: 4, maxPagesPerTick: pages + 4, maxRowsPerTick: 1_000_000, budgetMs: 60_000 }, + () => (pages - 1) / pages + ) + + await reaper.reap(database, [NOW]) + + expect( + await database.query( + `SELECT COUNT(*) AS rows FROM relay_control_connection_reservations + WHERE state = 'released'` + ) + ).toEqual([{ rows: '0' }]) + }) + + it('skips a row a request holds instead of waiting for it', async () => { + await seed() + const holder = new pg.Client({ connectionString: scopedUrl() }) + await holder.connect() + try { + await holder.query('BEGIN') + await holder.query( + `SELECT reservation_id FROM relay_control_connection_reservations + WHERE reservation_id = 'r-000001' FOR UPDATE` + ) + const reaper = new HeapWindowReaper( + 'relay_control_connection_reservations', + PREDICATE, + { pagesPerStatement: 1_000, maxPagesPerTick: 1_000, maxRowsPerTick: 1_000_000, budgetMs: 60_000 }, + () => 0 + ) + + // The pool's lock_timeout would fail this statement if it waited on the held row. + expect(await reaper.reap(database, [NOW])).toBe((ROWS * 2) / 3 - 1) + expect( + await database.query( + `SELECT reservation_id FROM relay_control_connection_reservations + WHERE state = 'released'` + ) + ).toEqual([{ reservation_id: 'r-000001' }]) + } finally { + await holder.query('ROLLBACK') + await holder.end() + } + }) + + it('plans each statement as a TID range scan, never a sequential scan', async () => { + await seed() + await database.query('ANALYZE relay_control_connection_reservations') + const client = new pg.Client({ connectionString: scopedUrl() }) + await client.connect() + try { + const plan = await client.query( + `EXPLAIN DELETE FROM relay_control_connection_reservations WHERE ctid = ANY(ARRAY( + SELECT ctid FROM relay_control_connection_reservations + WHERE ctid >= CAST($1 AS tid) AND ctid < CAST($2 AS tid) AND ${PREDICATE.replace('?', '$3')} + FOR UPDATE SKIP LOCKED))`, + ['(0,0)', '(16,0)', NOW] + ) + const text = plan.rows.map((row) => String(row['QUERY PLAN'])).join('\n') + expect(text).toContain('Tid Range Scan') + expect(text).not.toContain('Seq Scan') + } finally { + await client.end() + } + }) +}) diff --git a/cloud/dev/scripts/operate-relay-asia-admission.mjs b/cloud/dev/scripts/operate-relay-asia-admission.mjs index d5736db3ed2..c79323a9b39 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.mjs @@ -21,7 +21,7 @@ const SHAPES = { domain: 'relay.onorca.dev', allCells: [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', 'production-gce-c32', 'production-gce-c33' + 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34' ], // The launch set was registered together; each later cell registers alone beside it. registrationWaves: [ @@ -29,8 +29,10 @@ const SHAPES = { ['production-gce-c30'], ['production-gce-c31'], ['production-gce-c32'], - ['production-gce-c33'] + ['production-gce-c33'], + ['production-gce-c34'] ], + // C34 is a migration-only spare: no promotion wave until a reviewed change adds one. promotionWaves: [ ['production-gce-c27'], ['production-gce-c28', 'production-gce-c29'], diff --git a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs index ac5aa89ba9f..a1b8eb3853c 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs @@ -553,11 +553,13 @@ test('accepts only reviewed Asia admission waves', () => { ['rollback', 'production-gce-c30'], ['rollback', 'production-gce-c31'], ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29'], - ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'], + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34'], ...['production-gce-c32', 'production-gce-c33'].flatMap((cellId) => [ 'inspect', 'verify', 'register', 'registered', 'promote', 'recover-promotion', 'rollback' ].map((mode) => [mode, cellId])), - ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'] + ...['inspect', 'verify', 'register', 'registered', 'rollback'] + .map((mode) => [mode, 'production-gce-c34']), + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34'] ] for (const [mode, cellIds] of accepted) { assert.deepEqual( @@ -586,7 +588,12 @@ test('accepts only reviewed Asia admission waves', () => { ['promote', 'production-gce-c27,production-gce-c30'], ['promote', 'production-gce-c28,production-gce-c29,production-gce-c30'], ['promote', 'production-gce-c30,production-gce-c31'], + // The C34 spare stays migration-only: no reviewed promotion wave names it. ['promote', 'production-gce-c34'], + ['recover-promotion', 'production-gce-c34'], + ['register', 'production-gce-c33,production-gce-c34'], + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'], + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'], ['rollback', 'production-gce-c27,production-gce-c30'], ['rollback', 'production-gce-c30,production-gce-c31'], ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], @@ -648,6 +655,29 @@ test('registers C31 alone beside the general C27-C30', async () => { assert.deepEqual(subject.selector().membership.general, general) }) +test('registers the C34 spare alone as migration-only in asia-east2', async () => { + const general = [...launchCells, 'production-gce-c30', 'production-gce-c31'] + const subject = harness({ + generation: 17, + membership: { + existingOnly: [], + migrationOnly: [], + general: [...general, 'production-gce-c32', 'production-gce-c33'] + } + }) + const result = await operateRelayAsiaAdmission({ + environment: 'production', mode: 'register', cells: ['production-gce-c34'], + expectedGeneration: 17, imageDigest: digest, attemptId: 'asia_register_c34', token: 'not-logged' + }, subject) + const request = subject.requests.find(({ path }) => path.endsWith('/add-migration-cells')) + assert.deepEqual(request.body.cells, [{ + cellId: 'production-gce-c34', cellUrl: 'https://c34.relay.onorca.dev', region: 'asia-east2', + capacityRequests: 6_000, connectionHardCap: 3_000, connectionUnobservedBound: 60 + }]) + assert.deepEqual(result.states, { 'production-gce-c34': 'migration-only' }) + assert.equal(subject.selector().membership.general.includes('production-gce-c34'), false) +}) + const usRegions = { 'production-gce-c32': 'us-central1', 'production-gce-c33': 'us-central1' } test('registers C32 and C33 one at a time in us-central1 at the Asia shape', async () => { diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs index 5d00dbacf1a..6bbb65c50ce 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs @@ -20,7 +20,8 @@ const SHAPES = { 'production-gce-c30': 'asia-east2-a', 'production-gce-c31': 'asia-east2-b', 'production-gce-c32': 'us-central1-a', - 'production-gce-c33': 'us-central1-b' + 'production-gce-c33': 'us-central1-b', + 'production-gce-c34': 'asia-east2-c' }, // The launch set, then each later additive cell; a plan targets one wave, never live cells. waves: [ @@ -28,7 +29,8 @@ const SHAPES = { ['production-gce-c30'], ['production-gce-c31'], // Declared together, so they plan together: a lone C32 plan would hit C33's missing template. - ['production-gce-c32', 'production-gce-c33'] + ['production-gce-c32', 'production-gce-c33'], + ['production-gce-c34'] ] } } diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs index 0790560a318..906c8bb1ace 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs @@ -14,7 +14,8 @@ const productionCells = () => Object.fromEntries([ [30, 'asia-east2-a'], [31, 'asia-east2-b'], [32, 'us-central1-a'], - [33, 'us-central1-b'] + [33, 'us-central1-b'], + [34, 'asia-east2-c'] ].map(([ordinal, zone]) => [`production-gce-c${ordinal}`, { hostname: `c${ordinal}`, region: zone.slice(0, -2), zone, machine_type: 'e2-standard-4', boot_disk_gb: 30, @@ -57,6 +58,14 @@ test('accepts the additive C31 wave in the next zone of the rotation', () => { assert.equal(result.relay_gce_cells['production-gce-c31'].zone, 'asia-east2-b') }) +test('accepts the additive C34 spare wave in asia-east2-c at the Asia pool', () => { + const result = prepareRelayAsiaTopologyInput({ existingCells: productionCells(), + existingAdditionalRegions: additionalRegions, environment: 'production', + cellIds: 'production-gce-c34', image }) + assert.equal(result.relay_gce_cells['production-gce-c34'].zone, 'asia-east2-c') + assert.equal(result.relay_gce_cells['production-gce-c34'].database_pool_max, 16) +}) + test('accepts the additive US C32+C33 wave at the default pool only', () => { const cellIds = 'production-gce-c32,production-gce-c33' for (const [cellId, zone] of [ @@ -103,7 +112,8 @@ test('matches every committed production Asia cell entry', () => { 'production-gce-c27,production-gce-c28,production-gce-c29', 'production-gce-c30', 'production-gce-c31', - 'production-gce-c32,production-gce-c33' + 'production-gce-c32,production-gce-c33', + 'production-gce-c34' ]) { const committedImage = committed[wave.split(',')[0]].image assert.doesNotThrow(() => prepareRelayAsiaTopologyInput({ @@ -116,8 +126,8 @@ test('matches every committed production Asia cell entry', () => { environment: 'production', cellIds: 'production-gce-c30', image }), /differs from the reviewed topology/) - // The US cells launch on the newest digest, the one C31 launched on. - for (const cellId of ['production-gce-c32', 'production-gce-c33']) { + // The US cells and the C34 spare launch on the newest cell digest, the one C31 launched on. + for (const cellId of ['production-gce-c32', 'production-gce-c33', 'production-gce-c34']) { assert.equal(committed[cellId].image, committed['production-gce-c31'].image, cellId) } }) @@ -152,7 +162,8 @@ test('rejects an uncommitted subnet or cell, partial wave, wrong image, and drif 'production-gce-c30,production-gce-c31', 'production-gce-c32', 'production-gce-c33', - 'production-gce-c34' + 'production-gce-c33,production-gce-c34', + 'production-gce-c35' ]) { assert.throws(() => prepareRelayAsiaTopologyInput({ existingCells: productionCells(), existingAdditionalRegions: additionalRegions, diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index b53dbe40d26..d643f2e2178 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -101,7 +101,9 @@ describe('production Relay capacity cell admission', () => { // Migration-only canaries: the US-only capacity rollout never touches them either. 'production-gce-c17', 'production-gce-c18', // US cells at the Asia shape: the 1,000-cap capacity rollout never touches them. - 'production-gce-c32', 'production-gce-c33' + 'production-gce-c32', 'production-gce-c33', + // The migration-only Asia spare. + 'production-gce-c34' ]) { const hostname = cellId.slice('production-gce-'.length) assert.deepEqual(parseProductionCapacityCellArguments([ @@ -118,7 +120,7 @@ describe('production Relay capacity cell admission', () => { paceWindowMs: 0 }) } - for (const cellId of ['production-gce-c12', 'production-gce-c34']) { + for (const cellId of ['production-gce-c12', 'production-gce-c35']) { const hostname = cellId.slice('production-gce-'.length) assert.throws(() => parseProductionCapacityCellArguments([ '--director-origin', 'https://relay.onorca.dev', diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.mjs index 32ac89e4e6d..79dddc7e921 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.mjs @@ -2,9 +2,9 @@ import { pathToFileURL } from 'node:url' import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs' // Every cell that carries the rehome identity: the eighteen US cells and the -// five asia-east2 cells that drain mis-homed hosts back the other way. +// six asia-east2 cells that drain mis-homed hosts back the other way. const PRODUCTION_CELL = - /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33)$/ + /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33|34)$/ const DIRECTOR_ORIGIN = 'https://relay.onorca.dev' export function parseRehomeTrustProbeArguments(argv, environment = process.env) { diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs index 932fa450112..c7d8b5e5d4b 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs @@ -116,7 +116,7 @@ test('fails when both trust-probe attempts return a transient 503', async () => test('approves the asia-east2 and US 3,000 rehome sources and still rejects unlisted cells', () => { for (const cellId of [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', 'production-gce-c32', 'production-gce-c33' + 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34' ]) { const parsed = parseRehomeTrustProbeArguments( argv.map((value) => (value === 'production-gce-c7' ? cellId : value)), @@ -124,7 +124,7 @@ test('approves the asia-east2 and US 3,000 rehome sources and still rejects unli ) assert.equal(parsed.cellId, cellId) } - for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c34']) { + for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c35']) { assert.throws( () => parseRehomeTrustProbeArguments( @@ -183,7 +183,7 @@ test('approves exactly the committed production rehome source cells', () => { const sources = new Set( [...tfvars.slice(start, tfvars.indexOf(']', start)).matchAll(/"([^"]+)"/g)].map(([, cell]) => cell) ) - assert.ok(sources.has('production-gce-c33')) + assert.ok(sources.has('production-gce-c34')) for (let ordinal = 1; ordinal <= 40; ordinal++) { const cellId = `production-gce-c${ordinal}` const approved = (() => { diff --git a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs index 0a59d8d9939..8a6eba98b69 100644 --- a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs +++ b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs @@ -50,7 +50,8 @@ test('accepts only the reviewed Asia topology waves', () => { 'production:production-gce-c27,production-gce-c28,production-gce-c29', 'production:production-gce-c30', 'production:production-gce-c31', - 'production:production-gce-c32,production-gce-c33' + 'production:production-gce-c32,production-gce-c33', + 'production:production-gce-c34' ] ) }) diff --git a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs index bade8de8f52..bc68bf49c69 100644 --- a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs +++ b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs @@ -7,12 +7,12 @@ import { } from './relay-cloud-sql-connection-budget.mjs' test('production shared consumers keep allowance and reserve below the ceiling', () => { - // cells: 22 pools at 10 (220, C32/C33 included) + the five asia-east2 pools at 16 (80). + // cells: 22 pools at 10 (220, C32/C33 included) + the six asia-east2 pools at 16 (96). const report = readRelayCloudSqlConnectionBudget() - assert.deepEqual(report.consumers, { cells: 300, directors: 15, auth: 20, api: 50 }) - assert.deepEqual(report.asia, { cells: 5, poolMax: 16 }) - assert.equal(report.configuredMaximum, 385) + assert.deepEqual(report.consumers, { cells: 316, directors: 15, auth: 20, api: 50 }) + assert.deepEqual(report.asia, { cells: 6, poolMax: 16 }) + assert.equal(report.configuredMaximum, 401) assert.equal(report.rolloutOverlap.relayDirectorCandidate, 30) assert.equal(report.rolloutOverlap.apiCandidate, 65) assert.equal(report.rolloutOverlap.authCandidate, 35) @@ -22,10 +22,10 @@ test('production shared consumers keep allowance and reserve below the ceiling', assert.equal(report.maintenanceAdminAllowance, 5) assert.equal(report.explicitReserve, 10) assert.equal(report.usableCeiling, 490) - assert.equal(report.operatingMaximum, 455) - assert.equal(report.remainingWithinUsableCeiling, 35) - assert.equal(report.budgetedTotal, 465) - assert.equal(report.unallocated, 35) + assert.equal(report.operatingMaximum, 471) + assert.equal(report.remainingWithinUsableCeiling, 19) + assert.equal(report.budgetedTotal, 481) + assert.equal(report.unallocated, 19) assert.equal(report.withinBudget, true) }) diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index 8d13c761f54..7313ce627d6 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -4,9 +4,9 @@ import { requireSameEvidenceCode } from './relay-evidence-code-provenance.mjs' // Migration-only by policy: zero hosts and no reservation, so a wave rolls one without // displacing anybody. It enters and must leave migration-only, never general. -// C32 and C33 stay here until each one's canary promotes it; that follow-up moves it to general. +// C34 is an Asia spare that stays migration-only by policy. export const SAME_CAP_MIGRATION_ONLY_CELLS = [ - 'production-gce-c17', 'production-gce-c18', 'production-gce-c32', 'production-gce-c33' + 'production-gce-c17', 'production-gce-c18', 'production-gce-c34' ] export const SAME_CAP_CELLS = [ @@ -15,7 +15,7 @@ export const SAME_CAP_CELLS = [ 'production-gce-c19', 'production-gce-c20', 'production-gce-c21', 'production-gce-c22', 'production-gce-c23', 'production-gce-c24', 'production-gce-c25', 'production-gce-c26', 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', + 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', ...SAME_CAP_MIGRATION_ONLY_CELLS ] diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index 8aa4ffb4c4b..c8d5ccb4f2b 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -63,7 +63,7 @@ test('requires one canary or a bounded reviewed batch', () => { rollbackDigest, confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c31` }).cells, ['production-gce-c31']) - for (const cellId of ['production-gce-c32', 'production-gce-c33']) { + for (const cellId of ['production-gce-c32', 'production-gce-c33', 'production-gce-c34']) { assert.deepEqual(validateSameCapWave({ mode: 'canary-apply', cellIds: cellId, @@ -74,10 +74,10 @@ test('requires one canary or a bounded reviewed batch', () => { } assert.throws(() => validateSameCapWave({ mode: 'canary-apply', - cellIds: 'production-gce-c34', + cellIds: 'production-gce-c35', targetDigest, rollbackDigest, - confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c34` + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c35` }), /cells/) }) @@ -123,12 +123,16 @@ test('the wave workflow chains exactly ten serial cell jobs', () => { assert.doesNotMatch(dispatch, /\n cell_11:/) }) -test('lists C32 and C33 as migration-only beside C17 and C18 until their canaries promote them', () => { - assert.deepEqual(SAME_CAP_MIGRATION_ONLY_CELLS, [ - 'production-gce-c17', 'production-gce-c18', 'production-gce-c32', 'production-gce-c33' - ]) - assert.equal(SAME_CAP_CELLS.includes('production-gce-c30'), true) - assert.equal(SAME_CAP_CELLS.includes('production-gce-c31'), true) +test('lists the C34 spare as migration-only beside C17 and C18, and C30-C33 as general', () => { + assert.deepEqual( + SAME_CAP_MIGRATION_ONLY_CELLS, + ['production-gce-c17', 'production-gce-c18', 'production-gce-c34'] + ) + for (const cellId of [ + 'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33' + ]) { + assert.equal(SAME_CAP_CELLS.includes(cellId), true, cellId) + } }) test('rolls the migration-only cells but never mixes the two classes in one wave', () => { @@ -194,15 +198,28 @@ test('rolls the migration-only cells but never mixes the two classes in one wave confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${c31Mixed}`, canaryRunId: '42' }), /all general or all migration-only/) - // Until its canary promotes it, a same-cap restore must hand a US 3,000 cell back isolated. - assert.equal(entryAdmission('production-gce-c32'), 'migration-only') - const usUnpromoted = 'production-gce-c26,production-gce-c32' - assert.throws(() => validateSameCapWave({ + // C32 and C33 are general since their 2026-10-01 promotions: they roll beside US 1k cells, + // isolate and restore (delta 2), and never share a wave with C17/C18. + for (const cellId of ['production-gce-c32', 'production-gce-c33']) { + assert.equal(entryAdmission(cellId), 'general', cellId) + assert.equal(selectorWaveDelta(cellId), 2, cellId) + } + const usPromoted = 'production-gce-c26,production-gce-c32,production-gce-c33' + assert.deepEqual(validateSameCapWave({ mode: 'batch-apply', - cellIds: usUnpromoted, + cellIds: usPromoted, targetDigest, rollbackDigest, - confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usUnpromoted}`, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usPromoted}`, + canaryRunId: '42' + }).cells, ['production-gce-c26', 'production-gce-c32', 'production-gce-c33']) + const usMixed = 'production-gce-c32,production-gce-c17' + assert.throws(() => validateSameCapWave({ + mode: 'batch-apply', + cellIds: usMixed, + targetDigest, + rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usMixed}`, canaryRunId: '42' }), /all general or all migration-only/) // A mixed wave has no single selector delta for its later cells to offset from. diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 27c52c401ce..7f27742ce3f 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -263,7 +263,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(String(cellShape(cellId).cap), tfvarsHardCap(cellId), cellId) } assert.equal(resolveCellShape('production-gce-c12').status, 1) - assert.equal(resolveCellShape('production-gce-c34').status, 1) + assert.equal(resolveCellShape('production-gce-c35').status, 1) }) @@ -275,11 +275,10 @@ describe('same-cap roll scripts accept every same-cap cell', () => { const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell)) // Only a declared rehome source may roll at a trusted protocol at all; the job refuses // the rest before it plans, and the next test covers them at protocol 0. - // C32 and C33 are already rehome sources but stay migration-only until their canaries. - const unpromotedSources = ['production-gce-c32', 'production-gce-c33'] + // The C34 spare is a rehome source that stays migration-only. assert.deepEqual( SAME_CAP_CELLS.filter((cell) => !REHOME_SOURCE_CELLS.has(cell)), - SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => !unpromotedSources.includes(cell)) + SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => cell !== 'production-gce-c34') ) for (const [cellId, protocol] of trusted.flatMap((cell) => [[cell, 1], [cell, 3]])) { const { cap, pool } = cellShape(cellId) diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs index 084960d396b..adcb7807fba 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs @@ -24,14 +24,16 @@ const CELL_SHAPES = { 'production-gce-c30': 'asia-east2-a', 'production-gce-c31': 'asia-east2-b', 'production-gce-c32': 'us-central1-a', - 'production-gce-c33': 'us-central1-b' + 'production-gce-c33': 'us-central1-b', + 'production-gce-c34': 'asia-east2-c' }, waves: [ ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], ['production-gce-c30'], ['production-gce-c31'], // Declared together, so they plan together: a lone C32 plan would hit C33's missing template. - ['production-gce-c32', 'production-gce-c33'] + ['production-gce-c32', 'production-gce-c33'], + ['production-gce-c34'] ] }, staging: { diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs index 0e9f6e0a6a2..a4838aba046 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs @@ -161,6 +161,18 @@ test('accepts the additive production C31 wave only in asia-east2-b', () => { ) }) +test('accepts the additive production C34 spare wave only in asia-east2-c', () => { + const c34Config = { ...productionConfig, cells: ['production-gce-c34'] } + assert.deepEqual( + validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c34', 'asia-east2-c') }, c34Config), + { environment: 'production', cells: ['production-gce-c34'], changes: 4 } + ) + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c34') }, c34Config), + /fixed-one Asia MIG shape/ + ) +}) + // A US cell joins the root region: no additional-region network, no region label or line, and // the default pool emits no line, exactly as the startup template renders a root-region cell. function usCellPlan(hostname, zone) { @@ -269,7 +281,8 @@ test('accepts only a reviewed Asia topology wave', () => { 'production-gce-c27,production-gce-c28,production-gce-c29', 'production-gce-c29,production-gce-c27,production-gce-c28', 'production-gce-c30', - 'production-gce-c31' + 'production-gce-c31', + 'production-gce-c34' ]) { assert.doesNotThrow( () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), @@ -286,7 +299,8 @@ test('accepts only a reviewed Asia topology wave', () => { 'production-gce-c32', 'production-gce-c33', 'production-gce-c32,production-gce-c33,production-gce-c34', - 'production-gce-c34' + 'production-gce-c33,production-gce-c34', + 'production-gce-c35' ]) { assert.throws( () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index b214389e6b2..0d2e501f1f1 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -215,7 +215,19 @@ promote while a same-cap restore has just returned an empty general US cell: the would land there and the canary would roll the new cell back. Both cells are declared rehome sources and sit in the same-cap migration-only list until each one's canary promotes it, then move to the general list. The shadow gate's fleet pool list tracks the 16-connection Asia pools, so -whether a US cell belongs there is decided at promotion, not assumed. +whether a US cell belongs there is decided at promotion, not assumed. Both were promoted to general +on 2026-10-01, so the same-cap job now rolls them as general cells. They stay out of the fleet pool +list because their pool is the US default of 10. + +C34 is an Asia spare at the C31 shape in `asia-east2-c`, so the six Asia cells spread 2/2/2. It is +its own topology wave and registers alone as migration-only, then the director is configured with +`cell-ids` set to C34. It has no promotion wave: the Asia admission script and workflow refuse +`promote` for it, and placement and regional rehome select only general cells. It is a +migration-only landing zone that only an explicit evacuation or migration naming it can target. +Do not name it in the multi-target `promote-general-cell` or `retire-migration-cell` modes, which +accept any migration-only cell. It is a declared rehome source, sits in the same-cap migration-only +list, and stays out of the fleet pool list. Promoting it later takes its own reviewed change adding a +promotion wave and canary entry. Rollback returns Asia cells to migration-only; it does not destroy the network or use existing-only. The production topology dispatch remains unavailable until the diff --git a/cloud/infra/terraform/README.md b/cloud/infra/terraform/README.md index cf447a88dd2..70001984c7b 100644 --- a/cloud/infra/terraform/README.md +++ b/cloud/infra/terraform/README.md @@ -332,7 +332,7 @@ cell templates/MIGs/backends, and exact shared URL-map host additions. It rejects deletes, replacements, loss of an existing host route, US-resource changes, and unrelated drift. Do not add production C27-C29 until the compatible image has been published and each entry can pin its immutable -digest. A later cell, such as C30 or C31, is its own reviewed wave. The shared URL map +digest. A later cell, such as C30, C31 or the C34 spare, is its own reviewed wave. The shared URL map pulls every live cell into its plan, so the workflow plans each live cell at the image its state template already serves, and the validator rejects any change to a cell outside the wave. US C32 and C33 use the same workflow at the same diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index baed00d496d..a3521914b20 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -450,6 +450,21 @@ relay_gce_cells = { connection_hard_cap = 3000 connection_unobserved_bound = 60 } + # Asia spare: registered migration-only as a drain landing zone; c completes the 2/2/2 zone spread. + "production-gce-c34" = { + hostname = "c34" + region = "asia-east2" + zone = "asia-east2-c" + machine_type = "e2-standard-4" + boot_disk_gb = 30 + boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" + capacity_requests = 6000 + database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters). + image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269" + initially_enabled = false + connection_hard_cap = 3000 + connection_unobserved_bound = 60 + } } relay_region_rehome_source_cell_ids = [ @@ -476,7 +491,8 @@ relay_region_rehome_source_cell_ids = [ "production-gce-c30", "production-gce-c31", "production-gce-c32", - "production-gce-c33" + "production-gce-c33", + "production-gce-c34" ] # Slack #orca-relay-alerts, created out of band on 2026-08-05. Declared here because an apply diff --git a/cloud/package.json b/cloud/package.json index 1770e91a7b1..e2c02ba2416 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -2,7 +2,7 @@ "name": "orca-cloud", "private": true, "version": "0.0.0", - "packageManager": "pnpm@10.24.0", + "packageManager": "pnpm@10.34.6", "engines": { "node": ">=24 <27", "pnpm": ">=10" @@ -26,7 +26,7 @@ "typecheck": "pnpm -r typecheck" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "tsx": "^4.23.15", "typescript": "^5.9.3", "vitest": "^4.1.11" diff --git a/cloud/packages/postgres-schema/package.json b/cloud/packages/postgres-schema/package.json index 05cd4bd7221..594c799a5b5 100644 --- a/cloud/packages/postgres-schema/package.json +++ b/cloud/packages/postgres-schema/package.json @@ -13,7 +13,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/packages/push-contract/package.json b/cloud/packages/push-contract/package.json index 75698aa46f9..19f4941ba4a 100644 --- a/cloud/packages/push-contract/package.json +++ b/cloud/packages/push-contract/package.json @@ -16,7 +16,7 @@ "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/packages/relay-contract/package.json b/cloud/packages/relay-contract/package.json index 976840da87d..5aa329a3d81 100644 --- a/cloud/packages/relay-contract/package.json +++ b/cloud/packages/relay-contract/package.json @@ -16,7 +16,7 @@ "zod": "^3.25.76" }, "devDependencies": { - "@types/node": "^24.10.0", + "@types/node": "^24.19.0", "typescript": "^5.9.3", "vitest": "^4.1.11" } diff --git a/cloud/pnpm-lock.yaml b/cloud/pnpm-lock.yaml index 6042ee85cd4..c688889cb50 100644 --- a/cloud/pnpm-lock.yaml +++ b/cloud/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 tsx: specifier: ^4.23.15 version: 4.23.15 @@ -19,13 +19,13 @@ 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.2)(tsx@4.23.15)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) apps/push: dependencies: '@hono/node-server': - specifier: ^1.19.17 - version: 1.19.17(hono@4.13.7) + specifier: ^2.1.2 + version: 2.1.2(hono@4.13.10) '@orca-cloud/postgres-schema': specifier: workspace:* version: link:../../packages/postgres-schema @@ -36,8 +36,8 @@ importers: specifier: ^10.5.0 version: 10.9.1 hono: - specifier: ^4.13.7 - version: 4.13.7 + specifier: ^4.13.10 + version: 4.13.10 pg: specifier: ^8.22.0 version: 8.22.0 @@ -52,26 +52,26 @@ importers: version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 '@types/pg': specifier: ^8.20.0 version: 8.20.0 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.2)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) apps/relay: dependencies: '@hono/node-server': - specifier: ^1.19.17 - version: 1.19.17(hono@4.13.7) + specifier: ^2.1.2 + version: 2.1.2(hono@4.13.10) '@orca-cloud/postgres-schema': specifier: workspace:* version: link:../../packages/postgres-schema @@ -79,11 +79,11 @@ importers: specifier: workspace:* version: link:../../packages/relay-contract hono: - specifier: ^4.13.7 - version: 4.13.7 + specifier: ^4.13.10 + version: 4.13.10 jose: - specifier: ^6.1.3 - version: 6.2.3 + specifier: ^6.2.12 + version: 6.2.12 pg: specifier: ^8.22.0 version: 8.22.0 @@ -91,15 +91,15 @@ importers: specifier: ^1.0.3 version: 1.0.3 ws: - specifier: ^8.21.3 - version: 8.21.3 + specifier: ^8.22.0 + version: 8.22.0 zod: specifier: ^3.25.76 version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -107,76 +107,76 @@ importers: specifier: ^8.18.1 version: 8.18.1 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.2)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) apps/relay-fence-broker: dependencies: '@hono/node-server': - specifier: ^1.19.17 - version: 1.19.17(hono@4.13.7) + specifier: ^2.1.2 + version: 2.1.2(hono@4.13.10) hono: - specifier: ^4.13.7 - version: 4.13.7 + specifier: ^4.13.10 + version: 4.13.10 zod: specifier: ^3.25.76 version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 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.2)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) apps/relay-ops: dependencies: '@hono/node-server': - specifier: ^1.19.17 - version: 1.19.17(hono@4.13.7) + specifier: ^2.1.2 + version: 2.1.2(hono@4.13.10) hono: - specifier: ^4.13.7 - version: 4.13.7 + specifier: ^4.13.10 + version: 4.13.10 zod: specifier: ^3.25.76 version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 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.2)(tsx@4.22.4)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) packages/postgres-schema: devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 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.2)(tsx@4.23.15)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) packages/push-contract: dependencies: @@ -185,14 +185,14 @@ importers: version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 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.2)(tsx@4.23.15)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) packages/relay-contract: dependencies: @@ -201,14 +201,14 @@ importers: version: 3.25.76 devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^24.19.0 + version: 24.19.0 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.2)(tsx@4.23.15)) + version: 4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) packages: @@ -221,321 +221,165 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - 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'} + '@hono/node-server@2.1.2': + resolution: {integrity: sha512-w0tqJ/ilmggKokOSvbp9y+F91Y4ROTyCTL/8EKj9B4qOhOZzNm/tyKElzxrz+9+4fJKQrBSaahIuCGRye5RJYg==} + engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -587,36 +431,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -659,8 +509,8 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/node@24.13.2': - resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + '@types/node@24.19.0': + resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==} '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -744,11 +594,6 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -802,16 +647,16 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} - hono@4.13.7: - resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + hono@4.13.10: + resolution: {integrity: sha512-dQuLsa5oO+47QVMVMaaD9cIv8ctmVtK1iRvwWngkfloFJMeFeuoUFDswIqZGxmGX3hrRzREgEArjkK9OgsQEhA==} engines: {node: '>=16.9.0'} https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -857,24 +702,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} @@ -1027,11 +876,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.22.4: - resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} - engines: {node: '>=18.0.0'} - hasBin: true - tsx@4.23.15: resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} @@ -1045,8 +889,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} @@ -1141,8 +985,8 @@ packages: engines: {node: '>=8'} hasBin: true - ws@8.21.3: - resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -1178,165 +1022,87 @@ snapshots: tslib: 2.8.1 optional: true - '@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)': + '@hono/node-server@2.1.2(hono@4.13.10)': dependencies: - hono: 4.13.7 + hono: 4.13.10 '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1416,19 +1182,19 @@ snapshots: '@types/estree@1.0.9': {} - '@types/node@24.13.2': + '@types/node@24.19.0': dependencies: - undici-types: 7.18.2 + undici-types: 7.24.6 '@types/pg@8.20.0': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.19.0 pg-protocol: 1.15.0 pg-types: 2.2.0 '@types/ws@8.18.1': dependencies: - '@types/node': 24.13.2 + '@types/node': 24.19.0 '@vitest/expect@4.1.11': dependencies: @@ -1439,21 +1205,13 @@ 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.2)(tsx@4.22.4))': + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.19.0)(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.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) + vite: 8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15) '@vitest/pretty-format@4.1.11': dependencies: @@ -1507,35 +1265,6 @@ snapshots: es-module-lexer@2.1.0: {} - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -1618,7 +1347,7 @@ snapshots: google-logging-utils@1.1.3: {} - hono@4.13.7: {} + hono@4.13.10: {} https-proxy-agent@7.0.6: dependencies: @@ -1627,7 +1356,7 @@ snapshots: transitivePeerDependencies: - supports-color - jose@6.2.3: {} + jose@6.2.12: {} json-bigint@1.0.0: dependencies: @@ -1817,12 +1546,6 @@ snapshots: tslib@2.8.1: optional: true - tsx@4.22.4: - dependencies: - esbuild: 0.28.1 - optionalDependencies: - fsevents: 2.3.3 - tsx@4.23.15: dependencies: esbuild: 0.28.2 @@ -1833,9 +1556,9 @@ snapshots: typescript@5.9.3: {} - undici-types@7.18.2: {} + undici-types@7.24.6: {} - vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4): + vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1843,28 +1566,15 @@ snapshots: rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 24.13.2 - esbuild: 0.28.2 - fsevents: 2.3.3 - 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 + '@types/node': 24.19.0 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)): + vitest@4.1.11(@types/node@24.19.0)(vite@8.0.16(@types/node@24.19.0)(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.2)(tsx@4.22.4)) + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1881,37 +1591,10 @@ 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.2)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.15) 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.2)(tsx@4.23.15)) - '@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.23.15) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 24.19.0 transitivePeerDependencies: - msw @@ -1922,7 +1605,7 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - ws@8.21.3: {} + ws@8.22.0: {} xtend@4.0.2: {} diff --git a/cloud/pnpm-workspace.yaml b/cloud/pnpm-workspace.yaml index cc61e60464c..97bf7cd8c2e 100644 --- a/cloud/pnpm-workspace.yaml +++ b/cloud/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - apps/* - packages/* +minimumReleaseAge: 4320 diff --git a/config/build-plugins/jsonc-parser-esm.ts b/config/build-plugins/jsonc-parser-esm.ts new file mode 100644 index 00000000000..f3403bc9463 --- /dev/null +++ b/config/build-plugins/jsonc-parser-esm.ts @@ -0,0 +1,6 @@ +import { resolve } from 'node:path' + +// UMD relative requires cannot survive a self-contained bundle. +export const JSONC_PARSER_ESM_ALIAS = { + 'jsonc-parser': resolve(import.meta.dirname, '../../node_modules/jsonc-parser/lib/esm/main.js') +} diff --git a/config/build-plugins/markdown-parser-exports.ts b/config/build-plugins/markdown-parser-exports.ts new file mode 100644 index 00000000000..2b468c0b02f --- /dev/null +++ b/config/build-plugins/markdown-parser-exports.ts @@ -0,0 +1,11 @@ +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const parserRequire = createRequire(require.resolve('remark-parse')) +const mathRequire = createRequire(require.resolve('rehype-katex')) + +// Both preview paths use DOM-free parsing so their dependencies also run in workers. +export const markdownParserAliases = { + 'decode-named-character-reference': parserRequire.resolve('decode-named-character-reference'), + 'hast-util-from-html-isomorphic': mathRequire.resolve('hast-util-from-html-isomorphic') +} diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 31340869e22..0640a984437 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -19,6 +19,7 @@ type OutputChunk = Rollup.OutputChunk // The CLI loads these paths after electron-vite replaces out/main. export const CLI_MAIN_ENTRY_NAMES = [ 'agent-hooks/managed-agent-hook-controls', + 'gitlab/project-ref-parser', 'orca-profiles/profile-index-store', 'claude-accounts/keychain', ...[ @@ -55,9 +56,7 @@ const PLAIN_NODE_ENTRY_NAMES = [ const WORKER_THREAD_ENTRY_NAMES = [ 'stt-worker', 'warp-theme-parser-worker', - 'cursor-desktop-profile-worker-entry', - 'session-scanner-opencode-sqlite-worker-entry', - 'session-scanner-worker-entry', + 'foreign-sqlite-reader-entry', 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry', 'usage-scan-worker-entry', @@ -123,10 +122,15 @@ function assertNoElectronRequire( entryName: string, entry: OutputChunk, byFileName: Map, + electronFreeChunkCode: Map, runtime: EntryRuntime = 'plain-Node process' ): void { for (const chunk of collectReachableChunks(entry, byFileName)) { - if (ELECTRON_REQUIRE_RE.test(chunk.code)) { + const code = chunk.code + if (electronFreeChunkCode.get(chunk) === code) { + continue + } + if (ELECTRON_REQUIRE_RE.test(code)) { throw new Error( `[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` + `requires electron. "${entryName}" runs as a ${runtime}, where ` + @@ -134,6 +138,7 @@ function assertNoElectronRequire( `v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.` ) } + electronFreeChunkCode.set(chunk, code) } } @@ -273,17 +278,30 @@ export function createPlainNodeEntryGuardPlugin( } } + const electronFreeChunkCode = new Map() for (const entryName of PLAIN_NODE_ENTRY_NAMES) { const entry = entryByName.get(entryName) if (entry) { - assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process') + assertNoElectronRequire( + entryName, + entry, + byFileName, + electronFreeChunkCode, + 'plain-Node process' + ) } } for (const entryName of WORKER_THREAD_ENTRY_NAMES) { const entry = entryByName.get(entryName) if (entry) { - assertNoElectronRequire(entryName, entry, byFileName, 'worker thread') + assertNoElectronRequire( + entryName, + entry, + byFileName, + electronFreeChunkCode, + 'worker thread' + ) } } diff --git a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 index 115345cf8a0..10b4d9d67e1 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 @@ -2,46 +2,34 @@ # -HiddenTools: the private accounts are denied every machine PATH directory holding one of these # executables, and their own PATH carries logging shims for them, so SSH sessions have no host toolchain. # -HostCellProbe receives a context hashtable (accounts, port, keys, shim log) once provisioning passes. -param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,4)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe) +param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,4)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe,[string]$InboxPreparationReceipt) $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1') $target=@{arm64=@{os='Arm64';folder='OpenSSH-ARM64';machine='0xAA64';archive='698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42'};x64=@{os='X64';folder='OpenSSH-Win64';machine='0x8664';archive='23f50f3458c4c5d0b12217c6a5ddfde0137210a30fa870e98b29827f7b43aba5'}}[$Arch] $scopeServer=if($Server -eq 'inbox'){'Windows inbox OpenSSH.Server capability binaries'}else{'Microsoft Win32-OpenSSH 10.0.0.0p2-Preview'} $report = @{scope="$scopeServer $Arch private loopback authentication and stock cmd.exe dispatch"; server=$Server; status='running'; imageVersion=$env:ImageVersion; cleanup=@('not-confirmed'); globalBootstrapCleanup='Not qualified: service bootstrap may create ProgramData SSH and OpenSSH registry entries; disposable CI VM destruction is the boundary'; observations=@(); stages=@(); diagnosticCaptureFailures=@()} $script:receiptWritten=$false function Write-Stage([string]$Stage) { - $timestamp=[DateTime]::UtcNow.ToString('o') - $report.stages += @{stage=$Stage; utc=$timestamp} - try { - $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($report | ConvertTo-Json -Depth 6)) - $temporary="$Receipt.pending" - $stream=[IO.FileStream]::new($temporary,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read) - try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()} - [IO.File]::Move($temporary,$Receipt,$true) - $script:receiptWritten=$true - } catch {Write-Warning 'Progress receipt could not be updated; cleanup must still run'} - Write-Host "Native SSH stage: $Stage ($timestamp)" + if(Write-WindowsSshReceiptStage $report $Receipt $Stage){$script:receiptWritten=$true} } Write-Stage 'preflight-start' if(-not $script:receiptWritten){throw 'Initial progress receipt unavailable; refuse provisioning'} -if ($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1' -or [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ne $target.os) { throw "Requires isolated native $Arch GitHub runner" } -$admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -if (-not $admin) { throw 'Administrative private service/account setup required' } +Assert-IsolatedWindowsSshCi $Arch Write-Stage 'existing-server-query-start' $inboxDir=Join-Path $env:WINDIR 'System32\OpenSSH' -function Assert-GlobalServerDormant { - $global=Get-CimInstance Win32_Service -Filter "Name='sshd'" - if(-not $global){return} - # Inbox mode may register the global service; it must stay stopped and never be started here. - if($Server -ne 'inbox' -or $global.State -ne 'Stopped' -or $global.PathName.Trim('"') -ne (Join-Path $inboxDir 'sshd.exe')){throw 'Refuse an existing global SSH server'} -} -Assert-GlobalServerDormant +Assert-WindowsSshGlobalServerDormant $Server Write-Stage 'existing-server-query-complete' Write-Stage 'default-shell-query-start' $openSshKey = 'HKLM:\SOFTWARE\OpenSSH' -$registry = Get-ItemProperty -LiteralPath $openSshKey -ErrorAction SilentlyContinue # Host-cell probes may set DefaultShell per cell; cleanup restores this stock state. -if ($registry.DefaultShell -or $registry.DefaultShellCommandOption) { throw 'Requires stock cmd.exe OpenSSH shell at start' } +Assert-WindowsSshStockShell Write-Stage 'default-shell-query-complete' +if($InboxPreparationReceipt){ + if($Server -ne 'inbox'){throw 'Inbox preparation receipt cannot qualify a preview server'} + $preparation=Get-Content -LiteralPath $InboxPreparationReceipt -Raw | ConvertFrom-Json + if($preparation.status -ne 'passed' -or $preparation.arch -ne $Arch -or $preparation.sourceSha -ne $env:GITHUB_SHA -or $preparation.runId -ne $env:GITHUB_RUN_ID -or $preparation.runAttempt -ne $env:GITHUB_RUN_ATTEMPT -or $preparation.runnerName -ne $env:RUNNER_NAME -or $preparation.imageVersion -ne $env:ImageVersion){throw 'Inbox preparation receipt identity or verdict mismatch'} + $report.inboxCapabilityPreparation=$preparation +} $id = [Guid]::NewGuid().ToString('N').Substring(0,10) $name = "orca$id" $accountNames = @($name) + @(if($Accounts -gt 1){2..$Accounts | ForEach-Object {"$name$_"}}) @@ -180,12 +168,7 @@ try { $report.nativeInputs=$verified Write-Stage 'preview-native-input-verification-complete' } else { - Write-Stage 'inbox-capability-start' - $capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' - $report.inboxCapabilityInitialState=[string]$capability.State - if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null} - Assert-GlobalServerDormant - Write-Stage 'inbox-capability-complete' + Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage} $verified=@() foreach($binary in @('sshd.exe','ssh.exe','ssh-keygen.exe','sftp.exe','sftp-server.exe')){ $path=Join-Path $sshDir $binary @@ -431,21 +414,38 @@ LogLevel DEBUG1 foreach($directory in $deniedToolDirs){Invoke-Bounded icacls.exe (@($directory.TrimEnd('\'),'/remove:d')+@($ownedAccounts | ForEach-Object {"*$($_.sid)"})) 120 | Out-Null} Write-Stage 'cleanup-toolchain-acl-complete' Write-Stage 'cleanup-user-profile-start' + $profileTargets=@(foreach($account in $ownedAccounts){ + if($account.sid){@{sid=$account.sid;watch=$null;done=$false;profiles=@();waitMs=0;disposition=$null}} + }) $report.profileCleanup=@() - foreach($account in $ownedAccounts){ - if(-not $account.sid){continue} - $profileWait=[Diagnostics.Stopwatch]::StartNew() - do { - $profiles=@(Get-CimInstance Win32_UserProfile | Where-Object SID -eq $account.sid) - if(-not @($profiles | Where-Object Loaded).Count){break} - Start-Sleep -Milliseconds 500 - } while($profileWait.Elapsed.TotalSeconds -lt 30) - $report.profileUnloadWaitMs=$profileWait.ElapsedMilliseconds - $report.privateProfile=@($profiles | ForEach-Object {@{loaded=$_.Loaded;status=$_.Status}}) - Write-Stage 'cleanup-user-profile-observed' - $loadedProfiles=@($profiles | Where-Object Loaded) - $report.profileCleanup+=if($loadedProfiles.Count){'Loaded profile retained for disposable CI VM destruction'}else{'Unloaded profile removed'} - $profiles | Where-Object {-not $_.Loaded} | Remove-CimInstance + $report.privateProfiles=@() + do { + foreach($entry in @($profileTargets | Where-Object {-not $_.done})){ + if(-not $entry.watch){$entry.watch=[Diagnostics.Stopwatch]::StartNew()} + $profiles=@(Get-CimInstance Win32_UserProfile -Filter "SID='$($entry.sid)'") + if(@($profiles | Where-Object SID -ne $entry.sid).Count){throw 'Private profile query returned an unrelated SID'} + if(@($profiles | Where-Object Loaded).Count -and $entry.watch.ElapsedMilliseconds -lt 30000){continue} + # A profile may reload after the polling snapshot. + $profiles=@(Get-CimInstance Win32_UserProfile -Filter "SID='$($entry.sid)'") + if(@($profiles | Where-Object SID -ne $entry.sid).Count){throw 'Private profile query returned an unrelated SID'} + $loadedProfiles=@($profiles | Where-Object Loaded) + if($loadedProfiles.Count -and $entry.watch.ElapsedMilliseconds -lt 30000){continue} + $profiles | Where-Object {-not $_.Loaded} | Remove-CimInstance + $entry.profiles=@($profiles | ForEach-Object {@{loaded=$_.Loaded;status=$_.Status}}) + $entry.waitMs=$entry.watch.ElapsedMilliseconds + $entry.disposition=if($loadedProfiles.Count){'Loaded profile retained for disposable CI VM destruction'}else{'Unloaded profile removed'} + $entry.done=$true + $report.profileCleanup=@($profileTargets | Where-Object done | ForEach-Object disposition) + $report.privateProfiles=@($profileTargets | Where-Object done | ForEach-Object {@{sid=$_.sid;waitMs=$_.waitMs;profiles=$_.profiles;disposition=$_.disposition}}) + $report.profileUnloadWaitMs=$entry.waitMs + $report.privateProfile=$entry.profiles + Write-Stage 'cleanup-user-profile-observed' + } + if(@($profileTargets | Where-Object {-not $_.done}).Count){Start-Sleep -Milliseconds 500} + } while(@($profileTargets | Where-Object {-not $_.done}).Count) + if($profileTargets.Count){ + $report.profileUnloadWaitMs=$profileTargets[-1].waitMs + $report.privateProfile=$profileTargets[-1].profiles } Write-Stage 'cleanup-user-profile-complete' Write-Stage 'cleanup-user-start' diff --git a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 index 0304ac7a275..5bd3ccf30cd 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 @@ -33,5 +33,166 @@ try { if(($result.hidden -join '|') -ne "$nodeDir|$gccDir"){throw 'Toolchain PATH entries not hidden'} if(($result.kept -join '|') -ne "$plainDir|$(Join-Path $pathRoot 'missing')|Q:\no-such-drive"){throw 'Plain PATH entries not kept in order'} } finally {Remove-Item -LiteralPath $pathRoot -Recurse -Force -ErrorAction SilentlyContinue} +# Mock only the machine boundary; exercise the shared installer without servicing this host. +. (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1') +function Assert-IsolatedWindowsSshCi([string]$Arch){if($script:refuseCapabilityHost){throw 'Injected host refusal'}} +function Assert-WindowsSshGlobalServerDormant([string]$Server){$script:globalChecks++;if($script:globalChecks -eq $script:refuseGlobalCheck){throw 'Injected global server refusal'}} +function Assert-WindowsSshStockShell {$script:shellChecks++;if($script:shellChecks -eq $script:refuseShellCheck){throw 'Injected shell refusal'}} +function Get-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityQueries++;return @{State=$script:capabilityState}} +function Add-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityAdds++;if($script:failCapabilityInstall){throw 'Injected capability install failure'}} +function Reset-CapabilityControl([string]$State){ + $script:capabilityState=$State;$script:capabilityQueries=0;$script:capabilityAdds=0 + $script:globalChecks=0;$script:shellChecks=0;$script:refuseGlobalCheck=0;$script:refuseShellCheck=0 + $script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false + $script:capabilityStages=[Collections.Generic.List[string]]::new() +} +foreach($state in @('Installed','NotPresent')){ + Reset-CapabilityControl $state + $capabilityReport=@{} + Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)} + $expectedAdds=if($state -eq 'Installed'){0}else{1} + if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne 'inbox-capability-start,inbox-capability-complete'){throw 'Shared capability preparation did not preserve the install and guard boundaries'} +} +foreach($fault in @('host','global-before','shell-before','global-after','shell-after','install')){ + Reset-CapabilityControl 'NotPresent' + switch($fault){ + 'host' {$script:refuseCapabilityHost=$true} + 'global-before' {$script:refuseGlobalCheck=1} + 'shell-before' {$script:refuseShellCheck=1} + 'global-after' {$script:refuseGlobalCheck=2} + 'shell-after' {$script:refuseShellCheck=2} + 'install' {$script:failCapabilityInstall=$true} + } + $rejected=$false + try {Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)}} catch {$rejected=$true} + if(-not $rejected -or $script:capabilityStages.Contains('inbox-capability-complete')){throw "Capability fault did not fail closed: $fault"} + if($fault -in @('host','global-before','shell-before') -and $script:capabilityAdds){throw 'Capability mutation preceded its host guards'} +} +$capabilityRoot=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N')) +try { + New-Item -ItemType Directory -Path $capabilityRoot | Out-Null + $capabilityReceipt=Join-Path $capabilityRoot 'capability.json' + Reset-CapabilityControl 'Installed' + Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt + $completed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json + if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed'){throw 'Capability success receipt missing its observed initial state'} + Reset-CapabilityControl 'NotPresent';$script:failCapabilityInstall=$true + $rejected=$false + try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true} + $failed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json + if(-not $rejected -or $failed.status -ne 'failed' -or $failed.inboxCapabilityInitialState -ne 'NotPresent' -or $failed.error -ne 'Injected capability install failure'){throw 'Failed capability preparation masqueraded as a passing receipt'} +} finally {Remove-Item -LiteralPath $capabilityRoot -Recurse -Force -ErrorAction SilentlyContinue} +& { + param($ProofAst) + $cleanup=@($ProofAst.FindAll({param($node) $node -is [Management.Automation.Language.TryStatementAst] -and $node.Body.Extent.Text.Contains("Write-Stage 'cleanup-start'")},$true)) + if($cleanup.Count -ne 1 -or -not $cleanup[0].Extent.Text.Contains('[Diagnostics.Stopwatch]::StartNew()')){throw 'Cleanup clock boundary missing'} + # Run the real cleanup gates with virtual clocks; no Windows machine operation may escape these mocks. + $cleanupBlock=[scriptblock]::Create($cleanup[0].Extent.Text.Replace('[Diagnostics.Stopwatch]::StartNew()','(New-ProfileControlStopwatch)').Replace('[DateTime]::UtcNow','(Get-ProfileControlUtcNow)')) + $ownedSids=@('S-1-5-21-100-200-300-1001','S-1-5-21-100-200-300-1002','S-1-5-21-100-200-300-1003') + $foreignSid='S-1-5-21-100-200-300-9000';$control=@{} + function New-ProfileControlStopwatch { + $watch=[pscustomobject]@{StartedMilliseconds=$control.clockMs;Control=$control} + $watch | Add-Member ScriptProperty ElapsedMilliseconds {$this.Control.clockMs-$this.StartedMilliseconds} + return $watch + } + function Get-ProfileControlUtcNow {[DateTime]::new(2026,10,2,0,0,0,[DateTimeKind]::Utc).AddMilliseconds($control.clockMs)} + function Start-Sleep([int]$Milliseconds,[int]$Seconds){$control.clockMs+=$Milliseconds+1000*$Seconds} + function Write-Stage([string]$Stage){$control.stages.Add($Stage)} + function Record-PrivateServiceDiagnostics([switch]$AfterStop){} + function Test-Path([string]$LiteralPath){if($LiteralPath -ne 'HKLM:\SOFTWARE\OpenSSH'){throw 'Unexpected filesystem query'};return $false} + function Get-ItemProperty([string]$LiteralPath,[object]$ErrorAction){if($LiteralPath -ne 'HKLM:\SOFTWARE\OpenSSH'){throw 'Unexpected registry query'};return $null} + function Remove-ItemProperty {throw 'Unexpected registry mutation'} + function Stop-Service([string]$Name,[switch]$Force,[object]$ErrorAction){if($Name -ne 'orca-sshd-control'){throw 'Foreign service stop'};if($control.case.Fault -ne 'server-exit'){$control.serverLive=$false}} + function Invoke-Bounded([string]$Program,[string[]]$Arguments){if($Program -ne 'sc.exe' -or ($Arguments -join '|') -ne 'delete|orca-sshd-control'){throw 'Unexpected native command'};if($control.case.Fault -ne 'service-absence'){$control.servicePresent=$false}} + function Get-Process([int]$Id,[object]$ErrorAction){if($Id -ne 100){throw 'Foreign process query'};if($control.serverLive){@{Id=100}}} + function Get-Service([string]$Name,[object]$ErrorAction){if($Name -ne 'orca-sshd-control'){throw 'Foreign service query'};if($control.servicePresent){@{Name=$Name}}} + function Get-ProfileControlLoaded([string]$Sid){ + $state=$control.profiles[$Sid] + if($state.Mode -eq 'late'){return $control.clockMs -lt $state.UnloadAtMs} + if($state.Mode -in @('reload','reload-at-deadline')){return $state.CurrentLoaded} + return $state.Mode -eq 'loaded' + } + function Get-CimInstance([Parameter(Position=0)][string]$ClassName,[string]$Filter){ + if($ClassName -eq 'Win32_Service'){ + if($Filter -ne "Name='orca-sshd-control'"){throw 'Foreign service query'} + if($control.servicePresent){@{PathName=$(if($control.case.Fault -eq 'service-identity'){'C:\foreign\sshd.exe'}else{'C:\fake\ossh-control\sshd.exe'});ProcessId=$(if($control.case.Fault -eq 'service-pid'){200}else{100})}};return + } + if($ClassName -eq 'Win32_Process'){ + if($control.case.Fault -eq 'child-exit'){@{ExecutablePath='C:\fake\OpenSSH\session.exe';ProcessId=101;ParentProcessId=100;CreationDate=(Get-ProfileControlUtcNow)}};return + } + if($ClassName -ne 'Win32_UserProfile' -or $Filter -notmatch "^SID='(S-1-5-21-100-200-300-\d+)'$" -or $Matches[1] -notin $ownedSids){throw 'Profile query must target an owned SID'} + $sid=$Matches[1];$control.clockMs+=$control.case.QueryCostMs;$control.profileQueries[$sid]++ + if($control.case.Fault -eq 'query' -and $sid -eq $ownedSids[1]){throw 'Injected profile query failure'} + if($control.case.Fault -eq 'foreign-result' -and $sid -eq $ownedSids[1]){[pscustomobject]@{SID=$foreignSid;Loaded=$false;Status=0};return} + $state=$control.profiles[$sid] + if($state.Mode -eq 'absent' -or $state.Deleted){return} + $loaded=Get-ProfileControlLoaded $sid + if($state.Mode -eq 'reload' -and $control.profileQueries[$sid] -eq 1){$loaded=$false;$state.CurrentLoaded=$true} + if($state.Mode -eq 'reload-at-deadline' -and $control.clockMs -ge 30000 -and -not $state.Reinjected){$loaded=$false;$state.CurrentLoaded=$true;$state.Reinjected=$true} + [pscustomobject]@{SID=$sid;Loaded=$loaded;Status=0} + } + function Remove-CimInstance { + param([Parameter(ValueFromPipeline=$true)][object]$InputObject) + process { + if($InputObject.SID -notin $ownedSids -or $InputObject.Loaded -or (Get-ProfileControlLoaded $InputObject.SID)){throw 'Unsafe profile deletion attempted'} + if($control.case.Fault -eq 'delete'){throw 'Injected profile deletion failure'} + $control.profiles[$InputObject.SID].Deleted=$true;$control.removed.Add($InputObject.SID) + } + } + function Get-LocalUser([string]$Name,[object]$ErrorAction){if(-not $control.users.ContainsKey($Name)){throw 'Foreign account query'};if($control.users[$Name]){@{Name=$Name}}} + function Remove-LocalUser([string]$Name){if(-not $control.users.ContainsKey($Name)){throw 'Foreign account deletion'};if($control.case.Fault -ne 'account'){$control.users[$Name]=$false}} + function Remove-Item([string]$LiteralPath,[switch]$Recurse,[switch]$Force){if($LiteralPath -ne 'C:\fake\ossh-control'){throw 'Foreign filesystem deletion'};if($control.case.Fault -eq 'keys'){throw 'Injected private key deletion failure'};$control.keysRemoved=$true} + function Assert-ProfileCleanup([hashtable]$Case){ + $control.Clear();$control.case=$Case;$control.clockMs=0;$control.serverLive=$true;$control.servicePresent=$true;$control.keysRemoved=$false + $control.stages=[Collections.Generic.List[string]]::new();$control.removed=[Collections.Generic.List[string]]::new() + $control.profiles=@{};$control.users=@{};$control.profileQueries=@{} + $ownedAccounts=@(for($index=0;$index -lt $ownedSids.Count;$index++){ + $sid=$ownedSids[$index];$control.profileQueries[$sid]=0 + $control.profiles[$sid]=@{Mode=$Case.Modes[$index];UnloadAtMs=$Case.UnloadAtMs[$index];CurrentLoaded=$true;Deleted=$false} + $name="orca-control-$index";$control.users[$name]=$true;@{name=$name;sid=$(if($Case.MissingSid -and $index -eq 2){$null}else{$sid})} + }) + $report=@{status='proof-passed-cleanup-pending'};$openSshKey='HKLM:\SOFTWARE\OpenSSH';$serviceName='orca-sshd-control' + $root='C:\fake\ossh-control';$createdService=$true;$ownedServerPid=100;$sshDir='C:\fake\OpenSSH';$preexisting=@();$deniedToolDirs=@() + & $cleanupBlock + if($Case.Fault){ + if($report.status -ne 'failed' -or $report.cleanupError -ne $Case.Error -or $control.keysRemoved -or $control.stages.Contains('cleanup-private-files-complete')){throw "Cleanup fault did not fail at its gate: $($Case.Name)"} + if($Case.Fault -notin @('account','keys') -and @($control.users.Values | Where-Object {-not $_}).Count){throw "Failure bypassed account cleanup gate: $($Case.Name)"} + return + } + if($report.status -ne 'passed' -or @($control.users.Values | Where-Object {$_}).Count -or -not $control.keysRemoved){throw "Cleanup omitted account/key gates: $($Case.Name)"} + if(($control.removed.ToArray() | Sort-Object) -join ',' -ne (($Case.Removed | Sort-Object) -join ',')){throw "Wrong removed SIDs: $($Case.Name)"} + if(@($report.profileCleanup | Where-Object {$_ -eq 'Loaded profile retained for disposable CI VM destruction'}).Count -ne $Case.Retained){throw "Wrong retained disposition: $($Case.Name)"} + foreach($sid in $Case.FullWindow){ + $observed=@($report.privateProfiles | Where-Object sid -eq $sid) + if($observed.Count -ne 1 -or $observed[0].waitMs -lt 30000){throw "Short profile observation window: $($Case.Name)"} + } + if($control.clockMs -gt $Case.MaxClockMs){throw "Profile windows were serialized: $($Case.Name)"} + if($Case.MissingSid -and $control.profileQueries[$ownedSids[2]]){throw 'Missing SID gained profile deletion authority'} + if($report.privateProfiles.Count -and ($report.profileUnloadWaitMs -ne $report.privateProfiles[-1].waitMs -or ($report.privateProfile | ConvertTo-Json -Compress) -ne ($report.privateProfiles[-1].profiles | ConvertTo-Json -Compress))){throw 'Legacy scalar observation lost owned-account order'} + } + $cases=@( + @{Name='three loaded full windows';Modes=@('loaded','loaded','loaded');Retained=3;Removed=@();FullWindow=$ownedSids}, + @{Name='unload at 29999ms';Modes=@('late','unloaded','absent');UnloadAtMs=@(29999,0,0);Retained=0;Removed=$ownedSids[0..1];FullWindow=@($ownedSids[0])}, + @{Name='reload before deletion';Modes=@('reload','unloaded','absent');Retained=1;Removed=@($ownedSids[1]);FullWindow=@($ownedSids[0])}, + @{Name='independent unloads';Modes=@('late','loaded','late');UnloadAtMs=@(5000,0,20000);Retained=1;Removed=@($ownedSids[0],$ownedSids[2]);FullWindow=@($ownedSids[1])}, + @{Name='slow provider queries';Modes=@('loaded','loaded','loaded');QueryCostMs=200;Retained=3;Removed=@();FullWindow=$ownedSids;MaxClockMs=40000}, + @{Name='reload at expired window';Modes=@('reload-at-deadline','unloaded','absent');Retained=1;Removed=@($ownedSids[1]);FullWindow=@($ownedSids[0])}, + @{Name='late unload honestly retained';Modes=@('loaded','loaded','late');UnloadAtMs=@(0,0,45000);Retained=3;Removed=@();FullWindow=$ownedSids}, + @{Name='missing SID is skipped';Modes=@('unloaded','absent','unloaded');Retained=0;Removed=@($ownedSids[0]);MissingSid=$true} + ) + foreach($case in $cases){ + if(-not $case.UnloadAtMs){$case.UnloadAtMs=@(0,0,0)} + if(-not $case.MaxClockMs){$case.MaxClockMs=33000} + Assert-ProfileCleanup $case + } + $failures=@{ + query='Injected profile query failure';delete='Injected profile deletion failure';'foreign-result'='Private profile query returned an unrelated SID' + 'service-identity'='Private service identity changed; refuse stop';'service-pid'='Private service identity changed; refuse stop' + 'server-exit'='Private sshd process still live; no PID-only kill attempted' + 'service-absence'='Private service still registered';'child-exit'='Private SSH child processes remain; preserve files and discard ephemeral runner' + account='Private account still exists';keys='Injected private key deletion failure' + } + foreach($failure in $failures.GetEnumerator()){Assert-ProfileCleanup @{Name=$failure.Key;Fault=$failure.Key;Error=$failure.Value;Modes=@('unloaded','unloaded','unloaded');UnloadAtMs=@(0,0,0)}} +} $ast # Extract functions through the AST: never provision the fixture while testing diagnostics. -'PASS: fixture parse, five numeric-diagnostic cases and the toolchain PATH split' +'PASS: fixture parse, diagnostics, PATH split, capability boundaries, profile windows and cleanup failure gates' diff --git a/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 b/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 new file mode 100644 index 00000000000..d2bdc9096a3 --- /dev/null +++ b/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 @@ -0,0 +1,61 @@ +function Assert-IsolatedWindowsSshCi([ValidateSet('arm64','x64')][string]$Arch) { + $os=@{arm64='Arm64';x64='X64'}[$Arch] + if($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1' -or [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ne $os){throw "Requires isolated native $Arch GitHub runner"} + $admin=([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if(-not $admin){throw 'Administrative private service/account setup required'} +} + +function Assert-WindowsSshGlobalServerDormant([ValidateSet('preview','inbox')][string]$Server) { + $global=Get-CimInstance Win32_Service -Filter "Name='sshd'" + if(-not $global){return} + $inboxDir=Join-Path $env:WINDIR 'System32\OpenSSH' + # Inbox installation may register the global service; it must never be started here. + if($Server -ne 'inbox' -or $global.State -ne 'Stopped' -or $global.PathName.Trim('"') -ne (Join-Path $inboxDir 'sshd.exe')){throw 'Refuse an existing global SSH server'} +} + +function Assert-WindowsSshStockShell { + $registry=Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\OpenSSH' -ErrorAction SilentlyContinue + if($registry.DefaultShell -or $registry.DefaultShellCommandOption){throw 'Requires stock cmd.exe OpenSSH shell at start'} +} + +function Write-WindowsSshReceiptStage([hashtable]$Report,[string]$Receipt,[string]$Stage) { + $timestamp=[DateTime]::UtcNow.ToString('o') + $Report.stages+=@{stage=$Stage;utc=$timestamp} + try { + $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Report | ConvertTo-Json -Depth 6)) + $temporary="$Receipt.pending" + $stream=[IO.FileStream]::new($temporary,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read) + try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()} + [IO.File]::Move($temporary,$Receipt,$true) + $written=$true + } catch {$written=$false;Write-Warning 'Progress receipt could not be updated; cleanup must still run'} + Write-Host "Native SSH stage: $Stage ($timestamp)" + return $written +} + +function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage) { + Assert-IsolatedWindowsSshCi $Arch + Assert-WindowsSshGlobalServerDormant 'inbox' + Assert-WindowsSshStockShell + & $Stage 'inbox-capability-start' + $capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' + $Report.inboxCapabilityInitialState=[string]$capability.State + if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null} + Assert-WindowsSshGlobalServerDormant 'inbox' + Assert-WindowsSshStockShell + & $Stage 'inbox-capability-complete' +} + +function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt) { + $report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'} + try { + if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-start')){throw 'Initial progress receipt unavailable; refuse provisioning'} + Install-WindowsInboxSshCapability $Arch $report {param($name) + if(-not (Write-WindowsSshReceiptStage $report $Receipt $name)){throw 'Capability preparation progress receipt unavailable'} + } + $report.status='passed' + } catch {$report.status='failed';$report.error=$_.Exception.Message;throw} + finally { + if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-finished')){throw 'Final capability preparation receipt unavailable'} + } +} diff --git a/config/docker/headless-pairing/Dockerfile.build b/config/docker/headless-pairing/Dockerfile.build index 19f76290c75..0300d8a82d4 100644 --- a/config/docker/headless-pairing/Dockerfile.build +++ b/config/docker/headless-pairing/Dockerfile.build @@ -26,7 +26,7 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* RUN corepack enable \ - && corepack prepare pnpm@12.0.0 --activate \ + && corepack prepare pnpm@12.8.1 --activate \ && pnpm --version WORKDIR /workspace diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 20c3334bbdb..b0666c42a3d 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -164,9 +164,10 @@ const rpmElectronRuntimeDependencies = [ ] // Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build. -// Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with +// Keep in sync with isOsOpenedDocumentName() in src/main/startup/os-opened-documents.ts and with // config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows. const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx'] +const TABULAR_FILE_EXTENSIONS = ['csv', 'tsv'] // Why: the config must load on a host-only install without resolving unused Windows addons. // This is load-time tolerance only; beforePack enforces that the target's natives are installed. @@ -283,7 +284,7 @@ module.exports = { // before the GUI process starts, so those deps need the same treatment. // Why: out/package.json pins compiled output to CommonJS so parent // package.json files with type=module cannot change the packaged CLI loader. - // Why: the OpenCode SQLite worker entry is also spawned by the scanner + // Why: the foreign SQLite reader entry is also spawned by the scanner // service, which runs under ELECTRON_RUN_AS_NODE and so cannot see into // app.asar. Left packed, that spawn fails closed and every OpenCode session // disappears from Agent Session History in packaged builds only. Worker @@ -302,6 +303,7 @@ module.exports = { 'out/main/cursor/**', 'out/main/droid/**', 'out/main/gemini/**', + 'out/main/gitlab/project-ref-parser.js', 'out/main/grok/**', 'out/main/hermes/**', 'out/main/orca-profiles/profile-index-store.js', @@ -310,8 +312,7 @@ module.exports = { 'out/main/daemon-entry.js', 'out/main/session-scanner-service-entry.js', 'out/main/wsl-transcript-fs-process-entry.js', - 'out/main/cursor-desktop-profile-worker-entry.js', - 'out/main/session-scanner-opencode-sqlite-worker-entry.js', + 'out/main/foreign-sqlite-reader-entry.js', 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', 'out/main/parcel-watcher-process-entry.js', @@ -510,16 +511,25 @@ module.exports = { include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh') }, mac: { - // Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming + // Why rank Alternate: Orca joins Finder's "Open With" list without claiming // LSHandlerRank ownership, so whichever editor the user already prefers stays the default. // Why one entry per extension: app-builder-lib globs `*.${ext}`, which an array would break. - fileAssociations: MARKDOWN_FILE_EXTENSIONS.map((ext) => ({ - ext, - name: 'Markdown Document', - description: 'Markdown Document', - role: 'Editor', - rank: 'Alternate' - })), + fileAssociations: [ + ...MARKDOWN_FILE_EXTENSIONS.map((ext) => ({ + ext, + name: 'Markdown Document', + description: 'Markdown Document', + role: 'Editor', + rank: 'Alternate' + })), + ...TABULAR_FILE_EXTENSIONS.map((ext) => ({ + ext, + name: `${ext.toUpperCase()} Document`, + description: `${ext.toUpperCase()} Document`, + role: 'Editor', + rank: 'Alternate' + })) + ], icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', @@ -612,7 +622,7 @@ module.exports = { // override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the // default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to // application/x-genesis-32x-rom, so claiming it here would need a glob override. - mimeTypes: ['text/markdown'], + mimeTypes: ['text/markdown', 'text/csv', 'text/tab-separated-values'], // Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca. // The Linux installer should not claim those system package/file names. executableName: 'orca-ide', diff --git a/config/knip.json b/config/knip.json index 92c55c8ac7d..98891b9e687 100644 --- a/config/knip.json +++ b/config/knip.json @@ -9,9 +9,7 @@ "src/main/computer/sidecar-entry.ts", "src/main/speech/stt-worker.ts", "src/main/warp-themes/warp-theme-parser-worker.ts", - "src/main/rate-limits/cursor-desktop-profile-worker-entry.ts", - "src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts", - "src/main/ai-vault/session-scanner-worker-entry.ts", + "src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts", "src/main/ports/port-scan-command-worker-entry.ts", "src/main/ipc/parcel-watcher-process-entry.ts", "src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts", diff --git a/config/localization-audit.md b/config/localization-audit.md index 77a0e15bd57..1bb39bf2017 100644 --- a/config/localization-audit.md +++ b/config/localization-audit.md @@ -95,9 +95,9 @@ not invoke tools that can overwrite an entire target catalog. The coverage gate compares current candidates against `config/localization-coverage-allowlist.json`. The committed allowlist is -small (10 reviewed entries — one test fixture title, five non-English -language-name search keywords, and four reviewed product-name search -keywords): new candidates fail the check and must be localized or added with +small (11 reviewed entries — six non-English language-name search keywords, +four reviewed product-name search keywords, and one non-UI `label` placement +prop): new candidates fail the check and must be localized or added with a reviewed reason in the same change. The script scans `src/renderer/src` by default. That is the primary UI surface. diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 0918ed3a513..93d984594af 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -1,36 +1,15 @@ [ { - "filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts", - "kind": "object-property:title", - "text": "Nightly #1", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts", - "kind": "object-property:label", - "text": "Hermes", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx", - "kind": "jsx-attribute:label", - "text": "sidebar", + "filePath": "src/renderer/src/components/settings/appearance-search.ts", + "kind": "object-property:keywords", + "text": "Idioma", "dynamic": false, "count": 1 }, { "filePath": "src/renderer/src/components/settings/appearance-search.ts", "kind": "object-property:keywords", - "text": "语言", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/appearance-search.ts", - "kind": "object-property:keywords", - "text": "語言", + "text": "Langue", "dynamic": false, "count": 1 }, @@ -51,24 +30,17 @@ { "filePath": "src/renderer/src/components/settings/appearance-search.ts", "kind": "object-property:keywords", - "text": "Idioma", + "text": "語言", "dynamic": false, "count": 1 }, { "filePath": "src/renderer/src/components/settings/appearance-search.ts", "kind": "object-property:keywords", - "text": "Langue", + "text": "语言", "dynamic": false, "count": 1 }, - { - "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", - "kind": "object-property:keywords", - "text": "Ghostty", - "dynamic": false, - "count": 2 - }, { "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", "kind": "object-property:keywords", @@ -77,11 +49,11 @@ "count": 2 }, { - "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", + "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", "kind": "object-property:keywords", "text": "Ghostty", "dynamic": false, - "count": 1 + "count": 2 }, { "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", @@ -91,317 +63,16 @@ "count": 1 }, { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:title", - "text": "Default shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:description", - "text": "Shell used for new terminal panes", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", "kind": "object-property:keywords", - "text": "shell", + "text": "Ghostty", "dynamic": false, "count": 1 }, { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "terminal", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "fish", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "zsh", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "bash", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "nushell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "default", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:title", - "text": "Terminal shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:description", - "text": "Choose what Orca opens for new local terminal panes.", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:ariaLabel", - "text": "Terminal shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:label", - "text": "System shell (", - "dynamic": true, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:label", - "text": "Custom shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:placeholder", - "text": "fish, nu, or /bin/zsh", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:aria-label", - "text": "Custom shell executable", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-text", - "text": "Enter a shell name on PATH or an executable path. Orca starts it as a login shell.", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-text", - "text": ". Switch to System shell or choose an executable on this host.", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "arguments", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "args", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "login", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "wrapper", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "rcfile", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-text", - "text": "Advanced", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-text", - "text": "Shell arguments", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-expression", - "text": "Starts the shell as a login shell with -l.", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-expression", - "text": "Enter one argument per line. Leave it empty to pass no arguments.", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:ariaLabel", - "text": "Shell argument mode", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:label", - "text": "-l (default)", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:label", - "text": "Custom args", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:placeholder", - "text": "--rcfile /path/to/rcfile", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:aria-label", - "text": "Shell arguments, one per line", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:title", - "text": "Terminal shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:description", - "text": "Shell and arguments used for new local interactive terminal panes", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "terminal", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "fish", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "zsh", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "bash", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "nushell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "arguments", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "args", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "login", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "wrapper", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "rcfile", + "filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx", + "kind": "jsx-attribute:label", + "text": "sidebar", "dynamic": false, "count": 1 } diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh index a6fb31f9aec..6d6dd069592 100644 --- a/config/nsis/orca-installer-hooks.nsh +++ b/config/nsis/orca-installer-hooks.nsh @@ -6,7 +6,7 @@ !include "${__FILEDIR__}\orca-process-check.nsh" ; --------------------------------------------------------------------------- -; Markdown "Open with Orca" (issue #10138) +; Markdown and CSV/TSV "Open with Orca" (issues #10138, #23225) ; ; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows: ; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is @@ -21,29 +21,36 @@ ; exactly where the user left it. Never add a `Software\Classes\.` default ; value here. ; -; MARKDOWN_PROGID must stay in sync with the extension list handled by -; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts. +; Keep the extension list in sync with isOsOpenedDocumentName(). ; --------------------------------------------------------------------------- !define MARKDOWN_PROGID "Orca.Markdown" +!define TABULAR_PROGID "Orca.Tabular" -!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT - WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" +!macro ORCA_REGISTER_DOCUMENT_OPEN_WITH EXT PROGID + WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${PROGID}" WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" "" !macroend -!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT - DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" +!macro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH EXT PROGID + DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${PROGID}" DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" !macroend +!macro ORCA_REGISTER_DOCUMENT_PROGID PROGID NAME + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}" "" "${NAME}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\DefaultIcon" "" "$appExe,0" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\shell\open\command" "" '"$appExe" "%1"' +!macroend + !macro customInstall - WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document" - WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0" - WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}" - WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"' - !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md" - !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown" - !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx" + !insertmacro ORCA_REGISTER_DOCUMENT_PROGID "${MARKDOWN_PROGID}" "Markdown Document" + !insertmacro ORCA_REGISTER_DOCUMENT_PROGID "${TABULAR_PROGID}" "Tabular Document" + !insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".md" "${MARKDOWN_PROGID}" + !insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".markdown" "${MARKDOWN_PROGID}" + !insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".mdx" "${MARKDOWN_PROGID}" + !insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".csv" "${TABULAR_PROGID}" + !insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".tsv" "${TABULAR_PROGID}" ; Why: Explorer caches the association list until told otherwise. System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" !macroend @@ -100,8 +107,11 @@ ; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so ; dropping them during uninstallOldVersion is correct and keeps the pair symmetric. DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" - !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md" - !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown" - !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx" + DeleteRegKey SHELL_CONTEXT "Software\Classes\${TABULAR_PROGID}" + !insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".md" "${MARKDOWN_PROGID}" + !insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".markdown" "${MARKDOWN_PROGID}" + !insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".mdx" "${MARKDOWN_PROGID}" + !insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".csv" "${TABULAR_PROGID}" + !insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".tsv" "${TABULAR_PROGID}" System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" !macroend diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json index d951695a339..3a38be8e4a0 100644 --- a/config/oxlint-anti-slop.json +++ b/config/oxlint-anti-slop.json @@ -65,6 +65,13 @@ "anti-slop/no-module-mocking": "off" } }, + // Test-only side effect for explicit RPC registries; real catalog tests never import it. + { + "files": ["**/src/main/runtime/rpc/unused-default-rpc-methods.test-fixture.ts"], + "rules": { + "anti-slop/no-module-mocking": "off" + } + }, // The exemptions below are file-scoped rather than inline `oxlint-disable` comments // because the root lint scan does not load this plugin, so an inline directive naming // an anti-slop rule always reads back as an unused directive there. diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index c88d70350eb..9b1418cf28c 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -25,8 +25,6 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ 'node-pty', 'posthog-node', 'proper-lockfile', - // serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too. - 'serve-sim', 'qrcode', 'ssh2', 'tweetnacl', @@ -34,6 +32,9 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ 'yaml', 'zod' ] +// Why macOS only: serve-sim drives the iOS Simulator, and its native addon is a Mach-O that +// Windows signing rejects as a PE file. +const DARWIN_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['serve-sim'] const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [ '@vscode/windows-process-tree', '@orca/windows-registry' @@ -180,6 +181,7 @@ function collectPackagedRuntimePackages(electronPlatformName = process.platform) // Why: cross-builds must select native dependencies from the artifact target, not the build host. const packageRoots = [ ...PACKAGED_RUNTIME_PACKAGE_ROOTS, + ...(electronPlatformName === 'darwin' ? DARWIN_PACKAGED_RUNTIME_PACKAGE_ROOTS : []), ...(electronPlatformName === 'win32' ? WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS : []) ] for (const packageName of packageRoots) { diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 5b4c135c1da..bc3b05b8d31 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -639,7 +639,7 @@ "protection": "partial", "owner": "agent-session-runtime", "layer": "runtime-unit", - "surfaces": ["structured chat journal replay", "structured chat recovery"], + "surfaces": ["structured chat journal replay", "structured chat damaged history"], "platforms": ["macos", "linux", "windows"], "providers": ["local", "remote-runtime"], "coveredPlatforms": ["macos"], @@ -648,15 +648,17 @@ "motivatingLinks": [ "https://github.com/stablyai/orca/blob/main/src/main/native-chat/agent-session-journal/journal-open.ts" ], - "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, repair precedence, read-only schema latching and cursor cleanup while retaining live items rather than all historical bodies.", - "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; preserve prefix and future-schema latching after a gap, malformed suffix repair precedence, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content and recovery.", + "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, a newer schema winning over damage and cursor cleanup while retaining live items rather than all historical bodies; damage fails the load and deletes nothing.", + "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; name a future-schema row read past a gap, name the first damage, fail a damaged load with every row kept, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content.", "commands": [ + // As the 2026-09-11 evidence run ran it; the recovery test it names went with the read-time repair. "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts", - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-damage.test.ts" ], "testFiles": [ "src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts", - "src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts" + "src/main/native-chat/agent-session-journal/journal-damage.test.ts" ], "assertionRefs": [ { @@ -664,9 +666,18 @@ "assertions": [ "releases superseded revision bodies while reducing a long journal", "holds no read snapshot while reducing, so a checkpoint can pass mid-replay", - "keeps the prefix but latches read-only for a future row beyond a gap", - "keeps gap repair precedence when a later row is malformed", - "rejects an unanchored prefix before a later gap" + "reads past a gap to a future row, which names the newer row and no damage", + "names the first damage when a later row is malformed too", + "names a missing epoch row before a later gap" + ] + }, + { + "file": "src/main/native-chat/agent-session-journal/journal-damage.test.ts", + "assertions": [ + "fails to load and keeps every row: %s", + "reads past damage to a newer build's row, which fails the load as a newer Orca's instead", + "is refused when it is written, and the chat still loads with every other row", + "is founded afresh on open, with nothing deleted, and takes writes" ] } ], @@ -679,6 +690,15 @@ "result": "passed", "durationSeconds": 7.71, "summary": "245 tests passed across 22 files. Retained-heap oracle fails on baseline at 68.6 MB and passes under 8 MiB with streaming; gap/schema and cursor-cleanup assertions passed." + }, + { + "date": "2026-10-02", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts", + "result": "passed", + "durationSeconds": 5.56, + "summary": "450 tests passed across 42 files, run as the vitest invocation the test script makes. Damage fails the load with every row kept; the retained-heap, newer-row-past-a-gap and cursor-cleanup assertions passed." } ], "runtimeBudget": { @@ -948,7 +968,7 @@ "invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.", "oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts" ], "testFiles": [ @@ -963,6 +983,7 @@ "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx", "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx", "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx", + "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx", "src/renderer/src/lib/launch-structured-agent-session.test.ts", "mobile/src/session/mobile-native-chat-image-attachment.test.ts", "mobile/src/session/use-mobile-native-chat-image-attachments.test.ts", @@ -1033,13 +1054,13 @@ ], "evidenceRuns": [ { - "date": "2026-09-12", + "date": "2026-10-04", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", - "durationSeconds": 22.1, - "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests." + "durationSeconds": 42.3, + "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 197 tests after the delivery probe tests moved to their own file." }, { "date": "2026-09-12", @@ -12107,10 +12128,10 @@ "https://github.com/stablyai/orca/pull/12778" ], "invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, per-pane liveness, and tab-title synchronization must not call global pty:listSessions or aiVault.listSessions; they must use targeted APIs or cached provider-owned state.", - "oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, serializes worker work, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.", + "oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, bounds scanner-service calls at sixteen, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts" ], "testFiles": [ "src/main/ipc/pty-startup-barrier-and-listing.test.ts", @@ -12118,7 +12139,7 @@ "src/renderer/src/components/status-bar/resource-session-inventory.test.ts", "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts", "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts", - "src/main/ai-vault/session-scanner-worker-client.test.ts", + "src/main/ai-vault/session-scanner-service-client.test.ts", "src/main/ai-vault/session-title-file-reader.test.ts", "src/main/ai-vault/session-parse-cache-persistence.test.ts", "src/main/ipc/ai-vault.test.ts", @@ -12168,12 +12189,12 @@ ] }, { - "file": "src/main/ai-vault/session-scanner-worker-client.test.ts", + "file": "src/main/ai-vault/session-scanner-service-client.test.ts", "assertions": [ - "full scans and exact-title reads share one serial FIFO worker", - "active cancellation stays serialized and queued work remains bounded", - "worker faults restart queued work and idle time preserves incremental parse state", - "worker disposal rejects retained work and terminates the worker" + "the service waits for ready and runs the cache and interactive lanes independently", + "active and queued calls are bounded together at sixteen", + "cancellation reaches active work and kills a service that ignores it", + "service faults restart queued work under a restart circuit that a forced refresh reopens" ] }, { @@ -12202,13 +12223,13 @@ "summary": "4 files and 358 tests passed, covering readiness seed/recovery, zero interval polling, bounded unknown-spawn reconciliation, concurrent provider starts, exit fencing, cleanup, and out-of-order refresh fencing." }, { - "date": "2026-08-09", + "date": "2026-10-02", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts", "result": "passed", - "durationSeconds": 3.1, - "summary": "The focused run passed 112 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, persistent serial worker lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback." + "durationSeconds": 5.3, + "summary": "The focused run passed 138 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, per-lane scanner-service lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback." } ], "runtimeBudget": { @@ -12221,11 +12242,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. For the #12778 regression, title sync no longer receives a listSessions dependency at all: it sends at most 64 exact identities per batch to one serial worker or transcript-owning remote host, and old hosts degrade without broad fallback. Needs broader raw focus/workspace-switch/render/high-session PTY count coverage before promotion." + "evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. For the #12778 regression, title sync no longer receives a listSessions dependency at all: it sends at most 64 exact identities per batch to the local scanner service or transcript-owning remote host, and old hosts degrade without broad fallback. Needs broader raw focus/workspace-switch/render/high-session PTY count coverage before promotion." }, "performanceBudget": { "required": true, - "evidence": "This gate is the performance budget for global session listing in hot paths. AI Vault title sync permits zero global scans, at most 64 exact identities per request, one active worker operation, 16 queued operations, four concurrent transcript parses inside the worker, a 4,096-title index, and no worktree-path-triggered refresh. The worker emits the aiVault.scan.worker span with duration and session count for full scans." + "evidence": "This gate is the performance budget for global session listing in hot paths. AI Vault title sync permits zero global scans, at most 64 exact identities per request, one active scanner-service call per lane (cache, interactive), 16 active plus queued calls, four concurrent transcript parses inside the service, a 4,096-title index, and no worktree-path-triggered refresh. The service emits the aiVault.scan.service span with duration and session count for full scans." }, "promotionCriteria": [ "Add deterministic call-count instrumentation.", @@ -16958,15 +16979,38 @@ "invariant": "Hidden-output restore, snapshot replay, metadata-only replay, and clear-before-replay must preserve order and never overlay stale bytes on newer live terminal output; restoring a snapshot onto an already-dirty pane must yield a buffer exactly equal to the snapshot frame.", "oracle": "Apply snapshots onto adversarially dirty pane states (already on the alternate screen, stale content occupying cells the new frame leaves blank, scrollback present, wide glyphs, revived sessions with restarted PTY seq counters) and assert exact buffer equality with the snapshot frame; fault injection interleaves hidden chunks, live output, metadata-only replay, and clear-before-replay, then asserts ordered terminal buffer content, clear decisions, and replay diagnostics. Marker-presence checks are not acceptable evidence on restore paths.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts" + "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", + "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts", + "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts" ], "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts", + "assertions": [ + "repaints a pushed image exactly and keeps the history the TUI covers", + "keeps the pane's history when the host screen has another grid", + "keeps history unduplicated when a pushed image also carries history", + "paints the whole image when the TUI started while hidden", + "reads the pane buffer after a queued alt-screen entry parses", + "repaints from the normal buffer once the host TUI has exited", + "clears a raw byte replay in place on the alt screen", + "paints a requested image from an exited TUI exactly over an alt screen" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts", + "assertions": [ + "keeps the covered history for a live TUI exactly as the host holds it", + "repaints from the normal buffer once the host TUI has exited" + ] + }, { "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", "assertions": [ @@ -21411,6 +21455,90 @@ "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." + }, + { + "id": "terminal-performance.consumed-side-effect-retention", + "title": "Terminal side-effect queues release successfully applied and evicted effects", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-unit", + "surfaces": ["terminal output side effects", "renderer memory census"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Provider-independent queue and mocked IPC output contracts run on macOS. Remote-runtime uses this processor but has no live session run. Native mobile uses another processor and is unaffected; host ownership, ACKs, wire, paths, folder/git identity, PTY lifecycle and output bytes are unchanged. Linux, Windows, WSL and live remote execution are gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts" + ], + "invariant": "Release consumed title/payload objects after successful apply or overflow carry, preserving callback order, 64-effect drains, the 512-effect pending cap, bell and payload carry, empty-tail coalescing, reentrant clear/flush/enqueue, thrown-apply behavior and output delivery.", + "oracle": "Forced GC collects all 64 applied effects from a 100-effect bounded drain while the remaining 36 stay alive and deliver in order; it also collects the first evicted effect in a 513-effect burst while all 512 survivors remain alive. Clearing during apply immediately releases all 99 other pending effects, as the original queue did. Census reports 36 retained objects after the bounded drain. Explicit reentrant and error cases produce the same observations against the original queue.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts", + "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts", + "src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts", + "assertions": [ + "releases applied effects while preserving every pending effect and its delivery order", + "releases an evicted effect before the compaction threshold", + "releases every pending effect immediately when clear is called during apply" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts", + "assertions": [ + "keeps empty-tail coalescing observable during the apply callback", + "delivers the same effect requeued after clear without releasing its new slot", + "preserves nested flush order and effects enqueued after the inner compaction", + "preserves thrown apply errors and their existing empty-tail coalescing" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-10-01", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts", + "result": "passed", + "durationSeconds": 1.66, + "summary": "46 tests across five files passed. Four reentrant/error cases also pass against an isolated original-queue copy; stock and candidate both release 99 pending effects when clear runs during apply." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused renderer queue/output tests, including forced GC; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local author and independent review validation; no CI soak. Reference-lifetime tests require the test runner's existing --expose-gc." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Original queue fails both WeakRef collection assertions: all 64 applied effects and the evicted effect remain reachable. Clearing only consumed slots passes while all pending effects stay live. Baseline and candidate both pass all four reentrant/error observations." + }, + "performanceBudget": { + "required": true, + "evidence": "Retained objects fall from 100 to 36 after one bounded drain and from 513 to 512 after one overflow eviction before compaction. Release is constant work per consumed effect; no changed drain limit, timer, polling, batching, cache, transport call or subprocess. Existing compaction cadence remains; clear truncates then replaces its backing array to preserve immediate release and protect reentrant same-object requeue." + }, + "knownGaps": [ + "No real renderer heap-byte or input-latency measurement; references and complete delivery are the deterministic oracle.", + "No live Linux, Windows, WSL, SSH or paired-runtime session run; these use provider-independent queue code.", + "Thrown apply callbacks keep their consumed reference until the original compaction boundary to preserve exception/coalescing behavior." + ], + "promotionCriteria": [ + "Collect CI soak with zero unexplained GC flakes and retain callback-order, overflow-carry and reentrancy assertions." + ], + "demotionRule": "Keep experimental; investigate delivery, coalescing, error-path or pending-reference regressions without weakening the retention or fidelity oracle." } ] } diff --git a/config/scripts/agent-state-rules-bundle.mjs b/config/scripts/agent-state-rules-bundle.mjs new file mode 100644 index 00000000000..170d6b4c122 --- /dev/null +++ b/config/scripts/agent-state-rules-bundle.mjs @@ -0,0 +1,174 @@ +// Builds and publishes the agent state rules bundle (agent-state-rules.json). The app's loader +// validates the same file (src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts), and +// agent-state-rules-bundle.test.mjs proves the build passes it; this script only assembles the +// file and enforces the publishing rules a single file cannot express. +// +// node config/scripts/agent-state-rules-bundle.mjs build [--bundled-only] +// node config/scripts/agent-state-rules-bundle.mjs publish-next [--bundled-only] +// node config/scripts/agent-state-rules-bundle.mjs promote-stable + +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { agentStateRulesTag } from './release-tag-patterns.mjs' + +const RULES_DIR = join(import.meta.dirname, '..', '..', 'src/main/runtime/agent-state-rules') +export const AGENT_STATE_RULES_ASSET = 'agent-state-rules.json' + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')) +} + +/** The live-updatable agents' files, in release order, under the release's version. */ +export function buildAgentStateRulesBundle({ bundledOnly = false } = {}) { + const release = readJson(join(RULES_DIR, 'agent-state-rules-release.json')) + const files = release.liveUpdatable.map((id) => readJson(join(RULES_DIR, `${id}.json`))) + const bundle = { + version: release.version, + engineVersion: files[0].engineVersion, + ...(bundledOnly ? { bundledOnly: true } : {}), + files + } + return `${JSON.stringify(bundle, null, 2)}\n` +} + +function runGh(args) { + const result = spawnSync('gh', args, { encoding: 'utf8' }) + return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' } +} + +function ghOrThrow(gh, args) { + const result = gh(args) + if (result.status !== 0) { + throw new Error(`gh ${args.join(' ')} failed: ${result.stderr.trim()}`) + } + return result.stdout +} + +/** The file on `tag`'s release, or null when there is no release yet. */ +function downloadPublished(gh, repo, tag) { + const args = [ + 'release', + 'download', + tag, + '--repo', + repo, + '-p', + AGENT_STATE_RULES_ASSET, + '-O', + '-' + ] + const result = gh(args) + if (result.status === 0) { + return result.stdout + } + // Why throw on anything else, a missing asset included: that means an earlier upload broke. + if (/release not found/i.test(result.stderr)) { + return null + } + throw new Error(`gh ${args.join(' ')} failed: ${result.stderr.trim()}`) +} + +/** + * Puts `text` on the channel's release for its engine, as a prerelease that never becomes Latest + * (the app updater follows Latest). `gh` is injectable so the sequence is testable. + */ +export function publishAgentStateRules({ repo, channel, text, target, gh = runGh }) { + const candidate = JSON.parse(text) + const tag = agentStateRulesTag(candidate.engineVersion, channel) + const publishedText = downloadPublished(gh, repo, tag) + // Why strictly higher: apps refuse a version they already have, so republishing one would reach + // nobody, and a lower one would reach only apps that never took the higher. + const published = publishedText === null ? null : JSON.parse(publishedText).version + if (published !== null && candidate.version <= published) { + throw new Error( + `version ${candidate.version} is not higher than the published ${published}; bump agent-state-rules-release.json` + ) + } + const file = join(mkdtempSync(join(tmpdir(), 'agent-state-rules-')), AGENT_STATE_RULES_ASSET) + writeFileSync(file, text) + if (published === null) { + ghOrThrow(gh, [ + 'release', + 'create', + tag, + file, + '--repo', + repo, + '--target', + target, + '--prerelease', + '--latest=false', + '--title', + `Agent state rules (${channel})`, + '--notes', + 'Agent state rules for Orca. Running apps download this file; it is not an app release.' + ]) + } else { + // Why --clobber on the same tag: the app's fixed URL stays valid, and a fetch that lands + // mid-upload gets a 404 and keeps its last good copy. + ghOrThrow(gh, ['release', 'upload', tag, file, '--repo', repo, '--clobber']) + } + return { tag, version: candidate.version } +} + +/** + * Why no rebuild and no re-gate: stable gets exactly the bytes RC and dev builds soaked on next, + * which only the gated publish-next job writes. A bundledOnly next is promoted too: that is how + * stable rolls back to the rules it shipped. + */ +export function promoteAgentStateRules({ repo, engineVersion, target, gh = runGh }) { + const nextTag = agentStateRulesTag(engineVersion, 'next') + const text = downloadPublished(gh, repo, nextTag) + if (text === null) { + throw new Error(`${nextTag} has not been published`) + } + return publishAgentStateRules({ repo, channel: 'stable', text, target, gh }) +} + +function requireEnv(name) { + const value = process.env[name] + if (!value) { + throw new Error(`${name} is not set`) + } + return value +} + +function main([command, ...args]) { + const bundledOnly = args.includes('--bundled-only') + switch (command) { + case 'build': { + const out = args.find((arg) => !arg.startsWith('--')) + if (!out) { + throw new Error('usage: build [--bundled-only]') + } + writeFileSync(out, buildAgentStateRulesBundle({ bundledOnly })) + return + } + case 'publish-next': + case 'promote-stable': { + const repo = requireEnv('GITHUB_REPOSITORY') + const target = requireEnv('GITHUB_SHA') + const text = buildAgentStateRulesBundle({ bundledOnly }) + const { tag, version } = + command === 'publish-next' + ? publishAgentStateRules({ repo, channel: 'next', text, target }) + : promoteAgentStateRules({ repo, engineVersion: JSON.parse(text).engineVersion, target }) + console.log(`Published version ${version} to ${tag}.`) + return + } + default: + throw new Error(`unknown command ${command ?? '(none)'}`) + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + main(process.argv.slice(2)) + } catch (error) { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } +} diff --git a/config/scripts/agent-state-rules-bundle.test.mjs b/config/scripts/agent-state-rules-bundle.test.mjs new file mode 100644 index 00000000000..38fd0de6633 --- /dev/null +++ b/config/scripts/agent-state-rules-bundle.test.mjs @@ -0,0 +1,179 @@ +// The rules-release gate: the bundle a release would publish validates in the app's own loader, +// carries only agents whose transcripts the census replays, and only the protected workflow can +// publish it. +import { readdirSync, readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { + BUNDLED_AGENT_STATE_RULES_VERSION, + LIVE_UPDATABLE_AGENT_STATE_RULE_IDS, + parseAgentStateRulesBundle +} from '../../src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts' +import { BUNDLED_AGENT_STATE_RULE_FILES } from '../../src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts' +import { agentStateRulesDownloadUrl } from '../../src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts' +import { + AGENT_STATE_RULES_ENGINE_VERSION, + UNKNOWN_PANE_RULES_ID +} from '../../src/main/runtime/agent-state-rules/agent-state-rules-schema.ts' +import { CENSUS_TRANSCRIPTS } from '../../src/main/runtime/readiness-census-transcript-catalog.ts' +import { + AGENT_STATE_RULES_ASSET, + buildAgentStateRulesBundle, + promoteAgentStateRules, + publishAgentStateRules +} from './agent-state-rules-bundle.mjs' +import { agentStateRulesTag } from './release-tag-patterns.mjs' + +const REPO = 'stablyai/orca' +const NEXT = agentStateRulesTag(1, 'next') +const STABLE = agentStateRulesTag(1, 'stable') + +describe('agent state rules bundle build', () => { + it('builds a bundle the app accepts, under the bundled version and engine', () => { + const text = buildAgentStateRulesBundle() + const parsed = parseAgentStateRulesBundle(text, 'live-updatable') + expect(parsed.ok).toBe(true) + const bundle = JSON.parse(text) + expect(bundle.engineVersion).toBe(AGENT_STATE_RULES_ENGINE_VERSION) + expect(bundle.version).toBe(BUNDLED_AGENT_STATE_RULES_VERSION) + expect(bundle.files).toEqual( + BUNDLED_AGENT_STATE_RULE_FILES.filter((file) => + LIVE_UPDATABLE_AGENT_STATE_RULE_IDS.has(file.id) + ) + ) + expect(bundle.bundledOnly).toBeUndefined() + expect(JSON.parse(buildAgentStateRulesBundle({ bundledOnly: true })).bundledOnly).toBe(true) + }) + + it('lets a rules release change only agents the readiness census replays', () => { + const replayed = new Set(CENSUS_TRANSCRIPTS.flatMap((transcript) => transcript.agent ?? [])) + // Why unknown-pane: the census replays every recording on an agent-unknown pane too. + replayed.add(UNKNOWN_PANE_RULES_ID) + expect([...LIVE_UPDATABLE_AGENT_STATE_RULE_IDS].filter((id) => !replayed.has(id))).toEqual([]) + }) + + it('publishes to the exact URL the app fetches', () => { + for (const channel of ['next', 'stable']) { + expect(agentStateRulesDownloadUrl(channel)).toBe( + `https://github.com/${REPO}/releases/download/${agentStateRulesTag(AGENT_STATE_RULES_ENGINE_VERSION, channel)}/${AGENT_STATE_RULES_ASSET}` + ) + } + }) +}) + +/** A `gh` stand-in over an in-memory set of releases, recording each call. */ +function fakeGh(releases = {}) { + const calls = [] + const gh = (args) => { + calls.push(args) + const [, verb, tag] = args + if (verb === 'download') { + return tag in releases + ? { status: 0, stdout: releases[tag], stderr: '' } + : { status: 1, stdout: '', stderr: 'release not found' } + } + if (verb === 'upload' || verb === 'create') { + expect(args[3].endsWith(`/${AGENT_STATE_RULES_ASSET}`)).toBe(true) + releases[tag] = readFileSync(args[3], 'utf8') + } + return { status: 0, stdout: '', stderr: '' } + } + return { gh, calls, releases } +} + +const at = (version, engineVersion = 1) => + `${JSON.stringify({ version, engineVersion, files: [] })}\n` + +describe('publishAgentStateRules', () => { + it("creates the engine's channel release as a prerelease that can never be Latest", () => { + const fake = fakeGh() + expect( + publishAgentStateRules({ + repo: REPO, + channel: 'next', + text: at(2), + target: 'abc', + gh: fake.gh + }) + ).toEqual({ tag: NEXT, version: 2 }) + expect(fake.calls.find((args) => args[1] === 'create')).toEqual( + expect.arrayContaining([NEXT, '--prerelease', '--latest=false', '--target', 'abc']) + ) + expect(fake.releases[NEXT]).toBe(at(2)) + }) + + it('replaces the asset in place on an existing release, keeping the tag', () => { + const fake = fakeGh({ [NEXT]: at(1) }) + publishAgentStateRules({ repo: REPO, channel: 'next', text: at(2), target: 'abc', gh: fake.gh }) + expect(fake.calls.map((args) => args[1])).toEqual(['download', 'upload']) + expect(fake.calls[1]).toContain('--clobber') + expect(fake.releases[NEXT]).toBe(at(2)) + }) + + it.each([1, 2])('refuses version %s over a published 2, uploading nothing', (version) => { + const fake = fakeGh({ [NEXT]: at(2) }) + expect(() => + publishAgentStateRules({ + repo: REPO, + channel: 'next', + text: at(version), + target: 'abc', + gh: fake.gh + }) + ).toThrow('bump agent-state-rules-release.json') + expect(fake.calls.map((args) => args[1])).toEqual(['download']) + }) + + it('fails when the release exists but its download fails', () => { + const gh = () => ({ status: 1, stdout: '', stderr: 'no assets match the file pattern' }) + expect(() => + publishAgentStateRules({ repo: REPO, channel: 'next', text: at(2), target: 'abc', gh }) + ).toThrow('no assets match') + }) + + it('promotes the identical next bytes to stable', () => { + const nextText = JSON.stringify({ version: 3, engineVersion: 1, files: [] }, null, 2) + const fake = fakeGh({ [NEXT]: nextText, [STABLE]: at(2) }) + promoteAgentStateRules({ repo: REPO, engineVersion: 1, target: 'abc', gh: fake.gh }) + expect(fake.releases[STABLE]).toBe(nextText) + }) + + it('refuses to promote before next exists', () => { + const fake = fakeGh() + expect(() => + promoteAgentStateRules({ repo: REPO, engineVersion: 1, target: 'abc', gh: fake.gh }) + ).toThrow('has not been published') + }) +}) + +describe('agent state rules workflows', () => { + const read = (name) => parse(readFileSync(`.github/workflows/${name}`, 'utf8')) + const publish = read('agent-state-rules-publish.yml') + + it('publishes only on manual dispatch from main, in the protected environment', () => { + expect(Object.keys(publish.on)).toEqual(['workflow_dispatch']) + for (const name of ['publish-next', 'promote-stable']) { + const job = publish.jobs[name] + expect(job.environment).toBe('agent-state-rules') + expect(job.permissions).toEqual({ contents: 'write' }) + } + expect(publish.permissions).toEqual({ contents: 'read' }) + expect(publish.jobs.gate.if).toContain("github.ref == 'refs/heads/main'") + expect(publish.jobs['promote-stable'].if).toContain("github.ref == 'refs/heads/main'") + expect(publish.jobs['publish-next'].needs).toBe('gate') + // Why: every job must check out the dispatched commit, so publish runs what the gate tested. + const checkouts = Object.values(publish.jobs).flatMap((job) => + job.steps.filter((step) => step.uses?.startsWith('actions/checkout')) + ) + expect(checkouts.map((step) => step.with?.ref)).toEqual([undefined, undefined, undefined]) + }) + + it('is the only workflow that publishes rules releases', () => { + const publishers = readdirSync('.github/workflows').filter((name) => + /agent-state-rules-bundle\.mjs (?:publish|promote)|release create agent-state-rules/.test( + readFileSync(`.github/workflows/${name}`, 'utf8') + ) + ) + expect(publishers).toEqual(['agent-state-rules-publish.yml']) + }) +}) diff --git a/config/scripts/agent-status-hot-path-benchmark.test.ts b/config/scripts/agent-status-hot-path-benchmark.ts similarity index 88% rename from config/scripts/agent-status-hot-path-benchmark.test.ts rename to config/scripts/agent-status-hot-path-benchmark.ts index ece89b89d50..310de6723fb 100644 --- a/config/scripts/agent-status-hot-path-benchmark.test.ts +++ b/config/scripts/agent-status-hot-path-benchmark.ts @@ -8,10 +8,14 @@ * Counting passes patch `Map`/`Set`/`Object.assign`/`Object.values`, which deoptimizes them, so * counts and timings are taken in separate passes and never from the same run. * + * Run: ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.agent-status-benchmark.config.ts + * * Scale mirrors the reporting user rather than the 100-worktree fixture in * docs/reference/renderer-agent-status-performance.md: 423 worktrees, 634 terminal tabs. */ import { writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import type { AgentStatusBatchUpdate } from '@/store/slices/agent-status' @@ -23,6 +27,7 @@ import { TEST_REPO } from '@/store/slices/store-test-helpers' import { makePaneKey } from '../../src/shared/stable-pane-id' +import { getDefaultSettings } from '../../src/shared/constants' import { createAgentStatusPaneRoutingIndex, resolvePaneKeyFromRoutingIndex @@ -39,25 +44,24 @@ const counters = { maps: 0, sets: 0, tabComparisons: 0 } const NativeMap = globalThis.Map const NativeSet = globalThis.Set -const nativeArrayIterator = Array.prototype[Symbol.iterator] function withAllocationCounting(run: () => T): T { class CountingMap extends NativeMap { - constructor(entries?: readonly (readonly [K, V])[] | null) { + constructor(entries?: Iterable | null) { super(entries) counters.maps += 1 } } class CountingSet extends NativeSet { - constructor(values?: readonly V[] | null) { + constructor(values?: Iterable | null) { super(values) counters.sets += 1 } } counters.maps = 0 counters.sets = 0 - globalThis.Map = CountingMap as unknown as MapConstructor - globalThis.Set = CountingSet as unknown as SetConstructor + globalThis.Map = CountingMap + globalThis.Set = CountingSet try { return run() } finally { @@ -68,34 +72,33 @@ function withAllocationCounting(run: () => T): T { /** Tab list whose iteration is observable, so the nested-loop resolver's comparisons are countable. */ class CountingTabList extends Array { - [Symbol.iterator](): IterableIterator { - const inner = nativeArrayIterator.call(this) as IterableIterator - const wrapped: IterableIterator = { - next: () => { - const result = inner.next() - if (!result.done) { - counters.tabComparisons += 1 - } - return result - }, - [Symbol.iterator]: () => wrapped + [Symbol.iterator](): ArrayIterator { + const iterator = super[Symbol.iterator]() + const next = iterator.next.bind(iterator) + iterator.next = (...args) => { + const result = next(...args) + if (!result.done) { + counters.tabComparisons += 1 + } + return result } - return wrapped + return iterator } } function buildFixture(countTabIteration: boolean) { const store = createTestStore() + const settings = store.getState().settings ?? getDefaultSettings(tmpdir()) const tabsByWorktree: AppState['tabsByWorktree'] = {} const unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] = {} - const worktrees = [] + const worktrees: ReturnType[] = [] const paneKeys: string[] = [] const owners: { tabId: string; worktreeId: string }[] = [] for (let index = 0; index < WORKTREES; index += 1) { const worktreeId = `wt-${index}` worktrees.push(makeWorktree({ id: worktreeId, repoId: TEST_REPO.id })) - const tabs = [] - const unified = [] + const tabs: ReturnType[] = [] + const unified: ReturnType[] = [] for (let tab = 0; tab < (index % 2 === 0 ? 1 : 2); tab += 1) { const tabId = `tab-${index}-${tab}` tabs.push(makeTab({ id: tabId, worktreeId, title: `Terminal ${index}-${tab}` })) @@ -110,9 +113,7 @@ function buildFixture(countTabIteration: boolean) { paneKeys.push(makePaneKey(tabId, LEAF_ID)) owners.push({ tabId, worktreeId }) } - tabsByWorktree[worktreeId] = countTabIteration - ? (CountingTabList.from(tabs) as unknown as typeof tabs) - : tabs + tabsByWorktree[worktreeId] = countTabIteration ? CountingTabList.from(tabs) : tabs unifiedTabsByWorktree[worktreeId] = unified } store.setState({ @@ -122,8 +123,8 @@ function buildFixture(countTabIteration: boolean) { unifiedTabsByWorktree, terminalLayoutsByTabId: {}, setGeneratedTabTitlesFromAgentPrompts: () => {}, - settings: { ...store.getState().settings, tabAutoGenerateTitle: false } - } as Partial) + settings: { ...settings, tabAutoGenerateTitle: false } + }) return { store, paneKeys, owners } } @@ -141,7 +142,7 @@ function per1k(value: number): number { function runIndexedRouting(store: ReturnType, paneKeys: string[]): void { for (let event = 0; event < EVENTS; event += 1) { if (event % BATCH_SIZE === 0) { - store.setState({ agentStatusEpoch: event } as Partial) + store.setState({ agentStatusEpoch: event }) } const index = createAgentStatusPaneRoutingIndex(store.getState()) resolvePaneKeyFromRoutingIndex(index, paneKeys[event % paneKeys.length]) @@ -287,7 +288,8 @@ describe('agent-status hot path benchmark', () => { } const outputPath = - process.env.ORCA_AGENT_STATUS_BENCH_OUTPUT ?? '/tmp/agent-status-hot-path-benchmark.json' + process.env.ORCA_AGENT_STATUS_BENCH_OUTPUT ?? + join(tmpdir(), 'agent-status-hot-path-benchmark.json') writeFileSync( outputPath, `${JSON.stringify({ worktrees: WORKTREES, events: EVENTS, report }, null, 2)}\n` diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 09f5f913a94..40e47746747 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -363,15 +363,12 @@ export function mobileWebAppBuildOptions(routes) { */ export function entryStaticClosure(metafile, entryOutputPath) { const reached = new Set([entryOutputPath]) - const queue = [entryOutputPath] - while (queue.length > 0) { - const current = queue.shift() + for (const current of reached) { for (const imported of metafile.outputs[current]?.imports ?? []) { if (imported.kind !== 'import-statement' || reached.has(imported.path)) { continue } reached.add(imported.path) - queue.push(imported.path) } } return reached diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index baa18b6b39d..489599d7bf3 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -539,10 +539,10 @@ describe('the Phase C budget', () => { key ).toBeGreaterThanOrEqual(measured) } - // 1 to 9 per route, which is why four per route was a bound rather than a fit and why the + // 1 to 10 per route, which is why four per route was a bound rather than a fit and why the // envelope cannot be a line through the measurement either. expect(Math.min(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(1) - expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(9) + expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(10) }) it('sits exactly one margin over the swept tree and grants the worst route beyond it', () => { @@ -614,13 +614,13 @@ describe('the Phase C budget', () => { it('fails the build when the derived ceiling passes what the phone will accept', async () => { // The shell hands back null for a manifest over its own ceiling, so a derived ceiling above // that ships a green build no device can open. At the 42 images the tree carries, the envelope - // plus 42 plus the document crosses 256 at 32 routes, which Phase C reaches. The crossing came + // plus 42 plus the document crosses 256 at 30 routes, which Phase C reaches. The crossing came // in from 50 with the envelope: it grants the worst swept route to each one past the sweep, // where `4r + 16` granted four, so re-measuring a tree whose routes share more moves it out. expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS) - expect(assertAssetCeilingFitsShell(31, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(251) - expect(() => assertAssetCeilingFitsShell(32, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow( - /260 .*256/ + expect(assertAssetCeilingFitsShell(29, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(251) + expect(() => assertAssetCeilingFitsShell(30, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow( + /261 .*256/ ) }) }) diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 2bd9933d33a..66c8f535a5b 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -23,10 +23,12 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import process from 'node:process' import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' +import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-smoke.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' import { ORCAD_EMOJI_SHORTCODE_DATASET, + ORCAD_FOREIGN_SQLITE_READER_ENTRY, ORCAD_NODE_PTY_DIR, ORCAD_NODE_PTY_JS_ARTIFACTS, ORCAD_NODE_RUNTIME_MARKER_FILENAME, @@ -56,6 +58,10 @@ const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') // orcad restart would SIGKILL every running terminal. const DAEMON_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.daemon) const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js') +// Why beside orcad.js: the hook server's OpenCode binder and the OpenCode history scanner +// start this worker from the module dir, since orcad has no Electron resources tree. +const FOREIGN_SQLITE_READER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.foreignSqliteReader) +const FOREIGN_SQLITE_READER_OUT_FILE = join(OUT_DIR, ORCAD_FOREIGN_SQLITE_READER_ENTRY) const OUT_FILE = join(OUT_DIR, 'orcad.js') const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET if (!BUILD_TARGET) { @@ -205,6 +211,7 @@ function buildForkedChild(entryPoint, outfile) { const childResults = await Promise.all([ buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE), + buildForkedChild(FOREIGN_SQLITE_READER_ENTRY, FOREIGN_SQLITE_READER_OUT_FILE), ...['writer', 'backup'].map((role) => buildForkedChild( join(ROOT, ORCAD_CHILD_ENTRY_POINTS[role]), @@ -340,6 +347,16 @@ try { process.exitCode = 1 } +try { + smokeForeignSqliteReaderWorker(OUT_DIR) + if (nodeRuntimePath) { + smokeForeignSqliteReaderWorker(OUT_DIR, { runtimePath: nodeRuntimePath }) + } +} catch (error) { + console.error('[build-orcad] foreign SQLite reader worker check failed:', error) + process.exitCode = 1 +} + // Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on // this string, so two different builds carrying one version would share a directory — and an // already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 20dd1e0b17a..83332aefa44 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -9,6 +9,7 @@ * gracefully degraded. */ import { build } from 'esbuild' +import { JSONC_PARSER_ESM_ALIAS } from '../build-plugins/jsonc-parser-esm.ts' import { createHash } from 'node:crypto' import { copyFileSync, @@ -60,7 +61,6 @@ const MANAGED_HOOK_RUNTIME_ENTRY = join( 'agent-hooks', 'managed-hook-runtime.ts' ) -const JSONC_PARSER_ESM_ENTRY = join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js') const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join( ROOT, @@ -102,6 +102,7 @@ const RELAY_VERSION = '0.1.0' async function buildRelayBundles(outDir) { await build({ entryPoints: [RELAY_ENTRY], + alias: JSONC_PARSER_ESM_ALIAS, bundle: true, platform: 'node', target: 'node18', @@ -186,7 +187,7 @@ async function buildRelayBundles(outDir) { outfile: join(outDir, 'managed-hook-runtime.js'), // Why: jsonc-parser's default UMD build keeps relative dynamic requires // that break after bundling; its ESM entry is equivalent and self-contained. - alias: { 'jsonc-parser': JSONC_PARSER_ESM_ENTRY }, + alias: JSONC_PARSER_ESM_ALIAS, sourcemap: false, minify: true, define: { @@ -293,6 +294,7 @@ for (const platform of RELAY_BUILD_PLATFORMS) { mkdirSync(outDir, { recursive: true }) await build({ entryPoints: [wslHookEntry], + alias: JSONC_PARSER_ESM_ALIAS, bundle: true, platform: 'node', target: 'node18', diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index b93005600e9..3d9d93aaed2 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -5,6 +5,7 @@ import { createHash } from 'node:crypto' import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' export function windowsCliLauncherFingerprint(inputPaths, version) { const hash = createHash('sha256').update(version) @@ -66,6 +67,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) join(crateRoot, 'build.rs'), manifestPath, join(crateRoot, 'app.manifest'), + join(crateRoot, '.cargo', 'config.toml'), iconPath, join(repoRoot, 'config/scripts/build-windows-cli-launcher.mjs') ], @@ -117,6 +119,16 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(result.status ?? 1) } - copyFileSync(join(targetDirectory, 'release', 'orca.exe'), outputPath) + const builtPath = join(targetDirectory, 'release', 'orca.exe') + const vcRuntimeImports = findDynamicVcRuntimeImports( + readPeImportedDllNames(readFileSync(builtPath)) + ) + if (vcRuntimeImports.length > 0) { + // Why fatal: those DLLs ship with the Visual C++ Redistributable, so the CLI would fail to start on a clean Windows install. + throw new Error( + `orca.exe imports ${vcRuntimeImports.join(', ')}; the C runtime must be linked statically (native/windows-cli-launcher/.cargo/config.toml).` + ) + } + copyFileSync(builtPath, outputPath) writeFileSync(`${outputPath}.sha256`, fingerprint) } diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index e83b57f65e7..0e06cafd22e 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -17,6 +17,7 @@ import { windowsCliLauncherFileVersion, windowsCliLauncherFingerprint } from './build-windows-cli-launcher.mjs' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') @@ -131,6 +132,9 @@ describe('Windows CLI launcher', () => { expect(info.ProductVersion).toBe(version) const binary = readFileSync(launcherPath) expect(binary.includes(Buffer.from('requestedExecutionLevel level="asInvoker"'))).toBe(true) + const imports = readPeImportedDllNames(binary) + expect(imports.map((name) => name.toLowerCase())).toContain('kernel32.dll') + expect(findDynamicVcRuntimeImports(imports)).toEqual([]) const icon = readFileSync(join(projectRoot, 'resources', 'build', 'icon.ico')) const imageSize = icon.readUInt32LE(14) const imageOffset = icon.readUInt32LE(18) diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index 71a512ad407..9b213031aa7 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -13,6 +13,9 @@ const CASTING_DISABLE_PATTERN = /\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s[^\n]*typescript\/consistent-type-assertions/ const ANTI_SLOP_DISABLE_PATTERN = /\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s[^\n]*\banti-slop\// +const REACT_DOCTOR_DISABLE_PATTERN = + /^\s*\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s+react-doctor\/[\w-]+(?:\s*,\s*react-doctor\/[\w-]+)*\s*(?:--(?:(?!\*\/).)*)?(?:\*\/)?\s*$/ +const EXPLICIT_DISABLE_RULE_PATTERN = /(?:-disable(?:-next-line|-line)?\s+|^)[\w-]+(?:\/[\w-]+)?/ export const OXLINT_SCANS = [ { // Why: no --config, so Oxlint keeps discovering nested configs. Pinning the root @@ -41,7 +44,12 @@ export const OXLINT_SCANS = [ }, { label: 'React Doctor', - args: ['--config', 'config/oxlint-react-doctor.json'] + args: [ + '--config', + 'config/oxlint-react-doctor.json', + '--report-unused-disable-directives-severity', + 'warn' + ] }, { // Why changed-lines only: the renderer carries ~4.7k pre-existing restyle/raw-color @@ -250,6 +258,16 @@ export function collectBaseLineBlocks(root, comparisonBase, files = null) { } export function isMovedCode(highlightedLines, baseBlocks) { + return createMovedCodeMatcher(baseBlocks)(highlightedLines) +} + +export function createMovedCodeMatcher(baseBlocks) { + // Base-revision blocks stay fixed for the gate run; normalize each visited block once. + const normalizedBlocks = new Map() + return (highlightedLines) => matchMovedCode(highlightedLines, baseBlocks, normalizedBlocks) +} + +function matchMovedCode(highlightedLines, baseBlocks, normalizedBlocks) { const needle = highlightedLines.map(normalizeSourceLine).filter((line) => line !== '') if (needle.length === 0) { return false @@ -262,8 +280,12 @@ export function isMovedCode(highlightedLines, baseBlocks) { // and nearly all of it must be present. Genuinely new code shares neither the // anchor nor the ordering, so it stays reported. const MIN_COVERAGE = 0.9 - return baseBlocks.some((rawHaystack) => { - const haystack = rawHaystack.map(normalizeSourceLine).filter((line) => line !== '') + return baseBlocks.some((block) => { + let haystack = normalizedBlocks.get(block) + if (!haystack) { + haystack = block.map(normalizeSourceLine).filter((line) => line !== '') + normalizedBlocks.set(block, haystack) + } for (let start = 0; start < haystack.length; start += 1) { if (haystack[start] !== needle[0]) { continue @@ -301,7 +323,8 @@ export function diagnosticTouchesAddedLines( diagnostic, rangesByFile, root = process.cwd(), - baseBlocks = [] + baseBlocks = [], + movedCodeMatcher = isMovedCode ) { const file = normalizedDiagnosticPath(root, diagnostic.filename) const ranges = rangesByFile.get(file) @@ -313,7 +336,7 @@ export function diagnosticTouchesAddedLines( if (lineRange === null || !overlapsAddedLines(lineRange.start, lineRange.end, ranges)) { return false } - return !isMovedCode( + return !movedCodeMatcher( diagnosticHighlightedLines(root, diagnostic.filename, label.span), baseBlocks ) @@ -348,16 +371,34 @@ export function isCastingDirectiveUnusedWarning(diagnostic, root) { ) } -// Why: the anti-slop rules live in a JS plugin that only config/oxlint-anti-slop.json loads, so -// the root scan never sees those rule names and reports every anti-slop suppression as unused. -// `audit:anti-slop` is the scan that enforces them. -export function isAntiSlopDirectiveUnusedWarning(diagnostic, root) { +// Unloaded plugin directives are checked by their owning scan. +export function isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, scanLabel) { if (!/^Unused (?:oxlint|eslint)-disable/.test(diagnostic.message ?? '')) { return false } + if (scanLabel === 'React Doctor') { + const labels = diagnostic.labels ?? [] + return ( + labels.length > 0 && + labels.every(({ span }) => { + if (span.offset === undefined || span.length === undefined) { + return false + } + const file = path.isAbsolute(diagnostic.filename) + ? diagnostic.filename + : path.join(root, diagnostic.filename) + // Oxlint spans use UTF-8 byte offsets, including before non-ASCII comments. + const directive = readFileSync(file) + .subarray(span.offset, span.offset + span.length) + .toString('utf8') + const rules = directive.split('--')[0] + return EXPLICIT_DISABLE_RULE_PATTERN.test(rules) && !/\breact-doctor\//.test(rules) + }) + ) + } return (diagnostic.labels ?? []).some((label) => - diagnosticHighlightedLines(root, diagnostic.filename, label.span).some((line) => - ANTI_SLOP_DISABLE_PATTERN.test(line) + diagnosticHighlightedLines(root, diagnostic.filename, label.span).some( + (line) => ANTI_SLOP_DISABLE_PATTERN.test(line) || REACT_DOCTOR_DISABLE_PATTERN.test(line) ) ) } @@ -429,6 +470,7 @@ export function main( } const baseBlocks = collectBaseLineBlocks(root, comparisonBase) + const movedCodeMatcher = createMovedCodeMatcher(baseBlocks) let failures = 0 for (const scan of OXLINT_SCANS) { @@ -436,8 +478,8 @@ export function main( (diagnostic) => !isSuppressedDiagnostic(diagnostic, root) && !isCastingDirectiveUnusedWarning(diagnostic, root) && - !isAntiSlopDirectiveUnusedWarning(diagnostic, root) && - diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks) + !isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, scan.label) && + diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks, movedCodeMatcher) ) for (const diagnostic of diagnostics) { printDiagnostic(diagnostic, root) diff --git a/config/scripts/check-changed-code-quality.test.mjs b/config/scripts/check-changed-code-quality.test.mjs index e722acad6ef..783ee140adc 100644 --- a/config/scripts/check-changed-code-quality.test.mjs +++ b/config/scripts/check-changed-code-quality.test.mjs @@ -1,10 +1,12 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { describe, expect, it } from 'vitest' +import { runProcessSync } from '../../src/shared/child-process/run-process' +import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' import { OXLINT_SCANS, diagnosticTouchesAddedLines, - isAntiSlopDirectiveUnusedWarning, + isUnloadedPluginDirectiveUnusedWarning, isMovedCode, isRootCodeQualityPath, overlapsAddedLines, @@ -124,7 +126,7 @@ describe('moved-code exemption', () => { }) }) -describe('anti-slop directive unused warning', () => { +describe('unloaded plugin directive unused warning', () => { const root = path.resolve(import.meta.dirname, '..', '..') // Assembled so no line here is itself a directive the gate would scan. const directive = (rule) => `/* oxlint-disable ${rule} -- reason */` @@ -146,21 +148,149 @@ describe('anti-slop directive unused warning', () => { it('exempts a suppression the root scan cannot resolve', () => { withFixture(directive('anti-slop/no-module-mocking'), (diagnostic) => { - expect(isAntiSlopDirectiveUnusedWarning(diagnostic, root)).toBe(true) + expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(true) }) }) it('still reports an unused directive for a rule the root scan does load', () => { withFixture(directive('unicorn/no-array-reduce'), (diagnostic) => { - expect(isAntiSlopDirectiveUnusedWarning(diagnostic, root)).toBe(false) + expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(false) }) }) it('ignores diagnostics that are not unused-directive warnings', () => { withFixture(directive('anti-slop/no-module-mocking'), (diagnostic) => { expect( - isAntiSlopDirectiveUnusedWarning({ ...diagnostic, message: 'Unexpected any.' }, root) + isUnloadedPluginDirectiveUnusedWarning( + { ...diagnostic, message: 'Unexpected any.' }, + root, + 'code quality' + ) ).toBe(false) }) }) + + function scanFixture(label, file) { + const scan = OXLINT_SCANS.find((candidate) => candidate.label === label) + if (!scan) { + throw new Error(`Missing ${label} scan`) + } + const { command, prefixArgs } = resolveOxlintInvocation(root) + const result = runProcessSync({ + program: command, + args: [...prefixArgs, ...scan.args, '--format', 'json', file], + cwd: root, + timeoutMs: 30_000, + maxOutputBytes: 4 * 1024 * 1024 + }) + return JSON.parse(result.stdout).diagnostics + } + + it('accepts a used Doctor directive only through its loaded scan', () => { + const source = [ + "import { useEffect, useState } from 'react'", + directive('react-doctor/no-derived-state-effect'), + 'export function Title({ title }: { title: string }) {', + " const [value, setValue] = useState('')", + ' useEffect(() => { setValue(title) }, [title])', + ' return value', + '}' + ].join('\n') + withFixture(source, ({ filename }) => { + const normal = scanFixture('code quality', filename) + const unused = normal.find((diagnostic) => diagnostic.message.startsWith('Unused ')) + expect(unused).toBeDefined() + expect(isUnloadedPluginDirectiveUnusedWarning(unused, root, 'code quality')).toBe(true) + expect(scanFixture('React Doctor', filename)).toEqual([]) + }) + }) + + it('keeps an unused Doctor directive failing in its loaded scan', () => { + withFixture(directive('react-doctor/no-derived-state-effect'), ({ filename }) => { + const diagnostics = scanFixture('React Doctor', filename) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0].message).toMatch(/^Unused /) + expect(isUnloadedPluginDirectiveUnusedWarning(diagnostics[0], root, 'React Doctor')).toBe( + false + ) + }) + }) + + it('does not hide unused native rules in a mixed directive', () => { + withFixture( + directive('react-doctor/no-derived-state-effect, unicorn/no-array-reduce'), + (diagnostic) => { + expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(false) + } + ) + }) + + it('recognizes a standalone directive containing only Doctor rules', () => { + withFixture( + directive( + 'react-doctor/no-derived-state-effect, react-doctor/no-adjust-state-on-prop-change' + ), + (diagnostic) => { + expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(true) + } + ) + }) + + it('keeps adjacent native directive warnings visible', () => { + const doctor = directive('react-doctor/no-derived-state-effect') + const native = directive('unicorn/no-array-reduce') + for (const source of [`${doctor} ${native}`, `${native} ${doctor}`]) { + withFixture(source, ({ filename }) => { + const diagnostic = scanFixture('code quality', filename).find((candidate) => + candidate.labels.some((label) => label.span.offset === source.indexOf(native)) + ) + expect(diagnostic).toBeDefined() + expect(diagnostic.message).toMatch(/^Unused /) + expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(false) + }) + } + }) + + it('leaves used native directives to the scan that loads them', () => { + withFixture( + [ + 'export const banner = "λ"', + directive('typescript/no-explicit-any'), + 'export const answer: any = 42' + ].join('\n'), + ({ filename }) => { + expect(scanFixture('code quality', filename)).toEqual([]) + const diagnostics = scanFixture('React Doctor', filename) + expect(diagnostics).toHaveLength(1) + expect(isUnloadedPluginDirectiveUnusedWarning(diagnostics[0], root, 'React Doctor')).toBe( + true + ) + } + ) + }) + + it('does not exempt unused Doctor rules together with unloaded native rules', () => { + withFixture( + directive('react-doctor/no-derived-state-effect, typescript/no-explicit-any'), + ({ filename }) => { + const diagnostics = scanFixture('React Doctor', filename) + expect(diagnostics).toHaveLength(1) + expect(isUnloadedPluginDirectiveUnusedWarning(diagnostics[0], root, 'React Doctor')).toBe( + false + ) + } + ) + }) + + it('keeps blanket unused directives visible in the Doctor scan', () => { + for (const source of [directive(''), '// oxlint-disable-next-line -- reason']) { + withFixture(source, ({ filename }) => { + const diagnostics = scanFixture('React Doctor', filename) + expect(diagnostics).toHaveLength(1) + expect(isUnloadedPluginDirectiveUnusedWarning(diagnostics[0], root, 'React Doctor')).toBe( + false + ) + }) + } + }) }) diff --git a/config/scripts/check-node-runtime-pin.test.mjs b/config/scripts/check-node-runtime-pin.test.mjs index e1be15f0f7f..a805985d966 100644 --- a/config/scripts/check-node-runtime-pin.test.mjs +++ b/config/scripts/check-node-runtime-pin.test.mjs @@ -189,7 +189,7 @@ describe('committed pin', () => { it('runs in the static analysis job', () => { const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) - const commands = workflow.jobs.static_analysis.steps.map((step) => step.run ?? '') + const commands = workflow.jobs.preflight.steps.map((step) => step.run ?? '') expect(commands).toContain('pnpm run check:node-runtime-pin') }) }) diff --git a/config/scripts/check-react-doctor-changed.mjs b/config/scripts/check-react-doctor-changed.mjs index 743a64eee04..92a62b461f1 100644 --- a/config/scripts/check-react-doctor-changed.mjs +++ b/config/scripts/check-react-doctor-changed.mjs @@ -26,7 +26,7 @@ const result = spawnSync( [ ...prefixArgs, 'dlx', - 'react-doctor@0.9.1', + 'react-doctor@0.9.14', '.', '--yes', '--scope', diff --git a/config/scripts/check-readme-local-links.test.mjs b/config/scripts/check-readme-local-links.test.mjs index e1d69723ee5..c9128630f19 100644 --- a/config/scripts/check-readme-local-links.test.mjs +++ b/config/scripts/check-readme-local-links.test.mjs @@ -156,8 +156,7 @@ describe('README local link check', () => { ]) }) - // Why the ungated job: static_analysis is skipped for docs-only diffs, which is - // exactly the kind of PR that deletes a docs-site GIF the README embeds. + // Docs-only diffs skip preflight, so the detector must check README links. it('runs on every PR through the ungated detector and in the lint script', () => { const { scripts } = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8')) const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) diff --git a/config/scripts/check-root-directory-entries.test.mjs b/config/scripts/check-root-directory-entries.test.mjs index 6de07a053c7..57da7adbd95 100644 --- a/config/scripts/check-root-directory-entries.test.mjs +++ b/config/scripts/check-root-directory-entries.test.mjs @@ -114,6 +114,17 @@ describe('root directory guard', () => { expect(result.status).toBe(0) }) + it('allows the reviewed repository OpenCode permission config', () => { + const fixture = makeFixture() + const head = commitFiles(fixture.root, [ + ['opencode.json', '{"permission":{"*":{"*":"allow"}}}\n'] + ]) + + const result = runGuard({ ...fixture, head }) + + expect(result.status).toBe(0) + }) + it('rejects a new top-level directory', () => { const fixture = makeFixture() const head = commitFiles(fixture.root, [['new-folder/file.txt', 'too prominent\n']]) diff --git a/config/scripts/ci-background-step-barriers.test.mjs b/config/scripts/ci-background-step-barriers.test.mjs index d9dc6332a9f..d8ac8fd1c1f 100644 --- a/config/scripts/ci-background-step-barriers.test.mjs +++ b/config/scripts/ci-background-step-barriers.test.mjs @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' const pr = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const mobile = parse(readFileSync('.github/workflows/mobile.yml', 'utf8')) const cloud = parse(readFileSync('.github/workflows/cloud-verify.yml', 'utf8')) +const headless = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) function assertJoinedBefore(steps, id, consumer) { const start = steps.findIndex((step) => step.id === id) @@ -19,13 +20,13 @@ function assertJoinedBefore(steps, id, consumer) { describe('CI background step barriers', () => { it('joins every background check without suppressing failures', () => { for (const job of [ - pr.jobs.static_analysis, - pr.jobs.typecheck, + pr.jobs.preflight, pr.jobs.mobile_web_app, pr.jobs.package, pr.jobs.shell_contracts, mobile.jobs.verify, - cloud.jobs.security + cloud.jobs.security, + headless.jobs.persistence ]) { const pending = new Set() for (const step of job.steps) { @@ -50,14 +51,43 @@ describe('CI background step barriers', () => { it('joins planning before publishing the unit artifact', () => { assertJoinedBefore( - pr.jobs.typecheck.steps, + pr.jobs.preflight.steps, 'unit-plan', (step) => step.uses === 'actions/upload-artifact@v7' ) }) + it('joins the Linux Bun build before requiring both headless runtime artifacts', () => { + const steps = headless.jobs.persistence.steps + const consumer = (step) => step.run?.startsWith('pnpm test:node-server --artifact ') + assertJoinedBefore(steps, 'bun-orcad', consumer) + const start = steps.findIndex((step) => step.id === 'bun-orcad') + const join = steps.findIndex((step) => step.wait === 'bun-orcad') + const install = steps.findIndex((step) => step.uses?.endsWith('/install-node-dependencies')) + const setup = steps.findIndex((step) => step.uses?.startsWith('oven-sh/setup-bun@')) + expect(install).toBeGreaterThanOrEqual(0) + expect(setup).toBeGreaterThanOrEqual(0) + expect(install).toBeLessThan(setup) + expect(setup).toBeLessThan(start) + expect(steps[setup].if).toBe("runner.os == 'Linux'") + expect(steps[start].if).toBeUndefined() + expect(steps[start].run).toContain('if [ "$RUNNER_OS" != Linux ]; then exit 0; fi') + for (const build of [ + steps.findIndex((step) => step.uses?.endsWith('/prepare-orcad-prebuilds')), + steps.findIndex((step) => step.run === 'pnpm build:orcad') + ]) { + expect(build).toBeGreaterThan(start) + expect(build).toBeLessThan(join) + expect(steps[build].background).toBeUndefined() + } + const test = steps.find(consumer) + expect(test.run).toContain("${{ runner.os == 'Linux' && '--cross-runtime' || '' }}") + expect(test.env.ORCA_BUN_ORCAD_SLOT).toBe('${{ steps.bun-orcad.outputs.slot }}') + expect(test.env.BUN_EXECUTABLE).toBe('${{ steps.bun-orcad.outputs.executable }}') + }) + it('finishes native import-cycle analysis before mobile installation changes resolution', () => { - const steps = pr.jobs.static_analysis.steps + const steps = pr.jobs.preflight.steps assertJoinedBefore(steps, 'native-code-quality', (step) => step.uses?.endsWith('/install-mobile-dependencies') ) @@ -66,7 +96,7 @@ describe('CI background step barriers', () => { expect(steps.findIndex((step) => step.id === 'changed-code-quality')).toBeGreaterThan(install) }) - it('finishes both mobile typechecks before allocating test workers', () => { + it('joins independent mobile typechecks before allocating test workers', () => { const steps = mobile.jobs.verify.steps assertJoinedBefore(steps, 'production-types', (step) => step.name === 'Test') const ratchet = steps.findIndex((step) => step.name === 'Typecheck tests (ratchet)') diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 74b15e9b267..01b6c214e5d 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -28,7 +28,7 @@ it('warms the same Linux Node runtime the PR shards restore', () => { const install = arm.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const primer = readWorkflow('pr').jobs.static_analysis + const primer = readWorkflow('pr').jobs.preflight expect(arm['runs-on']).toBe(primer['runs-on']) expect(arm.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only') expect(install.with).toMatchObject(primer.steps.find((step) => step.uses === install.uses).with) @@ -42,6 +42,7 @@ it('populates shared Electron archives on both Linux architectures without chang ) expect(install.with['native-runtime']).toBe('node') expect(install.with['cache-electron-package']).toBe('true') + expect(install.with['cache-pnpm-store-lookup-only']).toBe('true') const populate = steps.find((step) => step.name === 'Populate shared Electron archive') expect(populate.run).toBe('node config/scripts/install-electron-package-binary.mjs') expect(steps.indexOf(populate)).toBeGreaterThan(steps.indexOf(install)) @@ -50,7 +51,7 @@ it('populates shared Electron archives on both Linux architectures without chang it('publishes incremental state under a key and prefix that new PRs restore', () => { const cache = steps.find((step) => step.id === 'typecheck-cache') - const prCache = readWorkflow('pr').jobs.typecheck.steps.find((step) => step.name === cache.name) + const prCache = readWorkflow('pr').jobs.preflight.steps.find((step) => step.name === cache.name) expect(cache.with.path).toBe(prCache.with.path) expect(cache.with['restore-keys']).toBe(prCache.with['restore-keys']) expect(cache.with.key).toBe( @@ -72,12 +73,12 @@ it('bounds warming to the required platforms and validates changes without grant expect(workflow.permissions).toEqual({ contents: 'read' }) expect(workflow.on.push.branches).toEqual(['main']) expect(workflow.on.push.paths).toContain('.github/actions/prepare-native-runtime/**') - expect(workflow.on.schedule).toEqual([{ cron: '41 * * * *' }]) + expect(workflow.on.schedule).toEqual([{ cron: '41 */6 * * *' }]) expect(workflow.on.pull_request.paths).toContain('.github/workflows/ci-cache-warmup.yml') expect(steps[0].with['persist-credentials']).toBe(false) }) -it('lets hourly warmers wait while pushes, PR updates, and manual runs can replace active work', () => { +it('lets scheduled warmers wait while pushes, PR updates, and manual runs can replace active work', () => { expect(workflow.concurrency).toEqual({ group: 'ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}', 'cancel-in-progress': "${{ github.event_name != 'schedule' }}" @@ -97,6 +98,9 @@ it('warms and probes both Windows images with the persistence job runtime', () = const install = job.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - expect(install.with).toEqual({ 'native-runtime': 'node' }) + expect(install.with).toEqual({ + 'native-runtime': 'node', + 'cache-pnpm-store-lookup-only': 'true' + }) expect(job.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only') }) diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index 584c37f6740..f04aff20b35 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -11,7 +11,9 @@ describe('CI dependency download caches', () => { it('scopes desktop stores to the root lockfile and lets mixed installs opt in', () => { expect(action.inputs['cache-dependency-path'].default).toBe('pnpm-lock.yaml') for (const step of action.runs.steps.filter((step) => step.uses === 'actions/setup-node@v6')) { - expect(step.with.cache).toBe("${{ github.event_name != 'pull_request' && 'pnpm' || '' }}") + expect(step.with.cache).toBe( + "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}" + ) expect(step.with['cache-dependency-path']).toBe('${{ inputs.cache-dependency-path }}') expect(step.with['package-manager-cache']).toBe(false) } @@ -30,15 +32,17 @@ describe('CI dependency download caches', () => { ]) }) - it('restores PR stores except measured Windows mixed installs, without a post-job save', () => { + it('restores PR stores except measured Windows, Linux and macOS installs, without a post-job save', () => { const resolve = action.runs.steps.find((step) => step.id === 'pnpm-store') const restore = action.runs.steps.find( (step) => step.name === 'Restore pnpm download store without saving' ) + expect(restore.if).toBe( + "github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml'))" + ) expect(resolve.if).toBe( - "github.event_name == 'pull_request' && (runner.os != 'Windows' || runner.arch != 'X64' || !contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml'))" + `${restore.if} || (github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only == 'true')` ) - expect(restore.if).toBe(resolve.if) expect(restore.uses).toBe('actions/cache/restore@v5') expect(restore.with.path).toBe('${{ steps.pnpm-store.outputs.path }}') expect(restore.with.key).toBe( @@ -65,39 +69,221 @@ describe('CI dependency download caches', () => { ]) }) + it('keeps producer lookup optional and compatible with the existing store archive', () => { + const lookup = action.runs.steps.find((step) => step.id === 'pnpm-store-lookup') + const restore = action.runs.steps.find((step) => step.id === 'pnpm-store-restore') + expect(action.inputs['cache-pnpm-store-lookup-only'].default).toBe('auto') + expect(lookup.uses).toBe('actions/cache@v5') + expect(lookup.if).toBe("steps.pnpm-store-mode.outputs.lookup-only == 'true'") + expect(lookup.with).toEqual({ + path: '${{ env.ORCA_PNPM_STORE_CACHE_PATH }}', + key: restore.with.key, + 'lookup-only': true + }) + expect(action.runs.steps.indexOf(lookup)).toBeLessThan( + action.runs.steps.findIndex((step) => step.name === 'Install dependencies') + ) + expect(action.outputs['pnpm-store-cache-hit'].value).toBe( + '${{ steps.pnpm-store-lookup.outputs.cache-hit || steps.pnpm-store-restore.outputs.cache-hit || steps.requested-node.outputs.cache-hit || steps.default-node.outputs.cache-hit }}' + ) + }) + it.each([ ['Windows x64 mixed PR', 'pull_request', 'Windows', 'X64', true, false, ''], ['Windows ARM64 mixed PR', 'pull_request', 'Windows', 'ARM64', true, true, ''], ['Windows x86 mixed PR', 'pull_request', 'Windows', 'X86', true, true, ''], - ['Windows x64 root-only PR', 'pull_request', 'Windows', 'X64', false, true, ''], + ['Windows x64 root-only PR', 'pull_request', 'Windows', 'X64', false, false, ''], + ['Windows ARM64 root-only PR', 'pull_request', 'Windows', 'ARM64', false, false, ''], + ['Windows x86 root-only PR', 'pull_request', 'Windows', 'X86', false, true, ''], + ['Windows x64 custom PR', 'pull_request', 'Windows', 'X64', 'cloud/pnpm-lock.yaml', true, ''], + [ + 'Windows ARM64 custom PR', + 'pull_request', + 'Windows', + 'ARM64', + 'cloud/pnpm-lock.yaml', + true, + '' + ], + ['Windows ARM64 root-only push', 'push', 'Windows', 'ARM64', false, false, 'pnpm'], + [ + 'Windows ARM64 root-only manual run', + 'workflow_dispatch', + 'Windows', + 'ARM64', + false, + false, + 'pnpm' + ], + ['Explicit Linux PR opt-out', 'pull_request', 'Linux', 'X64', false, false, '', 'false'], + ['Explicit Windows push opt-out', 'push', 'Windows', 'ARM64', false, false, '', 'false'], + [ + 'Explicit Windows manual opt-out', + 'workflow_dispatch', + 'Windows', + 'X64', + false, + false, + '', + 'false' + ], + [ + 'Explicit custom-store opt-out', + 'pull_request', + 'Windows', + 'X64', + 'cloud/pnpm-lock.yaml', + false, + '', + 'false' + ], + ['Windows x64 root-only push', 'push', 'Windows', 'X64', false, false, 'pnpm'], + ['Linux x64 root-only PR', 'pull_request', 'Linux', 'X64', false, false, ''], + ['Linux ARM64 root-only PR', 'pull_request', 'Linux', 'ARM64', false, false, ''], + ['Linux x86 root-only PR', 'pull_request', 'Linux', 'X86', false, true, ''], + ['Linux ARM root-only PR', 'pull_request', 'Linux', 'ARM', false, true, ''], + ['Linux x64 custom PR', 'pull_request', 'Linux', 'X64', 'cloud/pnpm-lock.yaml', true, ''], + ['Linux x64 root-only push', 'push', 'Linux', 'X64', false, false, 'pnpm'], + ['Linux ARM64 root-only manual', 'workflow_dispatch', 'Linux', 'ARM64', false, false, 'pnpm'], + ['macOS x64 root-only PR', 'pull_request', 'macOS', 'X64', false, false, ''], + ['macOS ARM64 root-only PR', 'pull_request', 'macOS', 'ARM64', false, false, ''], + ['macOS x86 root-only PR', 'pull_request', 'macOS', 'X86', false, true, ''], + ['macOS x64 mixed PR', 'pull_request', 'macOS', 'X64', true, true, ''], + ['macOS ARM64 mixed PR', 'pull_request', 'macOS', 'ARM64', true, true, ''], + ['macOS ARM64 custom PR', 'pull_request', 'macOS', 'ARM64', 'cloud/pnpm-lock.yaml', true, ''], + ['macOS ARM64 opted-out PR', 'pull_request', 'macOS', 'ARM64', true, false, '', 'false'], + ['macOS x64 root-only push', 'push', 'macOS', 'X64', false, false, 'pnpm'], + ['macOS ARM64 root-only manual', 'workflow_dispatch', 'macOS', 'ARM64', false, false, 'pnpm'], ['Linux x64 mixed PR', 'pull_request', 'Linux', 'X64', true, true, ''], ['Linux ARM64 mixed PR', 'pull_request', 'Linux', 'ARM64', true, true, ''], - ['macOS ARM64 mixed PR', 'pull_request', 'macOS', 'ARM64', true, true, ''], ['Windows x64 mixed push', 'push', 'Windows', 'X64', true, false, 'pnpm'], - ['Windows x64 mixed manual run', 'workflow_dispatch', 'Windows', 'X64', true, false, 'pnpm'] - ])('%s keeps its scoped store policy', (_name, event, os, arch, mixed, restore, cache) => { - const context = { - github: { event_name: event }, - runner: { os, arch }, - inputs: { - 'cache-dependency-path': mixed ? 'pnpm-lock.yaml\nmobile/pnpm-lock.yaml' : 'pnpm-lock.yaml' - }, - contains: (value, search) => value.toLowerCase().includes(search.toLowerCase()) - } - const evaluate = (expression) => - runInNewContext( - expression.replaceAll('inputs.cache-dependency-path', 'inputs["cache-dependency-path"]'), - context + ['Windows x64 mixed manual run', 'workflow_dispatch', 'Windows', 'X64', true, false, 'pnpm'], + ['Windows x64 lookup producer', 'push', 'Windows', 'X64', false, false, '', 'true', 'true'], + [ + 'Windows ARM64 lookup producer', + 'schedule', + 'Windows', + 'ARM64', + false, + false, + '', + 'true', + 'true' + ], + ['macOS ARM64 lookup producer', 'push', 'macOS', 'ARM64', false, false, '', 'true', 'true'], + [ + 'Linux x64 lookup producer', + 'workflow_dispatch', + 'Linux', + 'X64', + false, + false, + '', + 'true', + 'true' + ], + ['Opted-out lookup producer', 'push', 'Windows', 'ARM64', false, false, '', 'false', 'true'], + [ + 'macOS root PR lookup flag', + 'pull_request', + 'macOS', + 'ARM64', + false, + false, + '', + 'true', + 'true' + ], + ['macOS mixed PR lookup flag', 'pull_request', 'macOS', 'ARM64', true, true, '', 'true', 'true'] + ])( + '%s keeps its scoped store policy', + (_name, event, os, arch, mixed, restore, cache, storeCache = 'true', lookupOnly = 'false') => { + const context = { + github: { event_name: event }, + runner: { os, arch }, + steps: { + 'pnpm-store-mode': { + outputs: { + 'lookup-only': + event !== 'pull_request' && storeCache !== 'false' && lookupOnly === 'true' + ? 'true' + : '' + } + } + }, + inputs: { + 'cache-pnpm-store': storeCache, + 'cache-pnpm-store-lookup-only': lookupOnly, + 'cache-dependency-path': + typeof mixed === 'string' + ? mixed + : mixed + ? 'pnpm-lock.yaml\nmobile/pnpm-lock.yaml' + : 'pnpm-lock.yaml' + }, + contains: (value, search) => value.toLowerCase().includes(search.toLowerCase()) + } + const evaluate = (expression) => + runInNewContext( + expression + .replaceAll( + 'steps.pnpm-store-mode.outputs.lookup-only', + 'steps["pnpm-store-mode"].outputs["lookup-only"]' + ) + .replaceAll( + 'inputs.cache-pnpm-store-lookup-only', + 'inputs["cache-pnpm-store-lookup-only"]' + ) + .replaceAll('inputs.cache-dependency-path', 'inputs["cache-dependency-path"]') + .replaceAll('inputs.cache-pnpm-store', 'inputs["cache-pnpm-store"]'), + context + ) + for (const step of action.runs.steps.filter( + (step) => + step.id === 'pnpm-store' || step.name === 'Restore pnpm download store without saving' + )) { + expect(evaluate(step.if)).toBe( + restore || + (step.id === 'pnpm-store' && + event !== 'pull_request' && + storeCache !== 'false' && + lookupOnly === 'true') + ) + } + expect(evaluate(action.runs.steps.find((step) => step.id === 'pnpm-store-lookup').if)).toBe( + event !== 'pull_request' && storeCache !== 'false' && lookupOnly === 'true' ) - for (const step of action.runs.steps.filter( - (step) => - step.id === 'pnpm-store' || step.name === 'Restore pnpm download store without saving' - )) { - expect(evaluate(step.if)).toBe(restore) + for (const step of action.runs.steps.filter( + (step) => step.uses === 'actions/setup-node@v6' + )) { + expect(evaluate(step.with.cache.slice(3, -2))).toBe(cache) + expect(step.with['package-manager-cache']).toBe(false) + } } - for (const step of action.runs.steps.filter((step) => step.uses === 'actions/setup-node@v6')) { - expect(evaluate(step.with.cache.slice(3, -2))).toBe(cache) - expect(step.with['package-manager-cache']).toBe(false) + ) + + it('opts Windows server consumers out while preserving the main warmer store writer', () => { + const installer = './.github/actions/install-node-dependencies' + const persistence = workflow('node-server-tests').jobs.persistence.steps.find( + (step) => step.uses === installer + ) + const ssh = workflow('ssh-windows-hosts').jobs.hosts.steps.find( + (step) => step.uses === installer + ) + const warmer = workflow('ci-cache-warmup').jobs['warm-windows'].steps.find( + (step) => step.uses === installer + ) + expect(action.inputs['cache-pnpm-store'].default).toBe('true') + expect(persistence.with['cache-pnpm-store']).toBe("${{ runner.os != 'Windows' }}") + expect(ssh.with['cache-pnpm-store']).toBe('false') + expect(warmer.with['cache-pnpm-store']).toBeUndefined() + expect(warmer.with['cache-pnpm-store-lookup-only']).toBe('true') + expect(persistence.with['cache-pnpm-store-lookup-only']).toBe('true') + for (const name of ['warm', 'warm-linux-arm']) { + const install = workflow('ci-cache-warmup').jobs[name].steps.find((step) => + step.uses?.includes('install-node-dependencies') + ) + expect(install.with['cache-pnpm-store-lookup-only']).toBe('true') } }) diff --git a/config/scripts/ci-native-cache.test.mjs b/config/scripts/ci-native-cache.test.mjs index 6851ed62f9b..045cc5de6d8 100644 --- a/config/scripts/ci-native-cache.test.mjs +++ b/config/scripts/ci-native-cache.test.mjs @@ -123,8 +123,8 @@ describe('CI native cache ownership', () => { installerPath, Buffer.from( readFileSync(installerPath, 'utf8').replace( - "inputs.cache-pnpm-verification == 'true'", - "inputs.cache-pnpm-verification == 'false'" + 'enabled: ${{ inputs.cache-pnpm-verification }}', + "enabled: 'false'" ) ) ) diff --git a/config/scripts/ci-pnpm-store-mode.test.mjs b/config/scripts/ci-pnpm-store-mode.test.mjs new file mode 100644 index 00000000000..d8402612b04 --- /dev/null +++ b/config/scripts/ci-pnpm-store-mode.test.mjs @@ -0,0 +1,150 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runInNewContext } from 'node:vm' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' +import { runProcessSync } from './script-child-process.mjs' + +const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')) +const mode = action.runs.steps.find((step) => step.id === 'pnpm-store-mode') +const defaultContext = { + github: { event_name: 'push' }, + runner: { os: 'Linux', arch: 'X64', environment: 'github-hosted' }, + job: { container: { id: '' } }, + inputs: { + 'cache-pnpm-store': 'true', + 'cache-pnpm-store-lookup-only': 'auto', + 'cache-dependency-path': 'pnpm-lock.yaml', + 'node-version': '' + } +} +const expression = mode.if.replaceAll(/inputs\.([\w-]+)/g, 'inputs["$1"]') + +function eligible(changes) { + const context = structuredClone(defaultContext) + for (const [name, fields] of Object.entries(changes)) { + Object.assign(context[name], fields) + } + return runInNewContext(expression, context) +} + +function resolveMode(request, node, manager) { + const directory = mkdtempSync(join(tmpdir(), 'orca-store-mode-')) + const output = join(directory, 'output') + try { + writeFileSync( + join(directory, 'package.json'), + JSON.stringify({ engines: { node }, packageManager: manager }) + ) + const result = runProcessSync({ + program: 'bash', + args: ['-e', '-o', 'pipefail', '-c', mode.run], + cwd: directory, + env: { ...process.env, LOOKUP_REQUEST: request, GITHUB_OUTPUT: output } + }) + expect(result.code, result.stderr || result.stdout).toBe(0) + return readFileSync(output, 'utf8') + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +describe('automatic pnpm store mode', () => { + it.each([ + ['auto', '24', 'pnpm@12.8.1', '', true], + ['auto', '25', 'pnpm@12.8.1', 'pnpm', false], + ['auto', '24', 'pnpm@13.0.0', 'pnpm', false], + ['true', '25', 'pnpm@13.0.0', '', true] + ])( + 'routes resolved %s mode for Node %s / %s into both cache steps', + (request, node, manager, cache, lookup) => { + const context = structuredClone(defaultContext) + context.inputs['cache-pnpm-store-lookup-only'] = request + const resolved = resolveMode(request, node, manager).split('=')[1].trim() + const evaluate = (value) => + runInNewContext( + value + .replaceAll(/inputs\.([\w-]+)/g, 'inputs["$1"]') + .replaceAll( + 'steps.pnpm-store-mode.outputs.lookup-only', + 'steps["pnpm-store-mode"].outputs["lookup-only"]' + ), + { ...context, steps: { 'pnpm-store-mode': { outputs: { 'lookup-only': resolved } } } } + ) + const nodeSetup = action.runs.steps.find((step) => step.id === 'default-node') + expect(evaluate(nodeSetup.with.cache.slice(3, -2))).toBe(cache) + expect(evaluate(action.runs.steps.find((step) => step.id === 'pnpm-store-lookup').if)).toBe( + lookup + ) + } + ) + + it.each( + ['Linux', 'Windows', 'macOS'].flatMap((os) => ['X64', 'ARM64'].map((arch) => [os, arch])) + )('qualifies the measured %s/%s hosted root context', (os, arch) => { + expect(eligible({ runner: { os, arch } })).toBe(true) + }) + + it.each([ + ['PR', { github: { event_name: 'pull_request' } }], + ['opted-out store', { inputs: { 'cache-pnpm-store': 'false' } }], + ['opted-out lookup', { inputs: { 'cache-pnpm-store-lookup-only': 'false' } }], + ['unknown request', { inputs: { 'cache-pnpm-store-lookup-only': 'other' } }], + [ + 'mixed lockfiles', + { inputs: { 'cache-dependency-path': 'pnpm-lock.yaml\nmobile/pnpm-lock.yaml' } } + ], + ['custom lockfile', { inputs: { 'cache-dependency-path': 'cloud/pnpm-lock.yaml' } }], + ['Node 25', { inputs: { 'node-version': '25' } }], + ['job container', { job: { container: { id: 'container-id' } } }], + ['self-hosted runner', { runner: { environment: 'self-hosted' } }], + ['unknown host kind', { runner: { environment: '' } }], + ['unmeasured architecture', { runner: { arch: 'X86' } }], + ['unmeasured OS', { runner: { os: 'other' } }] + ])('retains the legacy policy for %s', (_name, changes) => { + expect(eligible(changes)).toBe(false) + }) + + it('allows an explicit request to preserve the existing force-lookup contract', () => { + expect( + eligible({ + inputs: { + 'cache-pnpm-store-lookup-only': 'true', + 'node-version': '25', + 'cache-dependency-path': 'custom-lock.yaml' + }, + runner: { environment: 'self-hosted' }, + job: { container: { id: 'container-id' } } + }) + ).toBe(true) + expect( + eligible({ + github: { event_name: 'pull_request' }, + inputs: { 'cache-pnpm-store-lookup-only': 'true' } + }) + ).toBe(false) + }) + + it.each([ + ['24', 'pnpm@12.8.1', 'true'], + ['24', 'pnpm@12.8.1+sha512.fixture', 'true'], + ['25', 'pnpm@12.8.1', 'false'], + ['24.x', 'pnpm@12.8.1', 'false'], + ['24', 'pnpm@12.8.2', 'false'], + ['24', 'pnpm@12.8.10', 'false'], + ['24', undefined, 'false'], + [undefined, 'pnpm@12.8.1', 'false'], + ['24', 12, 'false'] + ])('checks manifest Node %s and manager %s before choosing lookup', (node, manager, expected) => { + expect(resolveMode('auto', node, manager)).toBe(`lookup-only=${expected}\n`) + }) + + it('checks uppercase auto requests consistently with GitHub expression comparisons', () => { + expect(resolveMode('AUTO', '25', 'pnpm@12.8.1')).toBe('lookup-only=false\n') + }) + + it('does not constrain an explicit request to the automatic manifest profile', () => { + expect(resolveMode('true', '25', 'pnpm@13.0.0')).toBe('lookup-only=true\n') + }) +}) diff --git a/config/scripts/ci-pnpm-verification-cache.test.mjs b/config/scripts/ci-pnpm-verification-cache.test.mjs index c64d019d06c..b878b3ef046 100644 --- a/config/scripts/ci-pnpm-verification-cache.test.mjs +++ b/config/scripts/ci-pnpm-verification-cache.test.mjs @@ -1,21 +1,116 @@ -import { readFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' +import { classifyPrJobs, PR_CHECK_JOBS } from './pr-code-change-scope.mjs' +import { runProcessSync } from './script-child-process.mjs' const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')) const steps = action.runs.steps -const resolve = steps.find((step) => step.id === 'verification-cache') -const restore = steps.find((step) => step.id === 'verification-cache-restore') +const cache = steps.find((step) => step.id === 'verification-cache') +const cacheAction = parse( + readFileSync('.github/actions/restore-pnpm-verification/action.yml', 'utf8') +) +const resolve = cacheAction.runs.steps.find((step) => step.id === 'verification-cache') +const restore = cacheAction.runs.steps.find((step) => step.id === 'verification-cache-restore') const install = steps.find((step) => step.name === 'Install dependencies') const save = steps.find((step) => step.name === 'Save pnpm verification record on main') -const evaluate = (expression, context) => - runInNewContext( - expression.replaceAll('inputs.cache-pnpm-verification', 'inputs["cache-pnpm-verification"]'), - context - ) +const evaluate = (expression, context) => runInNewContext(expression, context) + +function resolveRecord({ + container = false, + os = 'Linux', + packageManager = 'pnpm@12.8.1+sha512.test' +} = {}) { + const root = mkdtempSync(join(tmpdir(), 'orca-verification-record-')) + try { + const output = join(root, 'outputs') + const calls = join(root, 'pnpm-calls') + const cacheRoot = join(root, 'cache with spaces') + const cachePath = join(cacheRoot, 'pnpm') + writeFileSync(join(root, 'package.json'), JSON.stringify({ packageManager })) + const execution = runProcessSync({ + program: 'bash', + args: [ + '-e', + '-o', + 'pipefail', + '-c', + ` + pnpm() { + printf '%s\\n' "$*" >> "$TEST_CALLS" + case "$*" in + 'cache path') printf '%s\\n' "$TEST_CACHE_PATH" ;; + '--version') printf '%s\\n' '12.8.1' ;; + *) return 2 ;; + esac + } + ${resolve.run} + ` + ], + cwd: root, + env: { + ...process.env, + RUNNER_OS: os, + RUNNER_ARCH: 'ARM64', + POLICY_HASH: 'policy-digest', + GITHUB_OUTPUT: output, + TEST_CALLS: calls, + TEST_CACHE_PATH: cachePath, + CONTAINER_TOOLCHAIN: String(container), + XDG_CACHE_HOME: cacheRoot + } + }) + return { + code: execution.code, + output: existsSync(output) ? readFileSync(output, 'utf8').replaceAll(root, '') : '', + calls: existsSync(calls) ? readFileSync(calls, 'utf8') : '' + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +} describe('pnpm-owned verification record', () => { + it('qualifies every shared-installer consumer when verification restoration changes', () => { + const result = classifyPrJobs(['.github/actions/restore-pnpm-verification/action.yml']) + for (const job of PR_CHECK_JOBS) { + expect(result[job], job).toBe(true) + } + }) + + it.skipIf(process.platform === 'win32')( + 'shares the exact key and archive path with containers without invoking host pnpm', + () => { + const host = resolveRecord() + const container = resolveRecord({ container: true }) + expect(host.code).toBe(0) + expect(container.code).toBe(0) + expect(container.output).toBe(host.output) + expect(container.output).toContain( + 'path=/cache with spaces/pnpm/lockfile-verified.jsonl' + ) + expect(container.output).toContain( + 'key=pnpm-verification-v1-Linux-ARM64-12.8.1-policy-digest' + ) + expect(host.calls).toBe('cache path\n--version\n') + expect(container.calls).toBe('') + } + ) + + it.skipIf(process.platform === 'win32').each([ + { container: true, os: 'Windows' }, + { container: true, os: 'macOS' }, + { container: true, packageManager: 'npm@12.8.1' } + ])('rejects an unsupported container identity: %j', (options) => { + const result = resolveRecord(options) + expect(result.code).not.toBe(0) + expect(result.output).toBe('') + expect(result.calls).toBe('') + }) + it.each([ ['Linux', 'X64', true], ['Linux', 'ARM64', true], @@ -31,7 +126,7 @@ describe('pnpm-owned verification record', () => { expect( evaluate(resolve.if, { runner: { os, arch }, - inputs: { 'cache-pnpm-verification': enabled } + inputs: { enabled } }) ).toBe(expected && enabled === 'true') } @@ -44,12 +139,15 @@ describe('pnpm-owned verification record', () => { expect(resolve.run).toContain('"$(pnpm cache path)"') expect(resolve.run).toContain('lockfile-verified.jsonl') expect(resolve.run).toContain('pnpm-verification-v1-%s-%s-%s-%s') - expect(resolve.run).toContain('"$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH"') + expect(resolve.run).toContain('pnpm_version="$(pnpm --version)"') + expect(resolve.run).toContain('"$RUNNER_OS" "$RUNNER_ARCH" "$pnpm_version" "$POLICY_HASH"') + expect(cache.uses).toBe('./.github/actions/restore-pnpm-verification') + expect(cache.with.enabled).toBe('${{ inputs.cache-pnpm-verification }}') expect(restore.uses).toBe('actions/cache/restore@v5') expect(restore.with.path).toBe('${{ steps.verification-cache.outputs.path }}') expect(restore.with['restore-keys']).toBeUndefined() expect(restore['continue-on-error']).toBe(true) - expect(steps.indexOf(restore)).toBeLessThan(steps.indexOf(install)) + expect(steps.indexOf(cache)).toBeLessThan(steps.indexOf(install)) expect(install.if).toBeUndefined() expect(install.run).toContain('pnpm install --frozen-lockfile --ignore-scripts') }) @@ -64,21 +162,20 @@ describe('pnpm-owned verification record', () => { const context = { github: { event_name: event, ref }, steps: { - 'verification-cache': { outputs: { key: 'a-key' } }, - 'verification-cache-restore': { outputs: { 'cache-hit': 'false' } } + 'verification-cache': { outputs: { key: 'a-key', 'cache-hit': 'false' } } } } const expression = save.if .replaceAll( - 'steps.verification-cache-restore.outputs.cache-hit', - 'steps["verification-cache-restore"].outputs["cache-hit"]' + 'steps.verification-cache.outputs.cache-hit', + 'steps["verification-cache"].outputs["cache-hit"]' ) .replaceAll('steps.verification-cache.outputs.key', 'steps["verification-cache"].outputs.key') expect(evaluate(expression, context)).toBe(expected) context.steps['verification-cache'].outputs.key = '' expect(evaluate(expression, context)).toBe(false) context.steps['verification-cache'].outputs.key = 'a-key' - context.steps['verification-cache-restore'].outputs['cache-hit'] = 'true' + context.steps['verification-cache'].outputs['cache-hit'] = 'true' expect(evaluate(expression, context)).toBe(false) expect(save.uses).toBe('actions/cache/save@v5') expect(steps.indexOf(save)).toBeGreaterThan(steps.indexOf(install)) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 7a3a3710d4e..788a5185523 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -626,7 +626,6 @@ "src/main/ai-vault/session-scanner-agent-root-overrides.test.ts": 1121, "src/main/ai-vault/session-scanner-antigravity-parser.test.ts": 72, "src/main/ai-vault/session-scanner-antigravity-source.test.ts": 330, - "src/main/ai-vault/session-scanner-background.test.ts": 354, "src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts": 245, "src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts": 170, "src/main/ai-vault/session-scanner-claude-subagents.test.ts": 137, @@ -706,7 +705,6 @@ "src/main/ai-vault/session-scanner-timeline.test.ts": 75, "src/main/ai-vault/session-scanner-unlimited-dedup.test.ts": 3562, "src/main/ai-vault/session-scanner-values.test.ts": 92, - "src/main/ai-vault/session-scanner-worker-client.test.ts": 48, "src/main/ai-vault/session-scanner.test.ts": 343, "src/main/ai-vault/session-sidecar-stat.test.ts": 25, "src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts": 173, @@ -5218,7 +5216,6 @@ "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts": 104, "src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts": 41, "src/renderer/src/components/codex-restart-chip.test.tsx": 2063, - "src/renderer/src/components/codex-restart-notice-key.test.ts": 63, "src/renderer/src/components/comment-code-context-state.test.ts": 34, "src/renderer/src/components/comment-reply-target-state.test.ts": 51, "src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts": 1586, @@ -5377,7 +5374,7 @@ "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 552, "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 59, "src/renderer/src/components/editor/conflict-review-file-tree-windowing.test.tsx": 2238, - "src/renderer/src/components/editor/csv-parse.test.ts": 122, + "src/renderer/src/components/editor/csv/csv-parse.test.ts": 122, "src/renderer/src/components/editor/details-markdown-html.test.ts": 99, "src/renderer/src/components/editor/diff-editor-hide-unchanged-options.test.ts": 35, "src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 48, @@ -7803,7 +7800,6 @@ "src/renderer/src/lib/codex-account-display-label.test.ts": 48, "src/renderer/src/lib/codex-pane-restart-eligibility.test.ts": 82, "src/renderer/src/lib/codex-pane-selection-lane.test.ts": 249, - "src/renderer/src/lib/codex-session-restart-route-recheck.test.ts": 1547, "src/renderer/src/lib/codex-session-restart-shell-flap.test.ts": 1603, "src/renderer/src/lib/codex-session-restart.test.ts": 6689, "src/renderer/src/lib/codex-stale-pane-account-identity.test.ts": 1587, diff --git a/config/scripts/ci-unit-dependency-graph.mjs b/config/scripts/ci-unit-dependency-graph.mjs index f491c1749de..7a3e035e45a 100644 --- a/config/scripts/ci-unit-dependency-graph.mjs +++ b/config/scripts/ci-unit-dependency-graph.mjs @@ -42,9 +42,14 @@ export function buildUnitDependencyGraph(sources) { if (path === null) { continue } - const resolved = EXTENSIONS.map((extension) => path + extension).find((candidate) => - sources.has(candidate) - ) + let resolved + for (const extension of EXTENSIONS) { + const candidate = path + extension + if (sources.has(candidate)) { + resolved = candidate + break + } + } if (!resolved) { opaque.add(file) continue diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 80252973e22..3e0cdb67cd8 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -23,6 +23,7 @@ export const UNIT_EXCLUDE = [ 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', 'src/main/shell-startup-feature-channel.test.ts', + 'src/main/zsh-deferred-startup-line-init.live-shell.test.ts', 'src/main/terminal-history-fish-session.node-pty.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', diff --git a/config/scripts/ci-unit-import-resolution-budget.test.mjs b/config/scripts/ci-unit-import-resolution-budget.test.mjs new file mode 100644 index 00000000000..bf1f04941b5 --- /dev/null +++ b/config/scripts/ci-unit-import-resolution-budget.test.mjs @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildUnitDependencyGraph } from './ci-unit-dependency-graph.mjs' + +describe('unit graph import resolution', () => { + it('avoids allocating an extension-candidate array for every resolved import', () => { + const consumers = Array.from({ length: 1000 }, (_, index) => `src/consumer-${index}.ts`) + const sources = new Map([ + ['src/leaf', 'export const value = 1'], + ...consumers.map((file) => [file, "import './leaf'"]) + ]) + const originalMap = Array.prototype.map + let extensionArrays = 0 + const spy = vi.spyOn(Array.prototype, 'map').mockImplementation(function (...args) { + if (this.length === 10 && this[0] === '' && this[1] === '.ts' && this[9] === '/index.js') { + extensionArrays += 1 + } + return originalMap.apply(this, args) + }) + try { + const graph = buildUnitDependencyGraph(sources) + spy.mockRestore() + expect([...graph.reverse]).toEqual([['src/leaf', new Set(consumers)]]) + expect(graph.opaque).toEqual(new Set()) + expect(extensionArrays).toBe(0) + } finally { + spy.mockRestore() + } + }) + + it('keeps first-match precedence across literal paths, extensions and index files', () => { + const sources = new Map([ + ['src/leaf', ''], + ['src/leaf.ts', ''], + ['src/leaf.tsx', ''], + ['src/component.tsx', ''], + ['src/component.js', ''], + ['src/folder/index.ts', ''], + ['src/folder/index.tsx', ''], + ['src/config.json', '{}'], + ['src/renderer/src/view.tsx', ''], + ['src/use.ts', "import './leaf'; import './component'; import './folder'; import './config'"], + ['src/aliases.ts', "import '@renderer/view'; import '@/view'; import 'external-package'"], + ['src/missing.ts', "import './missing-file'"], + ['src/dynamic.ts', 'import(variablePath)'], + ['config/owner.mjs', "import '../src/leaf'"], + ['tests/owner.ts', "import '../src/leaf'"] + ]) + expect(buildUnitDependencyGraph(sources)).toEqual({ + reverse: new Map([ + ['src/leaf', new Set(['src/use.ts', 'config/owner.mjs', 'tests/owner.ts'])], + ['src/component.tsx', new Set(['src/use.ts'])], + ['src/folder/index.ts', new Set(['src/use.ts'])], + ['src/config.json', new Set(['src/use.ts'])], + ['src/renderer/src/view.tsx', new Set(['src/aliases.ts'])] + ]), + opaque: new Set(['src/missing.ts', 'src/dynamic.ts', 'config/owner.mjs', 'tests/owner.ts']) + }) + }) +}) diff --git a/config/scripts/ci-unit-sequencer.mjs b/config/scripts/ci-unit-sequencer.mjs index 6b580bdb002..94dcd933f45 100644 --- a/config/scripts/ci-unit-sequencer.mjs +++ b/config/scripts/ci-unit-sequencer.mjs @@ -16,13 +16,14 @@ export default class TimingSequencer extends BaseSequencer { 'utf8' ) ) + const discovered = new Set(plan.files) if ( plan.version !== 1 || !plan.sourceSha || plan.sourceSha !== process.env.ORCA_SHARD_SOURCE_SHA || JSON.stringify([...plan.files].sort()) !== JSON.stringify(specs.map(key).sort()) || !Array.isArray(plan.executionFiles) || - plan.executionFiles.some((file) => !plan.files.includes(file)) + plan.executionFiles.some((file) => !discovered.has(file)) ) { throw new Error('Selection provenance or discovery differs') } diff --git a/config/scripts/ci-unit-sequencer.test.mjs b/config/scripts/ci-unit-sequencer.test.mjs index 931a3ccd158..a14ab60b497 100644 --- a/config/scripts/ci-unit-sequencer.test.mjs +++ b/config/scripts/ci-unit-sequencer.test.mjs @@ -6,39 +6,86 @@ import TimingSequencer from './ci-unit-sequencer.mjs' let root afterEach(() => { + vi.restoreAllMocks() vi.unstubAllEnvs() if (root) { rmSync(root, { recursive: true, force: true }) } }) -it.each(['valid', 'stale', 'missing-file', 'missing-artifact'])( - 'preserves complete shard coverage with %s planning evidence', - async (kind) => { - root = mkdtempSync(join(tmpdir(), 'unit-sequencer-')) - const files = ['src/a.test.ts', 'src/b.test.ts', 'src/c.test.ts', 'src/d.test.ts'] - const plan = { - version: 1, - sourceSha: kind === 'stale' ? 'old' : 'current', - files: kind === 'missing-file' ? files.slice(1) : files, - executionFiles: files.slice(0, 2) - } - const planPath = join(root, 'selection.json') - if (kind !== 'missing-artifact') { - writeFileSync(planPath, JSON.stringify(plan)) - } - vi.stubEnv('ORCA_UNIT_SELECTION_PLAN', planPath) - vi.stubEnv('ORCA_SHARD_SOURCE_SHA', 'current') - vi.stubEnv('ORCA_SHARD_MANIFEST', join(root, 'assignment.json')) - const assigned = [] - for (const index of [1, 2]) { - const sequencer = new TimingSequencer({ config: { root, shard: { index, count: 2 } } }) - const specs = files.map((file) => ({ moduleId: join(root, file) })) - assigned.push(...(await sequencer.shard(specs)).map((spec) => spec.moduleId)) - } - expect(assigned.sort()).toEqual( - (kind === 'valid' ? files.slice(0, 2) : files).map((file) => join(root, file)).sort() - ) - expect(new Set(assigned).size).toBe(assigned.length) +it.each([ + 'valid', + 'stale', + 'missing-file', + 'missing-artifact', + 'outside-selection', + 'empty-selection' +])('preserves complete shard coverage with %s planning evidence', async (kind) => { + root = mkdtempSync(join(tmpdir(), 'unit-sequencer-')) + const files = ['src/a.test.ts', 'src/b.test.ts', 'src/c.test.ts', 'src/d.test.ts'] + const plan = { + version: 1, + sourceSha: kind === 'stale' ? 'old' : 'current', + files: kind === 'missing-file' ? files.slice(1) : files, + executionFiles: + kind === 'outside-selection' + ? ['src/unknown.test.ts'] + : kind === 'empty-selection' + ? [] + : files.slice(0, 2) } -) + const planPath = join(root, 'selection.json') + if (kind !== 'missing-artifact') { + writeFileSync(planPath, JSON.stringify(plan)) + } + vi.stubEnv('ORCA_UNIT_SELECTION_PLAN', planPath) + vi.stubEnv('ORCA_SHARD_SOURCE_SHA', 'current') + vi.stubEnv('ORCA_SHARD_MANIFEST', join(root, 'assignment.json')) + const assigned = [] + for (const index of [1, 2]) { + const sequencer = new TimingSequencer({ config: { root, shard: { index, count: 2 } } }) + const specs = files.map((file) => ({ moduleId: join(root, file) })) + assigned.push(...(await sequencer.shard(specs)).map((spec) => spec.moduleId)) + } + expect(assigned.sort()).toEqual( + (kind === 'valid' ? files.slice(0, 2) : files).map((file) => join(root, file)).sort() + ) + expect(new Set(assigned).size).toBe(assigned.length) +}) + +it('validates a large selection without scanning the discovered array for each file', async () => { + root = mkdtempSync(join(tmpdir(), 'unit-sequencer-scale-')) + const files = Array.from({ length: 1600 }, (_, index) => `src/scale-${index}.test.ts`) + const executionFiles = files.slice(800) + const planPath = join(root, 'selection.json') + writeFileSync( + planPath, + JSON.stringify({ version: 1, sourceSha: 'current', files, executionFiles }) + ) + vi.stubEnv('ORCA_UNIT_SELECTION_PLAN', planPath) + vi.stubEnv('ORCA_SHARD_SOURCE_SHA', 'current') + vi.stubEnv('ORCA_SHARD_MANIFEST', join(root, 'assignment.json')) + const sequencer = new TimingSequencer({ config: { root, shard: { index: 1, count: 1 } } }) + const specs = files.map((file) => ({ moduleId: join(root, file) })) + const includes = Array.prototype.includes + let discoveredArrayScans = 0 + const scan = vi + .spyOn(Array.prototype, 'includes') + .mockImplementation(function (value, fromIndex) { + if ( + this.length === files.length && + this[0] === files[0] && + typeof value === 'string' && + value.startsWith('src/scale-') + ) { + discoveredArrayScans += 1 + } + return includes.call(this, value, fromIndex) + }) + const selected = await sequencer.shard(specs) + scan.mockRestore() + expect(selected.map((spec) => spec.moduleId).sort()).toEqual( + executionFiles.map((file) => join(root, file)).sort() + ) + expect(discoveredArrayScans).toBe(0) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index fee07d5e299..454693e46d7 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -242,6 +242,23 @@ describe('electron-builder config', () => { ]) }) + // Why: serve-sim's addon is a Mach-O, and Windows signing rejects every *.node that is not PE. + it('keeps serve-sim out of the Windows and Linux runtime closures', () => { + const { + PACKAGED_RUNTIME_PACKAGE_ROOTS, + createPackagedRuntimeNodeModuleResources + } = require('../packaged-runtime-node-modules.cjs') + expect(PACKAGED_RUNTIME_PACKAGE_ROOTS).not.toContain('serve-sim') + const serveSimTarget = join('node_modules', 'serve-sim') + expect(createPackagedRuntimeNodeModuleResources('linux').map((r) => r.to)).not.toContain( + serveSimTarget + ) + expect(electronBuilderConfig.linux.extraResources.map((r) => r.to)).not.toContain( + serveSimTarget + ) + expect(electronBuilderConfig.win.extraResources.map((r) => r.to)).not.toContain(serveSimTarget) + }) + // Why: the Windows CLI shim is delivered only via extraResources to // resources/bin/orca.cmd (beside the native resources/bin/orca.exe). If the // source tree is also packed into app.asar it gets extracted by @@ -323,12 +340,14 @@ describe('electron-builder config', () => { // invisible to it and a packed worker entry fails closed — dropping every // OpenCode session in packaged builds while dev stays green. Three legs must // agree on the filename, so all three are read rather than hardcoded. - it('unpacks the OpenCode SQLite worker entry the scanner service forks', async () => { - const spawnSource = await readFile( - join(SRC_MAIN_DIR, 'ai-vault', 'session-scanner-opencode-sqlite-worker-spawn.ts'), + it('unpacks the foreign SQLite reader entry the scanner service runs OpenCode reads on', async () => { + const entryPathSource = await readFile( + join(SRC_MAIN_DIR, 'foreign-sqlite-readers', 'foreign-sqlite-reader-entry-path.ts'), 'utf8' ) - const entryFilename = spawnSource.match(/WORKER_ENTRY_FILENAME = '([^']+)'/)?.[1] + const entryFilename = entryPathSource.match( + /FOREIGN_SQLITE_READER_ENTRY_FILENAME = '([^']+)'/ + )?.[1] expect(entryFilename).toBeDefined() expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`) diff --git a/config/scripts/electron-builder-markdown-associations.test.mjs b/config/scripts/electron-builder-markdown-associations.test.mjs index 58f6f8d8865..654c6db246f 100644 --- a/config/scripts/electron-builder-markdown-associations.test.mjs +++ b/config/scripts/electron-builder-markdown-associations.test.mjs @@ -8,6 +8,8 @@ const require = createRequire(import.meta.url) const electronBuilderConfig = require('../electron-builder.config.cjs') const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx'] +const TABULAR_EXTENSIONS = ['csv', 'tsv'] +const DOCUMENT_EXTENSIONS = [...MARKDOWN_EXTENSIONS, ...TABULAR_EXTENSIONS] // The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("") // value of Software\Classes\.. Additive `WriteRegNone ...\OpenWithProgids` must not @@ -23,23 +25,23 @@ const stripNsisCommentLines = (source) => const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8') -describe('electron-builder markdown file associations', () => { +describe('electron-builder document file associations', () => { // Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS // packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value // — silently taking .md from whichever editor owns it, for every existing user on their // next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot // prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must // stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks. - it('never claims the Windows default markdown handler', () => { + it('never claims the Windows default document handler', () => { expect(electronBuilderConfig.fileAssociations).toBeUndefined() expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined() }) - it('joins the macOS Open With list for every markdown extension without owning it', () => { + it('joins the macOS Open With list for every supported extension without owning it', () => { const associations = electronBuilderConfig.mac.fileAssociations // One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob. expect([...associations].map((association) => association.ext).sort()).toEqual( - [...MARKDOWN_EXTENSIONS].sort() + [...DOCUMENT_EXTENSIONS].sort() ) for (const association of associations) { expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' }) @@ -54,6 +56,14 @@ describe('electron-builder markdown file associations', () => { expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined() }) + it('adds CSV and TSV handlers to the Linux desktop entry', () => { + expect(electronBuilderConfig.linux.mimeTypes).toEqual([ + 'text/markdown', + 'text/csv', + 'text/tab-separated-values' + ]) + }) + it('points the single NSIS include at the installer hooks file on disk', () => { const includePath = electronBuilderConfig.nsis.include expect(existsSync(includePath)).toBe(true) @@ -84,7 +94,7 @@ describe('electron-builder markdown file associations', () => { expect(stripped).toMatch(DEFAULT_HANDLER_WRITE) }) - it('registers Windows markdown Open With additively, never as the default', async () => { + it('registers Windows document Open With additively, never as the default', async () => { const hooks = await readInstallerHooks() expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE) @@ -92,11 +102,18 @@ describe('electron-builder markdown file associations', () => { expect(hooks).toMatch( /WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/ ) - expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/) + expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_DOCUMENT_OPEN_WITH\s+EXT\s+PROGID/) for (const ext of MARKDOWN_EXTENSIONS) { - expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) - expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) + expect(hooks).toContain(`ORCA_REGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${MARKDOWN_PROGID}"`) + expect(hooks).toContain(`ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${MARKDOWN_PROGID}"`) } + for (const ext of TABULAR_EXTENSIONS) { + expect(hooks).toContain(`ORCA_REGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${TABULAR_PROGID}"`) + expect(hooks).toContain(`ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${TABULAR_PROGID}"`) + } + expect(hooks).toContain('!define TABULAR_PROGID "Orca.Tabular"') + expect(hooks).toContain('ORCA_REGISTER_DOCUMENT_PROGID "${TABULAR_PROGID}" "Tabular Document"') + expect(hooks).toContain('DeleteRegKey SHELL_CONTEXT "Software\\Classes\\${TABULAR_PROGID}"') expect(hooks).toMatch(/!macro\s+customInstall\b/) expect(hooks).toMatch(/!macro\s+customUnInstall\b/) }) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index da6d45c8a23..d5ddc5df43a 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -99,13 +99,14 @@ describe('Electron Vite output contract', () => { expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js') }) - it('keeps offline profile-state CLI imports unpacked at stable paths', () => { + it('keeps CLI main imports unpacked at stable paths', () => { const input = electronViteConfig.main?.build?.rollupOptions?.input if (!input || typeof input !== 'object' || Array.isArray(input)) { throw new Error('Expected named main-process inputs') } for (const name of [ + 'gitlab/project-ref-parser', 'orca-profiles/profile-index-store', 'persistence/profile-state/profile-state-access', 'persistence/profile-state/profile-state-active-location', @@ -121,6 +122,7 @@ describe('Electron Vite output contract', () => { ]) { expect(input).toHaveProperty(name) } + expect(electronBuilderConfig.asarUnpack).toContain('out/main/gitlab/project-ref-parser.js') expect(electronBuilderConfig.asarUnpack).toContain('out/main/persistence/profile-state/**') expect(electronBuilderConfig.asarUnpack).toContain( 'out/main/orca-profiles/profile-index-store.js' @@ -144,8 +146,11 @@ describe('Electron Vite output contract', () => { expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) expect(external('tldts', undefined, false)).toBe(false) expect(external('zod', undefined, false)).toBe(false) + expect(external('smol-toml', undefined, false)).toBe(false) + expect(external('smol-toml/package.json', undefined, false)).toBe(false) expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('tldts') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('smol-toml') }) it('bundles validation dependencies used by the sandboxed preload', () => { diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index c93b6a19d62..a91b4f528c4 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -8,9 +8,13 @@ import { basename, dirname, resolve } from 'node:path' import { ensureWindowsProcessTreeCommandLinePatch, inspectWindowsProcessTreeAddon, + nodeGypRebuildInvocation, + nodeGypRebuildTimeoutMs, stageWindowsProcessTreeNodeAddonApiHeaders, windowsProcessTreeAddonPath } from './windows-process-tree-gyp-rebuild.mjs' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { disableMsbuildFileTrackingOnWindows } from './msbuild-file-tracking.mjs' const require = createRequire(import.meta.url) const { assertNodePtyJobOwnership, nodePtyAddonPath } = require('./node-pty-job-ownership.cjs') @@ -400,33 +404,39 @@ function rebuildNodeRuntimeModules(moduleNames) { moduleDir = realpathSync(moduleDir) } console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`) - runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir }) + // pnpm exec inside an installed addon cannot discover the root build tool. + runNodeGyp( + moduleName, + nodeGypRebuildInvocation( + process.arch, + moduleDir, + process.env.npm_config_node_gyp || undefined + ) + ) if (moduleName === 'node-pty' && process.platform === 'win32') { runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')]) } } } -function runPnpm(args, { cwd = projectDir } = {}) { - // cmd.exe resolves both Corepack's pnpm.cmd and pnpm 12's native pnpm.exe. - const command = 'pnpm' +function runNodeGyp(moduleName, { args, cwd }) { const env = - process.platform === 'linux' && args.includes('node-gyp') + process.platform === 'linux' ? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() } - : process.env - const result = spawnSync(command, args, { + : disableMsbuildFileTrackingOnWindows({ ...process.env }) + const result = runProcessSync({ + program: process.execPath, + args, cwd, + env, stdio: 'inherit', - shell: process.platform === 'win32', - env + timeoutMs: nodeGypRebuildTimeoutMs(moduleName) }) - - if (result.error || result.status !== 0) { - console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`) - if (result.error) { - console.error(formatError(result.error)) - } - process.exit(result.status ?? 1) + if (result.code !== 0) { + console.error( + `[native-runtime] node-gyp rebuild failed in ${cwd}: ${describeProcessFailure(result)}` + ) + process.exit(result.code ?? 1) } } diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 47fe435edcc..68a10b5bb20 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -1,7 +1,7 @@ import { spawnSync } from 'node:child_process' import { - chmodSync, copyFileSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -9,10 +9,21 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' +import { isAbsolute, join, parse } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { + DEFAULT_MAX_OUTPUT_BYTES, + runProcessSync +} from '../../src/shared/child-process/run-process.ts' +import { resolveCliCommand } from '../../src/shared/node-cli-command-resolution.ts' +import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' +import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' import { copyScriptWithLocalModules } from './script-module-dependencies.mjs' +import { + nodeGypRebuildInvocation, + nodeGypRebuildTimeoutMs +} from './windows-process-tree-gyp-rebuild.mjs' const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) // The import walk sees `from './x.mjs'` only, so the createRequire'd CJS @@ -30,23 +41,30 @@ describe('ensure-native-runtime', () => { const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') writeFakeNativeModules(projectDir) writeNodePtyPatchFile(projectDir) - writeFakePnpm(binDir) + writeFakeNodeGyp(projectDir) + const verboseOutputBytes = DEFAULT_MAX_OUTPUT_BYTES + 1024 * 1024 const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { cwd: projectDir, encoding: 'utf8', - env: envWithPrependedPath(binDir, { + maxBuffer: DEFAULT_MAX_OUTPUT_BYTES * 4, + env: envForNativeFixture(projectDir, { ORCA_NATIVE_TEST_LOG: logPath, - ORCA_NATIVE_TEST_MARKER: markerPath + ORCA_NATIVE_TEST_MARKER: markerPath, + ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES: String(verboseOutputBytes) }) }) expect(result.status, result.stderr).toBe(0) + expect(result.stdout.indexOf('node-gyp stdout complete\n')).toBe(verboseOutputBytes) + expect(/^x+$/.test(result.stdout.slice(0, verboseOutputBytes))).toBe(true) + expect(result.stderr).toContain('node-gyp stderr complete\n') const log = readFileSync(logPath, 'utf8') - expect(log).toContain('pnpm exec node-gyp rebuild\n') + expect(log).toContain(`node-gyp rebuild --arch=${process.arch}\n`) + expect(log).toContain(`node-gyp timeout=${nodeGypRebuildTimeoutMs('node-pty')}\n`) + expect(log).toContain(`trackFileAccess=${process.platform === 'win32' ? 'false' : ''}\n`) expect(log).toContain(join('node_modules', 'node-pty')) if (process.platform === 'linux') { expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m) @@ -60,24 +78,29 @@ describe('ensure-native-runtime', () => { } }) - it.skipIf(process.platform !== 'win32')( - 'rebuilds other failed Windows addons with patched node-pty', - () => { + it.skipIf(process.platform !== 'win32').each([ + { trackingEnv: {}, tracking: 'false' }, + { trackingEnv: { TrackFileAccess: 'true' }, tracking: 'true' }, + { trackingEnv: { trackfileaccess: 'true' }, tracking: 'true' }, + { trackingEnv: { tRaCkFiLeAcCeSs: 'false' }, tracking: 'false' } + ])( + 'rebuilds other failed Windows addons with patched node-pty and tracking=$tracking', + ({ trackingEnv, tracking }) => { const projectDir = mkTempProject() try { const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true }) writeNodePtyPatchFile(projectDir) - writeFakePnpm(binDir) + writeFakeNodeGyp(projectDir) const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { cwd: projectDir, encoding: 'utf8', - env: envWithPrependedPath(binDir, { + env: envForNativeFixture(projectDir, { + ...trackingEnv, ORCA_NATIVE_TEST_LOG: logPath, ORCA_NATIVE_TEST_MARKER: markerPath }) @@ -85,7 +108,12 @@ describe('ensure-native-runtime', () => { expect(result.status, result.stderr).toBe(0) const log = readFileSync(logPath, 'utf8') - expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2) + expect( + log.split('\n').filter((line) => line === `node-gyp rebuild --arch=${process.arch}`) + ).toHaveLength(2) + expect( + log.split('\n').filter((line) => line === `trackFileAccess=${tracking}`) + ).toHaveLength(2) expect(log).toContain(join('node_modules', 'node-pty')) expect(log).toContain(join('node_modules', '@orca', 'windows-registry')) } finally { @@ -103,15 +131,14 @@ describe('ensure-native-runtime', () => { const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') writeLoadableNativeModules(projectDir) writeNodePtyPatchFile(projectDir) - writeFakePnpm(binDir) + writeFakeNodeGyp(projectDir) const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { cwd: projectDir, encoding: 'utf8', - env: envWithPrependedPath(binDir, { + env: envForNativeFixture(projectDir, { ORCA_NATIVE_TEST_LOG: logPath, ORCA_NATIVE_TEST_MARKER: markerPath }) @@ -121,7 +148,7 @@ describe('ensure-native-runtime', () => { expect(result.stderr).toContain( 'Patched node-pty build artifacts are missing; rebuilding native deps.' ) - expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n') + expect(readFileSync(logPath, 'utf8')).toContain(`node-gyp rebuild --arch=${process.arch}\n`) } finally { rmSync(projectDir, { recursive: true, force: true }) } @@ -137,16 +164,15 @@ describe('ensure-native-runtime', () => { const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') writeLoadableNativeModules(projectDir) writeNodePtyPatchFile(projectDir) writePatchedNodePtyBuildArtifacts(projectDir) - writeFakePnpm(binDir) + writeFakeNodeGyp(projectDir) const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { cwd: projectDir, encoding: 'utf8', - env: envWithPrependedPath(binDir, { + env: envForNativeFixture(projectDir, { ORCA_NATIVE_TEST_LOG: logPath, ORCA_NATIVE_TEST_MARKER: markerPath }) @@ -154,7 +180,7 @@ describe('ensure-native-runtime', () => { expect(result.status, result.stderr).toBe(0) expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active") - expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n') + expect(readFileSync(logPath, 'utf8')).toContain(`node-gyp rebuild --arch=${process.arch}\n`) } finally { rmSync(projectDir, { recursive: true, force: true }) } @@ -170,16 +196,15 @@ describe('ensure-native-runtime', () => { const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs') const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') - const binDir = join(projectDir, 'bin') writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' }) writeNodePtyPatchFile(projectDir) writePatchedNodePtyBuildArtifacts(projectDir) - writeFakePnpm(binDir) + writeFakeNodeGyp(projectDir) const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { cwd: projectDir, encoding: 'utf8', - env: envWithPrependedPath(binDir, { + env: envForNativeFixture(projectDir, { ORCA_NATIVE_TEST_LOG: logPath, ORCA_NATIVE_TEST_MARKER: markerPath }) @@ -187,12 +212,64 @@ describe('ensure-native-runtime', () => { expect(result.status, result.stderr).toBe(0) expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing') - expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild') + expect(readFileSync(logPath, 'utf8')).not.toContain('node-gyp rebuild') } finally { rmSync(projectDir, { recursive: true, force: true }) } } ) + it('finds the installed node-gyp entry from an addon without build tools on PATH', () => { + const { command, prefixArgs } = resolvePnpmCliInvocation() + const program = isAbsolute(command) ? command : resolveCliCommand(parse(command).name) + expect(isAbsolute(program), 'pnpm must be installed for the native rebuild contract').toBe(true) + const projectDir = mkTempProject() + try { + writeFakeNativeModules(projectDir) + const addonDir = join(projectDir, 'node_modules', 'node-pty') + const env = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'path') { + env[key] = '' + } + } + const oldInvocation = runProcessSync({ + program, + args: [ + ...prefixArgs, + '--config.verify-deps-before-run=false', + 'exec', + 'node-gyp', + '--version' + ], + cwd: addonDir, + env, + timeoutMs: 20_000 + }) + expect(oldInvocation.code).not.toBe(0) + expect(`${oldInvocation.stdout}\n${oldInvocation.stderr}`).toContain( + 'Command "node-gyp" not found' + ) + const { args, cwd } = nodeGypRebuildInvocation(process.arch, addonDir) + const installedInvocation = runProcessSync({ + program: process.execPath, + args: [args[0], '--version'], + cwd, + env, + timeoutMs: 20_000 + }) + expect( + installedInvocation.code, + `${installedInvocation.stdout}\n${installedInvocation.stderr}` + ).toBe(0) + const manifest = JSON.parse( + readFileSync(new URL('../../node_modules/node-gyp/package.json', import.meta.url), 'utf8') + ) + expect(installedInvocation.stdout.trim()).toBe(`v${manifest.version}`) + expect(existsSync(join(addonDir, 'pnpm-lock.yaml'))).toBe(false) + } finally { + removeTreeSync(projectDir) + } + }) }) function mkTempProject() { @@ -200,6 +277,17 @@ function mkTempProject() { // Walked, not listed: the script imports windows-process-tree-gyp-rebuild.mjs, and a fixture // missing it fails every case with a module-resolution error instead of the defect under test. copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts')) + writeFileSync( + join(projectDir, 'config', 'scripts', 'script-child-process.mjs'), + `import { appendFileSync } from 'node:fs' +import { describeProcessFailure, runProcessSync as run } from ${JSON.stringify(new URL('./script-child-process.mjs', import.meta.url).href)} +export { describeProcessFailure } +export function runProcessSync(options) { + appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-gyp timeout=\${options.timeoutMs}\\n\`) + return run(options) +} +` + ) for (const name of REQUIRED_CJS_SIBLINGS) { copyFileSync( fileURLToPath(new URL(`./${name}`, import.meta.url)), @@ -209,15 +297,15 @@ function mkTempProject() { return projectDir } -function envWithPrependedPath(binDir, extraEnv) { - const pathKey = - process.platform === 'win32' - ? (Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'Path') - : 'PATH' +function envForNativeFixture(projectDir, extraEnv) { + // An inherited tracking preference would mask the Windows default under test. + const inherited = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'trackfileaccess') + ) return { - ...process.env, + ...inherited, ...extraEnv, - [pathKey]: `${binDir}${delimiter}${process.env[pathKey] ?? ''}` + npm_config_node_gyp: join(projectDir, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js') } } @@ -258,6 +346,11 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { ` ) writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker }) + if (process.platform === 'win32') { + const buildDir = join(nodePtyDir, 'build', 'Release') + writePatchedNodePtyBuildArtifacts(projectDir) + writeFileSync(join(buildDir, 'conpty.node'), Buffer.from('msys-2.0.dll', 'utf16le')) + } } function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) { @@ -314,7 +407,10 @@ function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) { ) const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') mkdirSync(processTreeDir, { recursive: true }) - writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') + writeFileSync( + join(processTreeDir, 'index.js'), + 'exports.supportedProcessDataFlags = 4; exports.getProcessCreationTime = () => 1\n' + ) } function writeNodePtyPatchFile(projectDir) { @@ -338,33 +434,26 @@ function writePatchedNodePtyBuildArtifacts(projectDir) { } } -function writeFakePnpm(binDir) { - mkdirSync(binDir, { recursive: true }) - const shimPath = join(binDir, 'pnpm-shim.cjs') +function writeFakeNodeGyp(projectDir) { + const toolDir = join(projectDir, 'node_modules', 'node-gyp', 'bin') + mkdirSync(toolDir, { recursive: true }) writeFileSync( - shimPath, + join(toolDir, 'node-gyp.js'), ` -const { appendFileSync, writeFileSync } = require('node:fs') - -appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`) +const { appendFileSync, writeFileSync, writeSync } = require('node:fs') +appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-gyp \${process.argv.slice(2).join(' ')}\\n\`) appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`) -appendFileSync( - process.env.ORCA_NATIVE_TEST_LOG, - \`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\` -) -appendFileSync( - process.env.ORCA_NATIVE_TEST_LOG, - \`cxxflags=\${process.env.CXXFLAGS || ''}\\n\` -) +appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`) +appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`trackFileAccess=\${process.env.TrackFileAccess ?? ''}\\n\`) +if (process.env.ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES) { + const output = Buffer.alloc(Number(process.env.ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES), 'x') + for (let offset = 0; offset < output.length;) { + offset += writeSync(1, output.subarray(offset)) + } + writeSync(1, 'node-gyp stdout complete\\n') + writeSync(2, 'node-gyp stderr complete\\n') +} writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt') ` ) - - const posixPnpmPath = join(binDir, 'pnpm') - writeFileSync(posixPnpmPath, `#!/usr/bin/env node\nrequire(${JSON.stringify(shimPath)})\n`) - chmodSync(posixPnpmPath, 0o755) - writeFileSync( - join(binDir, 'pnpm.cmd'), - `@echo off\r\n"${process.execPath}" "%~dp0\\pnpm-shim.cjs" %*\r\n` - ) } diff --git a/config/scripts/foreign-sqlite-reader-worker-smoke.mjs b/config/scripts/foreign-sqlite-reader-worker-smoke.mjs new file mode 100644 index 00000000000..b83c9c088af --- /dev/null +++ b/config/scripts/foreign-sqlite-reader-worker-smoke.mjs @@ -0,0 +1,80 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { ORCAD_FOREIGN_SQLITE_READER_ENTRY } from '../../src/shared/orcad-artifacts.ts' + +// Why a child process: the read must run under the runtime orcad ships, which may not be +// the Node running the build. The verdict is the exit code, never matched output. +const PROBE = ` +const { Worker } = require('node:worker_threads') +const { DatabaseSync } = process.getBuiltinModule('node:sqlite') +const [entry, dbPath, missingPath] = process.argv.slice(2) +const db = new DatabaseSync(dbPath) +db.exec('CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)') +db.prepare('INSERT INTO session VALUES (?, ?, ?, ?)').run('ses_smoke', '/smoke', 100, null) +db.close() +const steps = [ + { + request: { id: 1, kind: 'openCodeBinderSessions', dbPath, cursor: { ms: 0, id: '' } }, + expected: [{ id: 'ses_smoke', directory: '/smoke', createdAtMs: 100, parentId: null }] + }, + { request: { id: 2, kind: 'cursorProfile', dbPath: missingPath }, expected: { status: 'missing' } }, + // The OpenCode history scanner's kinds share this entry. + { request: { id: 3, kind: 'list', dbPaths: [], limit: null }, expected: { candidates: [], issues: [] } } +] +const worker = new Worker(entry, { execArgv: [] }) +const fail = (code, message) => { + console.error(message) + process.exit(code) +} +setTimeout(() => fail(3, 'foreign SQLite reader worker did not answer'), 20000).unref() +worker.on('error', (error) => fail(4, String(error && error.stack || error))) +worker.on('exit', (code) => fail(5, 'foreign SQLite reader worker exited with ' + code)) +let step = 0 +worker.on('message', (response) => { + const { request, expected } = steps[step] + if (!response || response.id !== request.id || response.ok !== true || + JSON.stringify(response.value) !== JSON.stringify(expected)) { + fail(6, request.kind + ' answered ' + JSON.stringify(response)) + } + step += 1 + if (step === steps.length) { + process.exit(0) + } + worker.postMessage(steps[step].request) +}) +worker.postMessage(steps[0].request) +` + +/** + * Load the built foreign SQLite reader entry and run real reads through it. + * @param outDir - orcad output directory holding the entry. + * @param options.runtimePath - Node to run under; the build's own Node when omitted. + */ +export function smokeForeignSqliteReaderWorker(outDir, { runtimePath, timeoutMs = 30_000 } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-foreign-sqlite-smoke-')) + try { + const probe = join(directory, 'probe.cjs') + writeFileSync(probe, PROBE) + const result = runProcessSync({ + program: runtimePath ?? process.execPath, + args: [ + probe, + resolve(outDir, ORCAD_FOREIGN_SQLITE_READER_ENTRY), + join(directory, 'opencode.db'), + join(directory, 'missing.vscdb') + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs, + maxOutputBytes: 64 * 1024 + }) + if (result.code !== 0 || result.timedOut) { + throw new Error( + `Foreign SQLite reader worker smoke failed: ${describeProcessFailure(result)}` + ) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs new file mode 100644 index 00000000000..36121ad88d4 --- /dev/null +++ b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs @@ -0,0 +1,65 @@ +import { build } from 'esbuild' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { smokeForeignSqliteReaderWorker } from './foreign-sqlite-reader-worker-smoke.mjs' +import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' + +const ENTRY = 'foreign-sqlite-reader-entry.js' +const directories = [] +let builtDirectory + +function fixtureDirectory() { + const directory = mkdtempSync(join(tmpdir(), 'orca-foreign-sqlite-smoke-test-')) + directories.push(directory) + return directory +} + +beforeAll(async () => { + builtDirectory = fixtureDirectory() + await build({ + entryPoints: [resolve(ORCAD_CHILD_ENTRY_POINTS.foreignSqliteReader)], + outfile: join(builtDirectory, ENTRY), + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + external: ['electron'], + logLevel: 'silent' + }) +}, 60_000) + +afterAll(() => { + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('foreign SQLite reader build smoke', () => { + it('passes against the built entry', () => { + expect(() => smokeForeignSqliteReaderWorker(builtDirectory)).not.toThrow() + }) + + it('fails when the entry is missing', () => { + expect(() => smokeForeignSqliteReaderWorker(fixtureDirectory())).toThrow('smoke failed') + }) + + it('fails when the worker answers without reading', () => { + const directory = fixtureDirectory() + writeFileSync( + join(directory, ENTRY), + `const { parentPort } = require('node:worker_threads') + parentPort.on('message', ({ id }) => parentPort.postMessage({ id, ok: true, value: [] }))` + ) + expect(() => smokeForeignSqliteReaderWorker(directory)).toThrow('smoke failed') + }) + + it('runs in the orcad build under both runtimes', () => { + const source = readFileSync(resolve('config/scripts/build-orcad.mjs'), 'utf8') + expect(source).toContain('smokeForeignSqliteReaderWorker(OUT_DIR)') + expect(source).toContain( + 'smokeForeignSqliteReaderWorker(OUT_DIR, { runtimePath: nodeRuntimePath })' + ) + }) +}) diff --git a/config/scripts/generate-monaco-associations.mjs b/config/scripts/generate-monaco-associations.mjs index b5c2e99c00a..ea81be3fa47 100644 --- a/config/scripts/generate-monaco-associations.mjs +++ b/config/scripts/generate-monaco-associations.mjs @@ -10,6 +10,12 @@ export const associationsPath = fileURLToPath( new URL('../../src/renderer/src/lib/monaco-language-associations.json', import.meta.url) ) +// Monaco omits common Ruby task, template and configuration files. +const rubyAssociations = { + extensions: ['.rake', '.ru', '.jbuilder', '.thor'], + filenames: ['Guardfile', 'Capfile', 'Podfile', 'Brewfile', 'Vagrantfile'] +} + // Read registration metadata without importing Monaco or executing its grammar loaders. export function readMonacoAssociations() { const entry = ts.createSourceFile( @@ -62,6 +68,12 @@ export function readMonacoAssociations() { if (!metadata.id) { throw new Error(`Missing language id in ${file}`) } + if (metadata.id === 'ruby') { + metadata.extensions = [ + ...new Set([...metadata.extensions, ...rubyAssociations.extensions]) + ] + metadata.filenames = [...new Set([...metadata.filenames, ...rubyAssociations.filenames])] + } registrations.push(metadata) } ts.forEachChild(node, visit) diff --git a/config/scripts/generate-monaco-associations.test.mjs b/config/scripts/generate-monaco-associations.test.mjs index 0e7563cd49a..1076ea78c8a 100644 --- a/config/scripts/generate-monaco-associations.test.mjs +++ b/config/scripts/generate-monaco-associations.test.mjs @@ -3,10 +3,36 @@ import { describe, expect, it } from 'vitest' import { associationsPath, readMonacoAssociations } from './generate-monaco-associations.mjs' describe('Monaco filename associations', () => { - it('matches every registration shipped by the installed editor entry point', () => { + it('matches the installed editor registrations and curated Orca associations', () => { expect( JSON.parse(readFileSync(associationsPath, 'utf8')), - 'Run node config/scripts/generate-monaco-associations.mjs after upgrading Monaco' + 'Run node config/scripts/generate-monaco-associations.mjs after changing associations or Monaco' ).toEqual(readMonacoAssociations()) }) + + it('keeps built-in Ruby aliases alongside the curated Ruby associations', () => { + expect(readMonacoAssociations().find((language) => language.id === 'ruby')).toEqual({ + id: 'ruby', + extensions: expect.arrayContaining([ + '.rb', + '.rbx', + '.rjs', + '.gemspec', + '.pp', + '.rake', + '.ru', + '.jbuilder', + '.thor' + ]), + filenames: expect.arrayContaining([ + 'rakefile', + 'Gemfile', + 'Guardfile', + 'Capfile', + 'Podfile', + 'Brewfile', + 'Vagrantfile' + ]) + }) + }) }) diff --git a/config/scripts/git-binary-compatibility-workflow.test.mjs b/config/scripts/git-binary-compatibility-workflow.test.mjs index c03c7973f6a..f6e6a32b720 100644 --- a/config/scripts/git-binary-compatibility-workflow.test.mjs +++ b/config/scripts/git-binary-compatibility-workflow.test.mjs @@ -16,10 +16,14 @@ describe('Git binary compatibility PR gate', () => { const run = stepNamed('Verify Git binary compatibility matrix')?.run expect(run).toContain('ORCA_GIT_COMPAT_BINARY="$HOME/.cache/orca-git-compat/git-2.25.5/git"') + expect(run).toContain('GIT_EXEC_PATH="$HOME/.cache/orca-git-compat/git-2.25.5"') expect(run).toContain('alpine/git:edge-2.38.1|2.38.1') expect(run).toContain('alpine/git:v2.49.1|2.49.1') expect(run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"') expect(run).toContain('src/shared/git-binary-compatibility.test.ts') + expect(run).toContain('src/main/git/worktree-safety-real-git.test.ts') + expect(run).toContain('src/main/git/worktree-rebase-update-refs-real-git.test.ts') + expect(run).toContain('src/relay/git-review-draft-binary-compatibility.test.ts') expect(run).toContain('pids+=("$!")') expect(run).toContain('wait "$pid" || status=1') }) @@ -30,10 +34,17 @@ describe('Git binary compatibility PR gate', () => { expect(run).toContain('git-2.25.5.tar.gz') // Why asserted: the sha256 check only runs on the build path, so a cached binary // must come from a key that pins the same version the tarball line declares. - expect(run).toContain('if [ -x "$source/git" ]; then') + expect(run).toContain('[ -x "$source/git" ] && [ -x "$source/git-submodule" ]') + expect(run).toContain('[ -f "$source/git-sh-setup" ] && [ -f "$source/git-sh-i18n" ]') + expect(run).toContain( + '[ -f "$source/git-parse-remote" ] && [ -x "$source/git-sh-i18n--envsubst" ]' + ) expect(run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf') expect(run).toContain('-j"$(nproc)"') - expect(run).toContain('NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git') + expect(run).toContain('NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease') + expect(run).toContain( + 'git git-submodule git-sh-setup git-sh-i18n git-parse-remote git-sh-i18n--envsubst' + ) expect(run).toContain('sha256sum --check') expect(run).toContain('find "$source" -name \'*.o\' -delete') // The cached path and the build path must be the same directory or the guard @@ -61,7 +72,7 @@ describe('Git binary compatibility PR gate', () => { expect(steps[matrixIndex].run).not.toContain('make -C') expect(baselineSteps[cacheIndex].with.path).toBe(BASELINE_DIR) expect(baselineSteps[cacheIndex].with.key).toBe( - 'git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5' + 'git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5-submodule' ) }) diff --git a/config/scripts/github-opened-issue-repository.test.ts b/config/scripts/github-opened-issue-repository.test.ts new file mode 100644 index 00000000000..b2d0519c5cc --- /dev/null +++ b/config/scripts/github-opened-issue-repository.test.ts @@ -0,0 +1,440 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import type * as ReactModule from 'react' +import type * as GhUtils from '../../src/main/github/gh-utils' +import type * as IssueMetadata from '../../src/renderer/src/hooks/useIssueMetadata' +import type { GitHubWorkItem } from '../../src/shared/github/work-item-types' +import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types' +import type { TaskSourceContext } from '../../src/shared/task-source-context' +import type { Repo } from '../../src/shared/repo-types' + +const fixture = vi.hoisted(() => { + const state: { + loads: { key: string | null; load: () => Promise }[] + requests: { args: string[]; host?: string }[] + gh: ReturnType + apiUpdate: ReturnType + preference: 'origin' | 'upstream' + } = { loads: [], requests: [], gh: vi.fn(), apiUpdate: vi.fn(), preference: 'upstream' } + return state +}) + +vi.mock('react', async (original) => ({ + ...(await original()), + useState: (initial: unknown) => [typeof initial === 'function' ? initial() : initial, vi.fn()], + useMemo: (value: () => T) => value(), + useCallback: (value: T) => value, + useRef: (initial: T) => ({ current: initial }), + useEffect: vi.fn() +})) +vi.mock('zustand/react/shallow', () => ({ useShallow: (value: T) => value })) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => + selector({ patchWorkItem: vi.fn(), patchProjectRowContent: vi.fn() }), + { getState: () => ({ recordFeatureInteraction: vi.fn() }) } + ) +})) +vi.mock('@/lib/repo-runtime-owner', () => ({ + getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null }) +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({ + useGitHubDuplicateIssueCandidates: () => [] +})) +vi.mock('@/components/github/github-work-item-comment-mutations', () => ({ + notifyWorkItemDetailsMutation: vi.fn() +})) +vi.mock('@/hooks/useIssueMetadata', async (original) => ({ + ...(await original()), + useImmediateMutation: () => ({ isPending: () => false, run: vi.fn() }) +})) +vi.mock('@/hooks/useMetadataListRequest', () => ({ + useMetadataListRequest: (args: { cacheKey: string | null; load: () => Promise }) => { + fixture.loads.push({ key: args.cacheKey, load: args.load }) + return { data: [], loading: false, error: null } + } +})) +vi.mock('../../src/main/github/gh-utils', async (original) => ({ + ...(await original()), + ghExecFileAsync: fixture.gh, + acquire: vi.fn(), + release: vi.fn(), + getOwnerRepoForRemote: async (_path: string, remote: string) => ({ + owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner', + repo: 'widgets' + }) +})) +vi.mock('../../src/main/git/remote-name-listing', () => ({ + shouldProbeGitRemote: async () => true +})) + +import { GHEditSection } from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section' +import { + runGHEditLabelToggle, + runGHEditStateChange +} from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations' +import { findTaskPageDialogWorkItem } from '../../src/renderer/src/components/task-page-cache-selectors' +import { getTaskPageRepoSourceContext } from '../../src/renderer/src/components/task-page-source-context' +import { workItemsCacheKey } from '../../src/renderer/src/store/github/cache-identity' +import { createTestStore } from '../../src/renderer/src/store/slices/github-slice-test-harness' +import { getTaskSourceCacheScope } from '../../src/shared/task-source-context' +import { listLabels, listAssignableUsers } from '../../src/main/github/issue-field-options' +import { useRepoLabels, useRepoAssignees } from '../../src/renderer/src/hooks/useIssueMetadata' +import { updateIssue } from '../../src/main/github/issue-update' +import { materializeTaskPageItemList } from '../../src/renderer/src/components/task-page-github-work-item-mutations' +import { + resetTaskPageGitHubMutationRegistryForTests, + setTaskPageGitHubMutationQueryKey +} from '../../src/renderer/src/components/task-page-github-work-item-mutation-registry' + +function renderEditSection(props: Parameters[0]): void { + renderToStaticMarkup(createElement(GHEditSection, props)) +} + +const registeredRepo: Repo = { + id: 'repo-1', + path: join(tmpdir(), 'orca-opened-issue-repository-fixture'), + displayName: 'widgets', + badgeColor: 'primary', + addedAt: 1, + upstream: { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' } +} + +function sourceFor(preference: 'origin' | 'upstream'): TaskSourceContext { + const source = getTaskPageRepoSourceContext( + { ...registeredRepo, issueSourcePreference: preference }, + 'github' + ) + if (!source) { + throw new Error('Registered fixture must produce a source context') + } + return source +} + +const sourceContext = sourceFor('origin') +const fork: GitHubWorkItem = { + id: 'issue:5', + type: 'issue', + number: 5, + title: 'FORK title', + state: 'open', + url: 'https://github.com/fork-owner/widgets/issues/5', + labels: [], + updatedAt: '', + author: null, + repoId: 'repo-1' +} +const upstream: GitHubWorkItem = { + ...fork, + title: 'UPSTREAM title', + url: 'https://github.com/upstream-owner/widgets/issues/5' +} +const issueRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } + +type MetadataArgs = { repoPath: string; ownerRepo?: GitHubOwnerRepo } + +beforeEach(() => { + fixture.preference = 'upstream' + fixture.loads = [] + fixture.requests = [] + resetTaskPageGitHubMutationRegistryForTests() + setTaskPageGitHubMutationQueryKey('current-upstream-list') + fixture.gh.mockReset() + fixture.gh.mockImplementation(async (args: string[], options: { host?: string }) => { + fixture.requests.push({ args, host: options.host }) + return { stdout: '', stderr: '' } + }) + fixture.apiUpdate = vi.fn((args: Parameters[0]) => + updateIssue( + args.repoPath, + args.number, + args.updates, + null, + {}, + fixture.preference, + args.ownerRepo + ) + ) + vi.stubGlobal('window', { + api: { + gh: { + updateIssue: fixture.apiUpdate, + listLabels: (args: MetadataArgs) => + listLabels(args.repoPath, fixture.preference, null, {}, args.ownerRepo), + listAssignableUsers: (args: MetadataArgs) => + listAssignableUsers(args.repoPath, fixture.preference, null, {}, args.ownerRepo) + } + } + }) +}) + +afterEach(() => { + resetTaskPageGitHubMutationRegistryForTests() + vi.unstubAllGlobals() +}) + +it.each([ + { openedItem: fork, listItem: upstream, preference: 'upstream' }, + { openedItem: upstream, listItem: fork, preference: 'origin' }, + { openedItem: fork, listItem: fork, preference: 'origin' } +] as const)( + 'scopes $openedItem.title labels under $preference to its canonical list row', + async ({ openedItem, listItem, preference }) => { + fixture.preference = preference + expect(sourceFor('upstream')).toEqual(sourceContext) + const store = createTestStore() + const key = workItemsCacheKey( + registeredRepo.id, + 36, + '', + getTaskSourceCacheScope(sourceFor('upstream')) + ) + store.setState({ + workItemsCache: { [key]: { data: [listItem], fetchedAt: Date.now() } } + }) + const opened = + findTaskPageDialogWorkItem(store.getState().workItemsCache, { + id: openedItem.id, + repoId: openedItem.repoId, + url: openedItem.url + }) ?? openedItem + expect(opened.url).toBe(openedItem.url) + const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' } + let mutation: Promise = Promise.resolve() + runGHEditLabelToggle({ + itemId: opened.id, + itemNumber: opened.number, + itemRepoId: opened.repoId, + repoPath: registeredRepo.path, + sourceContext, + projectOrigin: undefined, + issueRepo: target, + label: 'fork-only-label', + localLabels: [], + run: async (_key, options) => { + options.onOptimistic?.() + mutation = options.mutate() + await mutation + options.onSuccess?.() + return true + }, + onLabelsChange: vi.fn(), + patchWorkItem: store.getState().patchWorkItem, + patchProjectRowIfNeeded: vi.fn(), + onMutated: vi.fn() + }) + await mutation + expect(fixture.requests[0].args).toContain(`${target.owner}/widgets`) + expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target })) + expect(store.getState().workItemsCache[key]?.data?.[0].labels).toEqual( + listItem.url === openedItem.url ? ['fork-only-label'] : [] + ) + } +) + +it.each([ + { openedItem: fork, owner: 'fork-owner', preference: 'origin' }, + { openedItem: fork, owner: 'fork-owner', preference: 'upstream' }, + { openedItem: upstream, owner: 'upstream-owner', preference: 'origin' }, + { openedItem: upstream, owner: 'upstream-owner', preference: 'upstream' } +] as const)( + 'loads $owner picker candidates while preference=$preference', + async ({ preference, openedItem, owner }) => { + fixture.preference = preference + renderEditSection({ + item: openedItem, + repoPath: registeredRepo.path, + repoId: fork.repoId, + sourceContext, + projectOrigin: undefined, + localState: 'open', + localLabels: [], + assignees: [], + onStateChange: vi.fn(), + onLabelsChange: vi.fn(), + onMutated: vi.fn(), + onUse: vi.fn() + }) + for (const request of fixture.loads.filter((load) => load.key !== null)) { + await request.load() + } + expect( + fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/'))) + ).toEqual([`repos/${owner}/widgets/labels`, `repos/${owner}/widgets/assignees?per_page=100`]) + } +) + +it.each([ + { openedItem: fork, listItem: fork }, + { openedItem: fork, listItem: upstream }, + { openedItem: upstream, listItem: fork }, + { openedItem: upstream, listItem: upstream } +])( + 'a $openedItem.title close only controls its own row while search lags (list=$listItem.title)', + async ({ openedItem, listItem }) => { + const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' } + let pending = Promise.resolve() + runGHEditStateChange({ + newState: 'closed', + localState: 'open', + itemId: fork.id, + itemNumber: fork.number, + itemRepoId: fork.repoId, + repoPath: registeredRepo.path, + sourceContext, + projectOrigin: undefined, + issueRepo: target, + run: (_key, options) => { + pending = (async () => { + options.onOptimistic?.() + await options.mutate() + options.onSuccess?.() + })() + return pending + }, + onStateChange: vi.fn(), + patchWorkItem: vi.fn(), + patchProjectRowIfNeeded: vi.fn(), + onMutated: vi.fn() + }) + await pending + expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target })) + const displayed = materializeTaskPageItemList({ + networkItems: [listItem], + previousItems: [listItem], + queryKey: 'current-upstream-list' + }) + expect(displayed[0]?.state).toBe(listItem.url === openedItem.url ? 'closed' : 'open') + } +) + +it('keeps ordinary metadata caches distinct by canonical repository and host', () => { + const identities = [ + issueRepo, + { ...issueRepo, owner: 'upstream-owner' }, + { ...issueRepo, host: 'ghe.example:8443' } + ] + for (const ownerRepo of identities) { + useRepoLabels(registeredRepo.path, registeredRepo.id, { ownerRepo }) + useRepoAssignees(registeredRepo.path, registeredRepo.id, { ownerRepo }) + } + expect(new Set(fixture.loads.filter((_, i) => i % 2 === 0).map((load) => load.key)).size).toBe(3) + expect(new Set(fixture.loads.filter((_, i) => i % 2 === 1).map((load) => load.key)).size).toBe(3) +}) + +it('keeps metadata requests without an explicit target compatible', async () => { + useRepoLabels(registeredRepo.path, registeredRepo.id) + useRepoAssignees(registeredRepo.path, registeredRepo.id) + for (const request of fixture.loads) { + await request.load() + } + expect(fixture.loads.map((load) => load.key)).toEqual([registeredRepo.id, registeredRepo.id]) + expect( + fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/'))) + ).toEqual([ + 'repos/upstream-owner/widgets/labels', + 'repos/upstream-owner/widgets/assignees?per_page=100' + ]) +}) + +it('keeps Project row metadata on the existing slug route', async () => { + const labels = vi.fn().mockResolvedValue({ ok: true, labels: [] }) + const users = vi.fn().mockResolvedValue({ ok: true, users: [] }) + vi.stubGlobal('window', { + api: { gh: { listLabelsBySlug: labels, listAssignableUsersBySlug: users } } + }) + renderEditSection({ + item: fork, + repoPath: registeredRepo.path, + repoId: fork.repoId, + sourceContext, + projectOrigin: { + owner: 'project-owner', + repo: 'outside', + host: 'ghe.example', + number: fork.number, + type: 'issue', + projectId: 'project-1', + projectItemId: 'row-1', + cacheKey: 'project-key' + }, + localState: 'open', + localLabels: [], + assignees: [], + onStateChange: vi.fn(), + onLabelsChange: vi.fn(), + onMutated: vi.fn(), + onUse: vi.fn() + }) + for (const request of fixture.loads.filter((load) => load.key !== null)) { + await request.load() + } + expect(labels).toHaveBeenCalledWith({ + owner: 'project-owner', + repo: 'outside', + host: 'ghe.example' + }) + expect(users).toHaveBeenCalledWith({ + owner: 'project-owner', + repo: 'outside', + host: 'ghe.example' + }) + expect(fixture.requests).toEqual([]) +}) + +it('rolls back a rejected fork label without changing the upstream row', async () => { + const store = createTestStore() + const key = workItemsCacheKey(registeredRepo.id, 36, '', getTaskSourceCacheScope(sourceContext)) + store.setState({ workItemsCache: { [key]: { data: [upstream, fork], fetchedAt: 1 } } }) + fixture.gh.mockRejectedValueOnce(new Error('Fixture rejects label')) + let pending = Promise.resolve(false) + const observed: string[][][] = [] + runGHEditLabelToggle({ + itemId: fork.id, + itemNumber: fork.number, + itemRepoId: fork.repoId, + repoPath: registeredRepo.path, + sourceContext, + projectOrigin: undefined, + issueRepo, + label: 'fork-only-label', + localLabels: [], + run: (_key, options) => { + pending = (async () => { + options.onOptimistic?.() + observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? []) + try { + await options.mutate() + return true + } catch { + options.onRevert?.() + observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? []) + return false + } + })() + return pending + }, + onLabelsChange: vi.fn(), + patchWorkItem: store.getState().patchWorkItem, + patchProjectRowIfNeeded: vi.fn(), + onMutated: vi.fn() + }) + expect(await pending).toBe(false) + expect(observed).toEqual([ + [[], ['fork-only-label']], + [[], []] + ]) +}) + +it('does not fall back to upstream metadata for an invalid explicit repository', async () => { + const invalid = { owner: '../escape', repo: 'widgets', host: 'github.com' } + await expect(listLabels(registeredRepo.path, 'upstream', null, {}, invalid)).resolves.toEqual([]) + await expect( + listAssignableUsers(registeredRepo.path, 'upstream', null, {}, invalid) + ).resolves.toEqual([]) + expect(fixture.requests).toEqual([]) +}) diff --git a/config/scripts/github-pr-assignee-repository.test.ts b/config/scripts/github-pr-assignee-repository.test.ts new file mode 100644 index 00000000000..0265aa92c41 --- /dev/null +++ b/config/scripts/github-pr-assignee-repository.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type * as GhUtils from '../../src/main/github/gh-utils' +import type * as ReactModule from 'react' +import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../src/shared/github/work-item-types' +import type { GitHubIssueUpdate } from '../../src/shared/issue-mutation-types' + +const fixture = vi.hoisted(() => { + const state: { + callbacks: unknown[] + mutation: Promise | null + gh: ReturnType + apiUpdate: ReturnType + patch: ReturnType + preference: 'origin' | 'upstream' + localGitOptions: { wslDistro?: string } + requests: { args: string[]; host?: string; cwd?: string; wslDistro?: string }[] + } = { + callbacks: [], + mutation: null, + gh: vi.fn(), + apiUpdate: vi.fn(), + patch: vi.fn(), + preference: 'origin', + localGitOptions: {}, + requests: [] + } + return state +}) + +vi.mock('react', async (original) => ({ + ...(await original()), + useState: (value: unknown) => [typeof value === 'function' ? value() : value, vi.fn()], + useMemo: (getValue: () => T) => getValue(), + useCallback: (callback: T) => { + fixture.callbacks.push(callback) + return callback + } +})) +vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: T) => selector })) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => + selector({ + patchWorkItem: fixture.patch, + patchProjectRowContent: fixture.patch, + repos: [], + settings: {} + }), + { getState: () => ({ recordFeatureInteraction: vi.fn() }) } + ) +})) +vi.mock('@/lib/repo-runtime-owner', () => ({ + getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null }) +})) +vi.mock('@/components/ui/popover', () => ({ + Popover: vi.fn(), + PopoverContent: vi.fn(), + PopoverTrigger: vi.fn() +})) +vi.mock('@/hooks/useIssueMetadata', () => ({ + useRepoAssignees: () => ({ data: [], loading: false, error: null }), + useImmediateMutation: () => ({ + isPending: () => false, + run: (_key: string, spec: { mutate: () => Promise }) => { + fixture.mutation = spec.mutate() + } + }) +})) +vi.mock('@/hooks/useGitHubSlugMetadata', () => ({ + useRepoAssigneesBySlug: () => ({ + data: [{ login: 'octo', name: null, avatarUrl: '' }], + loading: false, + error: null + }) +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/components/github/work-item-state-presentation', () => ({ ReviewerAvatar: vi.fn() })) +vi.mock('../../src/main/github/gh-utils', async (original) => ({ + ...(await original()), + ghExecFileAsync: fixture.gh, + acquire: vi.fn(), + release: vi.fn(), + getOwnerRepoForRemote: async (_path: string, remote: string) => ({ + owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner', + repo: 'widgets' + }) +})) +vi.mock('../../src/main/git/remote-name-listing', () => ({ + shouldProbeGitRemote: async () => true +})) + +import { PRAssigneesPanel } from '../../src/renderer/src/components/github/PRAssigneesPanel' +import { updateIssue } from '../../src/main/github/issue-update' +import { _resetOriginGitHubApiRepositoryCache } from '../../src/main/github/github-api-repository' + +const repoPath = join(tmpdir(), 'orca-pr-assignee-repository-fixture') + +beforeEach(() => { + fixture.callbacks = [] + fixture.mutation = null + fixture.requests = [] + fixture.localGitOptions = {} + fixture.gh.mockReset() + fixture.gh.mockImplementation( + async ( + args: string[], + options: { + host?: string + cwd?: string + wslDistro?: string + } + ) => { + fixture.requests.push({ + args, + host: options.host, + cwd: options.cwd, + wslDistro: options.wslDistro + }) + return { stdout: '', stderr: '' } + } + ) + _resetOriginGitHubApiRepositoryCache() + fixture.apiUpdate = vi.fn( + (args: { + repoPath: string + number: number + updates: GitHubIssueUpdate + ownerRepo?: GitHubOwnerRepo + }) => + updateIssue( + args.repoPath, + args.number, + args.updates, + null, + fixture.localGitOptions, + fixture.preference, + args.ownerRepo + ) + ) + vi.stubGlobal('window', { api: { gh: { updateIssue: fixture.apiUpdate } } }) +}) + +afterEach(() => vi.unstubAllGlobals()) + +it.each([ + { owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: false }, + { owner: 'upstream-owner', preference: 'origin', assigned: true, legacy: false }, + { owner: 'fork-owner', preference: 'upstream', assigned: false, legacy: false }, + { owner: 'fork-owner', preference: 'upstream', assigned: true, legacy: false }, + { owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: true } +] as const)( + 'keeps $owner PR assignees under $preference (remove=$assigned, legacy=$legacy)', + async ({ owner, preference, assigned, legacy }) => { + fixture.preference = preference + fixture.localGitOptions = owner === 'fork-owner' ? { wslDistro: 'Ubuntu' } : {} + const item: GitHubWorkItem = { + id: 'pr:5', + type: 'pr', + number: 5, + title: 'Opened PR', + state: 'open', + url: `https://github.com/${owner}/widgets/pull/5`, + prRepo: legacy ? undefined : { owner, repo: 'widgets', host: 'github.com' }, + labels: [], + updatedAt: '', + author: null, + repoId: 'repo-1', + assignees: assigned ? [{ login: 'octo', name: null, avatarUrl: '' }] : [] + } + PRAssigneesPanel({ item, repoPath, projectOrigin: undefined, onMutated: vi.fn() }) + const toggleAssignee = fixture.callbacks.at(-1) + if (typeof toggleAssignee !== 'function') { + throw new Error('PR panel did not create an assignee handler') + } + toggleAssignee('octo') + await fixture.mutation + expect(fixture.requests).toEqual([ + { + args: [ + 'issue', + 'edit', + '5', + '--repo', + `${owner}/widgets`, + assigned ? '--remove-assignee' : '--add-assignee', + 'octo' + ], + host: 'github.com', + cwd: repoPath, + wslDistro: fixture.localGitOptions.wslDistro + } + ]) + expect(fixture.apiUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath, + repoId: item.repoId, + ownerRepo: { owner, repo: 'widgets', host: 'github.com' } + }) + ) + } +) diff --git a/config/scripts/hang-watchdog-process-metrics.mjs b/config/scripts/hang-watchdog-process-metrics.mjs index 7e5ee4de53f..ddee2806e23 100644 --- a/config/scripts/hang-watchdog-process-metrics.mjs +++ b/config/scripts/hang-watchdog-process-metrics.mjs @@ -90,9 +90,10 @@ export async function sampleProductionPerformance(boundary, options) { heartbeatCount += 1 boundary.sendHeartbeat() }, options.heartbeatIntervalMs) - const cpuBefore = combinedCpuTimeMs(boundary.pids) - loopDelay.enable() + let cpuBefore try { + cpuBefore = combinedCpuTimeMs(boundary.pids) + loopDelay.enable() await options.sleep(options.sampleMs) } finally { loopDelay.disable() diff --git a/config/scripts/hang-watchdog-process-metrics.test.mjs b/config/scripts/hang-watchdog-process-metrics.test.mjs index 2d54e691d00..54943322a01 100644 --- a/config/scripts/hang-watchdog-process-metrics.test.mjs +++ b/config/scripts/hang-watchdog-process-metrics.test.mjs @@ -1,9 +1,15 @@ -import { describe, expect, it } from 'vitest' +import childProcess from 'node:child_process' +import { syncBuiltinESMExports } from 'node:module' +import perfHooks from 'node:perf_hooks' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { parsePhysicalFootprintBytes, - parseProcessCpuTimeMs + parseProcessCpuTimeMs, + sampleProductionPerformance } from './hang-watchdog-process-metrics.mjs' +const observations = { events: [], readCpu: null, histogram: null } + describe('hang watchdog process metrics', () => { it('uses the de-duplicated summary for multiple processes', () => { const output = ` @@ -43,3 +49,203 @@ Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page) expect(parseProcessCpuTimeMs('-1:00')).toBeNull() }) }) + +function cpuObservation(pid) { + return ['cpu', 'ps', ['-o', 'time=', '-p', String(pid)], { encoding: 'utf8' }] +} + +describe('production watchdog sample lifetime', () => { + beforeEach(() => { + vi.useFakeTimers() + observations.events = [] + let enabled = false + observations.histogram = { + enable() { + observations.events.push(['enable']) + const changed = !enabled + enabled = true + return changed + }, + disable() { + observations.events.push(['disable']) + const changed = enabled + enabled = false + return changed + }, + percentile: (value) => { + observations.events.push(['percentile', value]) + return value === 95 ? 1_900_000 : 3_100_000 + }, + max: 5_000_000 + } + vi.spyOn(childProcess, 'execFileSync').mockImplementation((command, args, options) => { + observations.events.push(['cpu', command, args, options]) + return observations.readCpu() + }) + vi.spyOn(perfHooks, 'monitorEventLoopDelay').mockImplementation((options) => { + observations.events.push(['monitor', options]) + return observations.histogram + }) + syncBuiltinESMExports() + }) + afterEach(() => { + vi.clearAllTimers() + vi.restoreAllMocks() + syncBuiltinESMExports() + vi.useRealTimers() + }) + + it.each([1, 2])('clears the heartbeat when initial CPU observation %s throws', async (failAt) => { + const error = new Error('PID observation failed') + let reads = 0 + observations.readCpu = () => { + if (++reads === failAt) { + throw error + } + return '0:01.20' + } + const sendHeartbeat = vi.fn() + const sleep = vi.fn() + await expect( + sampleProductionPerformance( + { pids: [101, 102], sendHeartbeat }, + { heartbeatIntervalMs: 2_000, sampleMs: 30_000, sleep } + ) + ).rejects.toBe(error) + expect(reads).toBe(failAt) + expect(sleep).not.toHaveBeenCalled() + expect(observations.events.filter(([kind]) => kind !== 'disable')).toEqual([ + ['monitor', { resolution: 10 }], + ...[101, 102].slice(0, failAt).map(cpuObservation) + ]) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(4_000) + expect(sendHeartbeat).not.toHaveBeenCalled() + }) + + it('preserves an invalid CPU diagnostic while releasing the heartbeat', async () => { + observations.readCpu = () => 'invalid CPU time' + const sendHeartbeat = vi.fn() + const sleep = vi.fn() + await expect( + sampleProductionPerformance( + { pids: [101], sendHeartbeat }, + { heartbeatIntervalMs: 2_000, sampleMs: 30_000, sleep } + ) + ).rejects.toThrow('Could not read CPU time for PID 101') + expect(sleep).not.toHaveBeenCalled() + expect(observations.events.filter(([kind]) => kind !== 'disable')).toEqual([ + ['monitor', { resolution: 10 }], + cpuObservation(101) + ]) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(4_000) + expect(sendHeartbeat).not.toHaveBeenCalled() + }) + + it('repeated failed owners leave no timers or later sends', async () => { + const error = new Error('sample CPU failure') + observations.readCpu = () => { + throw error + } + const sendHeartbeat = vi.fn() + const sleep = vi.fn() + for (let index = 0; index < 64; index++) { + await expect( + sampleProductionPerformance( + { pids: [101], sendHeartbeat }, + { heartbeatIntervalMs: 2_000, sampleMs: 30_000, sleep } + ) + ).rejects.toBe(error) + } + expect(sleep).not.toHaveBeenCalled() + expect(observations.events.filter(([kind]) => kind !== 'disable')).toEqual( + Array.from({ length: 64 }, () => [ + ['monitor', { resolution: 10 }], + cpuObservation(101) + ]).flat() + ) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(4_000) + expect(sendHeartbeat).not.toHaveBeenCalled() + }) + + async function runSample({ values, sleepError } = {}) { + const cpuValues = values ?? ['0:01.20', '0:02.30', '0:01.25', '0:02.35'] + let index = 0 + observations.readCpu = () => { + const value = cpuValues[index++] + if (value instanceof Error) { + throw value + } + return value + } + const sendHeartbeat = vi.fn(() => observations.events.push(['heartbeat'])) + const sleep = async (ms) => { + observations.events.push(['sleep', ms]) + await vi.advanceTimersByTimeAsync(ms) + if (sleepError) { + throw sleepError + } + } + return sampleProductionPerformance( + { pids: [101, 102], sendHeartbeat }, + { heartbeatIntervalMs: 2_000, sampleMs: 6_000, sleep } + ) + } + + const completedSampleEvents = [ + ['monitor', { resolution: 10 }], + cpuObservation(101), + cpuObservation(102), + ['enable'], + ['sleep', 6_000], + ['heartbeat'], + ['heartbeat'], + ['heartbeat'], + ['disable'] + ] + + it('keeps complete live results, observation order and heartbeat pacing', async () => { + expect(await runSample()).toEqual({ + cpuMs: 100, + heartbeatCount: 3, + eventLoopDelayP95Ms: 1.9, + eventLoopDelayP99Ms: 3.1, + eventLoopDelayMaxMs: 5 + }) + expect(observations.events).toEqual([ + ...completedSampleEvents, + cpuObservation(101), + cpuObservation(102), + ['percentile', 95], + ['percentile', 99] + ]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps a sample sleep rejection and its existing cleanup', async () => { + const error = new Error('sample sleep rejected') + await expect(runSample({ sleepError: error })).rejects.toBe(error) + expect(observations.events).toEqual(completedSampleEvents) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps a final CPU observation failure after cleanup', async () => { + const error = new Error('final CPU read failed') + await expect(runSample({ values: ['0:01.20', '0:02.30', error] })).rejects.toBe(error) + expect(observations.events).toEqual([...completedSampleEvents, cpuObservation(101)]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps the zero floor when the CPU total decreases', async () => { + expect(await runSample({ values: ['0:02.20', '0:03.30', '0:01.25', '0:02.35'] })).toEqual({ + cpuMs: 0, + heartbeatCount: 3, + eventLoopDelayP95Ms: 1.9, + eventLoopDelayP99Ms: 3.1, + eventLoopDelayMaxMs: 5 + }) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/config/scripts/headless-detector-compiler-cache.mjs b/config/scripts/headless-detector-compiler-cache.mjs new file mode 100644 index 00000000000..9ef4d5cb6b5 --- /dev/null +++ b/config/scripts/headless-detector-compiler-cache.mjs @@ -0,0 +1,160 @@ +import { createHash } from 'node:crypto' +import { + appendFileSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const ROOT = resolve(import.meta.dirname, '../..') + +function inventory(directory, prefix = '') { + if (lstatSync(directory).isSymbolicLink()) { + throw new Error('Compiler cache directory is a symlink') + } + return readdirSync(directory) + .flatMap((name) => { + const path = join(directory, name) + const file = `${prefix}${name}` + const stat = lstatSync(path) + if (stat.isSymbolicLink()) { + throw new Error('Compiler cache contains a symlink') + } + if (stat.isDirectory()) { + return inventory(path, `${file}/`) + } + if (!stat.isFile()) { + throw new Error('Compiler cache contains a special file') + } + return [{ file, sha256: createHash('sha256').update(readFileSync(path)).digest('hex') }] + }) + .sort((a, b) => a.file.localeCompare(b.file)) +} + +export function compilerCacheIdentity({ + policyHash = process.env.COMPILER_POLICY_HASH, + cacheRoot = process.env.RUNNER_TEMP, + platform = process.platform, + arch = process.arch, + node = process.version +} = {}) { + if (!policyHash || !cacheRoot) { + throw new Error('Compiler cache requires policy hash and cache root') + } + return { + key: `headless-compiler-v1-${platform}-${arch}-${node}-${policyHash}`, + path: join(cacheRoot, 'headless-detector-compiler') + } +} + +export function packCompilerCache({ root = ROOT, identity = compilerCacheIdentity() } = {}) { + const require = createRequire(join(root, 'package.json')) + const esbuildDir = dirname(require.resolve('esbuild/package.json')) + const nativeName = `@esbuild/${process.platform}-${process.arch}` + const nativeDir = dirname(require.resolve(`${nativeName}/package.json`, { paths: [esbuildDir] })) + rmSync(identity.path, { recursive: true, force: true }) + mkdirSync(join(identity.path, 'node_modules', '@esbuild'), { recursive: true }) + cpSync(esbuildDir, join(identity.path, 'node_modules', 'esbuild'), { + recursive: true, + dereference: true + }) + cpSync(nativeDir, join(identity.path, 'node_modules', nativeName), { + recursive: true, + dereference: true + }) + writeFileSync( + join(identity.path, 'manifest.json'), + JSON.stringify({ + key: identity.key, + node: process.version, + version: require('esbuild').version, + files: inventory(identity.path) + }) + ) +} + +export async function activateCompilerCache({ + root = ROOT, + identity = compilerCacheIdentity() +} = {}) { + const dependencies = join(root, 'node_modules') + let created = false + try { + if (existsSync(dependencies)) { + throw new Error('Compiler activation requires an empty dependency tree') + } + const files = inventory(identity.path).filter((row) => row.file !== 'manifest.json') + const manifest = JSON.parse(readFileSync(join(identity.path, 'manifest.json'), 'utf8')) + if ( + manifest.key !== identity.key || + manifest.node !== process.version || + JSON.stringify(files) !== JSON.stringify(manifest.files) + ) { + throw new Error('Compiler cache identity or contents differ') + } + mkdirSync(join(dependencies, '@esbuild'), { recursive: true }) + created = true + for (const name of ['esbuild', `@esbuild/${process.platform}-${process.arch}`]) { + symlinkSync(join(identity.path, 'node_modules', name), join(dependencies, name), 'dir') + } + const require = createRequire(join(root, 'package.json')) + const esbuild = require('esbuild') + if (esbuild.version !== manifest.version) { + throw new Error('Compiler API version differs') + } + await esbuild.build({ + stdin: { contents: 'export const value = 1' }, + write: false, + logLevel: 'silent' + }) + return { available: true } + } catch (error) { + if (created) { + rmSync(dependencies, { recursive: true, force: true }) + } + return { available: false, reason: String(error) } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const phase = process.argv[2] + const identity = + phase === 'identity' + ? compilerCacheIdentity() + : { + key: process.env.COMPILER_CACHE_KEY, + path: process.env.COMPILER_CACHE_PATH + } + if (!identity.key || !identity.path) { + throw new Error('Compiler cache identity required') + } + const output = (values) => { + for (const [name, value] of Object.entries(values)) { + appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`) + } + } + if (phase === 'identity') { + output(identity) + } else if (phase === 'pack') { + packCompilerCache({ identity }) + } else if (phase === 'activate') { + const result = await activateCompilerCache({ identity }) + output({ available: result.available }) + console.log( + result.available + ? 'Validated headless compiler cache' + : `Use normal dependency install: ${result.reason}` + ) + } else { + throw new Error('Expected identity, pack, or activate') + } +} diff --git a/config/scripts/headless-detector-compiler-cache.test.mjs b/config/scripts/headless-detector-compiler-cache.test.mjs new file mode 100644 index 00000000000..484807159a7 --- /dev/null +++ b/config/scripts/headless-detector-compiler-cache.test.mjs @@ -0,0 +1,204 @@ +import { + appendFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { parse } from 'yaml' +import { + activateCompilerCache, + compilerCacheIdentity, + packCompilerCache +} from './headless-detector-compiler-cache.mjs' +import { collectNodeServerInputs } from './node-server-change-scope.mjs' +import { runProcessSync } from './script-child-process.mjs' + +const temporary = [] +afterEach(() => { + for (const dir of temporary.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) +function fixture() { + const directory = mkdtempSync(join(tmpdir(), 'headless-compiler-cache-')) + temporary.push(directory) + const root = join(directory, 'checkout') + mkdirSync(root) + writeFileSync(join(root, 'package.json'), '{"type":"module"}') + const identity = compilerCacheIdentity({ policyHash: 'policy', cacheRoot: directory }) + packCompilerCache({ identity }) + return { root, identity } +} +function changeManifest(identity, update) { + const path = join(identity.path, 'manifest.json') + const manifest = JSON.parse(readFileSync(path, 'utf8')) + update(manifest) + writeFileSync(path, JSON.stringify(manifest)) +} + +it('separates policy, Node, platform and architecture identities with the same archive path', () => { + const options = { + policyHash: 'policy', + cacheRoot: '/cache', + platform: 'linux', + arch: 'x64', + node: 'v24.21.0' + } + const original = compilerCacheIdentity(options) + for (const override of [ + { policyHash: 'changed' }, + { node: 'v24.22.0' }, + { platform: 'darwin' }, + { arch: 'arm64' } + ]) { + const other = compilerCacheIdentity({ ...options, ...override }) + expect(other.key).not.toBe(original.key) + expect(other.path).toBe(original.path) + } +}) + +it('activates only the actual compiler packages and preserves import graph behavior', async () => { + const { root, identity } = fixture() + expect(await activateCompilerCache({ root, identity })).toEqual({ available: true }) + expect(readdirSync(join(root, 'node_modules')).sort()).toEqual(['@esbuild', 'esbuild']) + for (const name of [ + 'node-server-change-scope', + 'node-server-test-paths', + 'node-server-qualification', + 'orcad-entry-build' + ]) { + mkdirSync(join(root, 'config', 'scripts'), { recursive: true }) + cpSync( + new URL(`./${name}.mjs`, import.meta.url), + join(root, 'config', 'scripts', `${name}.mjs`) + ) + } + writeFileSync( + join(root, 'entry.ts'), + "import './first'; export * from './exports'; import('./dynamic'); require('./required'); import 'external-package'; import './native.node'" + ) + for (const name of ['first', 'exports', 'dynamic', 'required']) { + writeFileSync(join(root, `${name}.ts`), 'export const value = 1') + } + writeFileSync( + join(root, 'probe.mjs'), + "import { collectNodeServerInputs } from './config/scripts/node-server-change-scope.mjs'; console.log(JSON.stringify([...(await collectNodeServerInputs({ root: process.cwd(), entryPoints: ['entry.ts'] }))].sort()))" + ) + const baseline = [...(await collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }))].sort() + const candidate = runProcessSync({ + program: process.execPath, + args: ['probe.mjs'], + cwd: root, + timeoutMs: 10_000 + }) + expect(candidate.code, candidate.stderr).toBe(0) + expect(JSON.parse(candidate.stdout.trim())).toEqual(baseline) +}, 20_000) + +it.each(['key', 'node', 'version', 'files'])( + 'falls back on invalid manifest %s and cleans partial activation', + async (field) => { + const { root, identity } = fixture() + changeManifest(identity, (manifest) => { + manifest[field] = 'wrong' + }) + expect((await activateCompilerCache({ root, identity })).available).toBe(false) + expect(existsSync(join(root, 'node_modules'))).toBe(false) + } +) + +it.each(['modified', 'missing', 'extra', 'symlink', 'malformed'])( + 'falls back on %s cache contents before loading code', + async (kind) => { + const { root, identity } = fixture() + const compiler = join(identity.path, 'node_modules', 'esbuild', 'lib', 'main.js') + if (kind === 'modified') { + appendFileSync(compiler, '\nthrow Error("must not load")') + } + if (kind === 'missing') { + rmSync(compiler) + } + if (kind === 'extra') { + writeFileSync(join(identity.path, 'unexpected'), 'extra') + } + if (kind === 'symlink') { + rmSync(compiler) + symlinkSync(join(root, 'package.json'), compiler) + } + if (kind === 'malformed') { + writeFileSync(join(identity.path, 'manifest.json'), '{') + } + expect((await activateCompilerCache({ root, identity })).available).toBe(false) + expect(existsSync(join(root, 'node_modules'))).toBe(false) + } +) + +it('leaves existing dependencies alone and falls back on an absent archive', async () => { + const { root, identity } = fixture() + rmSync(identity.path, { recursive: true }) + expect((await activateCompilerCache({ root, identity })).available).toBe(false) + mkdirSync(join(root, 'node_modules')) + writeFileSync(join(root, 'node_modules', 'retained'), 'retained') + expect((await activateCompilerCache({ root, identity })).available).toBe(false) + expect(readFileSync(join(root, 'node_modules', 'retained'), 'utf8')).toBe('retained') +}) + +it('uses exact optional restores, seeds only main and retains full dependency fallback', () => { + const action = parse(readFileSync('.github/actions/prepare-headless-compiler/action.yml', 'utf8')) + const steps = action.runs.steps + const restore = steps.find((step) => step.id === 'cache') + expect(restore.uses).toBe('actions/cache/restore@v5') + expect(restore['continue-on-error']).toBe(true) + expect(restore.with['restore-keys']).toBeUndefined() + const save = steps.find((step) => step.uses === 'actions/cache/save@v5') + expect(save.if).toContain("github.ref == 'refs/heads/main'") + expect(save.if).toContain("github.event_name != 'pull_request'") + expect(save['continue-on-error']).toBe(true) + expect(save.with).toEqual(restore.with) + const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) + const detector = workflow.jobs.changes.steps + const cached = detector.find((step) => step.id === 'compiler') + expect(cached.if).toBe("steps.scope.outputs.graph_required == 'true'") + expect(cached['continue-on-error']).toBe(true) + expect( + detector.find((step) => step.uses === './.github/actions/install-node-dependencies').if + ).toBe( + "steps.scope.outputs.graph_required == 'true' && steps.compiler.outputs.available != 'true'" + ) + for (const event of ['push', 'pull_request']) { + expect(workflow.on[event].paths).toContain('.github/actions/prepare-headless-compiler/**') + } + const warmer = parse(readFileSync('.github/workflows/ci-cache-warmup.yml', 'utf8')) + const warmSteps = warmer.jobs.warm.steps + expect( + warmSteps.findIndex((step) => step.uses === './.github/actions/prepare-headless-compiler') + ).toBeGreaterThan( + warmSteps.findIndex((step) => step.uses === './.github/actions/install-node-dependencies') + ) + for (const event of ['push', 'pull_request']) { + expect(warmer.on[event].paths).toContain('.github/actions/prepare-headless-compiler/**') + } + const inputs = [ + ...steps.find((step) => step.id === 'identity').env.COMPILER_POLICY_HASH.matchAll(/'([^']+)'/g) + ].map((match) => match[1]) + for (const input of inputs) { + expect( + warmer.on.push.paths.some( + (pattern) => + pattern === input || + (pattern.endsWith('/**') && input.startsWith(pattern.slice(0, -2))) || + (pattern.endsWith('*') && input.startsWith(pattern.slice(0, -1))) + ), + input + ).toBe(true) + } +}) diff --git a/config/scripts/install-node-dependencies-action.test.mjs b/config/scripts/install-node-dependencies-action.test.mjs index 3006a0063da..46b925ccf07 100644 --- a/config/scripts/install-node-dependencies-action.test.mjs +++ b/config/scripts/install-node-dependencies-action.test.mjs @@ -1,4 +1,12 @@ -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -56,28 +64,38 @@ function executeInstallScript(fixture) { } describe('install-node-dependencies action', () => { - it.each(['/home/runner/pnpm store/v11', 'C:\\Users\\runner\\pnpm store\\v11'])( - 'preserves setup-node store path %s and lowercase architecture', - (storePath) => { - const fixture = createFixture() - const output = join(fixture.root, 'github-output') - try { - const result = run('bash', ['-e', '-o', 'pipefail', '-c', storeScript], { - env: { - ...process.env, - GITHUB_OUTPUT: output, - LOCKFILE_HASH: 'lockfile-digest', - PNPM_TEST_STORE_PATH: storePath, - PATH: `${fixture.bin}${delimiter}${process.env.PATH}` - } - }) - expect(result.status, result.stderr || result.stdout).toBe(0) - expect(readFileSync(output, 'utf8')).toBe(`path=${storePath}\narch=${process.arch}\n`) - } finally { - rmSync(fixture.root, { recursive: true, force: true }) + it.each([ + ['/home/runner/pnpm store/v11', 'true'], + ['/home/runner/pnpm store/v11', 'false'], + ['C:\\Users\\runner\\pnpm store\\v11', 'true'], + ['C:\\Users\\runner\\pnpm store\\v11', 'false'] + ])('preserves setup-node store path %s with producer lookup %s', (storePath, lookupOnly) => { + const fixture = createFixture() + const output = join(fixture.root, 'github-output') + const environment = join(fixture.root, 'github-env') + try { + const result = run('bash', ['-e', '-o', 'pipefail', '-c', storeScript], { + env: { + ...process.env, + GITHUB_OUTPUT: output, + GITHUB_ENV: environment, + STORE_LOOKUP_ONLY: lookupOnly, + LOCKFILE_HASH: 'lockfile-digest', + PNPM_TEST_STORE_PATH: storePath, + PATH: `${fixture.bin}${delimiter}${process.env.PATH}` + } + }) + expect(result.status, result.stderr || result.stdout).toBe(0) + expect(readFileSync(output, 'utf8')).toBe(`path=${storePath}\narch=${process.arch}\n`) + if (lookupOnly === 'true') { + expect(readFileSync(environment, 'utf8')).toBe(`ORCA_PNPM_STORE_CACHE_PATH=${storePath}\n`) + } else { + expect(existsSync(environment)).toBe(false) } + } finally { + rmSync(fixture.root, { recursive: true, force: true }) } - ) + }) it.each([ ['', 'store'], diff --git a/config/scripts/json-parser-benchmark-fixtures.mjs b/config/scripts/json-parser-benchmark-fixtures.mjs new file mode 100644 index 00000000000..385b4010895 --- /dev/null +++ b/config/scripts/json-parser-benchmark-fixtures.mjs @@ -0,0 +1,65 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export const JSON_PARSER_CASES = [ + { name: 'rg-10k', kind: 'rg', count: 10_000, cap: 2000 }, + { name: 'rg-100k', kind: 'rg', count: 100_000, cap: 2000 }, + { name: 'rg-100k-cap1', kind: 'rg', count: 100_000, cap: 1 }, + { name: 'rg-unicode', kind: 'rg', count: 10_000, cap: 2000, unicode: true }, + { name: 'rg-large-dense', kind: 'rg', count: 900_000, cap: 2000, large: true }, + { name: 'rg-fast-path', kind: 'rg', count: 1, cap: 2000, large: true }, + { name: 'session-messages', kind: 'session' }, + { name: 'session-skipped-objects', kind: 'session' }, + { name: 'session-skipped-string', kind: 'session' }, + { name: 'session-selected-string', kind: 'session' } +] + +export function writeJsonParserFixtures(directory) { + for (const fixture of JSON_PARSER_CASES) { + let value + if (fixture.kind === 'rg') { + const text = fixture.unicode + ? '\ufeff日本語😀x' + : fixture.large && fixture.count > 1 + ? 'xxxx' + : 'x' + const matchBytes = Buffer.byteLength(text) + value = { + type: 'match', + data: { + path: { text: `${text}.ts` }, + lines: { + text: text.repeat(fixture.count === 1 ? 4 * 1024 * 1024 : fixture.count) + }, + line_number: 1, + submatches: Array.from({ length: fixture.count }, (_, index) => ({ + match: { text }, + start: index * matchBytes, + end: (index + 1) * matchBytes + })) + } + } + } else { + value = { id: 'synthetic', messages: [{ text: 'one' }] } + if (fixture.name === 'session-messages') { + value.agent = { model: 'model', other: 'ignored' } + value.messages = Array.from({ length: 20_000 }, (_, index) => ({ + role: index % 2 ? 'assistant' : 'user', + text: '\ufeff日本語😀 hello world '.repeat(16), + timestamp: index, + metadata: { model: 'synthetic', tokens: 512 } + })) + } else if (fixture.name === 'session-skipped-objects') { + value.ignored = Array.from({ length: 200_000 }, (_, index) => ({ + id: index, + data: { text: 'x'.repeat(64), values: [1, 2, 3] } + })) + } else if (fixture.name === 'session-skipped-string') { + value.ignored = '日本語😀x'.repeat(1_500_000) + } else { + value.messages = [{ text: '日本語😀x'.repeat(1_500_000) }] + } + } + writeFileSync(join(directory, `${fixture.name}.json`), JSON.stringify(value)) + } +} diff --git a/config/scripts/json-parser-migration-benchmark.mjs b/config/scripts/json-parser-migration-benchmark.mjs new file mode 100644 index 00000000000..0aca1734e1b --- /dev/null +++ b/config/scripts/json-parser-migration-benchmark.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { createReadStream, readFileSync, statSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' +import { JSON_PARSER_CASES, writeJsonParserFixtures } from './json-parser-benchmark-fixtures.mjs' + +// Bundle each revision's two consumers as {baseline,candidate}-{rg,session}.mjs first. +const [bundleDirectory, workerFixture, workerArm] = process.argv.slice(2) +if (!bundleDirectory || !global.gc) { + throw new Error('Usage: node --expose-gc json-parser-migration-benchmark.mjs BUNDLE_DIRECTORY') +} + +async function load(arm, kind) { + return import(pathToFileURL(resolve(bundleDirectory, `${arm}-${kind}.mjs`)).href) +} + +function prepareRun(module, fixture, file) { + if (fixture.kind === 'rg') { + const text = readFileSync(file, 'utf8') + return () => + module.parseRipgrepMatchJson(text, fixture.cap, { + structuralTokens: 32 * 1024, + nestingDepth: 16 + }) + } + return () => + module.readStreamedSessionDocument({ + bytes: createReadStream(file, { highWaterMark: 64 * 1024 }), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: () => ({ count: 0, textLength: 0 }), + consume(state, value) { + state.count++ + state.textLength += typeof value?.text === 'string' ? value.text.length : 0 + } + }) +} + +function digest(value) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +async function consumedContentDigest(module, file) { + const result = await module.readStreamedSessionDocument({ + bytes: createReadStream(file, { highWaterMark: 64 * 1024 }), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: () => createHash('sha256'), + consume(hash, value) { + hash.update(JSON.stringify(value)).update('\n') + } + }) + return { record: result.record, consumedSha256: result.state.digest('hex') } +} + +if (workerFixture) { + const fixture = JSON_PARSER_CASES.find((item) => workerFixture.endsWith(`${item.name}.json`)) + assert(fixture) + const module = await load(workerArm, fixture.kind) + const run = prepareRun(module, fixture, workerFixture) + global.gc() + const before = process.memoryUsage() + let running = true + let maxLoopGapMs = 0 + let previous = performance.now() + const observe = () => { + const now = performance.now() + maxLoopGapMs = Math.max(maxLoopGapMs, now - previous) + previous = now + if (running) { + setImmediate(observe) + } + } + setImmediate(observe) + const started = performance.now() + const result = await run() + const elapsedMs = performance.now() - started + await new Promise((done) => setImmediate(done)) + running = false + const peakRssMiB = process.resourceUsage().maxRSS / 1024 + global.gc() + const retainedHeapDeltaMiB = (process.memoryUsage().heapUsed - before.heapUsed) / 1024 ** 2 + console.log( + JSON.stringify({ + elapsedMs, + peakRssMiB, + retainedHeapDeltaMiB, + maxLoopGapMs, + digest: digest(result) + }) + ) +} else { + const directory = mkdtempSync(join(tmpdir(), 'orca-json-parser-benchmark-')) + try { + writeJsonParserFixtures(directory) + global.gc() + const results = [] + for (const fixture of JSON_PARSER_CASES) { + const file = join(directory, `${fixture.name}.json`) + const runs = {} + const contents = {} + for (const arm of ['baseline', 'candidate']) { + const module = await load(arm, fixture.kind) + runs[arm] = prepareRun(module, fixture, file) + if (fixture.kind === 'session') { + contents[arm] = await consumedContentDigest(module, file) + } + } + assert.deepEqual(contents.candidate, contents.baseline) + for (let warmup = 0; warmup < 3; warmup++) { + assert.deepEqual(await runs.candidate(), await runs.baseline()) + } + const samples = { baseline: [], candidate: [] } + for (const pair of buildCounterbalancedSchedule(12, 'baseline', 'candidate')) { + for (const arm of pair) { + const started = performance.now() + await runs[arm]() + samples[arm].push(performance.now() - started) + } + } + const memory = { baseline: [], candidate: [] } + for (const pair of buildCounterbalancedSchedule(2, 'baseline', 'candidate')) { + for (const arm of pair) { + const child = spawnSync( + process.execPath, + ['--expose-gc', import.meta.filename, bundleDirectory, file, arm], + { + encoding: 'utf8', + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + windowsHide: true + } + ) + assert.equal(child.status, 0, child.stderr) + memory[arm].push(JSON.parse(child.stdout)) + } + } + for (const sample of [...memory.baseline, ...memory.candidate]) { + assert.equal(sample.digest, memory.baseline[0].digest) + } + results.push({ + name: fixture.name, + bytes: statSync(file).size, + baseline: summarizeBenchmarkSamples(samples.baseline), + candidate: summarizeBenchmarkSamples(samples.candidate), + samples, + memory + }) + } + console.log( + JSON.stringify( + { node: process.version, platform: process.platform, arch: process.arch, results }, + null, + 2 + ) + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/linear-sdk-runtime.test.mjs b/config/scripts/linear-sdk-runtime.test.mjs new file mode 100644 index 00000000000..896b8c7b7d5 --- /dev/null +++ b/config/scripts/linear-sdk-runtime.test.mjs @@ -0,0 +1,78 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { build } from 'vite' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { electronViteConfig } from '../../electron.vite.config' +import { runProcess } from '../../src/shared/child-process/run-process' + +const projectDir = resolve(import.meta.dirname, '../..') +const require = createRequire(import.meta.url) +let outputDir + +const smokeScript = ` + const assert = require('node:assert/strict') + const { createRequire } = require('node:module') + const loaderPath = process.argv[1] + const requireFromLoader = createRequire(loaderPath) + const sdkEntry = requireFromLoader.resolve('@linear/sdk') + const { loadLinearSdk } = require(loaderPath) + assert.equal(require.cache[sdkEntry], undefined, 'SDK must remain lazy') + + const sdk = loadLinearSdk() + assert.equal(sdk, requireFromLoader('@linear/sdk')) + assert.equal(loadLinearSdk(), sdk, 'repeat loads must reuse the SDK') + const client = new sdk.LinearClient({ apiKey: 'orca-offline-smoke-test' }) + assert.equal(typeof client.issues, 'function') + assert.equal(typeof client.teams, 'function') + assert.ok(sdk.AuthenticationLinearError.prototype instanceof Error) + console.log('Linear SDK loaded') +` + +beforeAll(async () => { + // Keep normal dependency resolution while executing outside Vitest's module loader. + outputDir = await mkdtemp(join(projectDir, 'node_modules', 'orca-linear-sdk-runtime-')) + const mainBuild = electronViteConfig.main.build + await build({ + configFile: false, + publicDir: false, + logLevel: 'silent', + build: { + ssr: true, + minify: mainBuild.minify, + outDir: outputDir, + rollupOptions: { + external: mainBuild.rollupOptions.external, + input: join(projectDir, 'src/main/linear/linear-sdk.ts'), + output: { format: 'cjs', entryFileNames: 'linear-sdk.cjs' } + } + } + }) +}) + +afterAll(async () => { + if (outputDir) { + await rm(outputDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +describe('resolved Linear SDK through the production CommonJS loader', () => { + it.each([ + { name: 'Node', program: process.execPath }, + { name: 'Electron', program: require('electron') } + ])( + 'loads and constructs the real SDK under $name without network access', + async ({ program }) => { + const result = await runProcess({ + program, + args: ['-e', smokeScript, join(outputDir, 'linear-sdk.cjs')], + cwd: projectDir, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ELECTRON_RUN_AS_NODE: '1' }, + timeoutMs: 20000 + }) + expect(result.code, result.stderr).toBe(0) + expect(result.timedOut).toBe(false) + expect(result.stdout.trim()).toBe('Linear SDK loaded') + } + ) +}) diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index b0f99275b43..6f4948712dd 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -2342,9 +2342,6 @@ "auto.components.settings.MobileEmulatorSettingsPane.ae1612c58c": { "ko": "가용성" }, - "auto.components.settings.MobilePane.35100bca5d": { - "ko": "휴대폰에서 terminals을 사용하는 동안 Orca는 휴대폰 화면에 맞게 terminals을 축소합니다. 앱을 닫거나 다른 곳으로 전환하면 terminals이 휴대폰 크기로 유지되는지(대화형 CLI 도구가 리플로우되지 않음) 또는 데스크탑으로 다시 크기가 조정되는지 여부가 제어됩니다. 배너에서 '이 terminals 복원' 또는 '모든 terminals 복원'을 사용해 언제든지 수동으로 크기를 조정할 수 있습니다." - }, "auto.components.settings.MobileSettingsPane.b0088412a1": { "ko": "또는 다음에서 Android APK를 받으세요" }, @@ -4886,12 +4883,6 @@ "auto.components.terminal.pane.CloseTerminalDialog.stop_command_title": { "ko": "실행 중인 명령을 중지할까요?" }, - "auto.components.terminal.pane.MobileDriverOverlay.54f7d6f69d": { - "ko": "모든 terminals 제어권 가져오기" - }, - "auto.components.terminal.pane.MobileDriverOverlay.faa367dc74": { - "ko": "휴대폰이 이 화면을 휴대폰 크기로 남겨 두었습니다" - }, "auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d": { "ko": "삽입" }, diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index 75f50d15ff6..08b33191891 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -52,6 +52,7 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Goose', 'Grok', 'Hermes', + 'Jcode', 'Jira', 'Kilocode', 'Kimi', @@ -79,6 +80,7 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'markdown', 'gh', 'idle', + 'jcode', 'anthropic', 'Discord', 'WSL', diff --git a/config/scripts/localization-extraction-change-scope.test.mjs b/config/scripts/localization-extraction-change-scope.test.mjs index 8852965ad30..b1c142b8cd0 100644 --- a/config/scripts/localization-extraction-change-scope.test.mjs +++ b/config/scripts/localization-extraction-change-scope.test.mjs @@ -39,10 +39,10 @@ it('preserves deleted and renamed inputs and falls back to extraction on detecti const workflow = parse( readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8') ) - const step = workflow.jobs.static_analysis.steps.find( + const step = workflow.jobs.preflight.steps.find( (candidate) => candidate.name === 'Verify localization extraction' ) - expect(step.env).toEqual({ + expect(step.env).toMatchObject({ BASE_SHA: '${{ github.event.pull_request.base.sha }}' }) // The base side comes from the merge ref's first parent, so the gate needs no merge base and diff --git a/config/scripts/managed-data-account-runtime.test.ts b/config/scripts/managed-data-account-runtime.test.ts new file mode 100644 index 00000000000..452629732ac --- /dev/null +++ b/config/scripts/managed-data-account-runtime.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { resolveConfig } from 'electron-vite' +import { build } from 'vite' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { runProcess } from '../../src/shared/child-process/run-process' + +const projectDir = resolve(import.meta.dirname, '../..') +const require = createRequire(import.meta.url) +let outputDir: string + +beforeAll(async () => { + outputDir = mkdtempSync(join(tmpdir(), 'orca-account-runtime-')) + const resolved = await resolveConfig( + { configFile: join(projectDir, 'electron.vite.config.ts') }, + 'build', + 'production' + ) + const main = resolved.config?.main + if (!main?.build) { + throw new Error('Expected main-process build config') + } + await build({ + ...main, + logLevel: 'silent', + build: { + ...main.build, + outDir: join(outputDir, 'bundle'), + sourcemap: false, + rollupOptions: { + ...main.build.rollupOptions, + input: join(projectDir, 'src/main/managed-data-accounts/credential-capture.ts'), + output: { format: 'cjs', entryFileNames: 'credential-capture.cjs' } + } + } + }) + mkdirSync(join(outputDir, 'source', 'devin'), { recursive: true }) + writeFileSync( + join(outputDir, 'source', 'devin', 'credentials.toml'), + 'windsurf_api_key = "offline-account-runtime-fixture"\n' + ) +}) + +afterAll(() => { + if (outputDir) { + rmSync(outputDir, { recursive: true, force: true }) + } +}) + +describe('managed account credentials in the production main bundle', () => { + it.each([ + { name: 'Node', program: process.execPath }, + { name: 'Electron', program: require('electron') } + ])( + 'captures Devin credentials under $name outside the dependency install', + async ({ name, program }) => { + const environment: Record = { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ELECTRON_RUN_AS_NODE: '1' + } + for (const key of [ + 'HOME', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_STATE_HOME', + 'XDG_CACHE_HOME' + ]) { + const directory = join(outputDir, name, key) + mkdirSync(directory, { recursive: true }) + environment[key] = directory + } + const script = ` + const assert = require('node:assert/strict') + const { captureDataAccountCredentials } = require(process.argv[1]) + captureDataAccountCredentials('devin', process.argv[2], process.argv[3]) + .then((integrations) => { + assert.deepEqual(integrations, ['devin']) + console.log('Private credentials captured') + }).catch((error) => { console.error(error); process.exitCode = 1 }) + ` + const destination = join(outputDir, name, 'captured') + const result = await runProcess({ + program, + args: [ + '-e', + script, + join(outputDir, 'bundle', 'credential-capture.cjs'), + join(outputDir, 'source'), + destination + ], + cwd: outputDir, + env: environment, + timeoutMs: 20000 + }) + expect(result.code, result.stderr).toBe(0) + expect(result.timedOut).toBe(false) + expect(result.stdout.trim()).toBe('Private credentials captured') + expect(readFileSync(join(destination, 'devin', 'credentials.toml'), 'utf8')).toContain( + 'offline-account-runtime-fixture' + ) + } + ) +}) diff --git a/config/scripts/mobile-entry-static-closure-work-budget.test.mjs b/config/scripts/mobile-entry-static-closure-work-budget.test.mjs new file mode 100644 index 00000000000..4064afe4068 --- /dev/null +++ b/config/scripts/mobile-entry-static-closure-work-budget.test.mjs @@ -0,0 +1,120 @@ +import { expect, it } from 'vitest' +import { entryStaticClosure } from './build-mobile-web-app-bundle.mjs' + +it.each([1, 12, 128, 1000])( + 'keeps the complete %s-output closure without shifting a second queue', + (count) => { + const paths = Array.from({ length: count }, (_value, index) => `dist/chunk-${index}.js`) + const metafile = { + outputs: Object.fromEntries( + paths.map((path, index) => [ + path, + { + bytes: index + 1, + imports: + index === 0 + ? paths.slice(1).map((child) => ({ kind: 'import-statement', path: child })) + : [] + } + ]) + ) + } + const before = structuredClone(metafile) + const originalShift = Array.prototype.shift + let reached + let shifts = 0 + Array.prototype.shift = function () { + shifts++ + return originalShift.call(this) + } + try { + reached = entryStaticClosure(metafile, paths[0]) + } finally { + Array.prototype.shift = originalShift + } + expect([...reached]).toEqual(paths) + expect(metafile).toEqual(before) + expect([...reached].reduce((total, path) => total + metafile.outputs[path].bytes, 0)).toBe( + (count * (count + 1)) / 2 + ) + const fresh = entryStaticClosure(metafile, paths[0]) + expect(fresh).not.toBe(reached) + reached.add('mutated-result') + expect([...fresh]).toEqual(paths) + expect(shifts).toBe(0) + } +) + +it('keeps breadth-first order, duplicates, cycles, missing chunks and dynamic boundaries', () => { + const metafile = { + outputs: { + entry: { + imports: [ + { kind: 'import-statement', path: 'one' }, + { kind: 'dynamic-import', path: 'deferred' }, + { kind: 'import-statement', path: 'two' }, + { kind: 'import-statement', path: 'one' } + ] + }, + one: { imports: [{ kind: 'import-statement', path: 'three' }] }, + two: { + imports: [ + { kind: 'import-statement', path: 'entry' }, + { kind: 'import-statement', path: 'three' }, + { kind: 'import-statement', path: 'missing' } + ] + }, + three: { imports: [] }, + deferred: { imports: [{ kind: 'import-statement', path: 'deferred-child' }] } + } + } + expect([...entryStaticClosure(metafile, 'entry')]).toEqual([ + 'entry', + 'one', + 'two', + 'three', + 'missing' + ]) + metafile.outputs.one.imports.push({ kind: 'import-statement', path: 'fresh-😀' }) + expect([...entryStaticClosure(metafile, 'entry')]).toEqual([ + 'entry', + 'one', + 'two', + 'three', + 'fresh-😀', + 'missing' + ]) + expect([...entryStaticClosure({ outputs: {} }, 'unknown')]).toEqual(['unknown']) +}) + +it('keeps a later output error after earlier discoveries in the same order', () => { + const error = new Error('later-output') + const reads = [] + const outputs = { + get entry() { + reads.push('entry') + return { + imports: [ + { kind: 'import-statement', path: 'one' }, + { kind: 'import-statement', path: 'two' } + ] + } + }, + get one() { + reads.push('one') + return { imports: [{ kind: 'import-statement', path: 'three' }] } + }, + get two() { + reads.push('two') + throw error + } + } + let caught + try { + entryStaticClosure({ outputs }, 'entry') + } catch (failure) { + caught = failure + } + expect(caught).toBe(error) + expect(reads).toEqual(['entry', 'one', 'two']) +}) diff --git a/config/scripts/mobile-release-shell-switch-workflow.test.mjs b/config/scripts/mobile-release-shell-switch-workflow.test.mjs index f6f05c779eb..5f45d6872ac 100644 --- a/config/scripts/mobile-release-shell-switch-workflow.test.mjs +++ b/config/scripts/mobile-release-shell-switch-workflow.test.mjs @@ -120,9 +120,10 @@ const BUNDLER_CACHE_PATHS = ['metro-cache', '.expo', 'node_modules/.cache'] const REVIEWED_COMPUTED_PATHS = [ '${{ steps.electron-package-cache.outputs.cache-root }}', '${{ steps.pnpm-store.outputs.path }}', + '${{ env.ORCA_PNPM_STORE_CACHE_PATH }}', // Only pnpm's lockfile-verified.jsonl record, never Metro transforms. '${{ steps.verification-cache.outputs.path }}', - "${{ github.event_name != 'pull_request' && 'pnpm' || '' }} store" + "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }} store" ] /** Every step a workflow runs, descending into the repository's own composite actions. */ diff --git a/config/scripts/mobile-typecheck-workflow.test.mjs b/config/scripts/mobile-typecheck-workflow.test.mjs new file mode 100644 index 00000000000..f234b538df5 --- /dev/null +++ b/config/scripts/mobile-typecheck-workflow.test.mjs @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const workflow = parse(readFileSync('.github/workflows/mobile.yml', 'utf8')) +const packageJson = JSON.parse(readFileSync('mobile/package.json', 'utf8')) +const job = workflow.jobs.verify +const steps = job.steps + +describe('mobile verification command ownership', () => { + it('runs the declared checks through installed tools without a script-time install', () => { + const production = steps.find((step) => step.name === 'Typecheck') + const tests = steps.find((step) => step.name === 'Typecheck tests (ratchet)') + + expect(packageJson.scripts.typecheck).toMatch(/^tsc\b/) + expect(production.run).toBe(`node node_modules/typescript/bin/${packageJson.scripts.typecheck}`) + expect(tests.run).toBe(packageJson.scripts['check:tests-typecheck']) + expect(tests.run).toMatch(/^node\s/) + expect(job.defaults.run['working-directory']).toBe('mobile') + for (const name of ['typecheck', 'check:tests-typecheck']) { + expect(packageJson.scripts[`pre${name}`]).toBeUndefined() + expect(packageJson.scripts[`post${name}`]).toBeUndefined() + } + }) + + it('finishes installation and joins both independent typechecks before tests', () => { + const installIndex = steps.findIndex((step) => step.name === 'Install dependencies') + const productionIndex = steps.findIndex((step) => step.name === 'Typecheck') + const ratchetIndex = steps.findIndex((step) => step.name === 'Typecheck tests (ratchet)') + const waitIndex = steps.findIndex((step) => step.wait === steps[productionIndex].id) + const testIndex = steps.findIndex((step) => step.name === 'Test') + + expect(steps[installIndex].run).toBe('pnpm install --frozen-lockfile') + expect(steps[installIndex].background ?? false).toBe(false) + expect(installIndex).toBeLessThan(productionIndex) + expect(steps[productionIndex].background).toBe(true) + expect(productionIndex).toBeLessThan(ratchetIndex) + expect(waitIndex).toBeGreaterThan(productionIndex) + expect(ratchetIndex).toBeLessThan(waitIndex) + expect(waitIndex).toBeLessThan(testIndex) + expect(steps[waitIndex].if).toBeUndefined() + expect(steps[productionIndex]['continue-on-error'] ?? false).toBe(false) + expect(steps[ratchetIndex]['continue-on-error'] ?? false).toBe(false) + }) +}) diff --git a/config/scripts/mobile-web-app-html-preview-render.test.mjs b/config/scripts/mobile-web-app-html-preview-render.test.mjs index 48f97486487..9f78cc33a16 100644 --- a/config/scripts/mobile-web-app-html-preview-render.test.mjs +++ b/config/scripts/mobile-web-app-html-preview-render.test.mjs @@ -611,7 +611,13 @@ for (const engine of ['chromium', 'webkit']) { await frame?.click('#fraglink', { timeout: 2000 }) } - const shown = await open(browser(), { signal: ctx.signal, extra: tall, act: tapFragment }) + const shown = await open(browser(), { + signal: ctx.signal, + extra: tall, + act: tapFragment, + reportAfterAct: 'frame-src' + }) + expect(shown.actError).toBeNull() // The precondition the whole case rests on: the base URL is the embedder's, which is what // makes a fragment resolve off-document here. expect(shown.inside?.baseUri ?? shown.mountedSrcDoc).toBeTruthy() diff --git a/config/scripts/mobile-web-app-preview-arm-driver.mjs b/config/scripts/mobile-web-app-preview-arm-driver.mjs index c09eb00be65..6ab78058755 100644 --- a/config/scripts/mobile-web-app-preview-arm-driver.mjs +++ b/config/scripts/mobile-web-app-preview-arm-driver.mjs @@ -4,6 +4,8 @@ import { recordRequestsTo } from './mobile-web-app-preview-request-log.mjs' import { watchImageEvidence } from './mobile-web-app-preview-image-evidence.mjs' import { artifact } from './mobile-web-app-preview-artifact-fixture.mjs' +import { pollReportsUntil } from './mobile-web-app-preview-csp-reports.mjs' +import { describePreviewFrame, untilAborted } from './mobile-web-app-preview-frame-diagnosis.mjs' import { previewFrame, settleAfterMount, @@ -42,6 +44,7 @@ export async function openPreviewArm( assets, doctype, reportReady = null, + reportAfterAct = null, /** What the shell told this page it may do. Defaults to the session route's own list, so an arm * that does not mention it measures the shipped screen (C8.1). */ grants = null, @@ -199,6 +202,15 @@ export async function openPreviewArm( // write, and the bounded wait says so rather than leaving a bare timeout. actError }) + // A refusal caused by the tap arrives after mount readiness. + if (reportAfterAct && !actError) { + await untilAborted( + pollReportsUntil(cspReports, nonce, reportAfterAct, signal), + signal, + async () => + `the policy reported no ${String(reportAfterAct)} refusal after the action: ${arm} | ${await describePreviewFrame(page, previewFrame(page), browserVersion)}` + ) + } const result = await readPreviewArm({ page, clip, diff --git a/config/scripts/mobile-web-app-working-spinner-render.test.mjs b/config/scripts/mobile-web-app-working-spinner-render.test.mjs new file mode 100644 index 00000000000..3ac7cdd2e37 --- /dev/null +++ b/config/scripts/mobile-web-app-working-spinner-render.test.mjs @@ -0,0 +1,145 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { chromium } from 'playwright-core' +import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs' +import { + createBundleServer, + installShellDouble, + projectDir, + readBridgeFaultGrant, + readBridgeProtocolVersion, + readShellCsp +} from './mobile-web-app-render-harness.mjs' +import { LAYOUT_SOURCE } from './mobile-web-app-terminal-probe-route.mjs' + +/** + * The working rings keep turning on the page. Each is a one-second rotation loop; a loop that + * stops after its first turn reads `rotate(360deg)` from then on. + */ + +const ROUTE = `/${MOBILE_WEB_APP_ROUTE_ROOT}/working-spinner-probe` +const SAMPLE_SECONDS = [1.3, 1.8, 2.4] +const bundles = mobileWebAppDependenciesPresent() +const describeRender = bundles ? describe : describe.skip + +function probeRouteSource({ spinnerModule, dotModule }) { + return `import { View } from 'react-native' +import { AgentSpinner } from ${JSON.stringify(spinnerModule)} +import { AgentStateDot } from ${JSON.stringify(dotModule)} + +export default function WorkingSpinnerProbeRoute() { + globalThis.__orcaWorkingSpinnerProbe = true + return ( + + + + + ) +} +` +} + +let browser = null +let origin = null +let scratch = null +let server = null + +beforeAll(async () => { + if (!bundles) { + return + } + const componentsDir = join(projectDir, 'mobile', 'src', 'components') + const cspHeader = await readShellCsp() + scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-working-spinner-')) + const appDir = join(scratch, 'app') + const routeDir = join(appDir, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(routeDir, { recursive: true }) + await writeFile(join(routeDir, '_layout.tsx'), LAYOUT_SOURCE) + await writeFile( + join(routeDir, 'working-spinner-probe.tsx'), + probeRouteSource({ + spinnerModule: join(componentsDir, 'AgentSpinner'), + dotModule: join(componentsDir, 'AgentStateDot') + }) + ) + const built = await buildMobileWebAppBundle({ + appDir, + outDir: join(scratch, 'bundle'), + pageRoutes: [{ pathname: ROUTE, grants: [] }] + }) + const served = await createBundleServer({ outDir: built.outDir, cspHeader }) + server = served.server + origin = served.origin + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) +}, 600_000) + +afterAll(async () => { + await browser?.close() + server?.close() + if (scratch) { + await rm(scratch, { recursive: true, force: true }) + } +}) + +async function openProbe() { + const faultGrant = await readBridgeFaultGrant() + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) + await page.addInitScript(installShellDouble, { + version: await readBridgeProtocolVersion(), + sessionId: 'working-spinner-session', + buildId: 'working-spinner-build', + route: { pathname: ROUTE, params: {} }, + host: { id: 'spinner-host', name: 'Spinner Host', endpoint: 'ws://spinner', lastConnected: 1 }, + storage: {}, + faultGrant, + grants: [faultGrant], + pageRoutes: [ROUTE], + replies: {} + }) + const errors = [] + page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`)) + await page.goto(`${origin}/`, { waitUntil: 'load' }) + await page.waitForFunction( + () => + globalThis.__orcaWorkingSpinnerProbe !== undefined || + (globalThis.__orcaRenderCheckFaults ?? []).length > 0, + { timeout: 60_000, polling: 100 } + ) + expect(await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])).toEqual([]) + return { errors, page } +} + +/** The rotating ring is the only element under each wrapper that carries an inline transform. */ +const readRotations = (page) => + page.evaluate(() => + ['working-spinner', 'working-dot'].map( + (id) => + [...document.querySelectorAll(`#${id} *`)].find((node) => node.style.transform)?.style + .transform ?? null + ) + ) + +describeRender('working rings on the page', () => { + it('keeps both rings turning past the first second', async () => { + const { errors, page } = await openProbe() + const started = Date.now() + const samples = [] + for (const seconds of SAMPLE_SECONDS) { + await page.waitForTimeout(Math.max(0, started + seconds * 1000 - Date.now())) + samples.push(await readRotations(page)) + } + for (const ring of [0, 1]) { + const angles = samples.map((sample) => sample[ring]) + expect(new Set(angles).size, `ring ${ring} after 1.3 s: ${angles.join(', ')}`).toBe( + angles.length + ) + } + expect(errors).toEqual([]) + await page.close() + }, 300_000) +}) diff --git a/config/scripts/moved-code-normalization-budget.test.mjs b/config/scripts/moved-code-normalization-budget.test.mjs new file mode 100644 index 00000000000..cd2e729608c --- /dev/null +++ b/config/scripts/moved-code-normalization-budget.test.mjs @@ -0,0 +1,164 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as quality from './check-changed-code-quality.mjs' + +const processCalls = vi.hoisted(() => ({ execFileSync: vi.fn(), spawnSync: vi.fn() })) +// oxlint-disable-next-line anti-slop/no-module-mocking -- Test-only external process boundary; actual main executes. +vi.mock('node:child_process', () => processCalls) +// oxlint-disable-next-line anti-slop/no-module-mocking -- Resolve only the external executable used by this fixture. +vi.mock('./oxlint-cli-invocation.mjs', () => ({ + resolveOxlintInvocation: () => ({ command: 'fixture-oxlint', prefixArgs: [] }) +})) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +const createMatcher = (blocks) => + quality.createMovedCodeMatcher?.(blocks) ?? ((lines) => quality.isMovedCode(lines, blocks)) + +describe('moved-code base normalization budget', () => { + it('normalizes fixed base lines once across repeated changed-line diagnostics', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-moved-code-budget-')) + const file = join(root, 'fixture.mjs') + const source = 'brandNewCall()\n' + writeFileSync(file, source) + const blocks = [Array.from({ length: 5000 }, (_, index) => ` base_line_${index}() `)] + const matcher = createMatcher(blocks) + const ranges = new Map([['fixture.mjs', [{ start: 1, end: 1 }]]]) + const diagnostic = { + filename: file, + labels: [{ span: { line: 1, offset: 0, length: source.length - 1 } }] + } + const original = String.prototype.replace + let normalizedBaseLines = 0 + const spy = vi.spyOn(String.prototype, 'replace').mockImplementation(function (...args) { + if (String(this).startsWith(' base_line_')) { + normalizedBaseLines += 1 + } + return original.apply(this, args) + }) + try { + const results = Array.from({ length: 100 }, () => + quality.diagnosticTouchesAddedLines(diagnostic, ranges, root, blocks, matcher) + ) + spy.mockRestore() + expect(results).toEqual(Array(100).fill(true)) + expect(normalizedBaseLines).toBe(5000) + } finally { + spy.mockRestore() + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the existing exemption decisions across mixed repeated highlights', () => { + const body = Array.from({ length: 20 }, (_, index) => `line${index}()`) + const blocks = [[' a() ', '', '\tb()'], body, ['first()', 'last()']] + const matcher = createMatcher(blocks) + const observations = [ + { lines: ['a()', ' ', 'b()'], moved: true }, + { lines: ['', ' '], moved: false }, + { lines: ['brandNewCall()', 'a()'], moved: false }, + { lines: ['first()', 'brandNewCall()'], moved: false }, + { lines: [...body.slice(0, 19), 'newDep,', body[19]], moved: true }, + { + lines: ['line0()', ...Array.from({ length: 18 }, (_, index) => `fresh${index}()`)], + moved: false + }, + { lines: ['b()', 'a()'], moved: false } + ] + for (let repeat = 0; repeat < 10; repeat += 1) { + for (const { lines, moved } of observations) { + expect(matcher(lines)).toBe(moved) + expect(quality.isMovedCode(lines, blocks)).toBe(moved) + } + } + }) + + it('does not normalize unvisited blocks and recomputes for the next invocation', () => { + const unused = [' base_line_unused() '] + const blocks = [['first()'], unused] + const matcher = createMatcher(blocks) + const original = String.prototype.replace + let unusedReads = 0 + const spy = vi.spyOn(String.prototype, 'replace').mockImplementation(function (...args) { + if (String(this).startsWith(' base_line_')) { + unusedReads += 1 + } + return original.apply(this, args) + }) + try { + expect(matcher([])).toBe(false) + expect(matcher(['first()'])).toBe(true) + expect(unusedReads).toBe(0) + } finally { + spy.mockRestore() + } + blocks[0][0] = 'changed()' + const nextMatcher = createMatcher(blocks) + expect(nextMatcher(['changed()'])).toBe(true) + expect(nextMatcher(['first()'])).toBe(false) + expect(quality.isMovedCode(['changed()'], blocks)).toBe(true) + }) + + it('shares the matcher across diagnostics and scans in the actual main entrypoint', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-moved-code-main-')) + const file = join(root, 'fixture.mjs') + writeFileSync(file, 'brandNewCall()\n') + vi.stubEnv('GITHUB_EVENT_NAME', '') + processCalls.execFileSync.mockImplementation((_command, args) => { + if (args[0] === 'rev-list') { + return 'head parent\n' + } + if (args[0] === 'merge-base') { + return 'baseline\n' + } + if (args[0] === 'ls-files') { + return '' + } + if (args.includes('--name-only')) { + return 'fixture.mjs\0' + } + if (args.includes('--unified=0')) { + return '@@ -0,0 +1 @@\n+brandNewCall()\n' + } + throw new Error(`Unexpected Git arguments: ${args.join(' ')}`) + }) + const baseline = Array.from({ length: 1000 }, (_, index) => `base_line_${index}()`) + const diagnostics = Array.from({ length: 10 }, () => ({ + filename: file, + message: 'New code finding', + labels: [{ span: { line: 1, offset: 0, length: 14 } }] + })) + processCalls.spawnSync.mockImplementation((command, args) => { + if (command === 'git') { + return { status: 0, stdout: args[0] === 'show' ? baseline.join('\n') : '' } + } + return { status: 1, stdout: JSON.stringify({ diagnostics }), stderr: '' } + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + const replace = String.prototype.replace + let normalizedBaseLines = 0 + vi.spyOn(String.prototype, 'replace').mockImplementation(function (...args) { + if (String(this).startsWith('base_line_')) { + normalizedBaseLines += 1 + } + return replace.apply(this, args) + }) + try { + expect(quality.main(root, 'baseline')).toBe(1) + const scans = processCalls.spawnSync.mock.calls.filter(([command]) => command !== 'git') + expect(scans).toHaveLength(quality.OXLINT_SCANS.length) + expect(error.mock.calls.filter(([message]) => message.startsWith('::error '))).toHaveLength( + diagnostics.length * quality.OXLINT_SCANS.length + ) + expect(normalizedBaseLines).toBe(2000) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/msbuild-file-tracking.mjs b/config/scripts/msbuild-file-tracking.mjs new file mode 100644 index 00000000000..0a2fac8b804 --- /dev/null +++ b/config/scripts/msbuild-file-tracking.mjs @@ -0,0 +1,14 @@ +// FileTracker's long-path-unsafe .tlog files serve incremental builds; these rebuilds are forced. +export function disableMsbuildFileTrackingOnWindows( + env = process.env, + platform = process.platform +) { + // Windows environment keys are case-insensitive, including caller overrides in copied objects. + if ( + platform === 'win32' && + !Object.keys(env).some((key) => key.toLowerCase() === 'trackfileaccess') + ) { + env.TrackFileAccess = 'false' + } + return env +} diff --git a/config/scripts/msbuild-file-tracking.test.mjs b/config/scripts/msbuild-file-tracking.test.mjs new file mode 100644 index 00000000000..fdde02db25c --- /dev/null +++ b/config/scripts/msbuild-file-tracking.test.mjs @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { disableMsbuildFileTrackingOnWindows } from './msbuild-file-tracking.mjs' + +describe('disableMsbuildFileTrackingOnWindows', () => { + it('turns tracking off on Windows when the caller left it unset', () => { + expect(disableMsbuildFileTrackingOnWindows({ PATH: 'x' }, 'win32')).toEqual({ + PATH: 'x', + TrackFileAccess: 'false' + }) + }) + + it.each(['TrackFileAccess', 'trackfileaccess', 'TRACKFILEACCESS', 'tRaCkFiLeAcCeSs'])( + 'preserves explicit %s values without adding a duplicate key', + (key) => { + for (const value of ['true', 'false', '']) { + const env = { [key]: value } + expect(disableMsbuildFileTrackingOnWindows(env, 'win32')).toBe(env) + expect(env).toEqual({ [key]: value }) + } + } + ) + + it.each(['linux', 'darwin'])('leaves %s hosts alone', (platform) => { + const env = { PATH: 'x' } + expect(disableMsbuildFileTrackingOnWindows(env, platform)).toBe(env) + expect(env).toEqual({ PATH: 'x' }) + }) +}) diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 7badba9fa98..71e788867e1 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -1,12 +1,6 @@ -import { build } from 'esbuild' import { appendFileSync, globSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { - externalNativeAddons, - ORCAD_CHILD_ENTRY_POINTS, - ORCAD_ENTRY_POINT -} from './orcad-entry-build.mjs' import { nodeServerTestPaths } from './node-server-test-paths.mjs' import { nodeServerQualification } from './node-server-qualification.mjs' @@ -37,11 +31,14 @@ const ALWAYS_FILES = new Set([ '.github/workflows/node-server-tests.yml', 'config/scripts/node-server-change-scope.mjs', 'config/scripts/node-server-change-scope.test.mjs', + 'config/scripts/headless-detector-compiler-cache.mjs', 'config/scripts/node-server-qualification.mjs', 'config/scripts/node-server-qualification.test.mjs' ]) const ALWAYS_PREFIXES = [ '.github/actions/install-node-dependencies/', + '.github/actions/restore-pnpm-verification/', + '.github/actions/prepare-headless-compiler/', '.github/actions/prepare-native-runtime/', '.github/actions/prepare-orcad-prebuilds/', // These areas also contain worker paths and fixtures opened without an import. @@ -68,6 +65,8 @@ export function discoverNodeServerTests(root = ROOT) { } export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) { + const [{ build }, { externalNativeAddons, ORCAD_CHILD_ENTRY_POINTS, ORCAD_ENTRY_POINT }] = + await Promise.all([import('esbuild'), import('./orcad-entry-build.mjs')]) const entries = entryPoints ?? [ ORCAD_ENTRY_POINT, ...Object.values(ORCAD_CHILD_ENTRY_POINTS), @@ -99,7 +98,11 @@ export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) ) } -export async function classifyNodeServerChanges(changedFiles, collect = collectNodeServerInputs) { +export async function classifyNodeServerChanges( + changedFiles, + collect = collectNodeServerInputs, + { deferGraph = false } = {} +) { if (changedFiles.length === 0) { return { shouldRun: true, reason: 'No complete changed-file evidence' } } @@ -113,6 +116,9 @@ export async function classifyNodeServerChanges(changedFiles, collect = collectN if (forced) { return { shouldRun: true, reason: `Build or CI input changed: ${forced}` } } + if (deferGraph) { + return { graphRequired: true, reason: 'Installed dependencies are needed to check imports' } + } try { const inputs = await collect() const matched = changedFiles.find((file) => inputs.has(file)) @@ -133,12 +139,16 @@ export async function classifyNodeServerChanges(changedFiles, collect = collectN if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) - const result = await classifyNodeServerChanges(changedFiles) + const result = await classifyNodeServerChanges(changedFiles, collectNodeServerInputs, { + deferGraph: process.argv.includes('--defer-graph') + }) console.log(result.reason) const policy = nodeServerQualification(changedFiles, result, { fullQualification: process.argv.includes('--full-qualification') }) - const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` + const output = result.graphRequired + ? 'graph_required=true\n' + : `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, output) } else { diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 0ff118ed678..38b56f0d600 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeAll, describe, expect, it } from 'vitest' @@ -11,6 +11,8 @@ import { import { nodeServerTestPaths } from './node-server-test-paths.mjs' import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' +import { runProcessSync } from './script-child-process.mjs' +import { NODE_SERVER_RUNNERS } from './node-server-qualification.mjs' const temporaryDirs = [] afterEach(() => { @@ -83,6 +85,9 @@ it.each([ 'config/patches/node-pty@1.1.0.patch', 'native/windows-registry/src/addon.cc', '.github/actions/install-node-dependencies/action.yml', + '.github/actions/restore-pnpm-verification/action.yml', + '.github/actions/prepare-headless-compiler/action.yml', + 'config/scripts/headless-detector-compiler-cache.mjs', '.github/actions/prepare-native-runtime/action.yml', '.github/actions/prepare-orcad-prebuilds/action.yml', '.github/workflows/node-server-tests.yml', @@ -91,15 +96,99 @@ it.each([ expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(true) }) +it('defers uncertain paths without issuing a qualification verdict', async () => { + const result = await classifyNodeServerChanges( + ['src/renderer/src/example.ts'], + async () => { + throw new Error('graph must not run before installation') + }, + { deferGraph: true } + ) + expect(result.graphRequired).toBe(true) + expect(result.shouldRun).toBeUndefined() +}) + +it.each([ + { files: [], deferred: true, expected: 'should_run=true' }, + { files: ['package.json'], deferred: true, expected: 'should_run=true' }, + { + files: ['src/main/persistence/profile-state/profile-state-windows.ts'], + deferred: true, + fullQualification: false, + qualification: false, + runners: ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + expected: 'should_run=true' + }, + { + files: ['src/main/persistence/profile-state/profile-state-windows.ts'], + deferred: true, + expected: 'should_run=true' + }, + { + files: ['src/main/providers/provider-windows.ts'], + deferred: true, + fullQualification: false, + expected: 'should_run=true' + }, + { files: ['src/renderer/src/example.ts'], deferred: true, expected: 'graph_required=true' }, + { files: ['src/renderer/src/example.ts'], deferred: false, expected: 'should_run=true' } +])('fails closed or requests dependencies in an uninstalled checkout: %j', (scenario) => { + const root = moduleTree( + Object.fromEntries( + ['node-server-change-scope', 'node-server-test-paths', 'node-server-qualification'].map( + (name) => [ + `config/scripts/${name}.mjs`, + readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8') + ] + ) + ) + ) + const changes = join(root, 'changes') + const stepOutput = join(root, 'step-output') + writeFileSync(changes, scenario.files.map((file) => `${file}\0`).join('')) + const result = runProcessSync({ + program: process.execPath, + args: [ + realpathSync(join(root, 'config/scripts/node-server-change-scope.mjs')), + changes, + ...(scenario.fullQualification === false ? [] : ['--full-qualification']), + ...(scenario.deferred ? ['--defer-graph'] : []) + ], + cwd: root, + env: { ...process.env, GITHUB_OUTPUT: stepOutput }, + timeoutMs: 5_000 + }) + expect(result.code).toBe(0) + const output = readFileSync(stepOutput, 'utf8') + expect(output).toContain(scenario.expected) + if (scenario.expected === 'graph_required=true') { + expect(output).not.toContain('should_run=') + expect(output).not.toContain('runners=') + } else { + expect(output).toContain(`qualification=${scenario.qualification !== false}`) + expect(output).toContain(`runners=${JSON.stringify(scenario.runners ?? NODE_SERVER_RUNNERS)}`) + } +}) + describe('the actual Bun build and profile-test dependency graph', () => { let inputs beforeAll(async () => { inputs = await collectNodeServerInputs() }, 60_000) + it('tracks the shared close probe without pulling in its mocked renderer adapter', () => { + expect(inputs.has('src/shared/pty-running-work-probe.ts')).toBe(true) + expect(inputs.has('src/shared/pty-running-work-probe.test.ts')).toBe(true) + expect(inputs.has('src/renderer/src/components/terminal/pty-running-work-probe.ts')).toBe(false) + expect(inputs.has('src/renderer/src/runtime/runtime-terminal-inspection.ts')).toBe(false) + expect([...inputs].some((file) => file.startsWith('src/renderer/'))).toBe(false) + }) + it.each([ 'config/scripts/ci-shard-timings.json', 'config/scripts/mobile-web-app-terminal-render.test.mjs', + 'src/renderer/src/components/terminal/pty-running-work-probe.ts', + 'src/renderer/src/runtime/runtime-terminal-inspection.ts', 'src/main/ssh/ssh-relay-upload-stage-commands.test.ts', 'src/main/menu/register-app-menu.ts' ])('skips unrelated work: %s', async (file) => { @@ -109,6 +198,8 @@ describe('the actual Bun build and profile-test dependency graph', () => { it.each([ ...Object.values(ORCAD_CHILD_ENTRY_POINTS), 'src/shared/keybindings/definitions-core-1.ts', + 'src/shared/pty-running-work-probe.ts', + 'src/shared/pty-running-work-probe.test.ts', 'src/main/runtime/orca-runtime.ts', 'src/main/windows/windows-process-table.ts', 'src/main/worker-thread-entry-path.ts', @@ -149,7 +240,7 @@ it('keeps every platform job and runs them when detection is skipped or fails', expect(detect.env.PUSH_BASE).toBe('${{ github.event.before }}') expect(detect.run).toContain('git fetch --no-tags --depth=1 origin "$PUSH_BASE"') expect(detect.run).toContain('git diff --name-only --no-renames -z "$PUSH_BASE" HEAD') - expect(detect.run).toContain('node-server-changes" --full-qualification') + expect(detect.run).toContain('node-server-changes" --defer-graph --full-qualification') expect(workflow.on.pull_request.types).toContain('ready_for_review') expect(workflow.on.schedule).toHaveLength(1) // A pull request may qualify one platform, so the merged commit must re-qualify all six. @@ -213,14 +304,33 @@ it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', ( expect(setupBun.with['bun-version']).toBe('1.4.2') const build = steps.find((step) => String(step.run).includes('build-orcad-bun.mjs')) expect(build.env.BUN_ORCAD_COMMIT).toMatch(/^[0-9a-f]{40}$/) - expect(build.run).toContain('ORCA_BUN_ORCAD_SLOT=') - expect(build.run).toContain('BUN_EXECUTABLE=') - for (const step of [setupBun, build]) { - expect(step.if).toBe("runner.os == 'Linux'") - } - expect(steps.map((step) => step.run).join('\n')).toContain( + expect(setupBun.if).toBe("runner.os == 'Linux'") + expect(build.id).toBe('bun-orcad') + expect(build.background).toBe(true) + expect(build.if).toBeUndefined() + expect(build['continue-on-error']).toBeUndefined() + expect(build.run).toMatch(/^if \[ "\$RUNNER_OS" != Linux \]; then exit 0; fi\n/) + expect(build.run).toContain( + 'pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts' + ) + expect(build.run).not.toContain('"$GITHUB_WORKSPACE/node_modules"') + expect(build.run).toContain('echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"') + expect(build.run).toContain('echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"') + expect(build.run).not.toContain('GITHUB_ENV') + const join = steps.findIndex((step) => step.wait === build.id) + expect(join).toBeGreaterThan(steps.indexOf(build)) + expect(steps[join].if).toBeUndefined() + expect(steps[join]['continue-on-error']).toBeUndefined() + const consumer = steps.find((step) => step.run?.startsWith('pnpm test:node-server --artifact ')) + expect(steps.indexOf(consumer)).toBeGreaterThan(join) + expect(consumer.run).toBe( "pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }}" ) + expect(consumer.if).toBeUndefined() + expect(consumer.env).toEqual({ + ORCA_BUN_ORCAD_SLOT: '${{ steps.bun-orcad.outputs.slot }}', + BUN_EXECUTABLE: '${{ steps.bun-orcad.outputs.executable }}' + }) const alpine = workflow.jobs.linux_musl.steps.find((step) => String(step.run).includes('docker run') ) diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index 9901b609a9b..777a2feb314 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -1,7 +1,7 @@ export const NODE_SERVER_RUNNERS = [ 'ubuntu-22.04', 'ubuntu-24.04-arm', - 'macos-14', + 'macos-15', 'macos-15-intel', 'windows-2022', 'windows-11-arm' @@ -12,6 +12,7 @@ const BUILD_PREFIXES = [ 'native/', 'config/patches/', '.github/actions/install-node-dependencies/', + '.github/actions/restore-pnpm-verification/', '.github/actions/prepare-native-runtime/', '.github/actions/prepare-orcad-prebuilds/' ] diff --git a/config/scripts/node-server-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs index 6018fe6a7e4..be4e0e4d9cd 100644 --- a/config/scripts/node-server-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -27,6 +27,7 @@ it.each([ 'native/windows-registry/src/addon.cc', 'config/patches/node-pty.patch', '.github/actions/install-node-dependencies/action.yml', + '.github/actions/restore-pnpm-verification/action.yml', '.github/actions/prepare-native-runtime/action.yml', '.github/actions/prepare-orcad-prebuilds/action.yml', 'src/main/ssh/ssh-provider.ts', @@ -74,11 +75,11 @@ it.each([ ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], false ], - ['src/main/daemon/darwin-process.ts', ['ubuntu-22.04', 'macos-14', 'macos-15-intel'], false], + ['src/main/daemon/darwin-process.ts', ['ubuntu-22.04', 'macos-15', 'macos-15-intel'], false], ['src/shared/linux-glibc.ts', ['ubuntu-22.04', 'ubuntu-24.04-arm'], true], [ 'src/main/daemon/posix-process.ts', - ['ubuntu-22.04', 'ubuntu-24.04-arm', 'macos-14', 'macos-15-intel'], + ['ubuntu-22.04', 'ubuntu-24.04-arm', 'macos-15', 'macos-15-intel'], true ] ])('selects both architectures and a Linux smoke for %s', (file, runners, qualification) => { @@ -92,7 +93,7 @@ it('combines platform families without adding Linux compatibility work', () => { scope ) ).toEqual({ - runners: ['ubuntu-22.04', 'macos-14', 'macos-15-intel', 'windows-2022', 'windows-11-arm'], + runners: ['ubuntu-22.04', 'macos-15', 'macos-15-intel', 'windows-2022', 'windows-11-arm'], qualification: false }) }) diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 6938919bc7c..a93c30869ca 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -21,6 +21,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = ...(artifact ? [ 'tests/e2e/daemon-running-work-probe.unit.test.ts', + 'src/shared/pty-running-work-probe.test.ts', 'src/main/orcad/orcad-packaged-node-pty.integration.test.ts', 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', 'src/main/orcad/orcad-node-launcher.integration.test.ts', diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index e168430fe0a..8ee6b243b3d 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -8,7 +8,8 @@ export const ORCAD_CHILD_ENTRY_POINTS = { watcher: 'src/main/ipc/parcel-watcher-process-entry.ts', daemon: 'src/main/daemon/daemon-entry.ts', writer: 'src/main/persistence/profile-state/profile-state-writer-worker-entry.ts', - backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts' + backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts', + foreignSqliteReader: 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts' } export const ORCAD_EXTERNAL_MODULES = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] diff --git a/config/scripts/orcad-template-release-workflow.test.mjs b/config/scripts/orcad-template-release-workflow.test.mjs index eee8d451fc3..311231ea4bf 100644 --- a/config/scripts/orcad-template-release-workflow.test.mjs +++ b/config/scripts/orcad-template-release-workflow.test.mjs @@ -123,6 +123,65 @@ describe('orcad template release wiring (design D2)', () => { expect(releaseMac.permissions.actions).toBe('read') }) + it('skips the template only for a tag that predates it', () => { + const cutSteps = releaseCut.jobs.cut.steps + const push = stepIndex(cutSteps, (step) => step.name === 'Push tag') + const detect = stepIndex(cutSteps, (step) => step.id === 'orcad-template-support') + expect(detect).toBeGreaterThan(push) + expect(cutSteps[detect].run).toContain(':config/scripts/packaged-orcad-template.cjs"') + expect(releaseCut.jobs.cut.outputs.ships_orcad_template).toBe( + '${{ steps.orcad-template-support.outputs.ships }}' + ) + expect(releaseCut.jobs['orcad-template'].if).toContain( + "needs.cut.outputs.ships_orcad_template == 'true'" + ) + + for (const name of ['build', 'build-mac']) { + const condition = releaseCut.jobs[name].if + // Every other dependency still has to succeed, as under the implicit success(). + for (const need of releaseCut.jobs[name].needs.filter((need) => need !== 'orcad-template')) { + expect(condition).toContain(`needs.${need}.result == 'success'`) + } + expect(condition).toContain("needs.orcad-template.result == 'success'") + expect(condition).toContain( + "(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')" + ) + } + + const buildSteps = releaseCut.jobs.build.steps + for (const step of [ + buildSteps.find((step) => step.name === 'Download the orcad deployment template'), + buildSteps.find((step) => step.id === 'reseal-orcad-template') + ]) { + expect(step.if).toContain("needs.cut.outputs.ships_orcad_template == 'true'") + } + const macDownload = releaseMac.jobs['build-mac'].steps.find( + (step) => step.with?.name === 'orcad-template' + ) + expect(macDownload.if).toBe("hashFiles('config/scripts/packaged-orcad-template.cjs') != ''") + }) + + it('keeps every job downstream of the template from inheriting its skip', () => { + const needsOf = (name) => [releaseCut.jobs[name].needs ?? []].flat() + const dependsOnTemplate = (name) => + needsOf(name).some((need) => need === 'orcad-template' || dependsOnTemplate(need)) + const downstream = Object.keys(releaseCut.jobs).filter(dependsOnTemplate) + expect(downstream).toEqual( + expect.arrayContaining(['build', 'build-mac', 'publish-release', 'homebrew-bump']) + ) + for (const name of downstream) { + // A skipped ancestor skips the job under the implicit success() that any `if` without a + // status function gets, so each one must override it and check its own needs instead. + const condition = releaseCut.jobs[name].if + expect(condition, name).toContain('!cancelled()') + for (const need of needsOf(name).filter( + (need) => need !== 'orcad-template' && need !== 'cut' + )) { + expect(condition, `${name} -> ${need}`).toContain(`needs.${need}.result == 'success'`) + } + } + }) + it('signs only Windows template binaries and reseals the manifest before the installer rebuild', () => { const steps = releaseCut.jobs.build.steps const stage = steps.find((step) => step.id === 'stage-inner') diff --git a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs index 5ee88ff0492..e3108d782be 100644 --- a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs +++ b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs @@ -92,7 +92,7 @@ it('only skips the unchanged smoke after a successful diff and dependency analys const workflow = parse( readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8') ) - const step = workflow.jobs.static_analysis.steps.find( + const step = workflow.jobs.preflight.steps.find( (candidate) => candidate.name === 'Boot orcad and round-trip a terminal' ) expect(step.env).toEqual({ diff --git a/config/scripts/patched-dependencies-frozen-install.test.mjs b/config/scripts/patched-dependencies-frozen-install.test.mjs index 89f98ef074b..53061c1dbd7 100644 --- a/config/scripts/patched-dependencies-frozen-install.test.mjs +++ b/config/scripts/patched-dependencies-frozen-install.test.mjs @@ -61,6 +61,11 @@ describe('patched dependencies', () => { for (const file of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) { copyFileSync(join(PROJECT_DIR, file), join(scratch, file)) } + mkdirSync(join(scratch, 'native', 'windows-registry'), { recursive: true }) + copyFileSync( + join(PROJECT_DIR, 'native', 'windows-registry', 'package.json'), + join(scratch, 'native', 'windows-registry', 'package.json') + ) mkdirSync(join(scratch, 'config'), { recursive: true }) cpSync(join(PROJECT_DIR, 'config', 'patches'), join(scratch, 'config', 'patches'), { recursive: true diff --git a/config/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts index 843d185b101..15be37ebcb2 100644 --- a/config/scripts/plain-node-entry-guard.test.ts +++ b/config/scripts/plain-node-entry-guard.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Plugin, Rollup } from 'vite' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { CLI_MAIN_ENTRY_NAMES, createPlainNodeEntryGuardPlugin, @@ -160,14 +160,20 @@ describe('guarded entry names', () => { // hand-written "must stay electron-free" comments, and the port-scan worker sits // one import away from a client that deliberately does require electron. describe('CLI and worker thread entry guard', () => { - function runEntryWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void { + function runEntryWriteBundle( + plugin: Plugin, + bundle: Rollup.OutputBundle, + watchMode = false + ): void { const hook = plugin.writeBundle if (typeof hook !== 'function') { throw new Error('Expected writeBundle hook') } hook.call( - { meta: { watchMode: false } } as never, - { dir: createOutputDir() } as Rollup.NormalizedOutputOptions, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook reads only meta.watchMode from its context. + { meta: { watchMode } } as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook reads only dir from the output options. + { dir: outputDir ?? createOutputDir() } as Rollup.NormalizedOutputOptions, bundle ) } @@ -184,6 +190,93 @@ describe('CLI and worker thread entry guard', () => { } as Rollup.OutputChunk } + function countElectronRequireScans(run: () => void): number { + const pattern = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/ + const originalTest = RegExp.prototype.test + let scans = 0 + const spy = vi.spyOn(RegExp.prototype, 'test').mockImplementation(function ( + this: RegExp, + value: string + ) { + if (this.source === pattern.source) { + scans += 1 + } + return originalTest.call(this, value) + }) + try { + run() + } finally { + spy.mockRestore() + } + return scans + } + + it('scans shared code once across every guarded entry', () => { + const shared = entryChunk('shared', 'require("node:fs")') + shared.isEntry = false + const bundle: Rollup.OutputBundle = { [shared.fileName]: shared } + for (const name of GUARDED_ENTRY_NAMES) { + const entry = entryChunk(name, `require("./shared.js"); // ${name}`, [shared.fileName]) + bundle[entry.fileName] = entry + } + const snapshot = structuredClone(bundle) + + const scans = countElectronRequireScans(() => { + runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle) + }) + + expect(bundle).toEqual(snapshot) + expect(scans).toBe(GUARDED_ENTRY_NAMES.length + 1) + }) + + it('does not retain a successful scan across output bundles', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const entry = entryChunk('stt-worker', 'require("node:fs")') + const bundle: Rollup.OutputBundle = { [entry.fileName]: entry } + const scans = countElectronRequireScans(() => { + runEntryWriteBundle(plugin, bundle) + runEntryWriteBundle(plugin, bundle) + }) + expect(scans).toBe(2) + + entry.code = 'require("electron/main")' + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow( + '[plain-node-entry-guard] "stt-worker" reaches chunk "stt-worker.js" that requires electron. ' + ) + }) + + it('still reads changed code on the same chunk within one bundle scan', () => { + const first = entryChunk('stt-worker', 'require("node:fs")') + const second = entryChunk('warp-theme-parser-worker', 'require("node:fs")') + const shared = entryChunk('shared', '') + shared.isEntry = false + let reads = 0 + Object.defineProperty(shared, 'code', { + get: () => (++reads === 1 ? 'require("node:fs")' : 'require("electron")') + }) + first.imports = [shared.fileName] + second.dynamicImports = [shared.fileName] + const bundle: Rollup.OutputBundle = { + [first.fileName]: first, + [second.fileName]: second, + [shared.fileName]: shared + } + + expect(() => runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle)).toThrow( + '[plain-node-entry-guard] "warp-theme-parser-worker" reaches chunk "shared.js"' + ) + expect(reads).toBe(2) + }) + + it('keeps watch mode free of entry scanning', () => { + const entry = entryChunk('stt-worker', 'require("electron")') + const bundle: Rollup.OutputBundle = { [entry.fileName]: entry } + const scans = countElectronRequireScans(() => { + runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle, true) + }) + expect(scans).toBe(0) + }) + it.each(CLI_MAIN_ENTRY_NAMES)('rejects direct and transitive Electron imports in %s', (name) => { const plugin = createPlainNodeEntryGuardPlugin() const entry = entryChunk(name, 'require("electron")') @@ -231,8 +324,8 @@ describe('CLI and worker thread entry guard', () => { it('follows shared chunks out of a worker entry', () => { const plugin = createPlainNodeEntryGuardPlugin() const bundle: Rollup.OutputBundle = { - 'session-scanner-opencode-sqlite-worker-entry.js': entryChunk( - 'session-scanner-opencode-sqlite-worker-entry', + 'foreign-sqlite-reader-entry.js': entryChunk( + 'foreign-sqlite-reader-entry', 'require("./chunks/shared.js")', ['chunks/shared.js'] ), diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs new file mode 100644 index 00000000000..2dd82ad5d26 --- /dev/null +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -0,0 +1,143 @@ +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) +const routes = [ + ['git_compatibility', 'src/shared/git-capability-cache.ts'], + ['codex_index_heal_contract', 'src/main/codex/codex-session-index-heal.ts'], + ['xterm_patch_sync', 'config/patches/xterm-upstream.json'], + ['shell_contracts', 'src/main/pty/pty-manager.ts'], + ['orcad_browser', 'src/main/orcad/orcad-browser-provider.ts'], + ['cross-version-wire', 'src/shared/orchestration-rpc-contract.ts'], + ['managed_hook_node18', 'src/shared/agent-hook-status.ts'] +] + +function evaluate(expression, context) { + return runInNewContext( + expression + .replace(/^\$\{\{\s*|\s*\}\}$/g, '') + .replaceAll('.cross-version-wire', '["cross-version-wire"]'), + context + ) +} + +function routedOutputs(files, reused) { + const scope = classifyPrJobs(files) + const context = { + steps: { + readiness: { outputs: { reused } }, + filter: { + outputs: Object.fromEntries( + Object.entries(scope).map(([key, value]) => [key, String(value)]) + ) + } + } + } + return Object.fromEntries( + ['test', 'static_analysis', 'typecheck', ...routes.map(([job]) => job)].map((name) => [ + name, + String(evaluate(workflow.jobs.code_paths.outputs[name], context)) + ]) + ) +} + +describe.each(routes)('%s preflight admission', (jobName, changedFile) => { + const job = workflow.jobs[jobName] + + it('waits for the detector and physical preflight job', () => { + expect(job.needs).toEqual(['code_paths', 'preflight']) + expect(job.if).toContain('!cancelled()') + expect(job['continue-on-error']).toBeUndefined() + }) + + it.each([ + { name: 'all prerequisites pass', admitted: true }, + { name: 'preflight fails', preflight: 'failure', admitted: false }, + { name: 'preflight is cancelled', preflight: 'cancelled', admitted: false }, + { name: 'preflight skips', preflight: 'skipped', admitted: false }, + { name: 'preflight result is missing', preflight: '', admitted: false }, + { name: 'preflight is unfinished', preflight: null, admitted: false }, + { name: 'detector fails', detector: 'failure', admitted: false }, + { name: 'detector is cancelled', detector: 'cancelled', admitted: false }, + { name: 'detector skips', detector: 'skipped', admitted: false }, + { name: 'detector result is missing', detector: '', admitted: false }, + { name: 'detector is unfinished', detector: null, admitted: false }, + { name: 'route is unselected', selected: 'false', admitted: false }, + { name: 'route is missing', selected: '', admitted: false }, + { name: 'route is absent', selected: null, admitted: false }, + { name: 'route is unknown', selected: 'unknown', admitted: false }, + { name: 'workflow is cancelled', cancelled: true, admitted: false } + ])('evaluates the actual workflow condition: $name', (scenario) => { + const admitted = evaluate(job.if, { + cancelled: () => scenario.cancelled ?? false, + needs: { + code_paths: { + result: scenario.detector === undefined ? 'success' : (scenario.detector ?? undefined), + outputs: { + [jobName]: scenario.selected === undefined ? 'true' : (scenario.selected ?? undefined) + } + }, + preflight: { + result: scenario.preflight === undefined ? 'success' : (scenario.preflight ?? undefined) + } + } + }) + expect(admitted).toBe(scenario.admitted) + }) + + it.each([ + { files: [] }, + { files: ['package.json'] }, + { files: ['.github/workflows/pr.yml'] }, + { files: [changedFile] } + ])( + 'requires unit work and preflight whenever the real classifier selects $files', + ({ files }) => { + const selected = routedOutputs(files, 'false') + expect(selected[jobName]).toBe('true') + expect(selected.test).toBe('true') + expect(selected.static_analysis).toBe('true') + expect(selected.typecheck).toBe('true') + } + ) + + it.each(['true', 'false', undefined])( + 'honors the actual readiness-masked output when reuse is %s', + (reused) => { + const outputs = routedOutputs([changedFile], reused) + expect(outputs[jobName]).toBe(String(reused !== 'true')) + expect(outputs.test).toBe(String(reused !== 'true')) + const admitted = evaluate(job.if, { + cancelled: () => false, + needs: { code_paths: { result: 'success', outputs }, preflight: { result: 'success' } } + }) + expect(admitted).toBe(reused !== 'true') + } + ) +}) + +it.each([ + { files: ['README.md'] }, + { files: ['mobile/src/App.tsx'] }, + { files: ['mobile/package.json'] }, + { files: ['cloud/apps/relay/src/index.ts'] } +])('does not add preflight consumers to a no-unit diff: $files', ({ files }) => { + const outputs = routedOutputs(files, 'false') + expect(outputs.test).toBe('false') + for (const [name] of routes) { + expect(outputs[name], name).toBe('false') + } +}) + +it('keeps the mobile-only bundle job in the first wave', () => { + const mobile = workflow.jobs.mobile_web_app + expect(mobile.needs).toEqual(['code_paths']) + expect(mobile.if).toBe("needs.code_paths.outputs.mobile_web_app == 'true'") + expect(classifyPrJobs(['mobile/src/App.tsx'])).toMatchObject({ + test: false, + mobile_web_app: true + }) +}) diff --git a/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs b/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs new file mode 100644 index 00000000000..b263b86f789 --- /dev/null +++ b/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +// #24901 changed the send builders and orchestration code, and this job skipped. It runs only for +// code a suite executes: loading a module the dispatcher registers is not coverage. +describe('cross-version wire routing for the send path', () => { + it.each([ + 'src/shared/agent-session-wire-refusals.ts', + 'src/shared/structured-agent-session-mutation.ts', + 'src/shared/structured-agent-session-send-mutation.ts', + 'src/shared/structured-agent-session-outbox.ts', + 'src/main/runtime/rpc/core.ts', + 'src/main/runtime/rpc/errors.ts', + 'src/main/runtime/rpc/rpc-streaming-dispatcher.ts', + 'src/main/runtime/rpc/orchestration-contract-fence.ts', + 'src/main/runtime/rpc/orchestration-session-caller.ts', + 'src/main/runtime/rpc/orchestration-legacy-compatibility.ts', + 'src/main/runtime/rpc/orchestration-mutation-executor.ts', + 'src/shared/orchestration-rpc-contract.ts', + 'src/main/runtime/orchestration/db/schema/migrate.ts' + ])('runs the cross-version suites when %s changes', (file) => { + expect(classifyPrJobs([file])).toMatchObject({ should_run: true, 'cross-version-wire': true }) + }) + + it.each([ + 'src/shared/structured-agent-session-outbox-admission.ts', + 'src/shared/structured-agent-session-outbox-delivery.ts', + 'src/shared/structured-agent-session-outbox-stop-withdrawal.ts', + 'src/shared/structured-agent-session-composer.ts', + 'src/shared/structured-agent-session-reducer.ts', + 'src/main/runtime/orchestration/send-agent-turn.ts', + 'src/main/runtime/orchestration/orchestration-caller-identity.ts', + 'src/main/runtime/rpc/orchestration-legacy-mail.ts', + 'src/main/runtime/rpc/methods/orchestration.ts', + 'src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts' + ])('leaves them off for %s, which no cross-version suite executes', (file) => { + expect(classifyPrJobs([file])).toMatchObject({ should_run: true, 'cross-version-wire': false }) + }) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 7f34a8304a9..9c658533e82 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -38,6 +38,7 @@ const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test']) const GLOBAL_FORCE_PREFIXES = [ '.github/workflows/pr.yml', '.github/actions/install-node-dependencies/', + '.github/actions/restore-pnpm-verification/', '.github/actions/prepare-native-runtime/', 'config/scripts/pr-code-change-scope' ] @@ -164,6 +165,11 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/shared/rpc-contract/agent-launch-params', 'src/shared/agent-session-wire', 'src/shared/agent-session-mutation-envelope', + // The send a client builds (the agent-session suite sends it to the release host) and the + // fingerprint the host's ledger and journal re-derive. + 'src/shared/structured-agent-session-mutation.ts', + 'src/shared/structured-agent-session-send-mutation.ts', + 'src/shared/structured-agent-session-outbox.ts', 'src/shared/agent-session-record', 'src/shared/agent-session-journal-', 'src/main/ai-vault/structured-session-ownership.ts', @@ -173,6 +179,15 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/runtime/agent-session-recovery-capsule', 'src/shared/agent-session-resume-marker', 'src/main/runtime/rpc/dispatcher', + // Run on every request the suites dispatch, whatever its method. + 'src/main/runtime/rpc/core.ts', + 'src/main/runtime/rpc/errors.ts', + 'src/main/runtime/rpc/rpc-streaming-dispatcher.ts', + 'src/main/runtime/rpc/orchestration-contract-fence.ts', + 'src/main/runtime/rpc/orchestration-session-caller.ts', + 'src/main/runtime/rpc/orchestration-legacy-compatibility.ts', + 'src/main/runtime/rpc/orchestration-mutation-executor.ts', + 'src/shared/orchestration-rpc-contract.ts', 'src/main/runtime/rpc/methods/agent-launch', 'src/main/runtime/rpc/methods/ai-vault.ts', 'src/main/runtime/rpc/methods/browser-tab-create-schema', @@ -325,6 +340,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/shared/child-process/windows-cmd-shim-resolution.test.ts', 'src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts', 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts', + 'src/main/jcode/hook-gate-script.test.ts', 'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts', 'src/main/codex/windows-hook-command.test.ts', 'src/main/codex/windows-hook-upgrade.test.ts', @@ -355,6 +371,8 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/runtime/unreadable-secret-store-preservation.win32.test.ts', 'src/main/ipc/pty-codex-account-attribution.test.ts', 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts', + 'src/main/ipc/preflight-provider-command-selection.test.ts', + 'src/main/ipc/preflight-runnable-local-cli.test.ts', 'src/relay/windows-port-scan.win32.test.ts', 'src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts', 'src/main/ssh/remote-node-runtime-store-windows.test.ts' diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 297d1f8e671..8b6bb23b8e8 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -587,14 +587,16 @@ describe('PR Checks skip wiring', () => { expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe( '${{ steps.filter.outputs.mobile_dependencies }}' ) - const steps = prWorkflow.jobs.static_analysis.steps + const steps = prWorkflow.jobs.preflight.steps const install = steps.findIndex( (step) => step.uses === './.github/actions/install-mobile-dependencies' ) const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality') expect(install).toBeGreaterThan(-1) expect(install).toBeLessThan(gate) - expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'") + expect(steps[install].if).toBe( + "needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'" + ) // The install itself moved into the action the packaging jobs share; assert it there so // this job cannot keep the step while the action stops installing anything. const action = parse( @@ -621,21 +623,17 @@ describe('PR Checks skip wiring', () => { }) it('gates each expensive job on its classifier and cache prerequisite', () => { - for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) { - expect(prWorkflow.jobs[jobName].needs, jobName).toEqual( - ['package', 'package_windows'].includes(jobName) - ? ['code_paths', 'static_analysis', 'typecheck'] - : ['code_paths'] - ) - expect(prWorkflow.jobs[jobName].if, jobName).toBe( + for (const jobName of expensiveJobs.filter( + (jobName) => !['test', 'static_analysis', 'typecheck'].includes(jobName) + )) { + expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths', 'preflight']) + expect(prWorkflow.jobs[jobName].if, jobName).toContain( `needs.code_paths.outputs.${jobName} == 'true'` ) } - expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) - expect(prWorkflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") - expect(prWorkflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") + expect(prWorkflow.jobs.test.if).toContain("needs.preflight.result == 'success'") expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'") - expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}') expect(prWorkflow.jobs.unit_plan).toBeUndefined() expect(prWorkflow.jobs.test_native_cache).toBeUndefined() }) @@ -659,7 +657,7 @@ describe('PR Checks skip wiring', () => { expect(verifyStep.run).toContain('expected skipped') expect(verifyStep.run).toContain('expected success') for (const job of prWorkflow.jobs.verify.needs) { - if (job === 'code_paths') { + if (job === 'code_paths' || job === 'preflight') { continue } const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN` diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 4b8831e357a..f9fe2d7d732 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -1,4 +1,5 @@ import { DEDICATED_E2E_SPECS } from './ci-e2e-job-selection.mjs' +import { linuxInstallPackageList } from './pr-e2e-linux-packages.test-fixture.mjs' import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parse as parseJsonc } from 'jsonc-parser' @@ -20,6 +21,7 @@ import { const projectDir = resolve(import.meta.dirname, '../..') const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8')) + const reliabilityManifest = parseJsonc( readFileSync(join(projectDir, 'config/reliability-gates.jsonc'), 'utf8') ) @@ -43,7 +45,7 @@ const nativeImeSpec = readFileSync( const filterStep = prWorkflow.jobs.code_paths.steps.find( (step) => step.name === 'Filter changed E2E specs' ) -const rollbackStep = prWorkflow.jobs.static_analysis.steps.find( +const rollbackStep = prWorkflow.jobs.preflight.steps.find( (step) => step.name === 'Check VM runtime rollback compatibility' ) const verifyStep = prWorkflow.jobs.verify.steps.find( @@ -107,8 +109,8 @@ describe('PR E2E gate contract', () => { // Why: without this the job could lose its filter and run on every PR — the // cost the path filter exists to avoid — while the gate assertions above // stay green. - expect(prWorkflow.jobs.e2e.needs).toBe('code_paths') - expect(prWorkflow.jobs.e2e.if).toBe("needs.code_paths.outputs.e2e_should_run == 'true'") + expect(prWorkflow.jobs.e2e.needs).toEqual(['code_paths', 'preflight']) + expect(prWorkflow.jobs.e2e.if).toContain("needs.code_paths.outputs.e2e_should_run == 'true'") expect(prWorkflow.jobs.code_paths.outputs.e2e_should_run).toBe( '${{ steps.e2e_filter.outputs.should_run }}' ) @@ -131,7 +133,7 @@ describe('PR E2E gate contract', () => { for (const job of prWorkflow.jobs.verify.needs) { const envVar = job.replaceAll('-', '_').toUpperCase() expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`) - if (job === 'code_paths') { + if (job === 'code_paths' || job === 'preflight') { continue } expect(successLoop).toContain(`"$${envVar}"`) @@ -218,7 +220,7 @@ describe('PR E2E gate contract', () => { const installStep = e2eWorkflow.jobs[jobName].steps.find((step) => step.name.startsWith('Install native build') ) - expect(installStep.run, jobName).toMatch(/\bzsh\b/) + expect(linuxInstallPackageList(installStep, jobName), jobName).toMatch(/(^|\s)zsh(\s|$)/) } }) @@ -305,10 +307,10 @@ describe('PR E2E gate contract', () => { // Why: this lane can now pay a Docker image build plus serial SSH specs. expect(e2eWorkflow.jobs['changed-e2e']['timeout-minutes']).toBeGreaterThanOrEqual(45) - const changedInstall = e2eWorkflow.jobs['changed-e2e'].steps.find((step) => + const install = e2eWorkflow.jobs['changed-e2e'].steps.find((step) => step.name.startsWith('Install native build') ) - expect(changedInstall.run).toContain('openssh-client') + expect(linuxInstallPackageList(install, 'changed-e2e')).toMatch(/(^|\s)openssh-client(\s|$)/) }) it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => { diff --git a/config/scripts/pr-e2e-linux-packages.test-fixture.mjs b/config/scripts/pr-e2e-linux-packages.test-fixture.mjs new file mode 100644 index 00000000000..dfbb2d4b798 --- /dev/null +++ b/config/scripts/pr-e2e-linux-packages.test-fixture.mjs @@ -0,0 +1,10 @@ +import { expect } from 'vitest' + +export function linuxInstallPackageList(step, jobName) { + const packages = step.env?.ORCA_E2E_APT_PACKAGES + if (packages !== undefined) { + expect(step.run, jobName).toContain('read -r -a packages <<< "$ORCA_E2E_APT_PACKAGES"') + expect(step.run, jobName).toContain('sudo apt-get install -y "${packages[@]}"') + } + return packages ?? step.run +} diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 84345ee9360..4be1aa2b740 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -1,29 +1,21 @@ import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' import { expect, it } from 'vitest' import { parse } from 'yaml' import { classifyPrJobs } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const typecheck = workflow.jobs.typecheck -const steps = typecheck.steps +const preflight = workflow.jobs.preflight +const steps = preflight.steps const compiler = steps.find((step) => step.run === 'pnpm run typecheck') const plan = steps.find((step) => step.id === 'unit-plan') -it('shares planning setup while keeping the heavy checks on separate runners', () => { - expect(workflow.jobs.unit_plan).toBeUndefined() - expect(workflow.jobs.test_native_cache).toBeUndefined() - expect(typecheck.needs).toEqual(['code_paths']) - expect(workflow.jobs.static_analysis.needs).toEqual(['code_paths']) - expect( - steps.filter((step) => step.uses === './.github/actions/install-node-dependencies') - ).toHaveLength(1) - expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) - expect(compiler.background).toBeUndefined() - expect(plan.background).toBe(true) - expect(plan.run).toBe('node config/scripts/ci-unit-plan.mjs') - expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') - expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) - const installs = workflow.jobs.static_analysis.steps.filter( +it('shares one setup and runs the unchanged compiler after static checks finish', () => { + expect(workflow.jobs.static_analysis).toBeUndefined() + expect(workflow.jobs.typecheck).toBeUndefined() + expect(preflight.needs).toEqual(['code_paths']) + expect(preflight['runs-on']).toBe('ubuntu-24.04-arm') + const installs = steps.filter( (step) => step.uses === './.github/actions/install-node-dependencies' ) expect(installs).toHaveLength(2) @@ -32,35 +24,259 @@ it('shares planning setup while keeping the heavy checks on separate runners', ( expect(install.with['node-version']).toBe('24') expect(install.with['persist-native-cache']).not.toBe('false') } + expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) + expect(compiler.background).toBeUndefined() + expect(plan.background).toBe(true) + expect(plan.run.trim().split('\n')).toEqual([ + 'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi', + 'node config/scripts/ci-unit-plan.mjs' + ]) + expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') + expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) + expect(steps.indexOf(compiler)).toBeGreaterThan( + steps.findIndex((step) => step.wait?.includes('localization-extraction')) + ) }) -it('requires compiler success and joined planning before publishing shards and admitting tests', () => { +it('requires physical preflight success before publishing shards and admitting consumers', () => { const join = steps.findIndex((step) => step.wait === 'unit-plan') const upload = steps.findIndex((step) => step.uses === 'actions/upload-artifact@v7') expect(join).toBeGreaterThan(steps.indexOf(compiler)) expect(upload).toBeGreaterThan(join) expect(steps[upload]['continue-on-error']).toBeUndefined() expect(steps[upload].with.name).toBe('unit-selection-attempt-${{ github.run_attempt }}') - expect(typecheck.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}') - expect(workflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + expect(preflight.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}') + expect(workflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}') for (const job of ['test', 'package', 'package_windows']) { - expect(workflow.jobs[job].needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) + expect(workflow.jobs[job].needs).toEqual(['code_paths', 'preflight']) } - expect(workflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") - expect(workflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") - expect(workflow.jobs.verify.needs).toContain('static_analysis') - expect(workflow.jobs.verify.needs).toContain('typecheck') + for (const result of ['success', 'failure', 'cancelled', 'skipped']) { + const admitted = runInNewContext(workflow.jobs.test.if, { + cancelled: () => false, + needs: { code_paths: { outputs: { test: 'true' } }, preflight: { result } } + }) + expect(admitted, result).toBe(result === 'success') + } + const verify = workflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + expect(verify.env.PREFLIGHT).toBe('${{ needs.preflight.result }}') + expect(verify.env.PREFLIGHT_SHOULD_RUN).toBe( + "${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}" + ) + expect(verify.run).toContain('check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"') + expect(workflow.jobs.verify.needs).toContain('preflight') + expect(workflow.jobs.verify.needs).not.toContain('typecheck') }) -it.each( - [['README.md'], ['mobile/src/App.tsx'], ['cloud/package.json'], ['src/main/index.ts'], []].map( - (changed) => ({ changed }) +it.each([ + { name: 'both phases succeed', phases: ['true', 'true'], result: 'success', admitted: true }, + { name: 'static-only succeeds', phases: ['true', 'false'], result: 'success', admitted: true }, + { name: 'type-only succeeds', phases: ['false', 'true'], result: 'success', admitted: true }, + { name: 'static-only fails', phases: ['true', 'false'], result: 'failure', admitted: false }, + { name: 'type-only fails', phases: ['false', 'true'], result: 'failure', admitted: false }, + { name: 'both phases fail', phases: ['true', 'true'], result: 'failure', admitted: false }, + { name: 'preflight is cancelled', result: 'cancelled', admitted: false }, + { name: 'preflight is unfinished', result: undefined, admitted: false }, + { name: 'both phases unselected', phases: ['false', 'false'], result: 'skipped', admitted: true }, + { name: 'required static skips', phases: ['true', 'false'], result: 'skipped', admitted: false }, + { + name: 'required compiler skips', + phases: ['false', 'true'], + result: 'skipped', + admitted: false + }, + { name: 'both required phases skip', result: 'skipped', admitted: false }, + { + name: 'unselected preflight fails', + phases: ['false', 'false'], + result: 'failure', + admitted: false + }, + { + name: 'static selection missing', + phases: [undefined, 'false'], + result: 'skipped', + admitted: false + }, + { + name: 'compiler selection missing', + phases: ['false', undefined], + result: 'skipped', + admitted: false + }, + { name: 'selection unknown', phases: ['', 'false'], result: 'skipped', admitted: false }, + { + name: 'docs have no route', + phases: ['false', 'false'], + result: 'skipped', + route: 'false', + admitted: false + }, + { name: 'source has no route', result: 'success', route: 'false', admitted: false }, + { name: 'route missing', result: 'success', route: '', admitted: false }, + { name: 'path detection fails', result: 'success', pathsResult: 'failure', admitted: false }, + { name: 'path detection skips', result: 'skipped', pathsResult: 'skipped', admitted: false }, + { name: 'workflow cancelled after success', result: 'success', cancelled: true, admitted: false }, + { + name: 'workflow cancelled with unselected phases', + phases: ['false', 'false'], + result: 'skipped', + cancelled: true, + admitted: false + } +])('admits advisory E2E only after eligible preflight: $name', (scenario) => { + const [static_analysis, typecheck] = scenario.phases ?? ['true', 'true'] + expect(workflow.jobs.e2e.needs).toEqual(['code_paths', 'preflight']) + const admitted = runInNewContext(workflow.jobs.e2e.if, { + cancelled: () => scenario.cancelled ?? false, + needs: { + code_paths: { + result: scenario.pathsResult ?? 'success', + outputs: { static_analysis, typecheck, e2e_should_run: scenario.route ?? 'true' } + }, + preflight: { result: scenario.result } + } + }) + expect(admitted).toBe(scenario.admitted) +}) + +it.each(['true', 'false'])( + 'preserves advisory E2E on ready-for-review only with proven required-check reuse: %s', + (reused) => { + const scope = classifyPrJobs(['src/main/runtime/orca-runtime.ts']) + const context = { + steps: { + readiness: { outputs: { reused } }, + filter: { + outputs: Object.fromEntries( + Object.entries(scope).map(([key, value]) => [key, String(value)]) + ) + }, + e2e_filter: { outputs: { should_run: 'true' } } + } + } + const outputs = Object.fromEntries( + ['static_analysis', 'typecheck', 'e2e_should_run'].map((name) => [ + name, + String(runInNewContext(workflow.jobs.code_paths.outputs[name].slice(3, -2), context)) + ]) + ) + expect(outputs).toEqual({ + static_analysis: reused === 'true' ? 'false' : 'true', + typecheck: reused === 'true' ? 'false' : 'true', + e2e_should_run: 'true' + }) + const needs = { code_paths: { result: 'success', outputs }, preflight: { result: 'skipped' } } + expect(runInNewContext(preflight.if, { needs })).toBe(reused !== 'true') + expect(runInNewContext(workflow.jobs.e2e.if, { needs, cancelled: () => false })).toBe( + reused === 'true' + ) + expect(workflow.jobs.verify.needs).not.toContain('e2e') + } +) + +it('pins every foreground and background step to its selected phase', () => { + const staticPhase = "needs.code_paths.outputs.static_analysis == 'true'" + const typePhase = "needs.code_paths.outputs.typecheck == 'true'" + const foreground = steps.filter( + (step) => !step.background && /outputs\.(static_analysis|typecheck)/.test(step.if ?? '') ) -)('keeps desktop typechecking and planning off unrelated paths: $changed', ({ changed }) => { + expect(foreground.map((step) => [step.name ?? step.run ?? step.uses, step.if])).toEqual([ + ['Reject low-evidence patterns', staticPhase], + ['Enforce type-aware code-quality baseline', staticPhase], + [ + './.github/actions/install-mobile-dependencies', + `${staticPhase} && needs.code_paths.outputs.mobile_dependencies == 'true'` + ], + ['Enforce React Doctor on changed lines', staticPhase], + ['Check Zustand selector fan-out budget', staticPhase], + ['Check reliability gate manifest', staticPhase], + ['Enforce dead design-system classes', staticPhase], + ['Check VM runtime rollback compatibility', staticPhase], + ['Enforce max-lines ratchet', staticPhase], + ['Enforce ts-nocheck ratchet', staticPhase], + ['Enforce runtime Electron-import ratchet', staticPhase], + ['Check Node runtime pin', staticPhase], + ['Boot orcad and round-trip a terminal', staticPhase], + ['Verify the generated RPC params catalog', staticPhase], + ['Verify bundled skill guides', staticPhase], + ['Verify skill freshness manifest', staticPhase], + ['Verify localization coverage', staticPhase], + ['Guard against project-owned .d.ts in preload/shared', staticPhase], + ['Check feature wall asset budget', staticPhase], + ['Verify macOS entitlements', staticPhase], + ['Cache TypeScript incremental state', typePhase], + ['pnpm run typecheck', typePhase], + ['actions/upload-artifact@v7', typePhase] + ]) + expect( + steps + .filter((step) => step.background) + .map((step) => [step.id, step.env.PREFLIGHT_PHASE_SELECTED]) + ).toEqual([ + ['root-lint', `\${{ ${staticPhase} }}`], + ['native-code-quality', `\${{ ${staticPhase} }}`], + ['changed-code-quality', `\${{ ${staticPhase} }}`], + ['localization-extraction', `\${{ ${staticPhase} }}`], + ['localization-catalogs', `\${{ ${staticPhase} }}`], + ['unit-plan', `\${{ ${typePhase} }}`] + ]) +}) + +it.each([ + { changed: ['README.md'], static_analysis: false, typecheck: false, mobile_dependencies: false }, + { + changed: ['mobile/src/App.tsx'], + static_analysis: true, + typecheck: false, + mobile_dependencies: true + }, + { + changed: ['cloud/package.json'], + static_analysis: false, + typecheck: false, + mobile_dependencies: false + }, + { + changed: ['src/main/index.ts'], + static_analysis: true, + typecheck: true, + mobile_dependencies: false + }, + { + changed: ['mobile/src/App.tsx', 'src/main/index.ts'], + static_analysis: true, + typecheck: true, + mobile_dependencies: true + }, + { changed: [], static_analysis: true, typecheck: true, mobile_dependencies: true } +])('preserves exact phase selection for unrelated paths: $changed', ({ changed, ...expected }) => { const scope = classifyPrJobs(changed) - expect(typecheck.if).toBe("needs.code_paths.outputs.typecheck == 'true'") - expect(scope.test).toBe(scope.typecheck) - if (scope.test) { - expect(scope.static_analysis).toBe(true) + expect({ + static_analysis: scope.static_analysis, + typecheck: scope.typecheck, + mobile_dependencies: scope.mobile_dependencies + }).toEqual(expected) + const needs = { + code_paths: { + outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)])) + } + } + expect(runInNewContext(preflight.if, { needs })).toBe( + expected.static_analysis || expected.typecheck + ) + expect(runInNewContext(compiler.if, { needs })).toBe(expected.typecheck) + expect(scope.test).toBe(expected.typecheck) +}) + +it('registers successful no-op background work when a phase is unselected or already failed', () => { + for (const step of steps.filter((step) => step.background)) { + expect(step.if).toBe('!cancelled()') + expect(step.env.PREFLIGHT_PHASE_SELECTED).toContain('needs.code_paths.outputs.') + expect(step.env.PREFLIGHT_PRIOR_SUCCESS).toBe("${{ job.status == 'success' }}") + expect(step.run.split('\n')[0]).toBe( + 'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi' + ) } }) diff --git a/config/scripts/pr-ready-check-gate.test.mjs b/config/scripts/pr-ready-check-gate.test.mjs index 27e5cf99103..ade7fada96a 100644 --- a/config/scripts/pr-ready-check-gate.test.mjs +++ b/config/scripts/pr-ready-check-gate.test.mjs @@ -7,10 +7,14 @@ import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const gate = workflow.jobs.verify.steps.find((step) => step.name === 'Require successful checks') const variable = (job) => job.replaceAll('-', '_').toUpperCase() +const requiredJobs = [ + 'preflight', + ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') +] function requiredResults(shouldRun) { return Object.fromEntries( - PR_CHECK_JOBS.flatMap((job) => [ + requiredJobs.flatMap((job) => [ [variable(job), shouldRun ? 'success' : 'skipped'], [`${variable(job)}_SHOULD_RUN`, String(shouldRun)] ]) @@ -42,7 +46,7 @@ describe.skipIf(process.platform === 'win32')( }) it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => { - for (const job of PR_CHECK_JOBS) { + for (const job of requiredJobs) { for (const result of ['', 'skipped', 'failure', 'cancelled']) { const verdict = await verify({ ...requiredResults(true), [variable(job)]: result }) expect(verdict.code, `${job}: ${result}`).toBe(1) @@ -57,7 +61,7 @@ describe.skipIf(process.platform === 'win32')( }) it('rejects unexpected downstream execution when the proven plan requires skips', async () => { - for (const job of PR_CHECK_JOBS) { + for (const job of requiredJobs) { const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' }) expect(verdict.code, job).toBe(1) } diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index 19fb2407233..11f171a2b25 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -157,7 +157,11 @@ describe('ready-for-review required check reuse', () => { "github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'" ) expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}') - expect(workflow.jobs.verify.needs).toEqual(['code_paths', ...PR_CHECK_JOBS]) + expect(workflow.jobs.verify.needs).toEqual([ + 'code_paths', + 'preflight', + ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') + ]) }) it.each(['pr-test-loc.yml', 'mobile.yml'])( diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 9f841ffb1be..7bc063b1d42 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -1,4 +1,5 @@ import { existsSync, globSync, readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' import { parse } from 'yaml' import { describe, expect, it } from 'vitest' import { UNIT_EXCLUDE } from './ci-unit-files.mjs' @@ -26,6 +27,7 @@ const shellContractFiles = [ 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', 'src/main/shell-startup-feature-channel.test.ts', + 'src/main/zsh-deferred-startup-line-init.live-shell.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', @@ -53,7 +55,7 @@ const realZshUsage = describe('PR workflow parallelism', () => { it('keeps lightweight orchestration jobs on the free slim runner', () => { expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim') - expect(workflow.jobs.typecheck['runs-on']).toBe('ubuntu-24.04-arm') + expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm') expect(workflow.jobs.verify['runs-on']).toBe('ubuntu-slim') expect(prTestLocWorkflow.jobs.loc['runs-on']).toBe('ubuntu-slim') expect(releasePolicyWorkflow.jobs.enforce['runs-on']).toBe('ubuntu-slim') @@ -78,7 +80,7 @@ describe('PR workflow parallelism', () => { const installStep = sharedTest.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const staticInstall = workflow.jobs.static_analysis.steps.find( + const staticInstall = workflow.jobs.preflight.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find( @@ -90,7 +92,7 @@ describe('PR workflow parallelism', () => { expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['24', '26']) expect(workflow.jobs.test.with.runner).toBe('ubuntu-24.04-arm') - expect(workflow.jobs.static_analysis['runs-on']).toBe('ubuntu-24.04-arm') + expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm') expect(sharedTest['runs-on']).toBe('${{ inputs.runner }}') expect(unitTestWorkflow.on.workflow_call.inputs.runner.default).toBe('ubuntu-latest') expect(nodeNextWorkflow.jobs.test.with.runner).toBeUndefined() @@ -120,7 +122,7 @@ describe('PR workflow parallelism', () => { } expect(staticInstall.with['native-runtime']).toBe('node') expect(staticInstall.with['node-version']).toBe('24') - expect(workflow.jobs.test.needs).toContain('static_analysis') + expect(workflow.jobs.test.needs).toContain('preflight') expect(workflow.jobs.test_native_cache).toBeUndefined() expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node') expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}') @@ -285,11 +287,12 @@ describe('PR workflow parallelism', () => { expect(pnpmIndex).toBeLessThan(nodeIndex) expect(pnpmIndex).toBeLessThan(requestedNodeIndex) const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1] - expect(packageManagerVersion).toBe('12.0.0') + expect(packageManagerVersion).toBe('12.8.1') expect(steps[pnpmIndex].uses).toBe('pnpm/setup@v2') expect(steps[pnpmIndex].with.version).toBeUndefined() expect(steps[pnpmIndex].with.install).toBe(false) - const saveOutsidePrs = "${{ github.event_name != 'pull_request' && 'pnpm' || '' }}" + const saveOutsidePrs = + "${{ github.event_name != 'pull_request' && inputs.cache-pnpm-store != 'false' && steps.pnpm-store-mode.outputs.lookup-only != 'true' && 'pnpm' || '' }}" expect(steps[nodeIndex].with.cache).toBe(saveOutsidePrs) expect(steps[nodeIndex].if).toBe("inputs.node-version == ''") expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''") @@ -304,7 +307,7 @@ describe('PR workflow parallelism', () => { ) expect(steps[restoreIndex].uses).toBe('actions/cache/restore@v5') expect(steps[restoreIndex].if).toBe( - "github.event_name == 'pull_request' && (runner.os != 'Windows' || runner.arch != 'X64' || !contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml'))" + "github.event_name == 'pull_request' && inputs.cache-pnpm-store != 'false' && !((runner.os == 'Linux' || runner.os == 'macOS') && (runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml') && (runner.os != 'Windows' || !(runner.arch == 'X64' && contains(inputs.cache-dependency-path, 'mobile/pnpm-lock.yaml')) && !((runner.arch == 'X64' || runner.arch == 'ARM64') && inputs.cache-dependency-path == 'pnpm-lock.yaml'))" ) }) @@ -346,11 +349,11 @@ describe('PR workflow parallelism', () => { (step) => step.uses === './.github/actions/install-node-dependencies' ) - for (const jobName of ['typecheck', 'git_compatibility']) { + for (const jobName of ['git_compatibility']) { expect(installFor(jobName).with, jobName).toBeUndefined() } expect(installFor('xterm_patch_sync')).toBeUndefined() - expect(installFor('static_analysis').with['native-runtime']).toBe('node') + expect(installFor('preflight').with['native-runtime']).toBe('node') expect(installFor('shell_contracts').with['native-runtime']).toBe('node') expect(sharedTestInstall.with['native-runtime']).toBe('node') expect(installFor('package').with['native-runtime']).toBe('electron') @@ -476,7 +479,7 @@ describe('PR workflow parallelism', () => { }) it('reuses TypeScript incremental state across typecheck runs', () => { - const steps = workflow.jobs.typecheck.steps + const steps = workflow.jobs.preflight.steps const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state') const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck') @@ -514,6 +517,21 @@ describe('PR workflow parallelism', () => { const evidence = workflow.jobs.unit_selection_evidence expect(evidence.uses).toBe('./.github/workflows/unit-selection-evidence.yml') expect(evidence.needs).toEqual(['test']) + for (const [result, cancelled, expected] of [ + ['success', false, true], + ['failure', false, true], + ['skipped', false, false], + ['cancelled', false, false], + ['success', true, false], + ['failure', true, false] + ]) { + expect( + runInNewContext(evidence.if.slice(3, -2), { + cancelled: () => cancelled, + needs: { test: { result } } + }) + ).toBe(expected) + } expect(workflow.jobs.verify.needs).not.toContain('unit_selection_evidence') expect(unitTestWorkflow.jobs.selection_evidence).toBeUndefined() const evidenceWorkflow = parse( @@ -526,8 +544,7 @@ describe('PR workflow parallelism', () => { it('keeps verify as the aggregate required check', () => { expect(workflow.jobs.verify.needs).toEqual([ 'code_paths', - 'static_analysis', - 'typecheck', + 'preflight', 'git_compatibility', 'codex_index_heal_contract', 'xterm_patch_sync', diff --git a/config/scripts/readme-downloads-badge.test.mjs b/config/scripts/readme-downloads-badge.test.mjs new file mode 100644 index 00000000000..736ccbbd9b2 --- /dev/null +++ b/config/scripts/readme-downloads-badge.test.mjs @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { countReleaseDownloads } from '../../.github/scripts/render-readme-downloads-badge.mjs' + +const asset = (downloads) => ({ download_count: downloads }) + +describe('README downloads badge', () => { + it('counts published app releases and skips drafts and agent state rules releases', () => { + expect( + countReleaseDownloads([ + { tag_name: 'v1.4.1', draft: false, assets: [asset(10), asset(5)] }, + { tag_name: 'v1.4.2-rc.0', draft: false, assets: [asset(3)] }, + { tag_name: 'mobile-v0.0.1', draft: false, assets: [asset(2)] }, + { tag_name: 'v1.4.3', draft: true, assets: [asset(100)] }, + { tag_name: 'agent-state-rules-engine-1-next', draft: false, assets: [asset(9000)] }, + { tag_name: 'agent-state-rules-engine-1-stable', draft: false, assets: [asset(9000)] } + ]) + ).toBe(20) + }) +}) diff --git a/config/scripts/rebuild-native-deps-node-pty.test.mjs b/config/scripts/rebuild-native-deps-node-pty.test.mjs index a9ec246dd95..d1153c5f20f 100644 --- a/config/scripts/rebuild-native-deps-node-pty.test.mjs +++ b/config/scripts/rebuild-native-deps-node-pty.test.mjs @@ -22,6 +22,51 @@ import { } from './rebuild-native-deps-test-fixtures.mjs' describe('rebuild-native-deps patched node-pty rebuild', () => { + it.skipIf(process.platform !== 'win32')( + 'passes the Windows tracking default and explicit overrides to forced Electron rebuilds', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir) + writeFakeWindowsProcessTree(projectDir) + writeFakeNodePtyConptyPayload(projectDir, process.arch) + + const env = { + ORCA_REBUILD_TEST_LOG: rebuildLogPath, + npm_config_platform: 'win32', + npm_config_arch: process.arch + } + for (const override of [ + {}, + { TrackFileAccess: 'true' }, + { trackfileaccess: 'true' }, + { tRaCkFiLeAcCeSs: 'false' } + ]) { + const result = runRebuildScript(projectDir, { ...env, ...override }) + expect(result.status, result.stderr).toBe(0) + } + + const calls = readFileSync(rebuildLogPath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(calls.map((call) => call.trackFileAccess)).toEqual([ + 'false', + 'true', + 'true', + 'false' + ]) + expect(calls.every((call) => call.force)).toBe(true) + } finally { + removeTreeSync(projectDir) + } + } + ) + it.skipIf(process.platform !== 'win32')( 'repairs a missing ConPTY runtime before probing without recompiling node-pty', () => { diff --git a/config/scripts/rebuild-native-deps-test-fixtures.mjs b/config/scripts/rebuild-native-deps-test-fixtures.mjs index 333c8f939ef..83223b63042 100644 --- a/config/scripts/rebuild-native-deps-test-fixtures.mjs +++ b/config/scripts/rebuild-native-deps-test-fixtures.mjs @@ -110,7 +110,7 @@ export function writeWindowsProcessTreePatchFile(projectDir) { export function mkTempProject() { const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-')) mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) - copyFileSync(sourceScriptPath, join(projectDir, 'config', 'scripts', 'rebuild-native-deps.mjs')) + copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts')) copyScriptWithLocalModules(sourceInstallScriptPath, join(projectDir, 'config', 'scripts')) copyScriptWithLocalModules(sourceNodePtyJobOwnershipPath, join(projectDir, 'config', 'scripts')) copyFileSync( @@ -134,7 +134,8 @@ export function runRebuildScript(projectDir, extraEnv = {}, args = []) { for (const key of Object.keys(env)) { if ( key.toLowerCase() === 'orca_strict_electron_install' || - key.toLowerCase() === 'npm_lifecycle_event' + key.toLowerCase() === 'npm_lifecycle_event' || + key.toLowerCase() === 'trackfileaccess' ) { delete env[key] } @@ -286,6 +287,7 @@ export async function rebuild(options) {${emitAddon} electronVersion: options.electronVersion, force: options.force, ignoreModules: options.ignoreModules, + trackFileAccess: process.env.TrackFileAccess ?? null, onlyModules: options.onlyModules, platform: options.platform }) + '\\n' diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 68de5d49e21..e565eeca97e 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -26,6 +26,7 @@ import { stageWindowsProcessTreeNodeAddonApiHeaders, windowsProcessTreeAddonPath } from './windows-process-tree-gyp-rebuild.mjs' +import { disableMsbuildFileTrackingOnWindows } from './msbuild-file-tracking.mjs' import { copyFileSync, existsSync, @@ -162,6 +163,7 @@ try { console.warn('[rebuild] Repaired the un-applied windows-process-tree command-line patch.') } } + disableMsbuildFileTrackingOnWindows() await rebuild({ buildPath: projectDir, electronVersion, diff --git a/config/scripts/release-e2e-dispatch-contract.test.mjs b/config/scripts/release-e2e-dispatch-contract.test.mjs index 2277cd399a4..d939a6231ef 100644 --- a/config/scripts/release-e2e-dispatch-contract.test.mjs +++ b/config/scripts/release-e2e-dispatch-contract.test.mjs @@ -28,7 +28,9 @@ describe('release E2E dispatch contract', () => { expect(releaseWorkflow.jobs.e2e).toBeUndefined() expect(dispatchJob.needs).toEqual(['cut', 'publish-release']) - expect(dispatchJob.if).toBe("${{ needs.cut.outputs.tag != '' }}") + expect(dispatchJob.if).toBe( + "${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }}" + ) expect(dispatchJob.permissions.actions).toBe('write') expect(dispatchStep.env.TAG).toBe('${{ needs.cut.outputs.tag }}') expect(dispatchStep.run).toContain('gh workflow run e2e.yml') diff --git a/config/scripts/release-policy.mjs b/config/scripts/release-policy.mjs index 4733e79f2b4..e9ef5d8264e 100644 --- a/config/scripts/release-policy.mjs +++ b/config/scripts/release-policy.mjs @@ -1,15 +1,19 @@ // Enforced by .github/workflows/release-policy.yml on release events. +import { + DESKTOP_RC_TAG as RC_TAG, + DESKTOP_STABLE_TAG as STABLE_TAG, + MOBILE_TAG, + isAgentStateRulesTag +} from './release-tag-patterns.mjs' + const BOT_LOGIN = 'github-actions[bot]' const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com' -const NUMBER = '(?:0|[1-9][0-9]*)' -const VERSION = `${NUMBER}\\.${NUMBER}\\.${NUMBER}` -const STABLE_TAG = new RegExp(`^v${VERSION}$`) -const RC_TAG = new RegExp(`^v${VERSION}-rc\\.${NUMBER}(?:\\.[0-9A-Za-z]+)?$`) -const MOBILE_TAG = new RegExp(`^mobile(?:-android)?-v${VERSION}$`) +// Why agent state rules here: their publish workflow is a bot author, and as a prerelease the +// release can never become Latest, which the app updater follows. export function isPrereleaseTag(tag) { - return RC_TAG.test(tag) || MOBILE_TAG.test(tag) + return RC_TAG.test(tag) || MOBILE_TAG.test(tag) || isAgentStateRulesTag(tag) } export function compareStableTags(left, right) { diff --git a/config/scripts/release-policy.test.mjs b/config/scripts/release-policy.test.mjs index 6478f6bb0c4..6d36717d785 100644 --- a/config/scripts/release-policy.test.mjs +++ b/config/scripts/release-policy.test.mjs @@ -147,4 +147,41 @@ describe('release policy', () => { await expect(restoreLatestStable(github, repoRef)).resolves.toBe('v1.4.100') }) + + it('keeps a bot-published agent state rules release as a prerelease, off Latest', async () => { + const rules = release('agent-state-rules-engine-1-next', 'github-actions[bot]') + const stable = release('v1.4.214', 'github-actions[bot]') + const github = createGithub({ + releases: [rules, stable], + tags: { 'v1.4.214': {} } + }) + + await run(github, rules, 'published') + + expect(github.rest.repos.deleteRelease).not.toHaveBeenCalled() + expect(github.rest.git.deleteRef).not.toHaveBeenCalled() + expect(github.rest.repos.updateRelease).toHaveBeenCalledWith({ + ...repoRef, + release_id: rules.id, + prerelease: true, + make_latest: 'false' + }) + expect(github.rest.repos.updateRelease).toHaveBeenLastCalledWith({ + ...repoRef, + release_id: stable.id, + make_latest: 'true' + }) + }) + + it('deletes an agent state rules release a person published', async () => { + const rules = release('agent-state-rules-engine-1-stable', 'someone', { prerelease: true }) + const github = createGithub({ releases: [rules] }) + + await run(github, rules, 'published') + + expect(github.rest.repos.deleteRelease).toHaveBeenCalledWith({ + ...repoRef, + release_id: rules.id + }) + }) }) diff --git a/config/scripts/release-tag-pattern-census.test.mjs b/config/scripts/release-tag-pattern-census.test.mjs new file mode 100644 index 00000000000..b42a424a36f --- /dev/null +++ b/config/scripts/release-tag-pattern-census.test.mjs @@ -0,0 +1,184 @@ +// Census: every release-triggered workflow and every script that lists this repo's releases +// classifies tags through release-tag-patterns.mjs, so a new tag family such as the agent state +// rules cannot reach a desktop-only path through one that forgot it. +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { + DESKTOP_RC_TAG, + DESKTOP_STABLE_TAG, + DESKTOP_STABLE_TAG_SHELL_PATTERN, + agentStateRulesTag, + isAgentStateRulesTag +} from './release-tag-patterns.mjs' + +const WORKFLOWS_DIR = '.github/workflows' +const SCRIPT_DIRS = ['config/scripts', '.github/scripts'] +const SHARED_IMPORT = /from '[^']*release-tag-patterns\.mjs'/ +const LISTS_RELEASES = /\/releases\?|listReleases|releases\.atom|'release',\s*'list'/ + +const RULES_TAGS = [agentStateRulesTag(1, 'next'), agentStateRulesTag(1, 'stable')] +const rulesRelease = (tag, extra = {}) => ({ + tag_name: tag, + name: tag, + draft: false, + prerelease: true, + author: { login: 'github-actions[bot]' }, + assets: [{ name: 'agent-state-rules.json', download_count: 5 }], + ...extra +}) + +function read(path) { + return readFileSync(path, 'utf8') +} + +function importsSharedPatterns(path) { + return SHARED_IMPORT.test(read(path)) +} + +function isReleaseTriggered(workflow) { + const on = workflow.on + if (typeof on === 'string') { + return on === 'release' + } + if (Array.isArray(on)) { + return on.includes('release') + } + return typeof on === 'object' && on !== null && 'release' in on +} + +function releaseTriggeredWorkflows() { + return readdirSync(WORKFLOWS_DIR) + .filter((name) => /\.ya?ml$/.test(name)) + .map((name) => join(WORKFLOWS_DIR, name)) + .filter((path) => isReleaseTriggered(parse(read(path)))) +} + +function scriptsNamedIn(text) { + return [...text.matchAll(/((?:config|\.github)\/scripts\/[\w.-]+\.mjs)/g)].map( + (match) => match[1] + ) +} + +function releaseListingScripts() { + return SCRIPT_DIRS.flatMap((dir) => + readdirSync(dir) + .filter((name) => name.endsWith('.mjs') && !name.includes('.test.')) + .map((name) => join(dir, name)) + ).filter((path) => LISTS_RELEASES.test(read(path))) +} + +/** + * Scripts that list releases yet admit only desktop tags by their own shape. Each entry proves, + * by calling the script, that a rules release never passes; a new listing script must import the + * shared patterns or add a proof here. + */ +const DESKTOP_ONLY_PROOFS = { + 'config/scripts/create-draft-release.mjs': async () => { + const { latestPreviousPublishedDesktopReleaseTag } = await import('./create-draft-release.mjs') + const releases = [ + ...RULES_TAGS.map((tag) => rulesRelease(tag)), + { tag_name: 'v1.4.1', draft: false } + ] + expect(latestPreviousPublishedDesktopReleaseTag(releases, 'v1.4.2')).toBe('v1.4.1') + }, + 'config/scripts/publish-complete-draft-releases.mjs': async () => { + const { isReleaseCutDraft } = await import('./publish-complete-draft-releases.mjs') + for (const tag of RULES_TAGS) { + expect(isReleaseCutDraft(rulesRelease(tag, { draft: true }))).toBe(false) + } + }, + 'config/scripts/latest-stable-release.mjs': async () => { + const { latestStableDesktopReleaseTag } = await import('./latest-stable-release.mjs') + const releases = [ + ...RULES_TAGS.map((tag) => rulesRelease(tag, { prerelease: false })), + { tag_name: 'v1.4.1' } + ] + expect(latestStableDesktopReleaseTag(releases)).toBe('v1.4.1') + }, + 'config/scripts/assert-github-release-is-draft.mjs': async () => { + const { matchingDesktopReleases } = await import('./assert-github-release-is-draft.mjs') + expect( + matchingDesktopReleases( + RULES_TAGS.map((tag) => rulesRelease(tag)), + 'v1.4.1' + ) + ).toEqual([]) + }, + 'config/scripts/verify-release-required-assets.mjs': async () => { + const { verifyRequiredReleaseAssets } = await import('./verify-release-required-assets.mjs') + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(RULES_TAGS.map((tag) => rulesRelease(tag))))) + ) + await expect( + verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag: 'v1.4.1', token: '' }) + ).rejects.toThrow('was not found') + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('release tag pattern census', () => { + it('finds the release-triggered workflows and release-listing scripts it guards', () => { + expect(releaseTriggeredWorkflows().length).toBeGreaterThan(0) + expect(releaseListingScripts().length).toBeGreaterThan(0) + }) + + it.each(releaseTriggeredWorkflows())('%s classifies tags through the shared patterns', (path) => { + const text = read(path) + const viaScript = scriptsNamedIn(text).some(importsSharedPatterns) + const viaShellPattern = text.includes(DESKTOP_STABLE_TAG_SHELL_PATTERN) + expect( + viaScript || viaShellPattern, + `${path} names no script importing release-tag-patterns.mjs and embeds no shared pattern` + ).toBe(true) + }) + + it.each(releaseTriggeredWorkflows())( + '%s checks out the shared patterns beside its script', + (path) => { + const workflow = parse(read(path)) + const sparse = Object.values(workflow.jobs) + .flatMap((job) => job.steps ?? []) + .map((step) => step.with?.['sparse-checkout']) + .filter((value) => typeof value === 'string') + for (const paths of sparse) { + if (scriptsNamedIn(paths).some(importsSharedPatterns)) { + expect(paths).toContain('config/scripts/release-tag-patterns.mjs') + } + } + } + ) + + it.each(releaseListingScripts())( + '%s imports the shared patterns or proves it admits only desktop tags', + async (path) => { + if (importsSharedPatterns(path)) { + return + } + const proof = DESKTOP_ONLY_PROOFS[path] + expect( + proof, + `${path} lists releases: import release-tag-patterns.mjs or add a proof` + ).toBeDefined() + await proof() + } + ) + + it('keeps no proof for a script that no longer lists releases', () => { + const listing = new Set(releaseListingScripts()) + expect(Object.keys(DESKTOP_ONLY_PROOFS).filter((path) => !listing.has(path))).toEqual([]) + }) + + it('never classifies an agent state rules tag as a desktop release', () => { + for (const tag of RULES_TAGS) { + expect(isAgentStateRulesTag(tag)).toBe(true) + expect(DESKTOP_STABLE_TAG.test(tag) || DESKTOP_RC_TAG.test(tag)).toBe(false) + } + expect(() => agentStateRulesTag(1, 'beta')).toThrow() + }) +}) diff --git a/config/scripts/release-tag-patterns.mjs b/config/scripts/release-tag-patterns.mjs new file mode 100644 index 00000000000..43bae852b5f --- /dev/null +++ b/config/scripts/release-tag-patterns.mjs @@ -0,0 +1,30 @@ +// The tag families published as GitHub releases of this repo. Every release-triggered workflow and +// every script that lists releases classifies tags through this file, so a new family (like the +// agent state rules) is excluded or admitted in one place; release-tag-pattern-census.test.mjs +// enforces that. + +const NUMBER = '(?:0|[1-9][0-9]*)' +const VERSION = `${NUMBER}\\.${NUMBER}\\.${NUMBER}` + +/** The stable desktop tag as a bash `[[ =~ ]]` pattern, for workflows that gate inline. */ +export const DESKTOP_STABLE_TAG_SHELL_PATTERN = + '^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$' + +export const DESKTOP_STABLE_TAG = new RegExp(DESKTOP_STABLE_TAG_SHELL_PATTERN) +export const DESKTOP_RC_TAG = new RegExp(`^v${VERSION}-rc\\.${NUMBER}(?:\\.[0-9A-Za-z]+)?$`) +export const MOBILE_TAG = new RegExp(`^mobile(?:-android)?-v${VERSION}$`) + +/** The agent state rules bundle, one release per rules engine and channel, updated in place. */ +const AGENT_STATE_RULES_TAG = /^agent-state-rules-engine-[1-9][0-9]*-(?:next|stable)$/ + +export function isAgentStateRulesTag(tag) { + return AGENT_STATE_RULES_TAG.test(tag) +} + +export function agentStateRulesTag(engineVersion, channel) { + const tag = `agent-state-rules-engine-${engineVersion}-${channel}` + if (!isAgentStateRulesTag(tag)) { + throw new Error(`not an agent state rules tag: ${tag}`) + } + return tag +} diff --git a/config/scripts/run-terminal-ibus-hangul-e2e.mjs b/config/scripts/run-terminal-ibus-hangul-e2e.mjs index dead1c42f13..57d97cc2fb4 100644 --- a/config/scripts/run-terminal-ibus-hangul-e2e.mjs +++ b/config/scripts/run-terminal-ibus-hangul-e2e.mjs @@ -178,7 +178,10 @@ async function runInsideSession(evidenceDir) { ['--nested', '--wayland', `--wayland-display=${process.env.WAYLAND_DISPLAY}`], { detached: true, - env: process.env, + env: + process.env.ORCA_E2E_WAYLAND_INPUT_DIAGNOSTICS === '1' + ? { ...process.env, WAYLAND_DEBUG: 'server' } + : process.env, stdio: ['ignore', windowManagerLogFd, windowManagerLogFd] } ) @@ -275,10 +278,9 @@ async function runInsideSession(evidenceDir) { : {}), ORCA_E2E_FORWARD_APP_LOGS: '1', ORCA_E2E_NATIVE_IBUS_HANGUL: '1', + ORCA_E2E_NATIVE_IBUS_XVFB: '1', [IME_ENGAGEMENT_RECEIPT_ENV]: receiptPath, - // Why: native IBus key injection only reaches a window the window manager - // has focused, so this run opts out of the background-launch policy. - ORCA_E2E_FOREGROUND: '1' + ORCA_BACKGROUND_LAUNCH: '1' }, stdio: 'inherit' } diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index c1d998717ff..2b23115859c 100644 --- a/config/scripts/runtime-serve-terminal-smoke.mjs +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -198,7 +198,7 @@ function resolveLaunch(userDataDir) { label: `electron (${serveEntry})`, command: override ?? 'npx', args: override ? serveArgs : ['electron', ...serveArgs], - env: {} + env: { ORCA_DEV_USER_DATA_PATH: userDataDir } } } diff --git a/config/scripts/ssh-hostile-hosts-workflow.test.mjs b/config/scripts/ssh-hostile-hosts-workflow.test.mjs index c79706ab4fd..35c2cddecc2 100644 --- a/config/scripts/ssh-hostile-hosts-workflow.test.mjs +++ b/config/scripts/ssh-hostile-hosts-workflow.test.mjs @@ -71,7 +71,7 @@ describe('SSH hostile-host workflow', () => { const job = workflow.jobs.macos_hosts expect(job.if).toContain('github.event.pull_request.draft != true') expect(job.needs).toBeUndefined() - const runners = { 'darwin-arm64': 'macos-14', 'darwin-x64': 'macos-15-intel' } + const runners = { 'darwin-arm64': 'macos-15', 'darwin-x64': 'macos-15-intel' } const macCells = HOSTILE_HOST_CELLS.filter((cell) => cell.host === 'local-sshd') expect( job.strategy.matrix.include.map(({ os, target, cell }) => ({ os, target, cell })) diff --git a/config/scripts/ssh-windows-hosts-workflow.test.mjs b/config/scripts/ssh-windows-hosts-workflow.test.mjs index c137b268c0d..3daef7cd4e2 100644 --- a/config/scripts/ssh-windows-hosts-workflow.test.mjs +++ b/config/scripts/ssh-windows-hosts-workflow.test.mjs @@ -13,6 +13,14 @@ const workflow = parse( ) const job = workflow.jobs.hosts const runStep = job.steps.find((step) => step.name?.startsWith('Run the Windows host cells')) +const provisioning = readFileSync( + join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1'), + 'utf8' +) +const capability = readFileSync( + join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1'), + 'utf8' +) const manifest = (arch) => JSON.parse( readFileSync( @@ -47,6 +55,99 @@ describe('SSH Windows-host workflow', () => { 'x64/windows-2022/preview' ]) expect(job.env).toMatchObject({ ORCA_BACKGROUND_LAUNCH: '1', ORCA_ISOLATED_SSH_CI: '1' }) + expect(job.strategy['fail-fast']).toBe(false) + expect(job['timeout-minutes']).toBe(75) + expect(runStep['timeout-minutes']).toBe(50) + }) + + it('overlaps only guarded ARM inbox capability preparation with the existing builds', () => { + const selfTestIndex = job.steps.findIndex((step) => step.name?.startsWith('Self-test')) + const prepareIndex = job.steps.findIndex((step) => step.id === 'inbox-capability') + const installIndex = job.steps.findIndex( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + const buildIndex = job.steps.findIndex((step) => step.name?.startsWith('Build this runner')) + const prebuildIndex = job.steps.findIndex( + (step) => step.uses === './.github/actions/prepare-orcad-prebuilds' + ) + const templateIndex = job.steps.findIndex((step) => step.name?.startsWith('Build the win32')) + const waitIndex = job.steps.findIndex((step) => step.wait === 'inbox-capability') + const runIndex = job.steps.indexOf(runStep) + expect([ + selfTestIndex, + prepareIndex, + installIndex, + buildIndex, + prebuildIndex, + templateIndex, + waitIndex, + runIndex + ]).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) + expect(job.steps[prepareIndex]).toMatchObject({ + background: true, + shell: 'pwsh' + }) + expect(job.steps[prepareIndex].if).toBeUndefined() + expect(job.steps[waitIndex].if).toBeUndefined() + expect(job.steps[prepareIndex].run.trim()).toMatch( + /^if\('\$\{\{ matrix\.server }}' -eq 'inbox' -and '\$\{\{ matrix\.arch }}' -eq 'arm64'\)\{[\s\S]+\}$/ + ) + expect(job.steps[prepareIndex].run).toContain('Initialize-WindowsInboxSshCapability') + expect(job.steps[prepareIndex].run).toContain('inbox-capability-preparation.json') + expect(runStep.run).toContain('-InboxPreparationReceipt $preparation') + expect(runStep.run).toContain( + "$preparation=Join-Path $receipts 'inbox-capability-preparation.json'" + ) + expect(runStep.run).toContain( + "if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){$preparation=" + ) + expect(runStep.background).toBeUndefined() + expect(job.steps.at(-1)).toMatchObject({ if: 'always()', uses: 'actions/upload-artifact@v7' }) + }) + + it('shares one capability installer without bypassing native verification or private cleanup', () => { + expect(capability.match(/Add-WindowsCapability -Online/g)).toHaveLength(1) + expect(provisioning).not.toContain('Add-WindowsCapability') + expect(provisioning).toContain(". (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1')") + expect(provisioning).toContain('Install-WindowsInboxSshCapability $Arch $report') + const install = capability.slice( + capability.indexOf('function Install-WindowsInboxSshCapability'), + capability.indexOf('function Initialize-WindowsInboxSshCapability') + ) + const mutation = install.indexOf('Add-WindowsCapability') + expect(install.indexOf('Assert-IsolatedWindowsSshCi')).toBeLessThan(mutation) + expect(install.indexOf('Assert-WindowsSshGlobalServerDormant')).toBeLessThan(mutation) + expect(install.lastIndexOf('Assert-WindowsSshGlobalServerDormant')).toBeGreaterThan(mutation) + expect(install.indexOf('Assert-WindowsSshStockShell')).toBeLessThan(mutation) + expect(install.lastIndexOf('Assert-WindowsSshStockShell')).toBeGreaterThan(mutation) + for (const check of [ + '(Machine $path) -ne $target.machine', + 'Get-AuthenticodeSignature -LiteralPath $path', + 'Inbox native input Microsoft signature invalid', + "Write-Stage 'host-cell-probe-start'", + "Write-Stage 'cleanup-default-shell-start'", + "Write-Stage 'cleanup-service-stop-delete-start'" + ]) { + expect(provisioning).toContain(check) + } + }) + + it('keeps preparation provenance separate from the oracle current capability state', () => { + for (const [field, environment] of [ + ['sourceSha', 'GITHUB_SHA'], + ['runId', 'GITHUB_RUN_ID'], + ['runAttempt', 'GITHUB_RUN_ATTEMPT'], + ['runnerName', 'RUNNER_NAME'], + ['imageVersion', 'ImageVersion'] + ]) { + expect(capability).toContain(`${field}=$env:${environment}`) + expect(provisioning).toContain(`$preparation.${field} -ne $env:${environment}`) + } + expect(provisioning).toContain("$preparation.status -ne 'passed'") + expect(provisioning).toContain('$preparation.arch -ne $Arch') + expect(provisioning).toContain('$report.inboxCapabilityPreparation=$preparation') + expect(capability).toContain('$Report.inboxCapabilityInitialState=[string]$capability.State') + expect(capability).toContain("$report.status='failed';$report.error=$_.Exception.Message;throw") }) it('fetches the preview release its hash manifests pin', () => { diff --git a/config/scripts/stable-release-tag-selection-budget.test.mjs b/config/scripts/stable-release-tag-selection-budget.test.mjs new file mode 100644 index 00000000000..1571b0b9a32 --- /dev/null +++ b/config/scripts/stable-release-tag-selection-budget.test.mjs @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { selectLatestStableReleaseTag } from './stable-release-tags.mjs' + +function expectedLatest(tags) { + let latest = null + let latestParts = [] + for (const tag of tags) { + const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag) + if (!match) { + continue + } + const parts = match.slice(1).map((part) => { + const value = Number.parseInt(part, 10) + return Number.isFinite(value) ? value : 0 + }) + let comparison = 0 + for (let index = 0; index < 3 && comparison === 0; index++) { + comparison = parts[index] - (latestParts[index] ?? 0) + } + if (latest === null || comparison >= 0) { + latest = tag + latestParts = parts + } + } + return latest +} + +function measurePartMaps(tags) { + const nativeMap = Array.prototype.map + let partMaps = 0 + Array.prototype.map = function (callback, thisArg) { + partMaps++ + return nativeMap.call(this, callback, thisArg) + } + let result + try { + result = selectLatestStableReleaseTag(tags) + } finally { + Array.prototype.map = nativeMap + } + return { result, partMaps } +} + +describe('stable release tag selection work', () => { + it.each([0, 1, 12, 128, 1000])('bounds numeric parsing for %i Git tag strings', (count) => { + const tags = Array.from( + { length: count }, + (_, index) => `v1.${(index * 37) % 17}.${(index * 101) % (count + 1)}` + ) + const input = [...tags] + const expected = expectedLatest(tags) + const measured = measurePartMaps(Object.freeze(tags)) + expect(measured.result).toBe(expected) + expect(tags).toEqual(input) + // Each unchanged comparator converts the two triples through four maps. + expect(measured.partMaps).toBeLessThanOrEqual(4 * Math.max(0, count - 1)) + }) + + it('preserves tie spelling, numeric fallback and invalid-tag admission', () => { + const cases = [ + { tags: [], expected: null }, + { tags: ['nightly', 'mobile-v1.2.3', 'v1.2.3-rc.1'], expected: null }, + { tags: ['v0001.4.003', 'v1.04.3'], expected: 'v1.04.3' }, + { tags: ['v1.04.3', 'v0001.4.003'], expected: 'v0001.4.003' }, + { tags: ['v1.2.3', 'v1.2.3\n'], expected: 'v1.2.3' }, + { tags: ['v1.2.3\r\n', 'v0.0.1'], expected: 'v0.0.1' }, + { tags: [`v${'9'.repeat(400)}.1.2`, 'v0.1.2'], expected: 'v0.1.2' }, + { + tags: ['v9007199254740993.1.0', 'v9007199254740992.1.0'], + expected: 'v9007199254740992.1.0' + } + ] + for (const { tags, expected } of cases) { + const input = [...tags] + expect(selectLatestStableReleaseTag(Object.freeze(tags))).toBe(expected) + expect(tags).toEqual(input) + } + const sparse = [] + sparse.length = 12 + sparse[3] = 'v1.2.3' + sparse[8] = 'v2.0.0' + expect(selectLatestStableReleaseTag(Object.freeze(sparse))).toBe('v2.0.0') + }) + + it('selects the same latest spelling across ordinary version namespaces and repeated calls', () => { + let randomState = 673151 + const next = () => { + randomState = (randomState * 1664525 + 1013904223) >>> 0 + return randomState + } + for (let seed = 0; seed < 256; seed++) { + const tags = [] + for (let index = 0, count = next() % 129; index < count; index++) { + const triple = [next() % 13, next() % 17, next() % 257] + const prefix = next() % 8 === 0 ? 'mobile-v' : 'v' + const suffix = next() % 9 === 0 ? '-rc.1' : '' + tags.push(`${prefix}${triple.join('.')}${suffix}`) + if (index % 11 === 0) { + tags.push(tags.at(-1)) + } + } + const input = [...tags] + const expected = expectedLatest(tags) + expect(selectLatestStableReleaseTag(tags)).toBe(expected) + expect(tags).toEqual(input) + tags.push('v99.99.99') + expect(selectLatestStableReleaseTag(tags)).toBe('v99.99.99') + } + }) +}) diff --git a/config/scripts/stable-release-tags.mjs b/config/scripts/stable-release-tags.mjs index 2650f23eaa8..09032370dc7 100644 --- a/config/scripts/stable-release-tags.mjs +++ b/config/scripts/stable-release-tags.mjs @@ -21,10 +21,14 @@ export function compareReleaseTags(a, b) { /** @param {string[]} tags @returns {string | null} */ export function selectLatestStableReleaseTag(tags) { - return ( - tags - .filter((tag) => STABLE_DESKTOP_RELEASE_TAG.test(tag)) - .sort(compareReleaseTags) - .at(-1) ?? null - ) + let latest = null + for (const tag of tags) { + if ( + STABLE_DESKTOP_RELEASE_TAG.test(tag) && + (latest === null || compareReleaseTags(latest, tag) <= 0) + ) { + latest = tag + } + } + return latest } diff --git a/config/scripts/telemetry-bundle-constant-patterns.mjs b/config/scripts/telemetry-bundle-constant-patterns.mjs index 87c2ae43cf7..f0e9d53ed1c 100644 --- a/config/scripts/telemetry-bundle-constant-patterns.mjs +++ b/config/scripts/telemetry-bundle-constant-patterns.mjs @@ -2,5 +2,6 @@ // them, but the injected identity and key remain adjacent in the declaration. export const BUILD_IDENTITY_RE = /\b(?:const|let|var)\s+BUILD_IDENTITY\s*=\s*["`](rc|stable)["`]/ export const WRITE_KEY_RE = /\b(?:const|let|var)\s+WRITE_KEY\s*=\s*["`](phc_[A-Za-z0-9_-]+)["`]/ +// Why the `,` alternative: the identity can be a later declarator in a shared `var` (`var a=!0,b=\`stable\``). export const MINIFIED_TELEMETRY_RE = - /\b(?:const|let|var)\s+[$\w]+\s*=\s*["'`](rc|stable)["'`][\s\S]{0,200}?[,$]\s*[$\w]+\s*=\s*["'`](phc_[A-Za-z0-9_-]+)["'`]/ + /(?:\b(?:const|let|var)\s+|,\s*)[$\w]+\s*=\s*["'`](rc|stable)["'`][\s\S]{0,200}?[,$]\s*[$\w]+\s*=\s*["'`](phc_[A-Za-z0-9_-]+)["'`]/ diff --git a/config/scripts/telemetry-bundle-constant-patterns.test.mjs b/config/scripts/telemetry-bundle-constant-patterns.test.mjs index ca87e3ee971..57f8e0c7217 100644 --- a/config/scripts/telemetry-bundle-constant-patterns.test.mjs +++ b/config/scripts/telemetry-bundle-constant-patterns.test.mjs @@ -22,4 +22,16 @@ describe('telemetry bundle constant patterns', () => { const bundle = 'var dde=`stable`,fde=`phc_example-key_123`,pde=(dde===`stable`)' expect(bundle).toMatch(MINIFIED_TELEMETRY_RE) }) + + it('accepts the identity as a later declarator in a shared declaration', () => { + const bundle = 'var wpe=!0,Tpe=`stable`,Epe=`phc_example-key_123`,Dpe=(Tpe===`stable`)' + expect(MINIFIED_TELEMETRY_RE.exec(bundle)?.slice(1, 3)).toEqual([ + 'stable', + 'phc_example-key_123' + ]) + }) + + it('rejects a minified identity with no adjacent write key', () => { + expect('var a=!0,b=`stable`,c=null').not.toMatch(MINIFIED_TELEMETRY_RE) + }) }) diff --git a/config/scripts/terminal-ime-e2e-workflow.test.mjs b/config/scripts/terminal-ime-e2e-workflow.test.mjs index cb12839a2af..4bf684d8984 100644 --- a/config/scripts/terminal-ime-e2e-workflow.test.mjs +++ b/config/scripts/terminal-ime-e2e-workflow.test.mjs @@ -12,8 +12,13 @@ describe('terminal IME e2e workflow', () => { it('runs only on schedule or manual dispatch', () => { expect(workflow.on.pull_request).toBeUndefined() - expect(workflow.on.workflow_dispatch).toBeNull() + expect(workflow.on.workflow_dispatch.inputs.diagnose_wayland_input).toMatchObject({ + required: false, + type: 'boolean', + default: false + }) expect(workflow.on.schedule).toEqual([{ cron: '30 9 * * *' }]) + expect(workflow.env.ORCA_BACKGROUND_LAUNCH).toBe('1') }) it('installs native IBus Hangul and X11 input tools', () => { diff --git a/config/scripts/terminal-ime-engagement-receipt.mjs b/config/scripts/terminal-ime-engagement-receipt.mjs index 8f0732908c1..5dcb91d599a 100644 --- a/config/scripts/terminal-ime-engagement-receipt.mjs +++ b/config/scripts/terminal-ime-engagement-receipt.mjs @@ -14,7 +14,8 @@ export const IME_ENGAGEMENT_RECEIPT_ENV = 'ORCA_E2E_IME_ENGAGEMENT_RECEIPT' export const EXPECTED_NATIVE_IME_TESTS = [ 'forwards the issue exact-byte sequence without loss or duplication', 'forwards the issue sentence stress sequence without leaked ASCII', - 'a digit typed right after a Hangul syllable reaches the pty' + 'a digit typed right after a Hangul syllable reaches the pty', + 'confirms native Hangul notes before a deliberate Enter saves' ] function parseReceipts(text) { diff --git a/config/scripts/terminal-ime-engagement-receipt.test.mjs b/config/scripts/terminal-ime-engagement-receipt.test.mjs index 613abc2ffae..8f9bdfa47d4 100644 --- a/config/scripts/terminal-ime-engagement-receipt.test.mjs +++ b/config/scripts/terminal-ime-engagement-receipt.test.mjs @@ -4,7 +4,7 @@ import { verifyImeEngagementReceipts } from './terminal-ime-engagement-receipt.mjs' -const [firstTest, secondTest, thirdTest] = EXPECTED_NATIVE_IME_TESTS +const [firstTest, secondTest, thirdTest, notesTest] = EXPECTED_NATIVE_IME_TESTS function receipt(test, overrides = {}) { return JSON.stringify({ @@ -20,7 +20,7 @@ describe('verifyImeEngagementReceipts', () => { it('accepts a run where every expected test observed real composition', () => { expect( verifyImeEngagementReceipts( - `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n` ) ).toEqual([]) }) @@ -38,19 +38,34 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a partial run where only one test reached the engine', () => { expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n`)).toEqual([ `no engagement receipt for "${secondTest}" — it was skipped, filtered out, or renamed`, - `no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed` + `no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed`, + `no engagement receipt for "${notesTest}" — it was skipped, filtered out, or renamed` ]) }) it('requires the digit receipt even when both original native tests passed', () => { - expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n${receipt(secondTest)}\n`)).toEqual([ + expect( + verifyImeEngagementReceipts( + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n` + ) + ).toEqual([ `no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed` ]) }) + it('requires the Notes receipt even when the terminal scenarios passed', () => { + expect( + verifyImeEngagementReceipts( + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + ) + ).toEqual([ + `no engagement receipt for "${notesTest}" — it was skipped, filtered out, or renamed` + ]) + }) + it('rejects a run that typed keys but never opened a composition', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest, { compositionStart: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + `${receipt(firstTest, { compositionStart: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n` ) expect(problems).toEqual([ `"${firstTest}" recorded no compositionstart — the IME never engaged` @@ -59,7 +74,7 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a composition that produced no Hangul, which a latin passthrough would satisfy', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest, { hangulComposition: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + `${receipt(firstTest, { hangulComposition: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n` ) expect(problems).toEqual([ `"${firstTest}" recorded no Hangul composition data — the engine produced no syllables` @@ -68,7 +83,7 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a renamed test rather than counting it toward coverage', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt('some new scenario')}\n` + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n${receipt('some new scenario')}\n` ) expect(problems).toEqual([ 'unexpected engagement receipt for "some new scenario" — update EXPECTED_NATIVE_IME_TESTS' @@ -77,7 +92,7 @@ describe('verifyImeEngagementReceipts', () => { it('reports a truncated receipt rather than parsing around it', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest)}\n{"test":"trunc\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + `${receipt(firstTest)}\n{"test":"trunc\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n` ) expect(problems).toEqual(['malformed receipt line: {"test":"trunc']) }) diff --git a/config/scripts/terminal-perf-dependency-preparation.test.mjs b/config/scripts/terminal-perf-dependency-preparation.test.mjs new file mode 100644 index 00000000000..3f4c5955f7a --- /dev/null +++ b/config/scripts/terminal-perf-dependency-preparation.test.mjs @@ -0,0 +1,116 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { runInNewContext } from 'node:vm' +import { parse } from 'yaml' +import { expect, it } from 'vitest' +import { runProcessSync } from './script-child-process.mjs' + +const workflow = parse(readFileSync('.github/workflows/terminal-perf.yml', 'utf8')) +const steps = workflow.jobs['terminal-perf'].steps +const selector = steps.find((step) => step.id === 'install-mode') +const script = selector.run.trim().match(/^node <<'NODE'\n([\s\S]*)\nNODE$/)[1] +const supportedAction = readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8') +const supportedManifest = { + engines: { node: '24' }, + packageManager: 'pnpm@12.8.1', + scripts: { postinstall: 'node config/scripts/rebuild-native-deps.mjs' } +} + +function select(options = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-terminal-preparation-')) + const output = join(directory, 'output') + try { + writeFileSync( + join(directory, 'package.json'), + JSON.stringify(options.manifest ?? supportedManifest) + ) + for (const [file, content] of [ + ['.github/actions/install-node-dependencies/action.yml', options.action ?? supportedAction], + ['.github/actions/prepare-native-runtime/action.yml', 'runs: {}'], + ['config/scripts/ensure-native-runtime.mjs', ''] + ]) { + if (options.missing === file) { + continue + } + const path = join(directory, file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + } + const result = runProcessSync({ + program: process.execPath, + args: ['-e', script], + cwd: directory, + env: { + ...process.env, + GITHUB_OUTPUT: output, + RUNNER_KIND: options.kind ?? 'github-hosted', + JOB_CONTAINER: options.container ?? '', + RUNNER_OS: options.os ?? 'Linux', + RUNNER_ARCH: options.arch ?? 'X64' + } + }) + expect(result.code, result.stderr || result.stdout).toBe(0) + return readFileSync(output, 'utf8').trim() + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +it('selects the measured current root profile with the actual installer metadata', () => { + expect(select()).toBe('shared=true') + expect( + select({ manifest: { ...supportedManifest, packageManager: 'pnpm@12.8.1+sha512.fixture' } }) + ).toBe('shared=true') +}) + +it.each([ + ['historical Node', { manifest: { ...supportedManifest, engines: { node: '22' } } }], + ['historical pnpm', { manifest: { ...supportedManifest, packageManager: 'pnpm@10.0.0' } }], + ['unmeasured pnpm', { manifest: { ...supportedManifest, packageManager: 'pnpm@12.8.10' } }], + ['missing toolchain', { manifest: {} }], + [ + 'extra lifecycle work', + { manifest: { ...supportedManifest, scripts: { postinstall: 'generate' } } } + ], + ['self-hosted runner', { kind: 'self-hosted' }], + ['job container', { container: 'container-id' }], + ['another OS', { os: 'Windows' }], + ['another architecture', { arch: 'ARM64' }], + ['missing installer', { missing: '.github/actions/install-node-dependencies/action.yml' }], + ['missing native action', { missing: '.github/actions/prepare-native-runtime/action.yml' }], + ['missing runtime script', { missing: 'config/scripts/ensure-native-runtime.mjs' }], + ['old installer interface', { action: 'inputs:\n native-runtime: {}\nruns: {}\n' }], + [ + 'output-only names', + { action: 'outputs:\n native-runtime: {}\n cache-pnpm-store-lookup-only: {}\n' } + ] +])('retains the original install for %s', (_name, options) => { + expect(select(options)).toBe('shared=false') +}) + +it.each(['true', 'false', ''])('routes mode %s to one complete preparation path', (shared) => { + const enabled = (step) => + runInNewContext(step.if.replaceAll('steps.install-mode.outputs.shared', 'shared'), { shared }) + const current = steps.find((step) => step.name === 'Prepare current dependencies') + const legacy = steps.filter((step) => + [ + 'Setup pnpm', + 'Setup Node.js', + "Use external node-gyp to avoid pnpm's bundled copy", + 'Install dependencies' + ].includes(step.name) + ) + expect(legacy).toHaveLength(4) + expect(enabled(current)).toBe(shared === 'true') + expect(legacy.every((step) => enabled(step) === (shared !== 'true'))).toBe(true) + expect(current.with).toEqual({ + 'native-runtime': 'electron', + 'cache-electron-package': 'true', + 'cache-pnpm-store-lookup-only': 'true' + }) + expect(legacy.at(-1).run).toBe('pnpm install --frozen-lockfile') + expect(steps.find((step) => step.name === 'Run terminal scale perf report gate').run).toContain( + 'pnpm run test:e2e:terminal-perf:scale:report' + ) +}) diff --git a/config/scripts/verify-mobile-web-app-bundle.mjs b/config/scripts/verify-mobile-web-app-bundle.mjs index b6297555701..48917d294ce 100644 --- a/config/scripts/verify-mobile-web-app-bundle.mjs +++ b/config/scripts/verify-mobile-web-app-bundle.mjs @@ -50,9 +50,9 @@ export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024 * A chunk is emitted per distinct set of importers, not per route, so a route's marginal cost is * what it fails to share rather than what it weighs. Re-measured on this head by building * `routes.slice(0, n)` for every n, which is what the fence below is derived from rather than - * fitted to. The spread it shows is 1 to 9: `pr` and `web` add one script each, `review` adds nine. + * fitted to. The spread it shows is 1 to 10: `pr` and `web` add one script each, `session` adds ten. * The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the - * swept tree reads 69 scripts at 16 routes, the old 15 read 67 on the same head. + * swept tree reads 74 scripts at 16 routes. * * This table is the fence's only input, so a route added to the tree stales it and the pins beside * the fence fail until it is re-measured. That is the point: the bound is re-derived, never bumped. @@ -61,19 +61,19 @@ export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [ ['./_layout.tsx', 3], ['./h/[hostId]/[...page].tsx', 7], ['./h/[hostId]/accounts.tsx', 9], - ['./h/[hostId]/agent-history/[worktreeId].tsx', 13], - ['./h/[hostId]/edit.tsx', 18], - ['./h/[hostId]/files/[worktreeId].tsx', 21], - ['./h/[hostId]/files/preview/[worktreeId].tsx', 28], - ['./h/[hostId]/history/[worktreeId].tsx', 30], - ['./h/[hostId]/index.tsx', 35], - ['./h/[hostId]/pr/[worktreeId].tsx', 36], - ['./h/[hostId]/review/[worktreeId].tsx', 45], - ['./h/[hostId]/session/[worktreeId].tsx', 54], - ['./h/[hostId]/source-control/[worktreeId].tsx', 59], - ['./h/[hostId]/tasks.tsx', 66], - ['./h/[hostId]/web.tsx', 67], - ['./h/_layout.tsx', 69] + ['./h/[hostId]/agent-history/[worktreeId].tsx', 14], + ['./h/[hostId]/edit.tsx', 19], + ['./h/[hostId]/files/[worktreeId].tsx', 24], + ['./h/[hostId]/files/preview/[worktreeId].tsx', 32], + ['./h/[hostId]/history/[worktreeId].tsx', 34], + ['./h/[hostId]/index.tsx', 39], + ['./h/[hostId]/pr/[worktreeId].tsx', 40], + ['./h/[hostId]/review/[worktreeId].tsx', 48], + ['./h/[hostId]/session/[worktreeId].tsx', 58], + ['./h/[hostId]/source-control/[worktreeId].tsx', 63], + ['./h/[hostId]/tasks.tsx', 71], + ['./h/[hostId]/web.tsx', 72], + ['./h/_layout.tsx', 74] ] const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts) @@ -191,7 +191,7 @@ export async function readMobileWebBundleMaxAssets() { * shells return null for a manifest over MOBILE_WEB_BUNDLE_MAX_ASSETS rather than dropping the * extra assets, so a route count that pushes the chunk envelope plus images plus the document past * it would pass this build and fail on the device with nothing to read. At today's 42 images that - * is 31 routes, inside what Phase C adds, which is why this is a build failure and not a comment. + * is 29 routes, inside what Phase C adds, which is why this is a build failure and not a comment. * The envelope grants the worst swept route to each one past the sweep, so re-measuring a tree * whose routes share more moves that crossing out again. */ diff --git a/config/scripts/vitest-real-agent-home-write-guard.test.ts b/config/scripts/vitest-real-agent-home-write-guard.test.ts index 6a98c4aa577..9bd42217f67 100644 --- a/config/scripts/vitest-real-agent-home-write-guard.test.ts +++ b/config/scripts/vitest-real-agent-home-write-guard.test.ts @@ -4,7 +4,10 @@ import { writeFile } from 'node:fs/promises' import { homedir, tmpdir, userInfo } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { takeRealAgentHomeWriteViolations } from './vitest-real-agent-home-write-guard' +import { + clearInheritedAgentStateEnv, + takeRealAgentHomeWriteViolations +} from './vitest-real-agent-home-write-guard' // Why never-created paths: each sits under a missing folder or is a forced no-op removal, so even // with the guard off (the ablation) nothing lands in the real home. @@ -17,6 +20,29 @@ afterEach(() => { }) describe('vitest real-agent-home write guard', () => { + it.each([undefined, '', '0', 'true', '1'])( + 'keeps only the explicitly opted-in Claude profile for %s', + (value) => { + vi.stubEnv('ORCA_REAL_CLAUDE_CLI_TEST', value) + vi.stubEnv('CLAUDE_CONFIG_DIR', '/tmp/explicit-claude-profile') + vi.stubEnv('CODEX_HOME', '/tmp/inherited-codex-profile') + vi.stubEnv('ORCA_USER_DATA_PATH', '/tmp/inherited-orca-state') + vi.stubEnv('ORCA_CODEX_LAUNCH_PREFLIGHT', '/tmp/inherited-live-cli') + + clearInheritedAgentStateEnv() + + expect(process.env.CLAUDE_CONFIG_DIR).toBe( + value === '1' ? '/tmp/explicit-claude-profile' : undefined + ) + expect(process.env.CODEX_HOME).toBeUndefined() + expect(process.env.ORCA_USER_DATA_PATH).toBeUndefined() + expect(process.env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined() + expect(() => rmSync(missingRealFolder('.claude'), { force: true })).toThrow( + /real-agent-home guard/ + ) + } + ) + it('refuses a named-import sync write under the real ~/.codex', () => { const target = join(missingRealFolder('.codex'), 'config.toml') expect(() => writeFileSync(target, '[projects."/tmp/x"]\n')).toThrow(/real-agent-home guard/) diff --git a/config/scripts/vitest-real-agent-home-write-guard.ts b/config/scripts/vitest-real-agent-home-write-guard.ts index 40c19fbcabc..e1f3fc3ee39 100644 --- a/config/scripts/vitest-real-agent-home-write-guard.ts +++ b/config/scripts/vitest-real-agent-home-write-guard.ts @@ -188,12 +188,18 @@ declare global { } const state = (globalThis.orcaRealAgentHomeWriteGuard ??= install()) // Why after install: the guard keeps the inherited paths as roots; tests that need one set their own. -const inheritedEnvToUnset = realAgentSuiteOptedIn() - ? [] - : [...INHERITED_STATE_ENV, ...INHERITED_LIVE_CLI_ENV] -for (const name of inheritedEnvToUnset) { - delete process.env[name] +export function clearInheritedAgentStateEnv(): void { + const inheritedEnvToUnset = realAgentSuiteOptedIn() + ? [] + : [...INHERITED_STATE_ENV, ...INHERITED_LIVE_CLI_ENV] + for (const name of inheritedEnvToUnset) { + if (name === 'CLAUDE_CONFIG_DIR' && process.env.ORCA_REAL_CLAUDE_CLI_TEST === '1') { + continue + } + delete process.env[name] + } } +clearInheritedAgentStateEnv() /** Drains recorded violations; only the guard's own self-test should need this. */ export function takeRealAgentHomeWriteViolations(): string[] { diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs index 80263ec4b65..a4a267b45ce 100644 --- a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -29,7 +29,6 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [ 'config/scripts/dev-cli-terminal-wrapper.mjs', 'config/scripts/dev-cli-terminal-wrapper.test.mjs', 'config/scripts/electron-builder-config.test.mjs', - 'config/scripts/ensure-native-runtime.test.mjs', 'config/scripts/live-remote-freeze-rpc.mjs', 'config/scripts/pty-transcript-secret-scan.test.mjs', 'config/scripts/remote-agent-session-authority-repro.mjs', diff --git a/config/scripts/windows-pe-imports.mjs b/config/scripts/windows-pe-imports.mjs new file mode 100644 index 00000000000..340bc08a2ae --- /dev/null +++ b/config/scripts/windows-pe-imports.mjs @@ -0,0 +1,54 @@ +// Why a hand-rolled reader: the release guard only needs the import table's DLL +// names, and a parser dependency would be a new supply-chain input for one check. +const IMPORT_DIRECTORY_INDEX = 1 + +export function readPeImportedDllNames(buffer) { + const peOffset = buffer.readUInt32LE(0x3c) + if (buffer.toString('latin1', peOffset, peOffset + 4) !== 'PE\0\0') { + throw new Error('Not a PE image') + } + const sectionCount = buffer.readUInt16LE(peOffset + 6) + const optionalHeaderSize = buffer.readUInt16LE(peOffset + 20) + const optionalHeader = peOffset + 24 + const isPe32Plus = buffer.readUInt16LE(optionalHeader) === 0x20b + const dataDirectories = optionalHeader + (isPe32Plus ? 112 : 96) + const importRva = buffer.readUInt32LE(dataDirectories + IMPORT_DIRECTORY_INDEX * 8) + if (importRva === 0) { + return [] + } + const sections = [] + for (let index = 0; index < sectionCount; index += 1) { + const header = optionalHeader + optionalHeaderSize + index * 40 + sections.push({ + virtualAddress: buffer.readUInt32LE(header + 12), + size: Math.max(buffer.readUInt32LE(header + 8), buffer.readUInt32LE(header + 16)), + rawOffset: buffer.readUInt32LE(header + 20) + }) + } + const fileOffset = (rva) => { + const section = sections.find( + (candidate) => + rva >= candidate.virtualAddress && rva < candidate.virtualAddress + candidate.size + ) + if (!section) { + throw new Error(`RVA 0x${rva.toString(16)} is outside every section`) + } + return rva - section.virtualAddress + section.rawOffset + } + const names = [] + for (let descriptor = fileOffset(importRva); ; descriptor += 20) { + const nameRva = buffer.readUInt32LE(descriptor + 12) + if (nameRva === 0) { + return names + } + const start = fileOffset(nameRva) + names.push(buffer.toString('latin1', start, buffer.indexOf(0, start))) + } +} + +// The Visual C++ runtime DLLs ship with the Redistributable, not with Windows. +const DYNAMIC_VC_RUNTIME_RE = /^(?:vcruntime|msvcp|msvcr)\d+(?:_\d+)?\.dll$/i + +export function findDynamicVcRuntimeImports(dllNames) { + return dllNames.filter((name) => DYNAMIC_VC_RUNTIME_RE.test(name)) +} diff --git a/config/scripts/windows-pe-imports.test.mjs b/config/scripts/windows-pe-imports.test.mjs new file mode 100644 index 00000000000..11cfbbfc78e --- /dev/null +++ b/config/scripts/windows-pe-imports.test.mjs @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' + +// Smallest PE32+ image with one section holding an import directory for `dllNames`. +function peWithImports(dllNames) { + const peOffset = 0x40 + const optionalHeaderSize = 240 + const sectionHeader = peOffset + 24 + optionalHeaderSize + const rawOffset = 0x200 + const virtualAddress = 0x1000 + const descriptorsSize = (dllNames.length + 1) * 20 + const strings = dllNames.map((name) => Buffer.from(`${name}\0`, 'latin1')) + const sectionSize = descriptorsSize + strings.reduce((sum, item) => sum + item.length, 0) + const buffer = Buffer.alloc(rawOffset + sectionSize) + buffer.write('MZ', 0, 'latin1') + buffer.writeUInt32LE(peOffset, 0x3c) + buffer.write('PE\0\0', peOffset, 'latin1') + buffer.writeUInt16LE(0x8664, peOffset + 4) + buffer.writeUInt16LE(1, peOffset + 6) + buffer.writeUInt16LE(optionalHeaderSize, peOffset + 20) + buffer.writeUInt16LE(0x20b, peOffset + 24) + buffer.writeUInt32LE(virtualAddress, peOffset + 24 + 112 + 8) + buffer.writeUInt32LE(sectionSize, sectionHeader + 8) + buffer.writeUInt32LE(virtualAddress, sectionHeader + 12) + buffer.writeUInt32LE(sectionSize, sectionHeader + 16) + buffer.writeUInt32LE(rawOffset, sectionHeader + 20) + let stringRva = virtualAddress + descriptorsSize + strings.forEach((item, index) => { + buffer.writeUInt32LE(stringRva, rawOffset + index * 20 + 12) + item.copy(buffer, rawOffset + (stringRva - virtualAddress)) + stringRva += item.length + }) + return buffer +} + +describe('windows PE imports', () => { + it('reads every imported DLL name in order', () => { + const names = ['KERNEL32.dll', 'VCRUNTIME140.dll', 'api-ms-win-crt-runtime-l1-1-0.dll'] + expect(readPeImportedDllNames(peWithImports(names))).toEqual(names) + }) + + it('reads an image with no imports', () => { + expect(readPeImportedDllNames(peWithImports([]))).toEqual([]) + }) + + it('rejects a file that is not a PE image', () => { + const buffer = Buffer.alloc(0x80) + buffer.writeUInt32LE(0x40, 0x3c) + expect(() => readPeImportedDllNames(buffer)).toThrow('Not a PE image') + }) + + it('flags only the Visual C++ Redistributable DLLs', () => { + expect( + findDynamicVcRuntimeImports([ + 'KERNEL32.dll', + 'ntdll.dll', + 'VCRUNTIME140.dll', + 'vcruntime140_1.dll', + 'MSVCP140.dll', + 'msvcr120.dll', + 'api-ms-win-crt-heap-l1-1-0.dll', + 'ucrtbase.dll' + ]) + ).toEqual(['VCRUNTIME140.dll', 'vcruntime140_1.dll', 'MSVCP140.dll', 'msvcr120.dll']) + }) +}) diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs index ba3c699a7a6..f782334628d 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs @@ -54,17 +54,27 @@ export const WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS = [ 'napi-inl.deprecated.h' ] -export function nodeGypRebuildInvocation(arch, packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) { +export function nodeGypRebuildInvocation( + arch, + packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR, + nodeGypEntry = join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js') +) { return { - args: [ - join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), - 'rebuild', - `--arch=${arch}` - ], + args: [nodeGypEntry, 'rebuild', `--arch=${arch}`], cwd: realpathSync(packageDir) } } +export function nodeGypRebuildTimeoutMs( + moduleName, + { platform = process.platform, arch = process.arch, ci = process.env.CI } = {} +) { + // Cold headers and toolchain discovery consumed over four minutes on Windows ARM CI. + return moduleName === 'node-pty' && platform === 'win32' && arch === 'arm64' && ci === 'true' + ? 600_000 + : 300_000 +} + /** The binary the addon actually loads. */ export function windowsProcessTreeAddonPath(packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) { return join(packageDir, 'build', 'Release', 'windows_process_tree.node') diff --git a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs index a33506116f1..620deba58c4 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs @@ -16,6 +16,7 @@ import { assertWindowsProcessTreeRuntimeCreationTime, inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, + nodeGypRebuildTimeoutMs, stageWindowsProcessTreeNodeAddonApiHeaders, WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS, WINDOWS_PROCESS_TREE_PACKAGE_DIR @@ -46,6 +47,41 @@ describe('windows-process-tree node-gyp rebuild', () => { expect(args).toContain('--arch=arm64') }) + it('preserves an external node-gyp entry and the physical addon cwd', () => { + const entry = join(tmpdir(), 'external-node-gyp', 'bin', 'node-gyp.js') + expect(nodeGypRebuildInvocation('arm64', import.meta.dirname, entry)).toEqual({ + args: [entry, 'rebuild', '--arch=arm64'], + cwd: realpathSync(import.meta.dirname) + }) + }) + + it('allows cold setup and compilation only for node-pty on a Windows ARM CI host', () => { + expect( + nodeGypRebuildTimeoutMs('node-pty', { platform: 'win32', arch: 'arm64', ci: 'true' }) + ).toBe(600_000) + }) + + it.each([ + { moduleName: 'node-pty', platform: 'win32', arch: 'x64', ci: 'true' }, + { moduleName: 'node-pty', platform: 'linux', arch: 'arm64', ci: 'true' }, + { moduleName: 'node-pty', platform: 'darwin', arch: 'arm64', ci: 'true' }, + { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: '' }, + { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: 'false' }, + { moduleName: 'node-pty', platform: 'win32', arch: 'arm64', ci: '1' }, + { moduleName: '@orca/windows-registry', platform: 'win32', arch: 'arm64', ci: 'true' }, + { moduleName: '@vscode/windows-process-tree', platform: 'win32', arch: 'arm64', ci: 'true' } + ])('keeps the five-minute bound for $moduleName on $platform/$arch with CI=$ci', (host) => { + expect(nodeGypRebuildTimeoutMs(host.moduleName, host)).toBe(300_000) + }) + + it('uses the execution host rather than an ARM cross-compilation target', () => { + const { args } = nodeGypRebuildInvocation('arm64', import.meta.dirname) + expect(args).toContain('--arch=arm64') + expect( + nodeGypRebuildTimeoutMs('node-pty', { platform: 'win32', arch: 'x64', ci: 'true' }) + ).toBe(300_000) + }) + it('copies node-addon-api headers into the patched include dir', () => { const packageDir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-headers-')) try { diff --git a/config/scripts/windows-pty-table-stress-observer.mjs b/config/scripts/windows-pty-table-stress-observer.mjs new file mode 100644 index 00000000000..103310d4417 --- /dev/null +++ b/config/scripts/windows-pty-table-stress-observer.mjs @@ -0,0 +1,191 @@ +import { createHash } from 'node:crypto' +import { errorMonitor } from 'node:events' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { redactTranscript } from './pty-transcript-secret-scan.mjs' + +const MAX_RECORDS = 32 +const MAX_EVENTS = 256 +const ESC = String.fromCharCode(27) +const CONTROL_SEQUENCE = new RegExp(`${ESC}(?:\\[[0-?]*[ -/]*[@-~]|[@-_])`, 'g') + +export function sanitizeStressText(text) { + const source = String(text ?? '') + const controls = [] + let sourceCursor = 0 + let scanIndex = 0 + const scanText = source.replace(CONTROL_SEQUENCE, (sequence, index) => { + scanIndex += index - sourceCursor + sourceCursor = index + sequence.length + // OSC framing keeps title payloads separate from the adjacent rendered text. + if (sequence === `${ESC}]` || sequence === `${ESC}\\`) { + scanIndex += sequence.length + return sequence + } + controls.push({ sequence, index: scanIndex }) + return '' + }) + const firstPass = redactTranscript(scanText).text + // A local identity can mask a wider email finding in the first pass. + const sanitized = redactTranscript(firstPass).text + let result = '' + let cursor = 0 + for (const { sequence, index } of controls) { + result += sanitized.slice(cursor, index) + sequence + cursor = index + } + return result + sanitized.slice(cursor) +} + +export function loadedStressInputHashes(addonPath, resolveModule) { + const files = [ + ['conpty.node', addonPath], + ['conpty.dll', join(dirname(addonPath), 'conpty', 'conpty.dll')], + ['OpenConsole.exe', join(dirname(addonPath), 'conpty', 'OpenConsole.exe')] + ] + const modules = [ + 'utils.js', + 'windowsTerminal.js', + 'windowsPtyAgent.js', + 'windowsConoutConnection.js', + 'worker/conoutSocketWorker.js' + ] + return [...files, ...modules.map((name) => [name])].map(([name, path]) => { + try { + const bytes = readFileSync(path ?? resolveModule(`node-pty/lib/${name}`)) + return { name, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') } + } catch (error) { + return { name, unavailable: error.code ?? 'unknown' } + } + }) +} + +function socketState(socket) { + if (!socket) { + return null + } + return { + connecting: socket.connecting === true, + destroyed: socket.destroyed === true, + readable: socket.readable === true, + writable: socket.writable === true + } +} + +export function createStressObserver(report) { + const started = performance.now() + const records = [] + let events = 0 + let omittedEvents = 0 + let omittedRecords = 0 + + function state(record, context) { + const { proc } = record + const agent = proc._agent + return { + ...context, + shellPid: proc.pid, + ptyId: proc._pty, + terminalReady: proc._isReady === true, + exitCallbackObserved: record.exited === true, + closeRequested: record.closed === true, + nativeExitCode: Number.isInteger(agent?.exitCode) ? agent.exitCode : null, + deferredOperations: Array.isArray(proc._deferreds) ? proc._deferreds.length : null, + inputSocket: socketState(agent?._inSocket), + outputSocket: socketState(proc._socket), + conoutWorkerThreadId: agent?._conoutSocketWorker?._worker?.threadId ?? null + } + } + + function emit(phase, details) { + if (events >= MAX_EVENTS) { + omittedEvents += 1 + return + } + events += 1 + report(phase, { elapsedMs: Math.round(performance.now() - started), ...details }) + } + + function watch(record, context) { + if (records.length >= MAX_RECORDS) { + // Keep the warmup survivor alongside the newest terminals. + const oldestRecent = records[0].context.round === -1 && records[0].context.slot === -1 ? 1 : 0 + records.splice(oldestRecent, 1) + omittedRecords += 1 + } + records.push({ record, context }) + const { proc } = record + const snapshot = () => state(record, context) + emit('spawn-returned', snapshot()) + let firstData = true + proc.onData((chunk) => { + if (firstData) { + firstData = false + emit('first-data', { ...snapshot(), bytes: Buffer.byteLength(chunk) }) + } + }) + proc.onExit((event) => emit('pty-exit-callback', { ...snapshot(), exitCode: event.exitCode })) + for (const [name, socket] of [ + ['input', proc._agent?._inSocket], + ['output', proc._socket] + ]) { + if (socket) { + socket.on(errorMonitor, (error) => + emit('pipe-error', { + ...snapshot(), + pipe: name, + code: error.code ?? null, + message: sanitizeStressText(String(error.message)) + }) + ) + for (const event of ['connect', 'ready_datapipe', 'end', 'close']) { + socket.on(event, () => emit(`pipe-${event}`, { ...snapshot(), pipe: name })) + } + } + } + const worker = proc._agent?._conoutSocketWorker?._worker + if (worker) { + worker.on('online', () => emit('conout-worker-online', snapshot())) + worker.on('message', (message) => { + if (message === 1) { + emit('conout-worker-ready', snapshot()) + } + }) + worker.on('exit', (code) => emit('conout-worker-exit', { ...snapshot(), code })) + worker.on(errorMonitor, (error) => + emit('conout-worker-error', { ...snapshot(), message: sanitizeStressText(error.message) }) + ) + } + const agent = proc._agent + if (typeof agent?._$onProcessExit === 'function') { + const original = agent._$onProcessExit + // Observe the existing callback without changing its receiver, arguments, or result. + agent._$onProcessExit = function (...args) { + emit('native-exit-callback', { ...snapshot(), exitCode: args[0] }) + return original.call(this, ...args) + } + } + } + + function pending(phase) { + report(phase, { + elapsedMs: Math.round(performance.now() - started), + observerEvents: events, + omittedEvents, + omittedRecords, + records: records.map(({ record, context }) => ({ + ...state(record, context), + output: sanitizeStressText(record.output.slice(-2048)) + })) + }) + } + + function checkpoint(phase, record) { + const entry = records.find((entry) => entry.record === record) + if (entry) { + emit(phase, state(entry.record, entry.context)) + } + } + + return { watch, pending, checkpoint } +} diff --git a/config/scripts/windows-pty-table-stress-observer.test.mjs b/config/scripts/windows-pty-table-stress-observer.test.mjs new file mode 100644 index 00000000000..a468adef9a9 --- /dev/null +++ b/config/scripts/windows-pty-table-stress-observer.test.mjs @@ -0,0 +1,318 @@ +import { EventEmitter, errorMonitor } from 'node:events' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { hostname, tmpdir, userInfo } from 'node:os' +import { join } from 'node:path' +import { scanTranscriptForSecrets } from './pty-transcript-secret-scan.mjs' +import { afterEach, describe, expect, it } from 'vitest' +import { + createStressObserver, + loadedStressInputHashes, + sanitizeStressText +} from './windows-pty-table-stress-observer.mjs' + +const directories = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function terminal() { + const proc = new EventEmitter() + proc.pid = 321 + proc._pty = 12 + proc._isReady = false + proc._deferreds = [() => {}] + proc._socket = new EventEmitter() + proc._agent = { + _inSocket: new EventEmitter(), + _conoutSocketWorker: { _worker: new EventEmitter() }, + _$onProcessExit(code) { + this.exitCode = code + return 'original-result' + } + } + proc.onData = (listener) => proc.on('ptyData', listener) + proc.onExit = (listener) => proc.on('ptyExit', listener) + return { proc, exited: false, closed: false, output: '' } +} + +function observation(record = terminal()) { + const events = [] + const observer = createStressObserver((phase, details) => events.push({ phase, ...details })) + observer.watch(record, { round: 0, slot: 1 }) + return { observer, events, record } +} + +describe('Windows PTY stress observer', () => { + it('distinguishes a silent deferred terminal, native exit, and the public exit callback', () => { + const { observer, events, record } = observation() + observer.pending('readiness-timeout-state') + expect(events.at(-1).records[0]).toMatchObject({ + shellPid: 321, + terminalReady: false, + deferredOperations: 1, + nativeExitCode: null, + exitCallbackObserved: false + }) + expect(record.proc._agent._$onProcessExit(9)).toBe('original-result') + observer.pending('exit-drain-state') + expect(events.at(-1).records[0]).toMatchObject({ + nativeExitCode: 9, + exitCallbackObserved: false + }) + record.exited = true + record.proc.emit('ptyExit', { exitCode: 9 }) + expect(events.at(-1)).toMatchObject({ phase: 'pty-exit-callback', exitCallbackObserved: true }) + }) + + it('preserves native callback receiver, arguments, return value, and thrown errors', () => { + const record = terminal() + const calls = [] + const failure = new Error('native callback failure') + record.proc._agent._$onProcessExit = function (...args) { + calls.push({ receiver: this, args }) + if (args[0] === 1) { + throw failure + } + return 'unchanged' + } + observation(record) + expect(record.proc._agent._$onProcessExit(0, 'extra')).toBe('unchanged') + expect(calls).toEqual([{ receiver: record.proc._agent, args: [0, 'extra'] }]) + expect(() => record.proc._agent._$onProcessExit(1)).toThrow(failure) + expect(calls).toHaveLength(2) + }) + + it('observes worker and socket errors without consuming an unhandled error', () => { + const { events, record } = observation() + const output = record.proc._socket + expect(output.listenerCount('error')).toBe(0) + expect(output.listenerCount(errorMonitor)).toBe(1) + const failure = Object.assign(new Error('broken pipe'), { code: 'EPIPE' }) + expect(() => output.emit('error', failure)).toThrow(failure) + expect(events.at(-1)).toMatchObject({ phase: 'pipe-error', pipe: 'output', code: 'EPIPE' }) + const worker = record.proc._agent._conoutSocketWorker._worker + expect(worker.listenerCount('error')).toBe(0) + expect(() => worker.emit('error', failure)).toThrow(failure) + expect(events.at(-1)).toMatchObject({ phase: 'conout-worker-error' }) + }) + + it('keeps first data separate from worker readiness and bounds repeated milestones', () => { + const { observer, events, record } = observation() + const worker = record.proc._agent._conoutSocketWorker._worker + worker.emit('message', 1) + expect(events.at(-1).phase).toBe('conout-worker-ready') + expect(events.some((event) => event.phase === 'first-data')).toBe(false) + record.proc.emit('ptyData', 'first') + record.proc.emit('ptyData', 'second') + expect(events.filter((event) => event.phase === 'first-data')).toHaveLength(1) + for (let index = 0; index < 1_000; index += 1) { + worker.emit('message', 1) + } + expect(events).toHaveLength(256) + observer.pending('exit-drain-state') + expect(events.at(-1).observerEvents).toBe(256) + expect(events.at(-1).omittedEvents).toBeGreaterThan(0) + }) + + it('bounds tracked terminals and redacts the assembled tail without changing raw input', () => { + const events = [] + const observer = createStressObserver((phase, details) => events.push({ phase, ...details })) + const raw = '\u001b[31mprivate@sensitive.test\r\nBearer secret01234567890123456789' + const first = terminal() + first.output = raw + observer.watch(first, { round: -1, slot: -1 }) + for (let index = 1; index < 40; index += 1) { + observer.watch(terminal(), { round: index, slot: 1 }) + } + observer.pending('exit-drain-state') + const last = events.at(-1) + expect(last.records).toHaveLength(32) + expect(last.omittedRecords).toBe(8) + expect(last.records[0]).toMatchObject({ round: -1, slot: -1 }) + expect(last.records.at(-1)).toMatchObject({ round: 39, slot: 1 }) + expect(last.records[0].output).not.toContain('private@sensitive.test') + expect(last.records[0].output).not.toContain('secret01234567890123456789') + expect(last.records[0].output.length).toBe(raw.length) + expect(last.records[0].output).toContain('\u001b[31m') + expect(first.output).toBe(raw) + }) + + it('hashes actual loaded files and qualifies missing runtime companions', () => { + const directory = mkdtempSync(join(tmpdir(), 'pty-stress-inputs-')) + directories.push(directory) + const addon = join(directory, 'conpty.node') + writeFileSync(addon, 'actual-loaded-bytes') + const hashes = loadedStressInputHashes(addon, () => addon) + expect(hashes[0]).toMatchObject({ name: 'conpty.node', bytes: 19 }) + expect(hashes[0].sha256).toMatch(/^[a-f0-9]{64}$/) + expect(hashes[1]).toEqual({ name: 'conpty.dll', unavailable: 'ENOENT' }) + expect(hashes[2]).toEqual({ name: 'OpenConsole.exe', unavailable: 'ENOENT' }) + expect(hashes.slice(3).every((hash) => hash.sha256 === hashes[0].sha256)).toBe(true) + const missing = loadedStressInputHashes(addon, () => { + throw Object.assign(new Error('missing module'), { code: 'MODULE_NOT_FOUND' }) + }) + expect(missing.slice(3).every((hash) => hash.unavailable === 'MODULE_NOT_FOUND')).toBe(true) + }) + + it('preserves OSC boundaries while redacting their title payload and adjacent CSI text', () => { + const esc = String.fromCharCode(27) + const raw = `${esc}]0;private@sensitive.test${esc}\\${esc}[31mprivate@sensitive.test` + const sanitized = sanitizeStressText(raw) + expect(sanitized).not.toContain('private@') + expect(sanitized).not.toContain('sensitive.test') + expect(sanitized).toContain(`${esc}]0;`) + expect(sanitized).toContain(`${esc}\\${esc}[31m`) + expect(sanitized.length).toBe(raw.length) + }) + + it.each([ + ['email domain', 'private@', 'sensitive.test', ['[31m']], + ['email name', 'pri', 'vate@sensitive.test', ['[31m', '[1m']], + ['vendor key', 'sk-secret01', '234567890abcdefghijkl', ['[31m', '[1m']], + ['bearer token', 'Bearer secret01', '234567890abcdefghijkl', ['[31m', '[1m']] + ])( + 'redacts %s interrupted by adjacent controls without moving their bytes', + (_kind, before, after, fragments) => { + const esc = String.fromCharCode(27) + const controls = fragments.map((fragment) => esc + fragment).join('') + const raw = `${before}${controls}${after}` + const sanitized = sanitizeStressText(raw) + expect(sanitized).not.toContain(before) + expect(sanitized).not.toContain(after) + expect(sanitized.slice(before.length, before.length + controls.length)).toBe(controls) + expect(sanitized.length).toBe(raw.length) + } + ) + + it('scrubs an interrupted OSC title independently from the adjacent rendered address', () => { + const esc = String.fromCharCode(27) + const title = `private@${esc}[31msensitive.test` + const raw = `${esc}]0;${title}${esc}\\private@sensitive.test` + const sanitized = sanitizeStressText(raw) + expect(sanitized).not.toContain('private@') + expect(sanitized).not.toContain('sensitive.test') + expect(sanitized.slice(0, 4)).toBe(`${esc}]0;`) + expect(sanitized.slice(12, 17)).toBe(`${esc}[31m`) + expect(sanitized.slice(4 + title.length, 6 + title.length)).toBe(`${esc}\\`) + expect(sanitized.length).toBe(raw.length) + }) + + it('keeps the warmup survivor and newest terminals beyond eleven rounds', () => { + const events = [] + const observer = createStressObserver((phase, details) => events.push({ phase, ...details })) + const survivor = terminal() + observer.watch(survivor, { round: -1, slot: -1 }) + let last + for (let round = 0; round < 11; round += 1) { + for (let slot = 0; slot < 3; slot += 1) { + last = terminal() + last.proc.pid = 1000 + round * 3 + slot + observer.watch(last, { round, slot }) + } + } + const agent = last.proc._agent + expect(agent._$onProcessExit(4, 'extra')).toBe('original-result') + expect(agent.exitCode).toBe(4) + expect(events.at(-1)).toMatchObject({ phase: 'native-exit-callback', round: 10, slot: 2 }) + last.exited = true + last.proc.emit('ptyExit', { exitCode: 4 }) + expect(events.at(-1)).toMatchObject({ phase: 'pty-exit-callback', round: 10, slot: 2 }) + observer.pending('readiness-timeout-state') + const snapshot = events.at(-1) + expect(snapshot.omittedRecords).toBe(2) + expect(snapshot.records).toHaveLength(32) + expect(snapshot.records[0]).toMatchObject({ round: -1, slot: -1 }) + expect(snapshot.records[1]).toMatchObject({ round: 0, slot: 2 }) + expect(snapshot.records.at(-1)).toMatchObject({ + round: 10, + slot: 2, + nativeExitCode: 4, + exitCallbackObserved: true + }) + }) + + it('retains late terminal state after exhausting the 256-milestone budget', () => { + const { observer, events, record: survivor } = observation() + const worker = survivor.proc._agent._conoutSocketWorker._worker + for (let index = 0; index < 300; index += 1) { + worker.emit('message', 1) + } + let last + for (let index = 0; index < 40; index += 1) { + last = terminal() + last.proc.pid = 2000 + index + observer.watch(last, { round: index, slot: 2 }) + } + expect(last.proc._agent._$onProcessExit(7)).toBe('original-result') + last.exited = true + last.proc.emit('ptyExit', { exitCode: 7 }) + expect(events).toHaveLength(256) + observer.pending('exit-drain-timeout-state') + const snapshot = events.at(-1) + expect(snapshot.observerEvents).toBe(256) + expect(snapshot.omittedEvents).toBeGreaterThan(0) + expect(snapshot.omittedRecords).toBe(9) + expect(snapshot.records).toHaveLength(32) + expect(snapshot.records.at(-1)).toMatchObject({ + shellPid: 2039, + nativeExitCode: 7, + exitCallbackObserved: true + }) + }) + + it.each([ + ['username', 'plain'], + ['username', 'csi'], + ['username', 'osc'], + ['hostname', 'plain'], + ['hostname', 'csi'], + ['hostname', 'osc'] + ])('scrubs the entire %s email in %s framing', (identity, framing) => { + const name = identity === 'username' ? userInfo().username : hostname() + const esc = String.fromCharCode(27) + const csi = `${esc}[31m` + const email = `${name}@privatecorp.test` + const raw = + framing === 'csi' + ? `${name}${csi}@privatecorp.test` + : framing === 'osc' + ? `${esc}]0;${email}${esc}\\` + : email + const sanitized = sanitizeStressText(raw) + expect(sanitized).not.toContain(name) + expect(sanitized).not.toContain('privatecorp.test') + expect(sanitized.length).toBe(raw.length) + const visible = sanitized + .replaceAll(csi, '') + .replaceAll(`${esc}]0;`, '') + .replaceAll(`${esc}\\`, '') + expect(scanTranscriptForSecrets(visible)).toEqual([]) + if (framing === 'csi') { + expect(sanitized.slice(name.length, name.length + csi.length)).toBe(csi) + } else if (framing === 'osc') { + expect(sanitized.slice(0, 4)).toBe(`${esc}]0;`) + expect(sanitized.slice(-2)).toBe(`${esc}\\`) + } + }) + + it.each(['vendor', 'bearer'])( + 'keeps %s priority when a credential contains the local username', + (kind) => { + const name = userInfo().username + const esc = String.fromCharCode(27) + const csi = `${esc}[31m` + const prefix = kind === 'vendor' ? 'sk-' : 'Bearer ' + const raw = `${prefix}${name}${csi}01234567890123456789` + const sanitized = sanitizeStressText(raw) + expect(sanitized).not.toContain(name) + expect(sanitized).not.toContain('01234567890123456789') + expect(sanitized.length).toBe(raw.length) + expect( + sanitized.slice(prefix.length + name.length, prefix.length + name.length + csi.length) + ).toBe(csi) + expect(scanTranscriptForSecrets(sanitized.replaceAll(csi, ''))).toEqual([]) + } + ) +}) diff --git a/config/scripts/windows-pty-table-stress.cjs b/config/scripts/windows-pty-table-stress.cjs index b703e18d19c..f6623f22eeb 100644 --- a/config/scripts/windows-pty-table-stress.cjs +++ b/config/scripts/windows-pty-table-stress.cjs @@ -13,16 +13,20 @@ async function exerciseTable() { assert.equal(process.platform, 'win32', 'This probe requires real Windows ConPTY') const rounds = Number(process.env.ORCA_PTY_TABLE_STRESS_ROUNDS ?? 8) assert.ok(Number.isInteger(rounds) && rounds > 0 && rounds <= 2000) + const { createStressObserver, loadedStressInputHashes, sanitizeStressText } = + await import('./windows-pty-table-stress-observer.mjs') + const observer = createStressObserver(report) const pty = require('node-pty') const nativePath = require.resolve('node-pty/lib/utils') const loaded = require(nativePath).loadNativeModule('conpty') const native = loaded.module const addonPath = resolve(dirname(nativePath), loaded.dir, 'conpty.node') report('native', { - addonPath, + addonPath: sanitizeStressText(addonPath), sha256: createHash('sha256').update(readFileSync(addonPath)).digest('hex'), node: process.version, - rounds + rounds, + inputs: loadedStressInputHashes(addonPath, require.resolve) }) // Unlike production's fallback, this crash probe requires a host that permits nested jobs. const hostJobAssigned = native.assignCurrentProcessToJob() @@ -57,6 +61,7 @@ async function exerciseTable() { resolveReady(true) } }) + observer.watch(record, { round, slot }) spawned.push(record) // Escaping one letter keeps echoed input from satisfying the output marker. proc.write(`echo ${marker.replace('READY', 'REA^DY')}\r`) @@ -74,7 +79,11 @@ async function exerciseTable() { ), new Promise((_, reject) => { timer = setTimeout(() => { - const transcripts = records.map(({ proc, output }) => ({ pid: proc.pid, output })) + observer.pending('readiness-timeout-state') + const transcripts = records.map(({ proc, output }) => ({ + pid: proc.pid, + output: sanitizeStressText(output) + })) reject(new Error(`PTY readiness timed out: ${JSON.stringify(transcripts)}`)) }, 15_000) }) @@ -93,6 +102,7 @@ async function exerciseTable() { report('kill', { round, slot, shellPid: record.proc.pid }) record.proc.kill() record.closed = true + observer.checkpoint('kill-returned-state', record) } let failure @@ -131,12 +141,15 @@ async function exerciseTable() { await Promise.race([ Promise.all(spawned.map((record) => record.exit)), new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('PTY exit callbacks did not drain')), 15_000) + timer = setTimeout(() => { + observer.pending('exit-drain-timeout-state') + reject(new Error('PTY exit callbacks did not drain')) + }, 15_000) }) ]) } catch (error) { if (failure) { - report('drain-error', { message: error.stack }) + report('drain-error', { message: sanitizeStressText(error.stack) }) } else { failure = { error } } @@ -147,10 +160,12 @@ async function exerciseTable() { if (failure) { throw failure.error } + observer.pending('complete-state') report('complete', { terminals: spawned.length }) } -exerciseTable().catch((error) => { - report('error', { message: error.stack }) +exerciseTable().catch(async (error) => { + const { sanitizeStressText } = await import('./windows-pty-table-stress-observer.mjs') + report('error', { message: sanitizeStressText(error.stack) }) process.exitCode = 1 }) diff --git a/config/ts-nocheck-baseline.txt b/config/ts-nocheck-baseline.txt index 7b102bdf419..787e9ffe89a 100644 --- a/config/ts-nocheck-baseline.txt +++ b/config/ts-nocheck-baseline.txt @@ -163,7 +163,6 @@ src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts src/main/runtime/runtime-file-commands-search-local-runtime-files.ts src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts src/main/runtime/runtime-file-commands-search-runtime-files.ts -src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.ts src/main/runtime/runtime-file-commands-terminal-artifact-access.ts src/main/runtime/runtime-file-commands-terminal-file-paths.ts src/main/runtime/runtime-file-commands-write-file-explorer-file.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 903562b67ed..6afcf4f142f 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -4,11 +4,13 @@ "../src/cli/**/*", "../src/shared/**/*", "../src/main/agent-state-file-reader.ts", + "../src/main/gitlab/project-ref-parser.ts", "../src/main/agent-hooks/grok-replay-guard.ts", "../src/main/claude/hook-script.ts", "../src/main/claude/claude-hook-event-versions.ts", "../src/main/claude/claude-managed-hook-events.ts", "../src/main/qoder/hook-service.ts", + "../src/main/qwen-code/hook-service.ts", "../src/main/codebuddy/hook-service.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-post-command.ts", @@ -103,6 +105,7 @@ "../src/main/amp/managed-plugin-install-status.ts", "../src/main/antigravity/hook-events.ts", "../src/main/antigravity/hook-script.ts", + "../src/main/antigravity/windows-hook-json-post.ts", "../src/main/antigravity/hook-service.ts", "../src/main/antigravity/hooks-json-bundle.ts", "../src/main/claude/hook-settings.ts", @@ -221,6 +224,9 @@ "../src/main/hermes/hook-service.ts", "../src/main/git-bash.ts", "../src/main/in-flight-run-dedupe.ts", + "../src/main/jcode/hook-settings.ts", + "../src/main/jcode/hook-config.ts", + "../src/main/jcode/hook-service.ts", "../src/main/kimi/hook-service.ts", "../src/main/kimi/kimi-hook-config-toml.ts", "../src/main/dsh/dsh-home-patch.ts", @@ -235,6 +241,7 @@ "../src/main/openclaude/hook-service.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", + "../src/main/startup/shell-path-probe.ts", "../src/main/startup/windows-shell-path-ownership.ts", // Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this // project's serve spec; the module has no imports, so listing it pulls in nothing else. @@ -251,8 +258,8 @@ ], "compilerOptions": { "composite": true, - // TypeScript 7 removed node10 resolution; Node16 preserves CommonJS emit for this package. - "module": "Node16", + // The CLI runs on Node 24; Node20 models synchronous ESM imports from CommonJS. + "module": "Node20", "moduleResolution": "Node16", "rootDir": "../src", "outDir": "../out" diff --git a/config/tsconfig.tc.cli.json b/config/tsconfig.tc.cli.json index bd59f47e7be..c613dd688d9 100644 --- a/config/tsconfig.tc.cli.json +++ b/config/tsconfig.tc.cli.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.cli.json", "compilerOptions": { - "module": "node16", + "module": "Node20", "moduleResolution": "node16" } } diff --git a/config/vitest.agent-status-benchmark.config.ts b/config/vitest.agent-status-benchmark.config.ts new file mode 100644 index 00000000000..4bd0561d0ed --- /dev/null +++ b/config/vitest.agent-status-benchmark.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' +import baseConfig from './vitest.config' + +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + include: ['config/scripts/agent-status-hot-path-benchmark.ts'], + fileParallelism: false, + retry: 0 + } +}) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index fb18038c92f..8d69c2e1d64 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 87m + + downloads: 93m @@ -15,7 +15,7 @@ downloads downloads - 87m - 87m + 93m + 93m diff --git a/docs/audits/acknowledged-tab-retirement/fixture.test.ts b/docs/audits/acknowledged-tab-retirement/fixture.test.ts index b7eff444c5d..4036c246734 100644 --- a/docs/audits/acknowledged-tab-retirement/fixture.test.ts +++ b/docs/audits/acknowledged-tab-retirement/fixture.test.ts @@ -13,7 +13,7 @@ import { buildWorkspaceSessionPayload } from '../../../src/renderer/src/lib/work import { buildHeadlessMobileSessionTerminalTabs } from '../../../src/main/runtime/mobile-session-terminal-projection' import { setRuntimeDesktopSurface } from '../../../src/main/runtime/runtime-desktop-surface' import { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' -import { advanceTerminalTopologyRevision } from '../../../src/main/runtime/workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../../../src/main/persistence/terminal-topology/terminal-topology-membership' import type { ExecutionHostId } from '../../../src/shared/execution-host' class AuditRuntime extends OrcaRuntimeService { diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 17ec41e8294..40f3cd2d23f 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -231,7 +231,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) --- diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index c8c4c18e596..71689cc0ceb 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -40,7 +40,7 @@ Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez. -[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -239,7 +239,7 @@ yay -S stably-orca-bin Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone. - **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android :** [Télécharger l'APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) +- **Android :** [Télécharger l'APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index ed37efa595f..df14248b9f7 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -231,7 +231,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 77cbb42154e..e984ceffa30 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -234,7 +234,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 0.0.50 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk) +- **Android:** [APK 0.0.52 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk) --- diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index baa17f95955..3e3d0e6b1a5 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -36,7 +36,7 @@ Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar. -[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -234,7 +234,7 @@ yay -S stably-orca-bin Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Baixar APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) +- **Android:** [Baixar APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 6da8f2c38ed..8e91e70d8f6 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -231,7 +231,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) --- diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index e60e683c738..aa514bc6784 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -288,7 +288,10 @@ Every lane, Codex included, combines through the fold. A child waiting on a human is a fold input (`childWorkLiveness: 'waiting'`, derived from the child's own `waiting` state; a child's `blocked` means it failed and stays live work) and makes the row wait whatever the main agent is doing, unless the main agent -is itself asking. Only the Codex hook lane feeds that input today. Known +is itself asking. The Codex hook lane feeds it from its child transcripts, and +the structured lanes from child records, which read `waiting` for a Codex child +thread's approval or input flag and for a Claude subagent's open permission +request. Known divergences, pinned by name in the parity table (`src/shared/main-agent-status-parity.test.ts`) where they are reachable, so a reader does not mistake them for drift: @@ -299,8 +302,13 @@ reader does not mistake them for drift: main agent event overwrites the slot, so the row stops reading `waiting` while the child is still asking, and a second asking child replaces the first. -- The structured lane has no per-child wait: a child's pending prompt makes - the session `attention`, which reads as the main agent's own `blocked`. +- In the structured lane a child's pending prompt also makes the session + `attention`, which reads as the main agent's own `blocked`: one needs-input + state whoever asked. A Claude subagent reads `waiting` only while the + journal holds its card pending: from after the card's row is written until + just before anyone closes it, so every publish that shows the child waiting + also shows the session's `attention`, and the row never reads `waiting` for + a Claude subagent's request. - The Codex hook lane drops its roster on a root `Stop` when it tracks no child transcripts, so a still-running or still-asking child stops holding the row. @@ -446,8 +454,8 @@ call it. `terminal wait --for tui-idle` is a reader too. Before STA-9100 hook state reached it only through the ` ready` titles the window writes, so a headless `orca serve` never saw it (#16095). Now an agent whose rule file says -`profile.hooks: "authoritative"` (OpenCode, OpenCode 2, Pi, OMP) has its -fresh row read straight from the store, through the same +`profile.hooks: "authoritative"` (OpenCode, OpenCode 2, Pi, OMP) or +`"turn-end"` (Codex) has its fresh row read straight from the store, through the same `selectFreshExplicitAgentStatusRow` join prompt-receipt verification uses (`src/main/runtime/tui-idle-hook-lane.ts`): @@ -469,11 +477,14 @@ fresh row read straight from the store, through the same OSC 133 zones itself; - every other agent stays `identity-only`: Claude sends no event when an approval is denied or Esc stops a tool, so its row can sit at `waiting` or - `working` forever, and the rules keep deciding. Codex is identity-only too: - before its `Interrupt` hook an Esc mid-turn leaves the row `working`, and an - older TUI can hand its hooks to a newer shared app server, so no version - check tells which Codex posts it. Current Codex settles fast anyway, since - `Interrupt` drives its `Codex ready` title. + `working` forever, and the rules keep deciding; +- Codex is `turn-end`: only a `done` decides (it settles the wait), and a + `working` or permission row leaves the decision to the rules. Before its + `Interrupt` hook an Esc mid-turn can leave the row `working`, and an older + TUI can hand its hooks to a newer shared app server, so no version check + tells which Codex posts it. A `done` is a real turn end on every version, so + trusting only that one keeps the headless gain (no quiet window after the + turn) without letting a missing cancel hang the wait. The titles stay for display; remote clients read them. diff --git a/docs/reference/antigravity-native-accounts.md b/docs/reference/antigravity-native-accounts.md new file mode 100644 index 00000000000..29fe943afde --- /dev/null +++ b/docs/reference/antigravity-native-accounts.md @@ -0,0 +1,90 @@ +# Native Antigravity Accounts + +Accounts reads the credential authority on the runtime that owns execution. A client chooses +an owning Orca runtime and a host/distro target before sending an operation; it never replaces +the client's Mac Keychain item for another host. The RPC capability is +`accounts.antigravity-native.v1`. Older paired hosts are refused before account mutations. +The RPC returns account summaries only, never credential JSON, access tokens or refresh tokens. +Displayed quota is tied to the subject and authentication method observed during its refresh; +an external identity change hides the previous account's quota without an automatic fetch. + +## Supported authority + +Normal macOS agy uses service `gemini`, account `antigravity`. Its go-keyring values use the +base64 or legacy hex wrapper. Orca passes writes through `security -i` stdin, validates bounded +output and reads the entire native value back. The command buffer limit is checked before +writing. A missing native item falls back to the CLI-specific +`~/.gemini/antigravity-cli/antigravity-oauth-token` file. The distinct legacy jetski fallback +is not imported. + +The compiled CLI bypasses keyring storage when SSH/WSL environment detectors or WSL kernel +identity apply. A runtime running under that evidenced bypass reads/writes its own CLI file; +it does not contact the client keychain. The file must be private and regular. A macOS +`cache/antigravity-keyring-unavailable` marker makes authority uncertain: Orca refuses instead +of assuming that the keychain or file wins. + +Native Windows Credential Manager, native Linux Secret Service, and operations directed from +Windows Orca to a selected WSL distro are explicitly unsupported pending verified adapters. +Windows file bypass is also refused until private ACL protection is verified. +Windows' `gemini:antigravity` raw blob and 2560-byte limit are different from the Mac wrapper; +Linux uses the login collection with `service=gemini`, `username=antigravity`. No dependency, +PowerShell compilation, credential-home flag, or cross-host fallback is invented here. +A separate SSH relay has no Accounts RPC; use a paired owning runtime that implements it. + +## Identity and snapshots + +A Google ID token supplies the normalized Google issuer and stable subject. The authentication +method also scopes identity. The label uses a verified email when available; email is never the +identity key. Account record IDs are random and survive token, expiry, refresh-token and email +rotation. Profiles without a stable subject can be displayed but cannot be saved for switching. + +Snapshots preserve the exact native JSON, including fields that Orca does not interpret. The +host's vault under `userData/antigravity-accounts/vault` requires meaningful OS encryption and +private permissions. Weak or unavailable encryption is refused. Unreadable/corrupt ciphertext +is preserved; it is never treated as an empty vault. This does not migrate the experimental +candidate's incompatible array vault or token-hash IDs. + +One host service serializes Add, Select, Remove, launch checks and refresh reconciliation. +It re-reads the vault after asynchronous native reads and captures external CLI refreshes into +the same stable account. Selection reconciles the outgoing snapshot, checks the expected native +bytes before writing, and checks native readback before publishing the selected ID. It avoids +writing an old snapshot over an already-active account. The current or selected account cannot +be removed; deletion checks the latest native value again before committing. + +A selected account is checked before new Orca PTY launches, including desktop daemon and +headless runtime paths. An externally changed native identity blocks the launch and asks the +user to select again. Existing sessions can retain their original credentials in memory. +Shell commands typed manually into a running terminal are outside the Orca launch guard. + +## Sign-in and concurrency limits + +Sign-in uses the supported ordinary agy browser/code flow. Users run agy on the owning host; +to add a different account they use its `/logout` command, complete the next sign-in, then save +the actual resulting account in Orca. This implementation does not advertise an Orca-managed +login or invent an agy `login`/`--login` flag. Browser completion and a second real Google +account remain user-driven; tests do not sign out or change the developer's real native item. + +Native keyring does not expose compare-and-swap. Orca's queue serializes its own calls, and +bounded before/after checks detect observed conflicts; another independently running agy or +Orca process can still write between the final check and the write or launch. A failed +verification may mean the native item changed but selection was not persisted. Refresh and +explicit selection resolve that state; automatic rollback could destroy a newer CLI refresh +and is deliberately avoided. The file backend has the same external-writer limit. + +## Evidence and contributor credit + +The foundation adapts the reviewed codec/macOS adapter from #21784 and account-service concepts +from #21797 (nwparker), with fresh identity, persistence, serialization and conflict handling. +The signed-in Accounts card and quota-error visibility acknowledge #19588 by @artile; quota +transport is reused from current main rather than its obsolete extraction code. Targeted +multi-account UI/target concepts acknowledge #23761 by @Tai-DT, replacing its placeholder login +and unused settings selection. The Accounts legacy-Gemini clarification acknowledges #21682 +and the original relevant migration contribution by @siddqamar, as requested in #17345. +No stale development stack was cherry-picked. + +Live proof uses a disposable Mac service/account item, a fully isolated hidden Electron home, +and synthetic accounts. A private task-only copy was also selected through the real service; +installed agy 1.2.14 consumed that verified file credential under its SSH bypass and returned +`command.name=usage`, `num_turns=0`, no conversation. The real native item remained unchanged. +This proves the Mac adapter mechanics and actual CLI file authority, not a second-account +native-keychain switch, native Windows/Linux switching, or WSL/SSH relay deployment. diff --git a/docs/reference/ci-demand-rollout.md b/docs/reference/ci-demand-rollout.md index c1be4219d6b..d8092ef412f 100644 --- a/docs/reference/ci-demand-rollout.md +++ b/docs/reference/ci-demand-rollout.md @@ -10,24 +10,34 @@ or a guaranteed forecast of savings. ## What runs now -| Work | Ordinary draft update | Ready PR / final checks | Main reference | -| -------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------- | --------------------------------------- | -| Static analysis and types | Immediately | Immediately | Existing workflows | -| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite | -| Packages | After successful static analysis and types | Same | Existing release workflows | -| Headless Node persistence | Linux x64 for ordinary runtime changes; all six platforms for sensitive inputs | All six platforms when server inputs changed | Full nightly qualification at 11:30 UTC | -| Headless Node glibc/musl qualification | Sensitive inputs only, after persistence succeeds | Both architectures after persistence succeeds | Both architectures | -| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC | +| Work | Ordinary draft update | Ready PR / final checks | Main reference | +| -------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Static analysis and types | Immediately | Immediately | Existing workflows | +| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite | +| Packages | After successful static analysis and types | Same | Existing release workflows | +| Headless Node persistence | Deferred until ready | Linux x64 for ordinary runtime changes; explicit platform families or all six for sensitive inputs | All six platforms for relevant main pushes; full nightly qualification at 11:30 UTC | +| Headless Node glibc/musl qualification | Deferred until ready | Linux-specific or full qualification, after persistence succeeds | Both architectures for relevant main pushes and nightly qualification | +| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC | -Headless-runtime-sensitive inputs include root/toolchain files, configuration, native code, -resources, platform-specific paths, persistence, SQLite, orcad, providers, -daemon, SSH, relay, and child-process code. Dependency discovery failure, a -missing event or an incomplete diff retains full qualification. An unrelated -change still skips these lanes through the existing dependency classifier. No native -artifact is shared across platforms or ABIs. The routine draft reduction is an -explicit coverage-placement change; it does not assert identical per-update -coverage. Every non-draft synchronize event and the ready-for-review event -restores full qualification. +Headless draft updates carry no verdict; readiness starts the checks. Relevant +ready PRs retain Linux x64 smoke coverage. Explicit Windows or macOS paths add both +architectures in that family, while Linux paths add Linux ARM and the glibc/musl +lanes. Root/toolchain inputs, native build inputs, shared execution/storage paths, +SSH, providers and relay changes retain every platform. Missing or incomplete +change evidence and failed import analysis also retain full qualification. +Unrelated changes skip through the dependency classifier. Relevant main pushes +qualify all six platforms and both Linux compatibility architectures; detection +uses the whole push before qualification can supersede an older relevant run. + +The detector checks known build inputs with the pinned Node toolchain first. +Other paths install dependencies for import analysis; an uncertain result never +skips qualification. This changes setup cost, not the qualification policy. + +Windows server prebuilds are cached separately by architecture and pinned build +inputs, including the runner image. Only successful qualification on main publishes +them. Consumers validate the payload and still run the pinned-Node load/spawn smoke; +a cache miss or invalid payload builds fresh. Nightly, manual and release builds +remain fresh. No native artifact is shared across platforms or ABIs. Expensive PR jobs wait for static/type success. This reduces fan-out for failed or rapidly superseded commits without sleeping on a runner. Successful isolated @@ -35,6 +45,15 @@ PRs pay the extra stage latency. Existing per-PR cancellation remains in place. Package assertions, native boundaries, SSH/folder coverage, cache warming and slow-test assertions are retained. +The daemon running-work test imports the shared probe directly, with the daemon's +process inspector supplied as its callback. The renderer keeps its existing +adapter and forwarding tests. This removes a mocked renderer dependency from the +headless graph without changing the probe algorithm or skipping backend tests. +At validation, the graph fell from 6,018 inputs (1,070 renderer inputs) to 4,879 +inputs (no renderer inputs), including nine added shared-probe cases. Renderer +adapter changes no longer qualify the headless matrix; shared probe and daemon +test changes still do. Future actual renderer imports remain discoverable. + ## Unit selection rollout PR planning runs alongside typechecking after their shared dependency setup; an diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 6c7f0d059cf..611ef54b8cb 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -46,6 +46,146 @@ used 42 aggregate runner-minutes across 11 test jobs. The estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are baseline observations; post-merge savings have not yet been measured. +## October 4 clock, byte and import test fixtures + +These changes retain production behavior, original case names and platform +outcomes. The paired pilots use three alternating one-worker Node 24 invocations +on Ubuntu 24 ARM. Every median below is a complete focused test invocation; +they do not establish whole-shard savings or queue-delay improvements. + +| Workload | Baseline median | Candidate median | Reduction | Hosted evidence | +| ---------------------------------------------------- | --------------- | ---------------- | --------- | ------------------------------------------------------------------------ | +| Codex settlement and Claude stop deadlines, 35 cases | 35.914s | 10.142s | 71.8% | [37186232658](https://github.com/stablyai/orca/actions/runs/37186232658) | +| Native-chat delivery, 15 cases | 22.321s | 7.376s | 67.0% | [37183731823](https://github.com/stablyai/orca/actions/runs/37183731823) | +| Six profile-storage byte suites, 118 cases | 12.629s | 9.978s | 21.0% | [37183141654](https://github.com/stablyai/orca/actions/runs/37183141654) | +| Encrypted account storage, six cases | 18.277s | 0.958s | 94.8% | [37184007241](https://github.com/stablyai/orca/actions/runs/37184007241) | +| SSH remote commands, 27 cases | 6.840s | 6.078s | 11.1% | [37184454532](https://github.com/stablyai/orca/actions/runs/37184454532) | +| OpenCode subscription, 28 cases | 47.347s | 6.284s | 86.7% | [37184858823](https://github.com/stablyai/orca/actions/runs/37184858823) | +| Window-service attachment, 31 cases | 11.910s | 1.619s | 86.4% | [37186340840](https://github.com/stablyai/orca/actions/runs/37186340840) | +| OpenCode 2 TUI ownership, 41 cases | 16.811s | 2.429s | 85.6% | [37187699312](https://github.com/stablyai/orca/actions/runs/37187699312) | +| Title-send authorization, nine cases | 29.545s | 12.995s | 56.0% | [37188423318](https://github.com/stablyai/orca/actions/runs/37188423318) | +| Range selection, 15 cases | 9.737s | 7.130s | 26.8% | [37188996198](https://github.com/stablyai/orca/actions/runs/37188996198) | + +Provider tests wait for the real fake-child write/ready barrier and drain the +host stream before installing a scoped parent clock. The original 2.5/5-second +Codex and 3-second Claude deadlines remain; before/at assertions check their +boundaries. Early rejection and refusal resolve without waiting on an unreachable +barrier; finally and suite teardown restore clocks and spies even after a native +Vitest timeout. Four healthy failure-path controls pass; old-helper hangs and +removed teardown are caught. Missing-child failure retains a real ten-second observation window. +Six faults for early/late stop deadlines, late queued resend and missing child +output fail the intended assertions. Native-chat tests still use the real React +outbox hooks. Their scoped clock retains probe, retry, churn and target-switch +windows, drains async act work, and unmounts before clock restoration. All eight +hook faults fail; two boundary faults pass the old coarse tests and fail the new +before/at assertions. Node/web typecheck, lint and formatting pass. + +Native Buffer.equals replaces deep per-byte assertion traversal. It checks the +complete original bytes and length; fixtures, SQLite operations, encryption and +processes are unchanged. The six profile suites retain 114 passes and four +existing Linux case-sensitivity skips; all 118 pass locally on macOS. Seven +profile last-byte/length faults and two encrypted-vault last-byte/length faults +fail their exact byte assertions. All six encrypted-vault cases still exercise +52 accounts near the 4 MiB encrypted cap, refused growth, restart/readback and +private permissions. + +The old SSH fixture created 15,197 short stage paths but never exceeded the real +1,048,576 UTF-16-character transport tail cap; its two valid entries also left +the 64-result assertion vacuous. The replacement uses about 1,300 real excluded +stage directories under long Unicode path components, a real shell channel and +the production execCommand limiter. Actual find output exceeds that cap, while +the generated filter retains both original valid entries. A separate population +of 65 valid directories proves the first-64 limit and native find ordering. File, +symlink, nested-install and failed-enumeration checks remain. All 27 case outcomes +match across treatments (23 passes and four unavailable PowerShell 5.1 skips on +the hosted image). Eight cap, ordering, filtering and failure faults are caught. +Paths use platform utilities; local PowerShell availability retains its original +skip policy. The deadline and SSH fixtures reuse existing process/stream code. +OpenCode subscription tests batch only uninterrupted fixture writes between the +original observation barriers. Both SQLite schema versions keep every row, rowid, +read cap, poll, clock position and case. No durability pragma or production code +changes. All 28 cases pass in every pair. Separate captures compare ordered +schema and rows, transaction state, pragmas, signals, page requests/results and +subscriber callbacks: 124,754,101 payload bytes match, canonical digest +`ba0eb6f09281746071d73fae88e2e8eb45332f36892b90998b374b1b8c59b3e3`. +Missing rows, collapsed frontier rowids, read-cap overruns, missing commit and +missing rollback each fail the intended case in both schemas. Positive rollback +controls pass. The unmeasured full-suite timing report ranked this fixture at +134.229 seconds; the paired 47.347-second figure above is the relevant focused +baseline, and the two figures must not be mixed into a claimed saving. + +Window attachment tests mock five unrelated registrar modules using their actual +types. The existing window ownership, reload, media permission, native file drop, +hydration barrier and updater scheduling cases remain real. Exact store/runtime/ +window arguments and daemon registration after the PTY handler are now asserted; +nine actual production wiring/order faults fail. The registrar implementations +retain separate handler tests. Concrete existing stubs moved to one fixture to +stay within the line limit. All 31 cases and statuses match across all pairs. The +final source differs from the timed source only by a required type-assertion +safety comment. +OpenCode 2 TUI tests use a scoped async clock only after the first real native +module import. The unchanged generated plugin runs against the existing fake TUI +and fetch. Original poll, permission, retry, preview, slow POST and endpoint +windows remain. Before/at assertions pin the 100 ms poll, 500 ms permission, +120 ms slow POST and 5-second endpoint boundaries. All 41 original case outcomes +match. Separate ordered captures preserve 678 TUI/event rows and 362 complete +POST start/completion rows; wall timestamps and cross-stream interleaving are +excluded from equivalence. Eleven generated-source faults fail their intended +identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/ +disposer rejection and timeout controls confirm restoration of clocks, fetch, +argv and environment. Teardown holds its fake clock through bounded native cleanup and pending-timer checks, then restores real clocks. The [teardown qualification](https://github.com/stablyai/orca/actions/runs/37195736834) passes all 41 cases and 12 failure and restoration control invocations, including interval and retry leaks missed by the earlier teardown. +Title-send authorization tests retain real terminal creation, graph binding and +positive evidence paths. Negative process-evidence probes use scoped clocks +after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at +6,600 ms, and the send guard retains its 1,050 ms deadline. Before/at assertions +check 6,599/6,600 and 1,049/1,050 ms. All nine original cases remain. Actual +early/late wrapper and guard deadlines, false spinner identity and unknown-agent +authorization faults fail their intended assertions; native clocks and runtime +instance spies are restored after each failure. +Range-selection tests stub only the saved-note send menu, which their empty +comment populations never render. A facade typed from the actual menu props +throws if invoked, and an afterEach assertion verifies no call with unconditional +mock clearing in finally. The real hook, Monaco constants/model, line and range +drag behavior, draft-card lifecycle and open-inline-card chord remain. All 15 +original test bodies are byte-unchanged. Three actual range/hunk/draft faults +fail their original assertions; a real draft-render menu call hits the facade +and sentinel, and an outer cleanup check proves mock state cleared after failure. +The actual saved-note menu retains its independent component tests. + +## October 2 headless detector compiler cache + +The deferred detector already avoids dependency setup for known build inputs. +For changes that need import analysis, the collector marks package imports external; +only esbuild and its platform binary are needed. A small compiler archive can replace +root dependency setup for this analysis, while qualification jobs still install normally. + +The existing Linux x64 warmer packs these two packages after its frozen, +script-free, policy-checked install. Only main publishes. Readers use an exact key +covering Node/platform/architecture, manifests, install policy, patches and the cache +implementation. The producer and reader use the same archive path. File hashes, +identity and a compiler smoke are checked before availability is reported; missing, +invalid or failed restores use the original full installer. Graph analysis retains +its existing conservative full-qualification verdict on errors. + +A [three-pair hosted comparison](https://github.com/stablyai/orca/actions/runs/37071724200) +passed on Ubuntu x64 with Node 24.21.0 and esbuild 0.28.2. Every pair produced the +same 6,018 source inputs. Sample 2 ran the compiler-only treatment first; samples 1 +and 3 ran the existing installer first. Each used a fresh dependency tree, and the +compiler treatment required a real cache hit and validated its bytes and smoke. + +| Sample | Full installer + graph | Compiler restore + graph | Paired saving | +| ------ | ---------------------- | ------------------------ | ------------- | +| 1 | 11.730s | 2.805s | 8.925s | +| 2 | 14.702s | 4.706s | 9.996s | +| 3 | 14.666s | 3.750s | 10.916s | + +The median paired saving is 9.996 seconds. Inter-step overhead, archive transfer, +validation and the real graph are included. Checkout, initial Node setup, dependency +resets, seed work, post-job cache saves, tests and queues are excluded. These are +warm detector measurements, not whole-workflow or billing savings. The trial uses +the same package layout and validation as the production helper; production also +resolves its policy fingerprint. Cold or changed identities still install fully. + ## SSH Windows slot reuse The SSH Windows host workflow uses the same server-slot preparation action as @@ -76,6 +216,171 @@ request reuse only following an exact prepared native-cache hit; manual SSH and all release builders retain fresh compilation. Subsequent staging checks still run. Hosted validation and the reuse interval remain to be measured. +## Windows root download stores: registry installs finish sooner + +Three paired samples on each Windows architecture compared the existing exact +main download-store restore with a fresh registry install. Each treatment used a +fresh dependency tree, store and pnpm metadata, with registry-first ordering in +sample 2. Both restored the same policy-checked verification record before timing. +All six pairs used Node 24.21.0 and pnpm 12.8.1; manifest digests and installed +lockfile digests matched, and both retained frozen, script-free installation. + +| Runner | Cached totals (seconds) | Registry totals (seconds) | Paired median saving | +| ------------- | --------------------------- | --------------------------- | -------------------- | +| Windows x64 | 26.820 / 28.885 / 27.751 | 13.644 / 14.126 / 12.908 | 14.759 seconds | +| Windows ARM64 | 216.540 / 288.492 / 189.342 | 119.856 / 238.562 / 115.611 | 73.731 seconds | + +The x64 samples are the three successful Windows 2022 jobs in +[run 37064549378](https://github.com/stablyai/orca/actions/runs/37064549378). +Its ARM cleanup guard rejected pnpm's setup-owned store path before measurement; +those incomplete ARM jobs are excluded. The corrected +[ARM-only run](https://github.com/stablyai/orca/actions/runs/37065220916) passed all +three samples. Earlier rejected measurements also stopped before installation +because an optional config file was absent; none count toward these timings. + +Intervals include actual store lookup/restore, inter-step overhead and root +installation. Checkout, toolchain setup, tree/store reset, verification-record +restoration and native preparation are excluded. ARM variation is substantial; +these samples do not measure whole-workflow, queue or billing savings. + +Root-only Windows x64/ARM64 PR installs now skip the download-store restore. +The existing x64 mixed-install exception remains. An explicit store opt-out also +lets Windows headless persistence and SSH jobs avoid the archive on main or +manual runs. Frozen installs, verification records, native caches and every +qualification check remain. Other lockfile sets and platforms keep their +existing policy. Default non-PR writers, including the warmer, still seed stores +for direct setup-node consumers and workflows that run package scripts. + +## October 2 Linux root store comparison + +A [six-job hosted comparison](https://github.com/stablyai/orca/actions/runs/37073978443) +measured the actual main root-store archive against direct registry installation, +with three fresh-runner pairs on each Linux architecture. All six jobs passed. +The middle sample on each architecture reversed treatment order. Between treatments, +the driver removed the dependency tree, store and pnpm metadata, then restored the +same policy-checked verification record before timing. Frozen, script-free installs +preserved policy files and produced identical installed lockfile digests in each pair. + +| Architecture/sample | Store restore + install | Direct registry install | Paired saving | +| ------------------- | ----------------------- | ----------------------- | ------------- | +| x64 / 1 | 6.516s | 5.372s | 1.144s | +| x64 / 2 | 6.743s | 3.950s | 2.793s | +| x64 / 3 | 6.600s | 3.985s | 2.615s | +| ARM64 / 1 | 7.632s | 3.346s | 4.286s | +| ARM64 / 2 | 5.504s | 3.575s | 1.929s | +| ARM64 / 3 | 5.450s | 3.364s | 2.086s | + +Median paired savings are 2.615 seconds on x64 and 2.086 seconds on ARM64; means +are 2.184 and 2.767 seconds. Both used Node 24.21.0 and pnpm 12.8.1. Actual store +lookup/transfer/restore, inter-step overhead and installation are timed. Checkout, +initial toolchain/dependency setup, preparing the existing process wrapper, +dependency resets, verification-record restores, native work, tests, post-job cache +saves and queues are excluded. Package services have already been used by initial +setup. These are warm-policy setup measurements, not workflow or billing savings. + +The shared installer consequently skips root-only Linux x64/ARM64 store restores +on PRs. It still installs and checks every package through pnpm. Mixed mobile and +custom lockfile sets, other architectures, verification/native caches, +main store writers and release installation policies keep their existing behavior. +The measured Windows exceptions remain. Mac restores were retained at this stage; +the following comparison supersedes that policy. No periodic job or cache is added. + +## October 2 macOS root store comparison + +A [six-job hosted comparison](https://github.com/stablyai/orca/actions/runs/37078232553) +used the same paired method on macOS 15 Intel and Apple Silicon. All six jobs +passed with actual main store cache hits. The middle sample reversed treatment +order. Each treatment started with a reset dependency tree, store and pnpm +metadata, followed by the same verification-record restore. Policy files and +installed lockfile digests matched within every pair. + +| Architecture/sample | Store restore + install | Direct registry install | Paired saving | +| ------------------- | ----------------------- | ----------------------- | ------------- | +| x64 / 1 | 87.270s | 60.584s | 26.686s | +| x64 / 2 | 103.280s | 52.640s | 50.640s | +| x64 / 3 | 61.564s | 40.768s | 20.796s | +| ARM64 / 1 | 26.024s | 14.375s | 11.649s | +| ARM64 / 2 | 37.000s | 19.726s | 17.274s | +| ARM64 / 3 | 34.616s | 14.888s | 19.728s | + +Median paired savings are 26.686 seconds on x64 and 17.274 seconds on ARM64; +means are 32.707 and 16.217 seconds. Node matched within each pair: 24.19.0 on +Intel and 24.20.0 on Apple Silicon, as resolved by the existing installer. Both +used pnpm 12.8.1. Timing includes actual cache lookup/transfer/restore, inter-step +overhead and installation. Checkout, initial setup, process-wrapper preparation, +resets, verification restores, native work, tests, cache saves and queues are +excluded. Initial setup has already used package services. These measurements +do not establish whole-workflow or billing savings. + +The existing root-only PR exception now also covers macOS x64/ARM64. Frozen, +script-free installs and pnpm policy checks still run. Mixed/custom lockfile sets, +other architectures, verification/native caches, main/manual store writers and +release installation policies retain their existing behavior. No periodic job +or cache is added. + +## October 2 store producers: keep caches without downloading hits + +The optional `cache-pnpm-store-lookup-only` installer input uses +[`actions/cache` lookup-only](https://github.com/actions/cache#inputs) on non-PR +runs. An exact hit refreshes cache access without extracting the archive; a miss +still installs from the registry and publishes the populated store at successful +job completion. The default remains the existing `setup-node` cache behavior. +The four Linux/Windows dependency warmers and Linux/macOS persistence producers +opt in. Windows persistence retains its existing store opt-out, and PR restore +policies are unchanged. This adds no recurring job or extra cache family. + +A [tiny framework control](https://github.com/stablyai/orca/actions/runs/37082688033) +proved that lookup left the payload absent, refreshed the existing cache's access +time, and published a miss that a fresh job restored. A +[nested composite control](https://github.com/stablyai/orca/actions/runs/37084946789) +then saved and restored a fresh payload using the actual environment-path pattern. +The installer exports its resolved store path through `GITHUB_ENV`: twice-nested composite post-job saves +cannot resolve their internal step outputs. The primary key is captured +by the cache action before cleanup. Paths, architecture and lockfile keys match +`setup-node`, so existing default-branch archives remain reusable. + +The [six-platform installer screen](https://github.com/stablyai/orca/actions/runs/37084946789), +[Linux repeats](https://github.com/stablyai/orca/actions/runs/37085164277), and +[corrected Windows repeats](https://github.com/stablyai/orca/actions/runs/37085248976) +compared the complete shared installer, including toolchain setup, cache actions, +policy verification, frozen installation and native probes where requested. +Each treatment reset dependencies, the store, pnpm metadata and the Windows +registry build directory. Treatment order reversed across architectures and +repeats. Every qualified pair required real main store cache hits, matching +policy/installed-lockfile digests and Node/pnpm versions, plus exact native-cache +hits on Linux and Windows. The initial Windows x64 screen stopped before timing +because its benchmark guard rejected the standard `D:\.pnpm-store` path; that +unqualified job is excluded. + +| Platform / sample | Restore + installer | Lookup + installer | Paired saving | +| ------------------------ | ------------------: | -----------------: | ------------: | +| macOS ARM64 | 37.785s | 20.024s | 17.761s | +| macOS x64 | 75.610s | 46.638s | 28.972s | +| Linux ARM64 / 1 | 10.005s | 7.242s | 2.763s | +| Linux ARM64 / 2 | 8.817s | 6.672s | 2.145s | +| Linux ARM64 / 3 | 8.800s | 6.581s | 2.219s | +| Linux x64 / 1 | 12.309s | 9.735s | 2.574s | +| Linux x64 / 2 | 10.131s | 8.690s | 1.441s | +| Linux x64 / 3 | 13.741s | 9.485s | 4.256s | +| Windows ARM64 / repeat 1 | 145.818s | 78.729s | 67.089s | +| Windows ARM64 / repeat 2 | 152.730s | 106.475s | 46.255s | +| Windows ARM64 / screen | 294.092s | 193.957s | 100.135s | +| Windows x64 / 1 | 30.936s | 20.256s | 10.680s | +| Windows x64 / 2 | 31.021s | 20.098s | 10.923s | + +All 13 qualified pairs improved. Median paired savings were 2.574 seconds on +Linux x64, 2.219 on Linux ARM64, 10.802 on Windows x64 and 67.089 on Windows +ARM64. Each macOS architecture had one pair; its 28.972 / 17.761 second savings +are a screen, supported by the earlier three-pair root-store comparisons. + +All pairs used pnpm 12.8.1. Node was 24.21.0 on Linux and Windows, 24.19.0 on +macOS Intel and 24.20.0 on macOS ARM. Source dependency policies were frozen for +this screen; later main dependency changes do not extend these measurements. +Timing excludes checkout, initial service/bootstrap use, wrapper compilation, +resets, result validation, post-job saves and queues. These are installation +measurements, not whole-workflow or billing savings. Cold publication is verified +separately by the small controls; no large synthetic store cache was uploaded. + ## October 1 Windows and dependency cache follow-up [PR #24355](https://github.com/stablyai/orca/pull/24355) merged at `197ea3a3`. @@ -402,6 +707,8 @@ All commands passed and plans matched within each comparison. These command timings exclude setup and queues; compiler variation contributes to the cold difference. The retained arrangement showed no cold compiler penalty. +The sequential gate in [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity) supersedes the earlier rejection below. + Combining static analysis too was rejected. An [alternating same-runner comparison](https://github.com/stablyai/orca/actions/runs/36835091650) saved runner occupancy, but cold compilation slowed from 61–64 to 83–89 seconds @@ -679,8 +986,8 @@ coverage, and release behavior: Shard 4 spent 535 worker-seconds importing and 357 executing tests; a uniform per-file import estimate misses that cost. See [timing refresh](../../config/scripts/ci-shard-timings.md). - Seed Node 24 native modules, the pinned Git compatibility binary, and TypeScript - state on the default branch, hourly - and when dependency/toolchain inputs change. One ten-minute-bounded hosted job + state on the default branch when dependency/toolchain inputs change, with + scheduled recovery (originally hourly; now every six hours). One ten-minute-bounded hosted job reuses existing cache keys and skips typechecking an already-cached commit. New PRs can restore default-branch caches, while caches saved by another PR are inaccessible. The audit found 80 entries totaling 10.67 GiB, including @@ -1216,7 +1523,7 @@ These are single cold/warm observations, not paired medians or a measured whole-workflow saving. They demonstrate usable exact-key reuse after publication; future savings depend on cache availability and unchanged native inputs. The trial seeds belong to this PR's merge ref. Other PRs require a main-branch seed -after merging this new namespace; the existing main push and hourly warming +after merging this new namespace; the existing main-push and scheduled warming jobs provide that seed. ## Separate mobile install verification: retain the current policy @@ -1269,7 +1576,7 @@ This measures the three-file oracle cohort. Whole-shard timings include other test bodies, imports and transforms, so a whole-suite saving needs separate measurement. -## Cache warming: let hourly ticks wait for active work +## Cache warming: let scheduled ticks wait for active work The hourly warmer previously cancelled an active warmer, even when both used the same source. On October 2, the [merge-triggered run](https://github.com/stablyai/orca/actions/runs/36965832780) @@ -1289,6 +1596,27 @@ scheduled run or its repeated successful lanes, and pending replacement still applies regardless of the cancellation expression. The bounded 20-run sample contains this collision; it does not establish a recurring or whole-CI saving. +## Cache warming: six-hour recovery interval + +Scheduled warming now runs at 00:41, 06:41, 12:41 and 18:41 UTC instead of hourly. +Main pushes that change cache inputs still seed immediately, and manual dispatch +remains available. All five jobs, probes, keys and publication rules remain. +This removes 20 scheduled workflows and 100 scheduled job starts per day (83%). + +Four consecutive October 2 scheduled runs used the same source. The +[18:50 UTC run](https://github.com/stablyai/orca/actions/runs/37050194510) used 474 +aggregate runner-seconds across five jobs, including 242 seconds on Windows ARM. +That job restored exact package, verification and native caches; package-store +restore alone took about 70 seconds. Repeating that observed duration twenty +fewer times would avoid about 158 runner-minutes daily, but this one-run estimate +is not a billing forecast or measured post-rollout saving. + +The longer interval can delay background repair after eviction or runner-image +changes. Existing consumers retain cold-cache installation/build fallback, and +normal cache reads update last access. Storage was near the repository limit +when audited, so retention and unchanged hit rates are not guaranteed. Observe +misses before reducing the recovery frequency further. + ## Daemon shutdown fixture: remove build tools after compilation The fixture now removes compiler and Python build dependencies, plus npm and @@ -1392,3 +1720,866 @@ one CI failure does not establish a failure-rate reduction. The bounded 50-head main sample ending at 8ff6296 contained no root package metadata changes. Removing app-version metadata from the Windows server cache key would not improve reuse in that sample, so the key remains unchanged. + +## Windows ARM SSH: prepare the inbox capability during independent builds + +The ARM inbox lane starts guarded Windows capability preparation after the pure +provisioning self-test and waits for it before any private SSH server or host cell +runs. Dependency installation and the unchanged native artifacts can run during +that preparation. Preview and x64 lanes keep their existing serial provisioning; +the registered background step completes without mutation in those lanes. + +The preparation and the foreground provider use the same installer and isolation +guards. The receipt must match the source, run, attempt, runner, image and native +architecture. The foreground provider still reads the installed capability and +verifies every native binary and Microsoft signature. Account ownership, ACLs, +DefaultShell, private service identity, host cells and cleanup remain independent +checks. A background failure propagates through the unconditional native wait. + +Two full four-lane pairs used frozen source refs and the same dependency and +native-install policy. The [first baseline](https://github.com/stablyai/orca/actions/runs/36986929163) +ran before the [first candidate](https://github.com/stablyai/orca/actions/runs/36986970976); +the [second candidate](https://github.com/stablyai/orca/actions/runs/36991232037) +was dispatched before the [second baseline](https://github.com/stablyai/orca/actions/runs/36991234729). +Runner image versions matched within each platform in both pairs. + +| Active job, seconds | First baseline | First candidate | Second baseline | Second candidate | +| ------------------- | -------------: | --------------: | --------------: | ---------------: | +| ARM inbox | 2,403 | 1,644 | 2,353 | 1,667 | +| ARM preview | 1,002 | 935 | 886 | 872 | +| x64 inbox | 636 | 732 | 616 | 620 | +| x64 preview | 562 | 561 | 623 | 566 | + +The ARM inbox observations improved by 759 and 686 seconds. Baseline dependency +installation and artifact builds consumed 501 and 498 seconds before capability +installation could start. Candidate capability installation ran during that +work, but also took about 261 and 232 seconds less than the baseline. Candidate +dependency installation was slower, particularly in the second pair. These +observations support overlap on ARM; they do not establish a guaranteed 11–13 +minute saving, a reduction in queue time, or the cause of installer variability. +The x64 lane showed no repeatable gain, so it keeps serial preparation. + +All 16 actual Windows providers and 48 host-cell verdicts passed across the two +pairs. Receipts verify native machine identity, private service absence, owned +process exit, account removal and key removal. Loaded profile disposition remains +separate from those required cleanup checks. Hosted execution also verified the +native background/wait syntax; older actionlint versions do not recognize it. + +### Overlap the private profile observation budgets + +After service deletion and owned process exit, profile cleanup polls each owned +SID with its own full 30-second monotonic budget. Independent budgets now run +together. Every deletion follows a fresh targeted read; loaded profiles remain +for disposable VM destruction. Service identity, PID ownership, process exit, +account removal and key removal still fail the complete provider on error. + +The maintained diagnostics self-test executes the actual cleanup try/catch with +scoped Windows API and clock controls. Eight positive cases cover full windows, +late unload, reload, query overhead, mixed states and missing SIDs; ten specific +failure cases cover foreign profiles and the required cleanup gates. Disposable +shortened-deadline and stale-snapshot mutations fail those controls. A separate +mocked real-clock observation took 30.179 seconds for three loaded profiles, +compared with about 90 seconds for serial full budgets. This measures polling, +not an actual Windows provider or the entire job. + +The third profile no longer gains incidental extra time while earlier profiles +consume their budgets. A profile unloading at 45 seconds may therefore remain +where serial cleanup removed it. This uses the existing disposable-VM fallback; +it does not remove a loaded profile or relax mandatory account/key cleanup. +Hosted qualification of the combined workflow remains pending. + +## Coordinator mail tests: advance observation windows without removing them + +Six cases advance their original six 1,500 ms and ten 100 ms observation windows +with a scoped clock. Real filesystem, SQLite, journal, RPC and runtime work still +finishes asynchronously. The original journal-read gate and all counter and +operation assertions remain. Cancellation during delayed startup and the +Date-only age case retain real timers. Teardown stops the host and closes the +database before advancing the known 2,000 ms orphan repair, then asserts no fake +timers remain and restores the clock in `finally`. + +Two opposite-order local pairs passed the same 23 cases and unchanged source +hashes. Selected-case totals fell from 13.674 to 3.318 seconds and from 13.276 to +6.323 seconds. Whole-file test totals fell from 24.845 to 10.829 seconds and from +21.500 to 19.410 seconds. Process wall times were 41.488/37.810 seconds and +42.140/78.450 seconds; the reverse candidate spent 56.31 seconds importing under +unrelated local load. Local process-wall savings were inconclusive. + +The later [hosted x64 and ARM comparison](https://github.com/stablyai/orca/actions/runs/37001891871) +passed the same 23 cases in `structured-chat-coordinator-mail.test.ts` in both +orders on each architecture, with frozen case and policy hashes. Median full-file +wall time was 33.506 → 23.149 seconds on x64 and 33.438 → 22.504 seconds on ARM. +Median test-body totals were 19.329 → 9.343 and 19.695 → 9.103 seconds, respectively. +These measurements qualify this file; they do not measure whole-PR time. + +Injected extra deliveries at 1,499 ms and 99 ms still fail the original assertions +in both clock modes. The latter candidate fails the unchanged journal-read gate +with the same extra provider start. A separate control confirms the orphan repair +actually executes against the closed database and leaves no fake timers. The +change retains all 121 original expectation sites and adds one teardown check; +it does not shorten the runtime's observation interval or claim a whole-PR gain. + +## Stub child shutdown clocks: Codex and Claude + +[Merged Codex change #24893](https://github.com/stablyai/orca/pull/24893) scopes timeout +clocks to two synthetic-child cases in `codex-app-server-connection.test.ts`. +The full platform graceful deadline and 1,000 ms forced wait remain; the test +waits for the actual stub SIGKILL before advancing the forced window. Streams, +process-table reads, Date and immediate callbacks remain real. Fault controls +still detect late exit, missing EPIPE, missing exit proof and unwanted notification. + +The [hosted ARM comparison](https://github.com/stablyai/orca/actions/runs/37074124526) +passed the same 32 full-file cases in baseline/candidate and candidate/baseline +order. File wall times were 13.671 / 13.674 seconds originally and 1.658 / 1.649 +seconds with scoped clocks. Installer time is excluded; generated caches remain +across the disclosed order. Real-child coverage and production shutdown code remain. + +[Merged Claude change #24897](https://github.com/stablyai/orca/pull/24897) changes only +two synthetic-child cases in `claude-agent-sdk-exit-proof.test.ts`. Both full +33-case runs passed, including the unchanged five real-child cases. In one local +macOS pair, the two bodies took 2,503 / 1,502 ms originally and 1.37 / 0.39 ms with +scoped clocks. They cross a real immediate callback before advancing the complete +1,500 ms graceful and 1,000 ms forced windows, restore timers in `finally`, and +retain the original false exit verdicts. Fault controls detect either deadline +shortened by one millisecond, an unproved true verdict and a leftover timer. +[Normal PR CI](https://github.com/stablyai/orca/actions/runs/37075819218) passed; +these local body measurements do not establish hosted or whole-PR time savings. + +## Sequential static analysis and typecheck: retain separate jobs + +The earlier recommendation below is superseded by [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity). + +A four-trial hosted screen kept the slim router unchanged and compared the two +independent ARM jobs with one ARM job running their unchanged checks sequentially. +The [compiler/planner census](https://github.com/stablyai/orca/actions/runs/37069472888) +matched all compiler inputs and the full 10,477-file unit inventory in separate, +shared root-only and shared mixed-install states. The [safety qualification](https://github.com/stablyai/orca/actions/runs/37075043747) +verified native joins after compiler failure and a real late action-post failure; +all four guarded downstream sentinels skipped and the audit passed. + +| Trial | Mode | Active ARM seconds | Router finish to heavy finish, seconds | +| -------------------------------------------------------------- | -------- | -----------------: | -------------------------------------: | +| [1](https://github.com/stablyai/orca/actions/runs/37075574191) | Separate | 157 | 124 | +| [2](https://github.com/stablyai/orca/actions/runs/37075887937) | Combined | 134 | 150 | +| [3](https://github.com/stablyai/orca/actions/runs/37076222202) | Combined | 129 | 134 | +| [4](https://github.com/stablyai/orca/actions/runs/37076786281) | Separate | 159 | 194 | + +Both pairs saved active ARM time: 23 and 30 seconds, or 14.6% and 18.9%, with one +heavy admission instead of two. The active critical path was 15 and 8 seconds +longer. Downstream eligibility changed by +26 and −60 seconds; observed ready-to-start +delay differences of +11 and −68 seconds explain that reversal. Created-to-start +delay is recorded separately and does not establish a quota or queue cause. + +Retain separate jobs for now. This screen shows a capacity saving, with a longer +active critical path and no repeatable latency gain. All trials used frozen +`cc73c8e1a72b0e9ee9c29e57458ce307f5f019c2` source, manual workflow dispatches, +Node 24.21.0 and the same four exact primary cache hits. Main's later +[Linux PR root-store policy change #24896](https://github.com/stablyai/orca/pull/24896) +is outside this screen. The trial ran actual heavy checks and proved unit and both +package eligibility, without launching those downstream matrices or measuring a +whole-PR speedup. + +## Linux headless runtime build overlap + +The historical pinned Bun artifact now builds in a native background step while +current native preparation and Node bundling run in the foreground. An +unconditional join precedes the unchanged artifact and cross-runtime tests. Bun +setup stays Linux-only; other platforms register and join a successful no-op. +The producer publishes step outputs consumed only by those tests. The existing +selector, native floors, template builders and cache policies remain. + +A [hosted alternating comparison](https://github.com/stablyai/orca/actions/runs/37072923774) +ran four serial/overlap arms on each of two Linux VMs: + +| Architecture | Serial preparation, seconds | Overlapped preparation, seconds | +| ------------ | --------------------------: | ------------------------------: | +| x64 | 20.831 / 19.576 | 11.149 / 10.914 | +| ARM64 | 15.155 / 14.396 | 8.566 / 8.553 | + +Every arm passed the same 961 cases across 92 files: 930 passed and 31 skipped. +Both cross-runtime persistence cases passed. The two live daemon-handover cases +kept their existing protocol-version skips. All four x64 arms passed actual Node +18 loading and pinned-runtime handoff. Installed/source inputs and artifact +inventories matched; each normal owned-process ledger was clean before cleanup. +Common native compiler warmup preceded timing and retained its generated Python +caches in the strict installed ledger. These are preparation savings of 5.8–9.7 +seconds, excluding setup, cold installs, runner start delays and whole-PR time. + +Actual [Bun failure](https://github.com/stablyai/orca/actions/runs/37078015921) and +[Node failure](https://github.com/stablyai/orca/actions/runs/37078021568) controls +qualified genuine compiler errors with fresh live opposite builders, native joins, +skipped consumers, restored inputs and verified exits. A [normal cancellation +control](https://github.com/stablyai/orca/actions/runs/37079655167) received SIGINT +while the actual Bun builder was freshly live; both builders and the detached +owned child had simultaneous earlier readiness. All three native joins had terminal dispositions of cancelled, success and +cancelled, and every consumer skipped. The temporary observer retired its owned processes; +the collector independently verified their absence and unchanged inputs. This +proves signal delivery and observer-owned retirement, without establishing +runner-only descendant cleanup at the join. The unchanged historical builder +starts finite build/smoke work, and its children retain GitHub's normal orphan +tracking marker. + +Earlier cancellation trials remain excluded from live-build qualification: one +collector stopped its observer before signal routing, and the corrected trial +received the signal after both builders finished. The qualifying trial requested +normal cancellation earlier in the same preparation sequence to account for +observed delivery delay; no workload, wait or proof predicate was shortened. + +## October 3 Terminal Perf dependency preparation + +The daily/manual Terminal Perf workflow still installed current dependencies through +raw lifecycle scripts and a global node-gyp installation. Its historical `ref` +input also accepts revisions that lack the shared installer, so replacing that +path unconditionally would break older runs. The current-profile path now uses +the existing shared installer with explicit Electron preparation and archive +caching. A guard requires GitHub-hosted Linux x64, Node 24/pnpm 12.8.1, the +native-only root postinstall and the needed local action inputs/files. Other +profiles and historical revisions keep their original frozen install. + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37101695800) +ran both preparation paths in each of two Linux x64 jobs, reversing their order. +Legacy/shared preparation took 25.164/16.956 seconds and 27.434/18.032 seconds: +8.208 and 9.402 seconds saved. Both used Node 24.21.0, pnpm 12.8.1 and Electron +43.7.5. Both shared native-module cache lookups missed, so this improvement did +not depend on a warm native build. Electron archive and root pnpm cache lookups +hit. Dependency trees, pnpm data and Electron archives were reset between paths; +compiler headers and external services were not. Bootstrap, resets, validation, +post-job cleanup, queueing and the production guard step are outside those times. +These are preparation measurements, not whole-workflow or billing savings. + +Both paths passed a native-module probe inside the actual Electron executable +with `ELECTRON_RUN_AS_NODE=1`, and built the same Electron-vite e2e application. +The candidate's 18 focused routing/fallback tests, workflow actionlint and changed +code-quality checks passed. Performance tests, budgets and report uploads remain +unchanged. The [existing October 2 run](https://github.com/stablyai/orca/actions/runs/36985792125) +failed the same-workspace 50/100-terminal budgets (46.9/50.2 ms against 25 ms). +This dependency change does not claim to resolve those application regressions. + +The [full candidate integration](https://github.com/stablyai/orca/actions/runs/37104625474) +passed on `df71ad849cd854a232f7063562785563743b641a`: current preparation was +selected, its native cache missed and rebuilt, the app built and all 32 report +annotation rows passed the unchanged budget checker. The downloaded report also +passed the same checker locally. This is integration evidence; it does not +attribute application latency changes to dependency preparation. Subsequent +rebases resolved report documentation and incorporated fixture teardown fixes. +Workflow, installer-action and toolchain content stayed unchanged. Main also +added an import and a Windows-only MSBuild setting to the native-runtime script: +the imported helper has no top-level side effects, and the Linux rebuild branch +is unchanged. Focused tests verify its Linux/macOS no-op behavior. Final-head PR +checks qualify separately. + +## October 4 reusable cells for terminal context scans + +Terminal cursor-context scans now request one reusable cell per invocation when +the adapter offers getNullCell, and pass it through all unchanged text/style +scans. Adapters without that optional method keep the existing allocating path. +The scratch cell is local and no cell reference escapes into returned context. +Browser composer/readiness text, colors, bold flags and wrapping are unchanged. + +Three alternating one-worker ARM pairs in +[37182789677](https://github.com/stablyai/orca/actions/runs/37182789677) +ran all 19 original cases from readiness census suite 2. Baseline complete +invocations were 37.141 / 37.879 / 37.090 seconds; candidate invocations were +33.887 / 33.387 / 32.916 seconds. Median 37.141 to 33.387 seconds saves 10.1%. +This is a focused workload measurement, not a whole-shard or queue-delay claim. + +Separate baseline/candidate captures retained all 192 cases across six census +suites. Every context and visible projection matched: 643,926 of each, with +7,465,308,324 complete length-prefixed payload bytes hashed per test/type/order. +The canonical capture digest was +`f7440c0f1b5bbb57127cd29245530029415c8e9e243c1744359f330b3c7ace19`. +These captures run outside the timing samples. All 41 cursor/composer/browser +consumer checks passed. Seven faults for lost dim filtering, wide continuation, +bold prompt, custom foreground, wrap preservation, adapter fallback and scratch +reuse failed their intended assertions. Node and web typecheck, lint and format +passed. Two added controls prove per-call scratch lifetime and adapter parity. + +## October 3 producer follow-up: automatic selection for the measured profile + +The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927) +passed all 46 PR checks, all five manual warmers and all 11 manual Headless +qualifications on `a2c489c0cca5e46d24333a4d40ba910af0de0208`. The same root installer +also serves recurring unit, browser and performance workflows that had not opted +in. The follow-up defaults the existing input to `auto`, reusing lookup mode for +non-PR root-only installs on GitHub-hosted Linux/macOS/Windows x64/ARM64 runners, +with no job container, the manifest's Node 24/pnpm 12.8.1 profile and no conflicting +Node override. Explicit `true` and `false` retain their previous meanings. Mixed +lockfiles, other toolchains, containers and self-hosted runners retain full cache +restoration; PR policies are unchanged. The manifest check runs only when the +context is potentially eligible, before setup-node chooses its cache behavior. + +A second cleanup audit distinguished nesting depth. The +[twice-nested control](https://github.com/stablyai/orca/actions/runs/37087090689) +published the environment-path payload and lost the output-path payload with an +`Input required and not supplied: path` warning. The +[direct control](https://github.com/stablyai/orca/actions/runs/37087211236) published +and restored both payloads. Current Electron archive callers are direct, so they +need no cache-path change. Keeping the producer's exported path also makes its +new lookup mode safe for callers that nest the shared installer. These tiny +controls establish publication behavior, not installer time savings. + +The [actual automatic-mode cold publisher control](https://github.com/stablyai/orca/actions/runs/37097980789) +passed both jobs on `7b8858bdc8f`. A twice-nested wrapper called the installer +without overriding its default input. The writer selected lookup, missed its +unique root-lockfile key, completed the frozen policy-checked install and saved +that key during cleanup. A fresh reader restored the exact key and installed the +same dependency successfully. The fixture retained the manifest toolchain and +applicable workspace policies; its one dependency keeps the publication check +small. Two earlier trials failed fixture assertions (the pnpm multi-document +header placement, then its empty cache-miss output), and are excluded. This proves +automatic selection and cold publication, not a new timing result. Local +verification passed eight suites / 184 tests, the changed-code quality gate and +compiled-composite actionlint. + +## October 4 shared PR preflight capacity + +Static analysis and the unchanged compiler now share one ARM runner and guarded +Node 24 install. Static checks finish and all background work joins before the +compiler starts; unit planning still overlaps compilation. Each phase keeps its +classifier output. Successful no-op background bodies register every required +join when a phase is unselected or an earlier step failed. Unit and package +consumers depend on physical job success, including action cleanup. + +Three counterbalanced pairs in +[37180613601](https://github.com/stablyai/orca/actions/runs/37180613601) +used the same frozen checkout `f199a20c3acd`, Node 24.21.0, pnpm 12.8.1, +policy hashes, native cache hits, warm TypeScript cache and 10,787-file unit plan. +Both arms used the PR root-only download-store policy. Total active job time was +152 / 153 / 151 seconds separately and 138 / 133 / 129 combined. Excluding the +extra measurement-only evidence steps gives 151 / 151 / 149 versus +136 / 132 / 128 seconds: median 151 to 132, saving 19 seconds (12.6%). +Two heavy runner admissions become one. This saves capacity; it does not prove a +whole-PR latency or queue gain. The median active dependency barrier increases +from 116 to 132 seconds because compilation follows static checks. + +The separate physical-failure run +[37180755694](https://github.com/stablyai/orca/actions/runs/37180755694) +proved that an included TypeScript error failed the actual compiler, its planner +still joined, and unit/package admissions skipped. A registered late action post +failure also blocked both consumers after successful foreground checks and +published shards. All 12 unselected/prior-failure no-op backgrounds joined, and +the downstream audit passed. Local workflow contracts passed 239 tests across +12 suites; lint and formatting passed. + +## October 3 retired-cache collection observation + +The same owner-collection assertion failed in unit shard 3 of +[37098089274](https://github.com/stablyai/orca/actions/runs/37098089274/attempts/1) +and [37100365037](https://github.com/stablyai/orca/actions/runs/37100365037/attempts/1), +requiring a full shard retry despite the focused suite passing locally. Its +three-turn collection budget was shorter than the six-turn plus final yield +pattern already used by the GitLab known-host retirement tests. + +The fixture now uses that existing observation budget. All seven tests, their assertions, +expiry clocks and production code are unchanged. The focused suite passes. A +local fault control changed only the production timer callback to hold its owner +strongly: the owner-collection assertion failed, with the other six tests passing. +The source was restored afterward. Extra collection turns therefore preserve the +strong-retention oracle. Hosted qualification is still required; these observations +do not prove a particular VM-retention cause or quantify avoided retries. + +## October 4 terminal oracle execution + +Three measured test-support changes preserve the original seeds, payloads, +chunk boundaries and meaningful assertions. Serializer comparisons reuse cells +and format only the first mismatch instead of allocating descriptors for every +cell. The terminal parity writer submits every original chunk in FIFO order and +awaits the final parser callback. The independent legacy frame oracle memoizes +measured code-point widths. Its discarded algebra-only case never called +production and still passed when production always threw. + +Three alternating one-worker hosted ARM pairs measured complete invocations: + +| Cohort | Baseline median | Candidate median | Saving | +| ---------------------------------------- | --------------- | ---------------- | ------ | +| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% | +| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% | +| Frame equivalence | 18.472s | 13.736s | 25.6% | + +[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) +retained 116 timed serializer passes and three existing/paired-control skips. +Separate captures matched all 190,796,645 raw bytes over 1,611 scenarios and +8,617 checkpoints (SHA256 `00ab219cfb31456af2ecd5e766d1b82d47abc751f6f2de0d7f795e36a936d3c7`), +including complete outputs and diagnostic payloads. Twenty candidate controls +passed; formatting/color/blank/clipping fault controls detected regressions. + +[37181073275](https://github.com/stablyai/orca/actions/runs/37181073275) +retained all 16 parity cases and default fuzz counts. Captures matched 2,325 +batches, 28,182 original chunks and 1,698,285 input bytes, with identical +terminal state and serialization per terminal/batch. Independent terminal +completion order differs, so comparison uses canonical per-terminal ordering +(SHA256 `c38ac1dbbefb9f6dc33ecfe7c495d65b707c1664614544622af93cfc1850e421`). +All 73 callback/parser/other-consumer controls passed; first-callback, reversed +chunks, missing empty boundary and early-completion faults failed. + +The frame candidate passed all 19 retained cases directly against the original +uncached legacy oracle, preserving 4,000 short and 800 near-cap seeded trials. +Sequence, surrogate width, byte width and span-transform faults failed real +assertions. A part-array alternative was rejected after adding time locally. +Hosted Node typecheck passed. These are focused workload savings, not measured +whole-shard or queue-delay improvements; application behavior is unchanged. + +## October 3 unit-selection evidence: include failed references + +The caller's `needs.test.result == 'success'` condition prevented the advisory +collector from reading failed unit runs, despite the reviewer's existing support +for failed tests. A six-run screen from the October 3 occupancy sample found only +one review artifact; it was a full fallback, so it did not validate selection. +Missing artifacts cannot establish that selection catches red tests. + +The caller now permits both success and failure while excluding cancellation and +skipped tests. The collector remains advisory and absent from `verify` dependencies. +Incomplete, interrupted or inconsistent shard records still cannot become complete +reference evidence. Existing omitted-failure tests preserve that negative control. + +The five artifacts from failed [run 37098089274, attempt 1](https://github.com/stablyai/orca/actions/runs/37098089274/attempts/1) +were reviewed locally using the unchanged script. It recognized a complete failed +reference covering 10,606 files and 9,270,307 worker-ms. Its candidate was the full +fallback, so `selectionEvaluated` remained false and no selection promotion is +justified by this control. Focused workflow/reviewer checks passed 24 tests, +including actual caller-expression outcomes for success, failure, skipped and +cancelled states. This repair supplies needed evidence for a later optimization; +it claims no runner-time savings and does not enable selected tests. + +The updated caller also passed the hosted red-run control in +[37100365037](https://github.com/stablyai/orca/actions/runs/37100365037). +The collector succeeded after one unit shard failed, while required verification +remained red. Its review recognized all five shards as a complete reference +(10,608 files, 8,965,977 worker-ms). This was again a full fallback with +`selectionEvaluated: false`, not evidence for enabling selected tests. + +## October 4 runtime imports and recovery fixtures + +Three helper-only tests now import the existing terminal modules directly rather +than initializing the runtime service. Ten copied-loop cases never exercised +runtime memoization: they passed with its cache, timestamp update or prune +invalidation disabled. Two actual helper checks remain. The existing runtime +prune suite now exercises real leaf cache reuse, split prompt timestamps, +ordinary output, fresh prompts and detection after retained-history eviction. +Each of those three production faults fails a real runtime assertion. + +Recovery tests now seed three exact fixture variants once, after the seed child +has closed. Each crash still receives an independent byte-for-byte copy of the +entire database/WAL family and remapped paths. Buffer.equals retains exact byte +comparison without recursive matcher overhead. All 46 original crash boundaries +and retries remain. Four additional copy-isolation/WAL checks run, and teardown +requires that all seed bytes remain unchanged after the full suite. + +Three alternating one-worker hosted ARM pairs in +[37182181976](https://github.com/stablyai/orca/actions/runs/37182181976) +measured these complete invocations: + +| Cohort | Baseline seconds | Candidate seconds | Median saving | +| --------------------------------- | ------------------------ | ------------------------ | ------------- | +| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% | +| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% | +| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% | + +The final runtime cohort has seven real cases versus 16 including the copied +loops; its new runtime case is included in candidate timing. Recovery has 50 +passes versus the original 46. Hosted Node typecheck passed. Recovery faults for +last-byte database/WAL corruption, shared database paths, missing WAL copies and +accepted/unaccepted seed collision failed the intended assertions. These are +focused workload savings, not measured whole-shard or queue-delay gains. + +An independent local cache screen left both caches disabled. Across 14 unchanged +files and 92 cases, a warm Vitest transform cache reduced median invocation time +3.090 to 1.948 seconds, excluding archive costs; its cold arm increased time to +3.281 seconds. Node compilation caching showed no gain. Controls reproduced stale +transforms after TypeScript configuration or plugin-option changes, so persisted +reuse requires a complete transform-input stamp and hosted net-cost evidence. +A separate 130,000-pane leaf-collection optimization was restored: its complete +migration-file timing stayed within noise. The regression fixture remains. + +## October 3 removal fixture cleanup ordering + +[37105566358](https://github.com/stablyai/orca/actions/runs/37105566358) +failed unit shard 4 with `ENOTEMPTY` removing the failed-removal fixture's temporary +directory; the other four shards passed. A client's removal reply intentionally +precedes the detached job's final record persistence. This fixture reset tracking +and removed the directory before waiting for that persistence, allowing a writer +to race cleanup. Its teardown now awaits the existing settlement helper before +resetting tracking or deleting the fixture. Production removal behavior and all +assertions are unchanged. + +All 1,348 runtime tests passed (one existing skip). A temporary controlled queue +held the final record write after the client replied: waiting before reset stayed +pending and passed; resetting before waiting lost the tracked job and failed the +same ordering assertion. The gate was released, both controls drained the captured +job, and the instrumentation was removed. Changed-code quality passed. This proves +the teardown ordering mechanism, not a measured avoided-retry saving. Final-head +hosted qualification remains required. + +## October 4 store oracle and retention fixtures + +The randomized in-place-store test validated the copying oracle twice after +accepted mutations and compared snapshots through the same production parser. +Its 5,000-step retention fixture generated enough tombstones to hit the count +limit, but never reached the 4,096-revision age boundary. + +The test retains all four seeds and 1,500 mutations per seed, removes the duplicate +validation, and projects snapshots directly from the copying oracle's validated +maps. Separate fixtures now check the revision before, at and after expiry and +count overflow. Production code is unchanged. + +Three alternating one-worker pairs on `ubuntu-24.04-arm` in +[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) +measured baseline invocation times 33.551 / 33.304 / 33.529 seconds and candidate +13.848 / 13.816 / 13.875 seconds: median 33.529 to 13.848 seconds, saving 19.681 +seconds (58.7%). Baseline passed seven tests; candidate passed eight. This is a +focused test saving, not a measured whole-shard or queue-delay change. + +Hosted Node typecheck passed. Separate fault controls failed the intended +assertion for early, late and disabled age expiry, disabled count compaction, +and a snapshot that drops child descriptions. The description fault passes with +the original parser-sharing oracle and fails with the independent projection. + +## October 4 Git contention and remaining readiness waits + +The full Git admission benchmark compared a disabled arm with no correctness +assertions to an enabled arm with structural ledger checks. Its default CI test +now saturates the real base and headroom budgets with FIFO-gated child processes, +queues older background and newer interactive work, releases base slots, and +requires interactive priority, matching outputs and complete permit release. +The full original diagnostic remains opt-in through +`ORCA_GIT_ADMISSION_STORM_MEASUREMENT=1`; both opt-in tests passed locally. +The existing Windows real-Git parity tests remain unchanged; this fixture retains +its existing POSIX platform scope. + +Two remaining Antigravity transcript tests used real 5,000ms refusal windows. +They now use the existing scoped `waitForTranscriptIdle` timer harness after the +emulator drains. All 60 tests, original captured transcripts, deadlines and +readiness assertions remain. + +Three alternating one-worker hosted ARM pairs in +[37180614492](https://github.com/stablyai/orca/actions/runs/37180614492) +measured these complete focused invocations: + +| Suite | Baseline seconds | Candidate seconds | Median saving | +| --------------------- | ------------------------ | ------------------------ | --------------- | +| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) | +| Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) | + +Each candidate passed its original meaningful checks. Hosted Node typecheck +passed. Separate scheduler faults for bypassed admission, withheld release and +FIFO-only priority failed the queued-contention or interactive-start assertion. +Two additional local transcript faults failed the original picker-rejection and +repaint-readiness assertions. These are focused suite savings; whole-shard time +and queue delay were not measured by this experiment. + +## October 4 aggregate unit-test comparison + +A [counterbalanced hosted comparison](https://github.com/stablyai/orca/actions/runs/37197643399) +measured 128.605 seconds less summed test-process time (4.36%) and a 37.694-second +reduction in the slowest shard (5.98%). It compares the accepted optimizations +with their original file snapshots on the same source, five fixed shard +assignments, Node 24, Ubuntu ARM and four workers per process. This is one paired +trial, not a population estimate or a measurement of PR queue delay. + +| Test process | Original snapshots | Accepted optimizations | +| ----------------------------- | ------------------ | ---------------------- | +| Shard 1 | 574.221s | 533.116s | +| Shard 2 | 572.553s | 569.593s | +| Shard 3 | 630.629s | 592.935s | +| Shard 4 | 565.412s | 546.759s | +| Shard 5 | 609.410s | 581.218s | +| Sum: runner time during tests | 2952.225s | 2823.621s | +| Maximum: test critical path | 630.629s | 592.935s | + +Both arms cover exactly 10,838 timed modules on source `9574c8adb253` and tree +`4d5487e8b827`. One added eight-case batching qualification passes in a separate +0.770-second invocation outside the table, completing the 10,839-module ordinary +census. The complete timed case and outcome +comparison accounts for eight approved coverage changes: 105,231 original cases +versus 105,242 candidate cases. Removed copied simulations and an algebra-only +case are accompanied by real runtime, retention, recovery and reusable-cell +regressions. No unexpected case or outcome difference is accepted. + +Each arm starts with distinct empty transform and result caches; Node compilation +caching is disabled. Cold-cache execution ordering remains Vitest's default and +can change with source size. Source snapshots, assignments, raw reports and case outcomes +are checked. Only three case-title fields containing random temporary paths or +a UUID use stable identities, bound to the exact two test-source hashes; raw +titles remain in the artifacts. + +The five dependency setups total 84.284 seconds and are shared by both arms. +The actual paired jobs consumed 5,969 seconds and spanned 2,031 seconds from the +first start to the last completion. Those job figures include both treatments, +setup, uploads and staggered starts; they cannot be assigned to either arm or +used as a workflow saving. The table measures test-process wall time, not CPU +time or the complete CI workflow. Focused-suite percentages elsewhere in this +report are separate measurements and must not be summed into these results. + +The [earlier aggregate trial](https://github.com/stablyai/orca/actions/runs/37193799646) +is rejected because a real test failed; its timings do not qualify a gain. Two +local invocations sharing one XDG directory reproduced the Muse refresher failure. +The fixture now isolates and restores that setting in both arms. The historical +serializer case ledger was also independently corrected from the original source +before this fresh trial; its seven original cases and twenty candidate cases are +an explicit coverage change rather than an assumed equal census. + +## October 4 shard-weight holdouts + +Fresh shard weights were generated with the production importer from a complete +successful run of the accepted source. Two subsequent hosted holdouts used those +same weights and assignments without retraining. The +[first pair](https://github.com/stablyai/orca/actions/runs/37199891967) alternated +existing and fresh assignments across the five jobs; the +[second pair](https://github.com/stablyai/orca/actions/runs/37201939057) reversed +each job's treatment order. + +| Test-process measurement | First: existing | First: fresh | Reversed: existing | Reversed: fresh | +| ------------------------ | --------------- | ------------ | ------------------ | --------------- | +| Sum across five shards | 2813.595s | 2776.421s | 2924.689s | 2878.481s | +| Maximum shard wall | 578.735s | 584.709s | 603.995s | 614.408s | + +Fresh weights reduced summed test-process time by 1.32% and 1.58%, but increased +the slowest shard's time by 1.03% and 1.72%. The small capacity saving comes with +a repeated critical-path regression, so the existing weights remain. The 3.01% +improvement projected from training module durations is not a measured speed +gain. + +Every arm covers the same 10,839 modules and 105,250 case outcomes on source +`9574c8adb253`, tree `4d5487e8b827`, Node 24.21.0, Ubuntu ARM and four workers. +Both trials use cold caches and the same training data, weights, plans and case +identity rules. Complete raw reports, assignments, hashes and opposite treatment +orders are checked before combining the results. Two pairs supply no statistical +confidence or account-wide queue measurement. The second run's jobs started 119 +seconds apart; that stagger and the paired jobs' setup and upload costs are +separate from the treatment timings above. + +## October 4 remaining unit-test opportunities + +The audit retained real child-process, PTY, SSH and crash-boundary tests. It +removed copied simulations or algebra-only cases after fault controls showed +that they could pass with production behavior broken. The retained or replacement +tests exercise production behavior directly. The focused timings in this report +use the final qualified checks. They must not be added together to estimate a +whole-workflow saving. + +Several further changes did not justify promotion: + +- A synchronous readiness-clock screen retained all 49 shard cases and their + outcomes, but complete invocation time changed only from 34.210 to 33.518 + seconds in one local pair. That 2% result was too small to ship without a + stronger result; the original implementation remains. +- A larger local transform-cache screen reduced warm test execution. Separately + measured medians for warm tests (17.251 seconds), extraction (3.539 seconds) and + archive creation (3.092 seconds) sum to 23.882 seconds, versus 23.473 seconds + with caching disabled. This component estimate excludes transfer costs; it is not an + end-to-end measurement. Unkeyed plugin options, inherited configuration and + import priority also produced stale reuse. Persisted test transforms remain + disabled. +- Two successful full-run shadow references identified about 1.9% of + recorded worker time as omittable. That is advisory worker time, not measured + runner occupancy. It does not supply the failed-reference evidence or a + complete selected-run comparison needed to enable test selection. +- Six sampled failed PR runs contained no failed unit job that could trigger + unit-matrix fail-fast. Successful unit siblings of failures in other jobs + cannot be counted as savings from that policy. The sample is too small to + establish a population-wide rate, and the policy remains unchanged. + +These screens reject the examined changes; they do not establish that every +future optimization is exhausted. Shorter admitted jobs and one shared preflight +reduce demand on the existing runner allowance. They do not increase that +allowance or prove lower queue delay under different account traffic. + +## October 5 remaining import, diagnostic and checkout work + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37247027814) +used three alternating pairs per treatment at source +`b18b174cd463f852051dc22adc8478b82dee4d1f`. Focused tests ran as fresh one-worker +processes on the four-core, 16 GB Linux ARM runner with Node 24.21.0, pnpm 12.8.1 +and Vitest 4.1.11; persisted transforms and Node compile caches were disabled. + +| Work | Original median | Candidate median | Median paired saving | +| ------------------------------------------------- | --------------- | ---------------- | -------------------- | +| Real incumbent-process test file | 16.478s | 5.976s | 10.520s | +| Commentable-line lifecycle test file | 9.595s | 6.880s | 2.714s | +| Agent-status diagnostic plus semantic test cohort | 11.547s | 8.235s | 3.361s | +| Windows x64 SSH checkout | 22.052s | 16.884s | 5.020s | +| Windows ARM SSH checkout | 46.758s | 33.910s | 13.456s | + +The incumbent cases own distinct sockets, shim directories and process groups. +Running them concurrently preserves all nine outcomes and every real five-second +lsof deadline. Both original and candidate still reject an unreaped helper and +false claims of clean enumeration. Fault receipts prove the actual modified probe +was imported and all owned helpers, groups, sockets and directories were cleaned. +The Windows platform gate retains all nine skips. + +The renderer lifecycle tests keep their six original bodies and real decorator, +zone and model behavior. Only the unrelated saved-note delivery menu is replaced +by a typed throwing facade. Its cleanup assertion rejects unexpected use. Actual +faults in memoization, value-equal refreshes and model replacement still fail their +original assertions; a real menu call fails the facade and the cleanup guard. + +The agent-status benchmark reports counters and timings but asserts only a +nonempty status map and positive elapsed time. It remains available through +`ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.agent-status-benchmark.config.ts`. +Ordinary discovery removes exactly that reporting test. Its 15 meaningful routing, +index-retention and batch cases remain unchanged. Three real production faults +pass the old reporting test and fail those retained contracts. Manual execution +preserves its JSON schema and all nine deterministic counters at 423 worktrees, +634 tabs and 1,000 events. + +Windows SSH hosts retain complete main, shared, relay, type, configuration, +native, resource and test trees, plus root files. Six actual checkouts per +architecture pinned candidate source `ce69b84d675b0a8b4763b03329c9d6549ac723f1`; +all 16,568 retained files match the full checkout's contents, modes and index. +The original 5,209 required inputs and six further inputs added by the source +rebase are present. Local full/sparse builds match 54 generated artifacts; +explicit test discovery is identical. Missing imported source, a named test or a +required descriptor still fails. Full Windows native/provisioning lanes remain +a separate qualification gate. + +Test timings exclude dependency setup, checkout and queues. Checkout timings +include the action and shell/runner observation boundaries but exclude later +builds and provisioning. These scoped savings must not be summed or treated as +measured changes to full-shard occupancy or PR latency. + +## October 5 mobile typecheck overlap + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37249591578) +ran three alternating pairs on the four-core, 16 GB Linux ARM runner, with Node +24.21.0 and TypeScript 6.0.3. The production compiler and test-typecheck ratchet +use installed tools after dependency installation. Both resolved compiler +programs have `noEmit: true`, with no incremental, composite or build-info writes. +The change moves the existing unconditional production join after the foreground +ratchet and before tests; it changes no compiler command or failure policy. + +| Complete typecheck stage | Original serial | Overlapped | +| ------------------------ | --------------- | ---------- | +| Pair 1 | 68.284s | 41.582s | +| Pair 2, reversed order | 65.385s | 41.042s | +| Pair 3 | 64.240s | 40.418s | +| Median | 65.385s | 41.042s | + +The median paired saving is 24.343 seconds, or 37.2% of this stage. The stage +bracket includes native background/wait boundaries and observation overhead, +but excludes dependency installation and the later test suite. All six stages +preserve the three workers' compiler/ratchet verdicts and stdout/stderr hashes. +The test compiler's existing diagnostics remain subject to the unchanged +ratchet. Neither compiler attempted native loading or recorded filesystem +mutations. Maximum aggregate owned-process RSS sampled every 200 milliseconds +was 5.137 GiB; this is a sampled value rather than a kernel peak. The ratchet now +runs even when the concurrently running production compiler later fails. + +Two real, independent type faults still prevent tests from starting. The +production fault preserves the unaffected ratchet and child output; the test +fault preserves the production output and fails the ratchet. Their explicit +control receipts pass even though their intentionally failing jobs are allowed +to finish collecting evidence. + +The separate [external cancellation control](https://github.com/stablyai/orca/actions/runs/37251289372) +held the two real installed compiler entrypoints before checking, while retaining +the production-background/ratchet-foreground topology. All three owned workers +were observed alive 14.275 seconds before the cancellation request. Both native +step outcomes became cancelled, tests did not start, and the runner's final +cleanup log names all three exact worker PIDs. The attempted earlier assertion +of PID absence failed: GitHub performs orphan cleanup after the always-tail +observer and artifact upload. Post-cleanup absence was not observed and is not +claimed. This control supplies no compiler-completion or timing measurement. + +## October 5 explicit RPC registry test setup + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37251277897) +ran three alternating pairs of the complete 156-file mixed cohort on source +`d1e08ddd666e3099fc51c79f01f0305f5162186d`, using four workers on the four-core +Linux ARM runner, Node 24.21.0, pnpm 12.8.1 and Vitest 4.1.11. Persisted module +transforms and Node compile caches were disabled. All 153 candidate test files +pass explicit method lists to their dispatchers. A shared throwing fixture +prevents those tests from loading the unused default method catalog and rejects +any accidental iteration of it. The three real catalog subjects remain +unchanged and run beside the candidate subjects in every arm. + +| Complete cohort process | Original | Candidate | Paired saving | +| ----------------------- | -------- | --------- | ------------- | +| Pair 1 | 142.184s | 89.238s | 52.946s | +| Pair 2, reversed order | 142.833s | 88.933s | 53.900s | +| Pair 3 | 141.274s | 90.186s | 51.088s | +| Median | 142.184s | 89.238s | 52.946s | + +The median paired saving is 37.2% of this fixed cohort. All six arms preserve +the complete ordered ledger, including duplicate parameterized case names: +1,469 passes and one existing setup-dependent skip, totaling 1,470 outcomes. +The real catalog subjects contribute 63 of those cases. Candidate bodies and +assertions are unchanged. One file at its line limit uses the dispatch method's +parameter type in place of its equivalent type-only import; its emitted code +matches the measured candidate. + +Seventeen control invocations preserve actual terminal-handler fault detection, +reject unexpected default-catalog consumption, and still detect a missing real +catalog registration. A plain consumption counter prevents global mock-history +resets from erasing the guard; actual `clearAllMocks` and `resetAllMocks` controls +demonstrate that distinction and preserve unrelated call history. The isolated +driver restores all source files after success, faults and a real cancellation. +Independent review also confirms all 153 candidate sources, the fixture, three +catalog subjects and declared qualification inputs survive the rebase unchanged. + +These are fresh-process wall times for this cohort, excluding dependency setup, +queues and other test shards. They do not measure full-shard balance, total PR +runner demand, or a change to the dashboard's PR runtime percentiles. + +## October 5 fused terminal cursor row scans + +Readiness checks repeatedly read terminal rows to recognize composer text. +The shared reader now collects undimmed text and the first visible glyph's style +in one pass. The cursor suffix remains a separate scan; dim glyph attributes, +empty cells, wide characters and wrapped spaces keep their existing behavior. +No grid data is retained between calls. + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37263358478) +used source `1bec53ceb23b5f296a38ffa0e085778d32fc7849`, Linux ARM, +Node 24.21.0, pnpm 12.8.1 and one worker. All six fresh processes ran the same +49-case readiness census module, with separate empty Vite caches, filesystem +transform caching disabled and Node compile caching disabled. + +| Complete focused process | Original | Candidate | Paired saving | +| ------------------------ | -------- | --------- | ------------- | +| Pair 1 | 66.412s | 60.464s | 5.948s | +| Pair 2, reversed order | 66.880s | 61.061s | 5.819s | +| Pair 3 | 68.056s | 65.821s | 2.234s | + +The median paired saving is 5.819 seconds, or 8.7% of this fixed workload. +All 294 timed case outcomes passed. Separate qualification preserves complete +context/composer outputs on 98 recordings and the logical cursor/projection +outputs of all 192 Runtime census cases. Nine actual scanner faults fail the +intended assertions; the 89-case IME/composer slice also passes. Blank-row tests +bound cell reads to 72 instead of the original 132 for a 12-column, five-row grid, +with and without a reusable cell adapter. + +The raw timer-driven polling trace differed and was excluded from equivalence +evidence. Logical per-frame output captures match; no raw polling-count equality +is claimed. These measurements exclude setup, queues and other modules and do +not establish a change in full-shard balance, PR percentiles or runner demand. + +## October 5 Qoder test import guards + +The direct Qoder Runtime tests now import the existing unused-default-RPC guard +before their Runtime fixture. Their complete test bodies remain unchanged. +The guard rejects an unexpected registry access instead of loading the full +default-method graph. The three real registry catalogs remain unmocked. + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37266875139) +used fixed source `1bec53ceb23b5f296a38ffa0e085778d32fc7849`, Linux ARM, +Node 24.21.0, pnpm 12.8.1 and four isolated fork workers. Every fresh process ran +both Qoder files and all three catalogs: 85 cases across five files. Each invocation +used a distinct empty Vite cache, with results, filesystem transform and Node +compile caching disabled. All 510 timed outcomes passed. + +| Complete five-file process | Original | Candidate | Paired saving | +| -------------------------- | -------- | --------- | ------------- | +| Pair 1 | 17.677s | 16.323s | 1.354s | +| Pair 2, reversed order | 16.827s | 16.021s | 0.806s | +| Pair 3 | 17.324s | 16.211s | 1.113s | + +The median paired saving is 1.113 seconds, or 6.4% of this fixed workload. +Separate actual faults in retained launch recipes, Qoder command selection and +method registration fail the same intended assertions before and after the +imports. Generated launch IDs and shifted stack lines differ in the raw failure +messages; they were preserved and are not claimed byte-identical. + +The single hosted trial occupied 134 runner-seconds including all six samples, +shared setup and upload. That is trial cost, not a production saving. These +focused process measurements do not establish full-shard savings, PR runtime +percentiles, queue relief or a change in the organization's runner allowance. diff --git a/docs/reference/deepseek-build-observation.md b/docs/reference/deepseek-build-observation.md new file mode 100644 index 00000000000..50686cf308d --- /dev/null +++ b/docs/reference/deepseek-build-observation.md @@ -0,0 +1,28 @@ +# DeepSeek Build terminal identity + +DeepSeek Build is the third-party [`innocarpe/deepseek-build`](https://github.com/innocarpe/deepseek-build) product, published as `@innocarpe/deepseek-build`. It is distinct from official DeepSeek Harness (`@deepseek-ai/dsh`), Reasonix, DSH Console and generic DeepSeek TUI wrappers. + +Orca recognizes manually started Build terminals through its existing process and title observations. `TerminalAgent` includes `dsb`; the launchable `TuiAgent` registry does not. No Build launcher, hook, readiness profile, resume command or history reader is registered. Existing generic terminal input remains available. + +The source and actual macOS release were checked at **v6.9.0**, source commit `74df67a56988e9a32845c4565cc62b021ea68c7d`. The darwin-arm64 release tarball SHA-256 is `a57f225a537fc5c027ac4592e3f37f7bdc28cc2d6e27a366934511ea565cb874`. + +- `package.json` publishes `dsb.js` and `deepseek-build.js` npm shims; the native child is `deepseek-build-agent`. +- `crates/dsb-cli/src/main.rs` separates the full-screen entry from `run`. Global value options such as `--cwd` can precede `run`; those invocations remain excluded from interactive recognition. +- `crates/dsb-cli/src/agent_launch.rs` emits the product OSC 0 title. The vendored pager's `notifications/title.rs` composes spinner, activity and product segments with ` - ` separators. +- The committed `dsb-6-9-0-folder` PTY fixture records the released binary's welcome screen and actual title in an isolated home and plain folder. It makes no successful-authentication or completed-model-turn claim. Its runtime test feeds raw chunks through `onPtyData` with foreground inspection unavailable. + +Explicit native owner markers retain their existing precedence. A Claude task merely mentioning Build is not a Build identity. Runtime publication reuses the existing optional `agentIdentity` string; no new RPC, stream opcode or status producer is added. Older hosts can omit identity, while older readers retain their existing unknown-agent handling. Execution-host process/title observations work without a Git repository; local source tests do not establish native Windows, Linux or SSH device coverage. + +For rendered proof, isolate both Electron and the actual PTY. On macOS, `login(1)` can replace the shell's inherited home. Test-only `ORCA_DISABLE_MACOS_LOGIN_SHELL=1` avoids that wrapper; do not change production launch policy for a proof. Require a nonce-bound file written by a helper executed in the spawned PTY, containing its actual `HOME`, `USERPROFILE`, `DEEPSEEK_BUILD_HOME`, `GROK_HOME` and trust-RPC flag, and verify it before agent launch. A terminal-text assertion can match command echo and is not isolation evidence. Explicit provider environment at the final execution boundary protects the test even after shell startup. + +The observation-type propagation and title/process recognition adapt Wooseong Kim's (`innocarpe`) [PR #23485](https://github.com/stablyai/orca/pull/23485), with source-backed corrections for the second npm shim and value options before `run`. Keep that predecessor open until a reviewed successor merges. + +Independent review follow-up: upstream 6.9.0 outer `Commands::Agent` forwards native PagerArgs options. Native `-p`/`--single` (alias `--print`), `--prompt-json` and `--prompt-file` are one-shot forms and are excluded from interactive process/foreground identity, including equals/compact short forms, npm wrappers and preceding value options. Positional interactive prompt text, native option values and the native `--` terminator stay distinct. Actual release native `--help` confirms exposed flags; source alias and forwarding are pinned above. + +Title follow-up confines Gemini identity/normalization and status sniffing before inspecting Build activity text. A verified Build title uses its leading own braille frame for working and leading `⚠ Action Required - ` for permission; embedded Gemini glyphs in activity/session/cwd text do not change its identity or status. Source-backed frame tests cover wrapped and alert variants, plus OSC input through the actual runtime/listing path. Native Gemini and other provider corpus contracts stay covered. + +Actual released outer `dsb agent -- --help` prints the native TUI help (`outer-forwarded-help.txt`), confirming Clap consumes the outer separator before forwarding. The observer distinguishes this from the native `--`: `dsb agent -- --print task` is one-shot, whereas `dsb agent -- -- --print` and direct native `-- --print` retain literal interactive prompt text. + +Native grammar follow-up: only the outer wrapper's `run` subcommand is one-shot. Native `deepseek-build-agent run`, forwarded `dsb agent run`, and `--leader-socket run` remain interactive; the last consumes `run` as a path value. Native `-c` is boolean, so Clap accepts `-cp task` and `-cptask` as continue plus single-turn prompt. Attached `-m`/`-r`/`-s`/`-w` values (including after `c`) do not expose a prompt flag. The released binary accepted the five review argument topologies with `--help` under an executed private-child environment assertion (`native-grammar-oracle.json`); this proves parsing/help, not successful model generation. + +Attached prompt values can begin with hyphens: native `-p-` and `-cp--print` consume `-` and `--print` as the single-turn prompt. The observer accepts the entire remainder after `p`, while the attached m/r/s/w value shields stay covered. The released native binary and outer `agent` wrapper accepted both forms with `--help` in a nonce-asserted private child (`attached-p-oracle.json`). diff --git a/docs/reference/dsh-harness-integration.md b/docs/reference/dsh-harness-integration.md new file mode 100644 index 00000000000..9a850e06900 --- /dev/null +++ b/docs/reference/dsh-harness-integration.md @@ -0,0 +1,44 @@ +# DeepSeek Harness integration + +Orca detects the community `@deepseek-harness-tui/dsh-tui` launcher (`dsh-tui`, alias +`dst`) and requires the official `@deepseek-ai/dsh` executable too. The launcher +boots the `dsh-tui` profile; Orca passes `.` to select the current workspace and +reach its composer on the first launch. The official Harness does not bundle this community TUI. +DSH Console and DeepSeek Build are separate products and are not interchangeable +with this launch contract. + +The official DSH 0.2 CLI accepts both `dsh --profile headless` and `dsh headless`. +Orca excludes the known `web`, `headless`, `sdk`, `sdk-minimal`, `acp`, and `desktop` +profiles from interactive process recognition, along with plugin management and +configuration dumps. Custom profile names remain eligible because profiles are +user configurable. Only launcher arguments are inspected; app prompts, resume IDs, +and patch filenames cannot change the selected profile's identity. + +Status hooks use the official `@deepseek-ai/dsh-hooks-claude-code` plugin, installed +as an owned block in `$DSH_HOME/cordis.patch.yml`. User entries outside the block are +preserved. Local installation respects `DSH_HOME`; the existing SSH installer uses +the execution host's default `~/.dsh` because SFTP cannot read its environment. +Hooks report session start, prompt submission, tool start/end, and stopping through +Orca's host status store. Approval has no dedicated hook; it is not inferred from +an uncaptured screen. Subagent lifecycle events are ignored for parent-pane status. + +DSH 0.2 still emits an empty `transcript_path` in Claude-compatible hooks. Its +session persistence defaults to compressed JSONL under `$DSH_HOME/sessions`. +Orca can resume a hook-associated session through `dsh-tui --resume `, but +currently does not discover DSH logs in Agent Session History. Resume support alone +does not establish transcript-history support. + +## Reproduce the official launcher check + +Install `@deepseek-ai/dsh@0.2.0-rc.2` into a disposable prefix, then run: + +```sh +ORCA_BACKGROUND_LAUNCH=1 ORCA_REAL_DSH_CLI=/path/to/prefix/node_modules/.bin/dsh \ + pnpm test src/shared/dsh-real-cli.test.ts +``` + +The opt-in test checks published version, composed profile configurations, and +headless help in an isolated home and working folder without a model request. +Interactive readiness is separately pinned to the captured community TUI transcript +in `src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt`; that older capture is +not proof of current TUI compatibility or paid generation. diff --git a/docs/reference/ime-regression-checklist.md b/docs/reference/ime-regression-checklist.md index f7c7c2bed78..bad36127dd5 100644 --- a/docs/reference/ime-regression-checklist.md +++ b/docs/reference/ime-regression-checklist.md @@ -100,3 +100,47 @@ Each fix must pass all of these checks: regenerate its bundle patch and lockfile together. - Prefer deterministic replay or state-transition tests. Native evidence is a second layer, never a substitute for regression coverage. + +## Enter in application text fields (#25035) + +Use `Input`, `Textarea`, or `CommandInput` for styled fields. Existing unstyled +fields with keyboard actions use `ImeInput` / `ImeTextarea` from +`lib/ime-text-field.tsx`; those preserve the DOM element, styles, refs, and +composition callbacks. They share `useImeEnterGestureOwnership` and keep +IME-owned keys out of both field actions and bubbling form/menu shortcuts. +Overlay primitives also reject IME-marked Escape in document capture, where +field-level propagation guards cannot intercept dismissal. +Do not add a second tracker at a call site already using a guarded field. +Native Chat and the File Explorer inline name field retain their existing +trackers because they also own specialized composition or element lifetimes. + +Required cases: + +- `isComposing`, `keyCode: 229` without `isComposing`, and `Process/229` must + never submit, choose a suggestion, or dismiss the field. +- The unmarked Enter redispatch stays owned on either side of keyup, including + a `Process/229` release. A + subsequent ordinary typing/navigation key ends that carry immediately; + hidden renderers may defer animation frames, and typing a filename suffix + must not cause the next deliberate Enter to disappear. +- Composition callbacks, blur, refs, and keyed remount cleanup still work. + Normal Enter, modifier submits, and Shift+Enter newlines remain available. +- Test the actual shared field when a consumer delegates IME handling to it; + a mock that replaces `CommandInput` with a raw input removes the protection. + +`ime-text-field.test.tsx` covers primitives, raw fields, parent handlers, and +command selection. File Explorer component tests cover all three operations +and input replacement. `file-explorer-ime-enter.spec.ts` drives Chromium +composition in New File, New Folder, and Rename in a folder workspace, then +checks the complete name in the Explorer and on disk, with both continued typing +and a redispatch followed by deliberate Enter. Overlay tests cover IME Escape +and ordinary dismissal; Markdown tests preserve an unmarked save shortcut while +composition state lingers. These are CDP event +contracts, not native OS keyboard evidence. + +The audit also covers settings and title fields, issue/review creation and +pickers, comments and annotations, search fields, Native Chat questions, +notebook execution shortcuts, and Markdown menu handlers. Terminal input keeps +its existing xterm/PTY ownership; mobile native fields use `onSubmitEditing` +instead of desktop DOM keydown actions. Remote workspaces use the same renderer +fields; file-operation routing and mixed-version wire contracts are unchanged. diff --git a/docs/reference/managed-data-accounts.md b/docs/reference/managed-data-accounts.md new file mode 100644 index 00000000000..c0a78d001d5 --- /dev/null +++ b/docs/reference/managed-data-accounts.md @@ -0,0 +1,23 @@ +# Managed OpenCode and Devin accounts + +Run enrollment in a terminal on the machine running Orca: + +```sh +orca account add --agent opencode --label Work +orca account add --agent opencode --integration opencode-go --label Work +orca account add --agent devin --label Work +orca account list --agent opencode --json +orca account select --agent opencode --account +orca account select --agent opencode --account system +orca account rm --agent opencode --account +``` + +OpenCode enrollment requires OpenCode 2 and runs its official `auth login --standalone` command. Devin runs `auth login --force-manual-token-flow`; obtain the enrollment token through Devin's supported login flow. These commands neither reuse a guessed token nor sign out the system account. Settings → AI Provider Accounts provides the enrollment command, refresh, selection, and removal for the selected Orca host. + +Each profile belongs to the execution host. OpenCode's SQLite credentials and Devin's credential TOML stay in private Orca user-data directories. Enrollment isolates XDG data/config/cache/state, copies only authenticated credentials, and then deletes the temporary directory. OpenCode capture rejects databases containing conversations and includes SQLite WAL contents. RPC summaries contain labels, IDs, and integration names, never tokens or credential paths. Only the authenticated local runtime socket can import a credential directory; paired clients cannot ask the host to read arbitrary paths. + +Selection affects newly launched explicit OpenCode/Devin commands and agent launches. It redirects XDG data and state; OpenCode inline-auth/database overrides cannot bypass the profile. Shell wrappers restore this selection after user startup files. Existing provider configuration and environment-based integrations remain available. Running terminals retain their current profile. Stop agents before removing a profile: removal also deletes conversations created in that private profile, without changing the system login. + +For SSH, enroll by running the command on a headless Orca runtime on the remote machine. The remote runtime owns its profiles and selection; a desktop client's credential paths never cross SSH. Direct SSH relay launches and Windows-hosted WSL panes do not consume the desktop host's profiles. Run a headless runtime inside that execution environment instead. Folder workspaces use the same host account store as git worktrees. Older Orca hosts reject new operations before login through capability negotiation. + +Validation covers OpenCode 2.0.16 on macOS and Linux arm64, including isolated official enrollment, selected and System background-terminal credential checks, reselection, and profile deletion. Linux checks used the Node headless runtime in an Ubuntu 24.04 container. Devin 3000.10.31 saved-login recognition was checked on macOS. Fresh Devin manual-token enrollment, a physical SSH host, Linux desktop UI, Windows, and Windows-hosted WSL still require verification. diff --git a/docs/reference/monaco-language-associations.md b/docs/reference/monaco-language-associations.md index a159477e176..ee985d85959 100644 --- a/docs/reference/monaco-language-associations.md +++ b/docs/reference/monaco-language-associations.md @@ -5,7 +5,11 @@ languages and loads their grammars on demand. Filename detection must not load t editor itself: it also runs during session restoration and before the editor mounts. `monaco-language-associations.json` contains the registration metadata from the -installed package's entry point. Regenerate it after upgrading Monaco: +installed package's entry point, plus curated Ruby associations in the generator. +Those add `.rake`, `.ru`, `.jbuilder`, `.thor`, `Guardfile`, `Capfile`, `Podfile`, +`Brewfile` and `Vagrantfile` to the existing Ruby grammar. Change these in +`config/scripts/generate-monaco-associations.mjs`, not the generated JSON. +Regenerate after changing the curated associations or upgrading Monaco: ```sh node config/scripts/generate-monaco-associations.mjs @@ -13,7 +17,7 @@ pnpm exec oxfmt --write src/renderer/src/lib/monaco-language-associations.json ``` The generator reads syntax trees without executing contributions or grammar loaders. -Its test compares the checked-in metadata to the installed package. The original +Its test compares the checked-in metadata to the installed package and curated associations. The original list was verified against a clone of `microsoft/monaco-editor`, tag `v0.55.1`, commit `516f350bdaf7a82f6731bd128a9ec86a6e5fa47d` (`src/basic-languages` and `src/language`). diff --git a/docs/reference/orchestration-configured-agent-aliases.md b/docs/reference/orchestration-configured-agent-aliases.md new file mode 100644 index 00000000000..2dc95ccd100 --- /dev/null +++ b/docs/reference/orchestration-configured-agent-aliases.md @@ -0,0 +1,22 @@ +# Configured command aliases for orchestration workers + +A worker can use the name of a direct executable configured in **Settings → Agents**. +Choose the built-in agent whose command-line interface the executable implements, +then set that agent's command override to the executable name or quoted full path. +For example, configure Codex's command as `codex-fugu`, then select it with +`orca orchestration worker-start --agent codex-fugu` and the normal placement options. + +The execution host resolves its own configuration. Launch receipts use the canonical +agent (`codex` in this example), and model/effort handling reuses that agent's existing +launch rules. A receipt records applied launch preferences; it does not prove provider +entitlement, successful generation, or an arbitrary vendor's model selection behavior. + +Aliases require a single executable token. Commands containing interpreter arguments, +environment assignments, or shell wrappers are not aliases. Multiple built-in agents +configured with the same executable name are ambiguous and require the canonical agent +ID. Disabled launchers remain disabled. An unconfigured name is refused even if it is +on PATH; Orca cannot infer a compatible launch interface from a process name. + +The same rule applies to folder workspaces and git worktrees. For a remote worker, +configure the command on its execution host. Older hosts may refuse aliases they do not +support. Configuring a command does not create new status producers or grant permissions. diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index b2bcb3c40f4..c7720c68dce 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -111,6 +111,23 @@ provider, it puts `unsupported` on the wire and makes that host refuse its own. reply-schema fallback must never shape a param. Gate on the token instead, where the client decides what it is willing to do with an arm it does not know. +## Session search agent negotiation + +`aiVault.searchStatus` optionally advertises `supportedAgents`; current search clients +send their own `supportedAgents` with `aiVault.searchSessions`. These are string lists, +so a future provider name does not make a peer reject the capability reply. The client +narrows explicit agent filters to the host's list before calling its request parser. +The host narrows retrieval to the client's list before publishing a page. + +A peer without this field uses the frozen v1.4.211 search vocabulary. The existing +`supportsQoderHistory` flag proves CodeBuddy, ZCode, and Qoder support; +`supportsJcodeHistory` independently proves Jcode support. An explicit list takes +precedence over both flags. If only the status method is missing, the client still +searches the conservative legacy subset; other status errors propagate. Empty host +intersections keep the requested filters and use the existing no-match scope, so +consent, readiness, and unknown-scope results retain their normal precedence. +Local IPC advertises this build's full list, and every remote leg negotiates separately. + ## Enforcement `tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real diff --git a/docs/site/.gitignore b/docs/site/.gitignore index 104c6398aca..61917c0abcb 100644 --- a/docs/site/.gitignore +++ b/docs/site/.gitignore @@ -8,4 +8,4 @@ next-env.d.ts # Keep local deployment credentials out of the publication boundary. .env .env.* -.npmrc +# Project .npmrc contains package policy; keep credentials in user-level npm config. diff --git a/docs/site/.npmrc b/docs/site/.npmrc new file mode 100644 index 00000000000..d7913f44df7 --- /dev/null +++ b/docs/site/.npmrc @@ -0,0 +1 @@ +minimum-release-age=4320 diff --git a/docs/site/content/docs/cli/orchestration.mdx b/docs/site/content/docs/cli/orchestration.mdx index d83d27836cf..88355cb8c82 100644 --- a/docs/site/content/docs/cli/orchestration.mdx +++ b/docs/site/content/docs/cli/orchestration.mdx @@ -44,7 +44,7 @@ orca orchestration worker-start --task --worktree new-child --name bill orca orchestration worker-start --task --worktree current --agent claude --model --effort high --json ``` -`--agent` takes any Orca agent ID enabled on the worker server, for example `claude`, `codex`, `cursor`, `antigravity`, `muse`, `opencode`, or `opencode2`. `--model` accepts opaque provider model IDs for Claude, Codex, Cursor, Antigravity, and Muse (for example `--agent muse --model muse-spark-1.3`); other agents, including opencode, run the model from their own config. `--effort` requires `--model` and only applies when that agent/model supports the level. Neither flag can combine with `--terminal` (reuse an existing pane). Overrides apply to that launch only and show under `launch.requested` / `launch.effective` in the start receipt. Federated starts need a worker host that advertises launch-preference support. +`--agent` takes any Orca agent ID enabled on the worker server, for example `claude`, `codex`, `cursor`, `antigravity`, `muse`, `opencode`, or `opencode2`. `--model` accepts opaque provider model IDs for Claude, Codex, Cursor, Antigravity, and Muse (for example `--agent muse --model muse-spark-1.3`); OpenCode accepts a per-launch model only in an existing worktree, when the execution host verifies its CLI version and model availability; OpenCode effort remains unsupported. Other agents run the model from their own config. `--effort` requires `--model` and only applies when that agent/model supports the level. Neither flag can combine with `--terminal` (reuse an existing pane). Overrides apply to that launch only and show under `launch.requested` / `launch.effective` in the start receipt. Federated starts need a worker host that advertises launch-preference support. Wait for completions (process every message in a Delivery, then ack): diff --git a/docs/site/content/docs/cli/overview.mdx b/docs/site/content/docs/cli/overview.mdx index 6d2f0792856..b9aecde4fa3 100644 --- a/docs/site/content/docs/cli/overview.mdx +++ b/docs/site/content/docs/cli/overview.mdx @@ -38,8 +38,11 @@ orca status --json ``` orca worktree ps --json orca worktree create --repo id: --name my-task --issue 123 --json +orca worktree create --repo id: --name review-pr-123 --pr 123 --json orca worktree current --json orca worktree set --worktree active --comment "reproduced bug" --json +orca worktree set --worktree active --gitlab-issue 42 --gitlab-mr 77 --json +orca worktree set --worktree active --pr null --json orca worktree rm --worktree id: --force --json ``` diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index d4cb4346d65..087caf0e02b 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -97,11 +97,14 @@ orca worktree show --worktree active --json orca worktree create --repo id: --name fix-login --json orca worktree create --name child-task --agent codex --prompt "Investigate the flaky login test" --json orca worktree set --worktree active --comment "reproduced failure; testing token refresh fix" --json +orca worktree set --worktree active --workspace-status in-review --unread --json orca worktree rm --worktree id: --force --json ``` When `worktree create` runs from inside an Orca-managed worktree, Orca records the new worktree as a child when it can infer the relationship. Pass `--parent-worktree active` to be explicit, or `--no-parent` when the new work is independent. +`worktree set --unread` puts the unread dot on the workspace in the sidebar, the same one Orca shows when an agent finishes; `--read` clears it. + Agent startup flags: ```bash @@ -112,6 +115,22 @@ orca worktree create --name hidden-setup --setup inherit --json `--agent` launches the selected agent in the first terminal. `--prompt` sends initial work to that agent. `--setup run|skip|inherit` controls repo setup hooks; `inherit` follows the repo policy. +Link issues and reviews using the existing workspace metadata: + +```bash +orca worktree create --repo id: --name review-task --pr 123 --json +orca worktree create --repo id: --name gitlab-task --gitlab-issue 42 --gitlab-mr 77 --json +orca worktree set --worktree active --gitlab-issue '#42' --gitlab-mr '!77' --json +orca worktree set --worktree active --gitlab-issue https://gitlab.example.com/group/project/-/work_items/42 --json +orca worktree set --worktree active --pr null --gitlab-mr null --json +``` + +`--pr` is a GitHub pull request number. `--gitlab-issue` accepts an issue number or `#42`; `--gitlab-mr` accepts a merge request number or `!77`. Numbers must be positive safe integers. The GitLab flags also accept HTTP(S) issue or merge request URLs, including self-hosted instances and nested groups. The URL's host and project must match the workspace's stored GitLab source context or the repository's stored remote. If that identity is missing or different, the command fails before updating metadata. A URL cannot choose another project, change the checkout, or fetch a review branch. + +Omitting a flag leaves its link unchanged. On `set`, literal `null` clears only the named link; `create` refuses `null`. Each provider has separate fields, so setting a GitLab issue or merge request preserves GitHub and Linear links. + +Folder-based repositories can store numeric links through these commands, but a number does not supply a provider host or project. Without existing source context or a stored remote, pasted GitLab URLs are refused and provider details or clickable links may be unavailable. These flags use fields already supported by the runtime; an older runtime that predates a field may ignore it, so check `worktree show --json` after writing to an older host. + ## Terminals ```bash diff --git a/docs/site/content/docs/model/meta.json b/docs/site/content/docs/model/meta.json index 0acd89b80e3..9ef9d2bfd9d 100644 --- a/docs/site/content/docs/model/meta.json +++ b/docs/site/content/docs/model/meta.json @@ -1,5 +1,12 @@ { "title": "The Orca Model", "defaultOpen": true, - "pages": ["worktrees", "tabs-panes-splits", "agents-sessions", "session-restore", "quick-open"] + "pages": [ + "worktrees", + "orca-yaml", + "tabs-panes-splits", + "agents-sessions", + "session-restore", + "quick-open" + ] } diff --git a/docs/site/content/docs/model/orca-yaml.mdx b/docs/site/content/docs/model/orca-yaml.mdx new file mode 100644 index 00000000000..0cc790121b2 --- /dev/null +++ b/docs/site/content/docs/model/orca-yaml.mdx @@ -0,0 +1,151 @@ +--- +title: orca.yaml & .worktreeinclude +description: Local worktree setup, terminal defaults, shared directories, and copied files. +--- + +Put `orca.yaml` and `.worktreeinclude` at the repository root. `orca.yaml` supplies project defaults; `.worktreeinclude` lists ignored files to carry into new worktrees. Commit these configuration files so other users can use the same rules. Keep secrets in the ignored files they refer to. + +This reference covers **local Git worktrees**. [Folder workspaces](/docs/model/worktrees#multi-repo-project-groups--folder-workspaces) do not create a Git checkout or run these copy/share steps. For other execution targets, see [Ways to run Orca](/docs/ways-to-run). + +## Example + +For a project that uses pnpm, this installs dependencies during setup and opens an agent tab and a Git status tab: + +```yaml +scripts: + setup: | + pnpm install + archive: | + echo Workspace archived +setupAgentStartupPolicy: wait-for-setup +defaultTabs: + - title: Agent + - title: Git status + command: git status --short +worktree: + sharedDirectories: + - .cache +``` + +The `.cache` directory must already exist and be gitignored in the primary checkout. The first tab deliberately has no command: when the desktop create composer launches an agent, its startup takes precedence over the first tab's configured command. + +## Accepted keys and defaults + +All keys are optional. Script, title, command, and path values must be strings; Orca trims them and drops empty or oversized values. Invalid fields are skipped while valid siblings still apply. Unknown keys do not configure additional behavior. + +| Key | Accepted value | When omitted | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `scripts.setup` | Nonempty script string, including a YAML block scalar (`\|`). Runs in the new worktree when setup is enabled. | No project setup script. | +| `scripts.archive` | Nonempty script string. Runs in the worktree before archive/removal when hooks are enabled. | No project archive script. | +| `setupAgentStartupPolicy` | Exactly `start-immediately` or `wait-for-setup`. | `start-immediately`; a local **wait** setting still takes precedence. | +| `issueCommand` | Nonempty command template for linked GitHub/GitLab items in the create composer. | No project template; the composer can still supply its built-in agent prompt. | +| `defaultTabs` | List of mappings with optional `title`, `command`, and `color`. At least one valid field is required per entry. `color` accepts `#RGB` or `#RRGGBB`. | Normal initial terminal behavior. | +| `worktree.sharedDirectories` | List of repository-relative directory paths. Only existing, gitignored directories are shared. | Only the user's Settings shared paths apply. | +| `environmentRecipes` | List of per-workspace environment recipes. See [Cloud VMs](/docs/ways-to-run#4-cloud-vms-per-workspace-environments) and the `orca-per-workspace-env` skill for recipe fields and lifecycle commands. | No project recipes. | + +`scripts` and `worktree` must be mappings; `defaultTabs`, `environmentRecipes`, and `sharedDirectories` must be lists. `setupRunPolicy` and `commandSourcePolicy` are **Settings values**, not accepted `orca.yaml` keys. + +### Parse failures and limits + +**Invalid YAML or a duplicate mapping key anywhere rejects the whole file.** Orca does not keep the last duplicate value. A non-mapping root, excessive alias expansion, or an oversized file also produces no configuration. A YAML checker that accepts duplicate keys does not establish that Orca will accept the file. + +| Limit | Result when exceeded | +| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Whole file: 256 KiB (262,144 UTF-8 bytes) **and** 262,144 UTF-16 code units | Whole file rejected. | +| Each string field: 64 KiB (65,536 UTF-8 bytes) **and** 65,536 UTF-16 code units, measured before trimming | Field dropped. | +| `defaultTabs`: 256 input entries | Entire tab list dropped. | +| `environmentRecipes`: 256 input entries | Entire recipe list dropped, with a recipe diagnostic. | +| `worktree.sharedDirectories`: first 100 input entries | Later entries ignored, even if earlier entries were invalid or duplicates. | +| YAML alias expansion: parser `maxAliasCount` of 100 | Whole file rejected if the parser's expansion budget is exceeded; this is not a simple count of alias tokens. | + +## Which checkout supplies the configuration? + +The **primary checkout** is the repository folder registered in Orca. It may be on a different branch from the new worktree. + +| Configuration | Read from | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Setup script, setup startup policy, and `defaultTabs` | The **new worktree's** `orca.yaml`. | +| Shared directory rules and their source directories | The **primary checkout's** `orca.yaml` and filesystem. | +| Copy rules and their source files | The **primary checkout's** `.worktreeinclude` and filesystem. | +| Archive script | The **primary checkout's** `orca.yaml`, executed with the worktree as its working directory. | +| Issue command | The **primary checkout's** `.orca/issue-command`, then its `orca.yaml`. | + +Changing a feature branch's `.worktreeinclude` or shared-directory list does not update the primary checkout. Before creating another worktree, make sure the primary checkout has the intended rules and the ignored source paths. Setup and tab defaults instead follow the revision checked out in the new worktree. These defaults are applied during creation; they do not synchronize existing tabs or files. + +## Setup, archive, and command selection + +In **Settings → Repository**, setup can run automatically, ask each time, or be skipped by default. A new repository defaults to running setup. The [CLI](/docs/cli/reference#worktrees) accepts `--setup run|skip|inherit`; `inherit` follows that Settings policy. A valid file does not bypass Orca's command approval. + +**Command source & orca.yaml** controls setup and archive scripts: + +| Settings choice | Scripts used | +| ---------------------------------- | ----------------------------------------------- | +| **orca.yaml only** (`shared-only`) | Only the YAML hook; a local hook is ignored. | +| **Local only** (`local-only`) | Only the local hook; the YAML hook is ignored. | +| **Run both** (`run-both`) | YAML first, then local, joined into one script. | + +When no source choice is saved, a nonempty local hook selects **Local only** for that hook; otherwise Orca uses **orca.yaml only**. Shared directories are independent of this command-source choice. + +Setup runs in a **Setup** terminal. On macOS/Linux, Orca writes a Bash runner with `set -e`; supported shell options on a leading `#!` line are replayed. On native Windows, the runner uses `.cmd` syntax and calls each nonempty line, stopping on failure. A leading POSIX-shell `#!` line opts into Bash only when Git Bash is configured and available; otherwise the `.cmd` runner refuses the script before running any commands. Choosing PowerShell as the terminal does not turn a setup script into PowerShell code. + +The setup runner receives: + +| Variable | Value | +| ----------------------------------------- | ----------------------------------------------------------------- | +| `ORCA_ROOT_PATH` | Primary checkout path. | +| `ORCA_WORKTREE_PATH` | New worktree path. | +| `ORCA_WORKSPACE_NAME` | Worktree directory basename, rather than a renamed display title. | +| `CONDUCTOR_ROOT_PATH`, `GHOSTX_ROOT_PATH` | Compatibility aliases for `ORCA_ROOT_PATH`. | + +Paths and shell syntax differ by platform. Use `$ORCA_WORKTREE_PATH` in Bash and `%ORCA_WORKTREE_PATH%` in `.cmd` scripts. `wait-for-setup` waits for successful setup before agent startup; either the YAML or local wait setting enables it. This controls startup order, not whether setup runs. + +Archive hooks run before local removal; the CLI requires `--run-hooks` to enable them. A failed archive hook blocks removal unless the caller explicitly accepts that failure. + +### Terminal defaults and issue commands + +`defaultTabs` creates terminal tabs once for the new worktree. Titles and colors still apply when commands are skipped. Tab commands follow the setup run decision and are suppressed by **Local only** (using the setup command-source policy). + +In the **desktop create composer**, an agent/startup command takes the first template tab, so the **first tab's template command is not run**. Reserve that tab for the agent and put other commands in later tabs. Local CLI creation with `--agent` instead creates a separate startup terminal alongside the configured tabs; every allowed template command can run. Tab commands do not wait for setup to finish, so a command that needs installed dependencies should be run after setup completes. The example's Git status command does not depend on the install. + +`issueCommand` has its own override: nonempty `.orca/issue-command` content wins over YAML. Clearing that local file restores the shared template. This is separate from the setup/archive command-source choice. Templates support `{{artifact_url}}` for the linked item's URL and legacy `{{issue}}` for its number; the composer decides whether to use the template. Shared YAML commands use Orca's approval flow, which can reuse saved trust; local overrides are treated as user-authored and do not trigger that shared-command prompt. + +```yaml +issueCommand: | + echo "Linked item: {{artifact_url}}" +``` + +## Sharing versus copying ignored paths + +All three mechanisms take paths relative to the primary checkout and preserve an existing destination. Settings paths are applied first, then YAML shared directories, then include copies. A path already present through sharing is not copied again. + +| Mechanism | Source entries | Result in a new local worktree | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| **Settings → Repository → Worktree Shared Paths** | Personal repository setting; existing files or directories. | APFS clone-copy on macOS when available; otherwise a link. A clone has independent contents, while a link shares edits. | +| `worktree.sharedDirectories` | Existing, **gitignored directories** listed in the primary `orca.yaml`. Adds to Settings paths. | Always a link to the primary directory, including on APFS. Windows tries a directory junction before a symlink. Edits affect the shared source. | +| `.worktreeinclude` | Existing, **gitignored files or directories** listed in the primary checkout. | Private copy, using APFS clone-copy when available and ordinary copying otherwise. It never falls back to a shared link. | + +YAML shared paths normalize backslashes to `/`, remove a leading `./` and trailing `/`, and deduplicate. Absolute paths, drive-prefixed paths, empty path segments, `.`/`..` segments, and any `.git` segment are rejected. A file, missing directory, or non-ignored directory is skipped. + +### .worktreeinclude format and copy budget + +```text +# .worktreeinclude +.env +.env.local +.vscode/settings.json +``` + +Use **one literal path per line**, anchored to the repository root. Blank lines and lines starting with `#` are ignored; inline comments are not stripped. Backslashes, a leading `./`, and trailing `/` are normalized and duplicates are removed. Glob patterns containing `*` or `?` and negation starting with `!` are skipped. Use relative paths without traversal or `.git`; only existing, gitignored entries are copied. The include file must be a regular file no larger than 256 KiB; Orca considers at most 1,000 valid literal path candidates. + +Ordinary include copying is limited to **2 GiB total file bytes and 50,000 filesystem entries** per new worktree, measured before copying. APFS clones do not consume the byte budget, but still consume the entry budget; a fallback to ordinary copying must fit the byte budget. An entry that exceeds the remaining budget is skipped and creation reports a warning. Earlier refused entries can also exhaust the bounded sizing walk, leaving later entries unmeasured. These are admission limits, not quotas against files growing during copying. + +Copying a top-level source symlink uses its target's contents. Nested symlinks remain links, so editing through one can still affect its referent. Large dependency trees usually belong in setup or deliberate sharing rather than `.worktreeinclude`. + +## When configuration appears to do nothing + +1. Check the correct checkout from the table above, the exact key spelling, value type, duplicate keys, and limits. A parser failure can disable setup, tabs, and shared directories together. +1. Check setup's run policy, command-source choice, approval, and first-tab startup behavior. `--setup run` changes the run decision; it does not override **Local only**. +1. Check that source paths exist in the primary checkout and are directories for `sharedDirectories`. Verify ignored status there with `git check-ignore -- path/to/entry`; tracked or unignored paths do not qualify for YAML sharing or include copying. +1. Check for an existing destination or a copy-budget warning. Missing shared directories are skipped without a warning; not every skipped field/path has a visible error. Absence of a warning does not prove acceptance. + +See [Worktrees](/docs/model/worktrees#shared-directories--gitignored-files) for the creation flow and [Settings](/docs/settings#repository) for repository preferences. diff --git a/docs/site/content/docs/model/worktrees.mdx b/docs/site/content/docs/model/worktrees.mdx index f71d21c2ca4..52e432c4b6c 100644 --- a/docs/site/content/docs/model/worktrees.mdx +++ b/docs/site/content/docs/model/worktrees.mdx @@ -42,11 +42,13 @@ A brand-new worktree is a clean checkout. Dependencies, caches, and local secret Orca fills that gap in three complementary ways: 1. **Worktree Shared Paths** (per repo, in Settings → Repository) — paths materialize from the primary checkout into each new worktree (APFS clone-copy on macOS when possible, otherwise a symlink). -1. **`worktree.sharedDirectories` in `orca.yaml`** — repo-checked-in list of **gitignored directories** to share the same way (symlink/share, not copy). Use this for large rebuildable trees like `node_modules` or `.cache`. Entries must exist as directories in the primary checkout **and** be gitignored; tracked or missing paths are skipped. +1. **`worktree.sharedDirectories` in `orca.yaml`** — repo-checked-in list of **gitignored directories** to share by link (including on APFS). Use this for large rebuildable trees like `node_modules` or `.cache`. Entries must exist as directories in the primary checkout **and** be gitignored; tracked or missing paths are skipped. 1. **`.worktreeinclude` at the repo root** — list of **gitignored files or directories to copy** (not symlink) into each new worktree, so each worktree owns its copy. Typical entries: `.env`, local config under `.vscode/`. Blank lines and `#` comments are allowed. Only **literal** paths are supported today — globs and negation are skipped with a warning. Paths that are tracked, missing, or not gitignored are not copied. `orca.yaml` shared directories **add to** the per-user Worktree Shared Paths list; they never replace it. Paths already shared/linked are not re-copied from `.worktreeinclude`. +See the [orca.yaml & .worktreeinclude reference](/docs/model/orca-yaml) for setup scripts, default tabs, command selection, path rules, limits, and which checkout supplies each setting. + ```yaml # orca.yaml (repo root) worktree: @@ -112,6 +114,8 @@ You can pin a worktree to the top of its project to keep long-running work in vi When a worktree has nested child worktrees (for example from orchestration or `worktree create` with a parent), the context menu can also offer **Sleep with Descendants (N)** and **Delete with Descendants…**. Sleep with descendants closes active panels on the selected workspace and every validated nested child in the same project, repo, and host — only workspaces with live terminals or browser tabs are targeted for sleep. Delete with descendants makes the existing cascading delete explicit. Stale lineage links, cycles, and children across host or repo boundaries are excluded. +A parent worktree shows its nested children under an **N children** chip; click the chip to show or hide them. To do the same from the keyboard, assign **Toggle Child Workspaces** under [Settings → Shortcuts](/docs/settings). It acts on the worktree under the pointer, or on the active worktree when nothing is hovered, and does exactly what that card's chip does. On a card with no chip of its own (it has no children, or a sidebar filter hides all of them), it uses the chip on the card's parent instead, and does nothing if there is none. + Double-click a worktree title in the sidebar to rename it inline. Double-clicking elsewhere on the card still opens the full edit dialog. In **Edit Worktree Details**, the issue field accepts **GitHub** or **Linear** (chip on the field; paste a URL to auto-detect). One linked issue per workspace — changing provider or clearing the field unlinks the previous one. For SSH workspaces whose host is disconnected, the card title row can show an inline reconnect control (see [SSH worktrees](/docs/ssh)). ## Resource Manager cleanup diff --git a/docs/site/content/docs/settings.mdx b/docs/site/content/docs/settings.mdx index a5bbe9b83f4..4fac0564d48 100644 --- a/docs/site/content/docs/settings.mdx +++ b/docs/site/content/docs/settings.mdx @@ -44,6 +44,7 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then - Ghostty import. - Warp theme import — bring in your Warp YAML themes with **Import themes from Warp** (auto-discovers Warp's themes folder per OS) or **Import from YAML** for any folder of Warp-format theme files. - JIS Yen (¥) to Backslash (\\) for macOS Japanese keyboards. +- **Terminal shell** (local macOS / Linux) — system shell or a custom executable, with login startup by default. **Custom shell → Advanced → Custom args** requests replacement arguments for ordinary local panes. See [startup files and argument limits](/docs/terminal#macos-and-linux-shell). - Windows default shell (PowerShell or CMD). - **Allow TUI Clipboard Writes (OSC 52)** — **on by default**. Lets Zellij, tmux, Neovim, fzf, Grok (and similar) write the system clipboard over the PTY, including over SSH. Turn off if you prefer the older lockdown. @@ -132,6 +133,7 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then - Full keymap — every binding remappable. - Toggle Sleeping Workspaces ships unbound; assign it here if you want a direct shortcut for the sidebar sleeping-worktree filter. +- **Toggle Child Workspaces** ships unbound; assign it here to show or hide a parent worktree's nested children, the same as clicking its **N children** chip. It targets the hovered worktree, or the active one when nothing is hovered. - **Toggle Workspace Board** ships unbound; assign it here to open or close the Workspace Board with one shortcut. Existing bindings for `workspace.openBoard` continue to work. - Close all editor tabs defaults to `Cmd+Option+W` on macOS and `Ctrl+Alt+W` on Windows / Linux. - **Tab navigation defaults (new installs):** next/previous tab **across all types** is `Cmd+Shift+]` / `Cmd+Shift+[` (Ctrl on Linux/Windows). Same-type next/previous is `Cmd+Option+]` / `Cmd+Option+[`. Previous recent tab is `Ctrl+Tab`. Existing installs keep customized overrides under `~/.orca/keybindings.json`. diff --git a/docs/site/content/docs/telemetry.mdx b/docs/site/content/docs/telemetry.mdx index 2dc4549401d..a8b8715812a 100644 --- a/docs/site/content/docs/telemetry.mdx +++ b/docs/site/content/docs/telemetry.mdx @@ -20,7 +20,7 @@ Alongside each event we include basic build and platform information: the Orca v The categories of behavior we observe: - **Lifecycle** — when the app opens. Used to estimate daily, weekly, and monthly active users. -- **Repos and workspaces** — when you add a repo or create a workspace. We record _how_ you did it (e.g. folder picker vs. clone URL; command palette vs. drag-and-drop), never the repo name, URL, path, branch name, or any free-form text. +- **Repos and workspaces** — when you add a repo or create a workspace. We record _how_ you did it (e.g. folder picker vs. clone URL; command palette vs. drag-and-drop), never the repo name, URL, path, branch name, or any free-form text. For a workspace create we also record how long it took and how that time split across its steps, whether a checkout Orca had prepared in advance was reused (and if so, what reset it needed, what prepared it, how long it took to prepare and how long it waited unused; if not, a fixed reason code), for a failed create which step it stopped in, plus coarse context: whether it came from the Orca window or from the CLI, phone or agent interface, whether Git ran locally, in WSL or over SSH, a bucketed worktree count, a bucketed count of the files the repo tracks, how many other creates and prepared-checkout jobs were running, and whether the repo has a post-checkout hook (never its contents). - **Agents** — which agents you use and their token counts. Never prompts or agent output. - **Agent errors** — a coarse error category and which agent kind was involved. We never see raw error messages or stack traces; per-incident detail stays in a local diagnostic trace file on your machine and only reaches Orca if you explicitly share a diagnostic bundle. - **Settings** — when you toggle one of a small whitelisted set of feature-flag or UX preferences. We record which preference changed and whether it's a boolean or an enum, never the raw value of any free-form setting. diff --git a/docs/site/content/docs/terminal.mdx b/docs/site/content/docs/terminal.mdx index 1be3d3892f3..8db8b24caee 100644 --- a/docs/site/content/docs/terminal.mdx +++ b/docs/site/content/docs/terminal.mdx @@ -46,6 +46,23 @@ If you've collected themes in Warp, click **Import themes from Warp** in the ter Imported themes appear alongside Orca's built-ins in the theme dropdown. +## macOS and Linux shell + +By default, local terminal panes on macOS and Linux open your system shell (`$SHELL`) as a **login shell** (`-l`). With the default arguments: + +- **zsh** reads `.zshenv`, `.zprofile`, `.zshrc`, and `.zlogin` in order from `$ZDOTDIR`, or your home directory (`~`) if unset. Each user file follows its system counterpart (`zshenv`, `zprofile`, `zshrc`, `zlogin`), usually under `/etc` or `/etc/zsh`. +- **bash** reads `/etc/profile`, then the first of `~/.bash_profile`, `~/.bash_login`, or `~/.profile` that exists. It does **not** read `~/.bashrc` on its own. If your `PATH` or version-manager setup (nvm, asdf, mise) lives in `~/.bashrc`, source it from that login file, as many distributions' default `~/.profile` or `~/.bash_profile` already do: + + ```bash + [ -n "${BASH_VERSION:-}" ] && [ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc" + ``` + + The guard keeps non-bash shells that also read `~/.profile` from loading bash-only setup, even with `set -u` enabled. Add it only if the login file bash reads does not already source `~/.bashrc`. + +When Orca uses its bash integration wrapper, it starts bash with `--rcfile` instead of `-l`. The wrapper sources the same login files without separately sourcing `~/.bashrc`; bash itself is not in login mode. + +To open a different shell, choose **Custom shell** under [Settings → Terminal → Terminal shell](/docs/settings). Under **Advanced → Custom args**, enter one argument per line to replace the default `-l` for ordinary local panes. An empty list requests no arguments: unwrapped interactive bash then reads `~/.bashrc` instead of the login files. When the terminal daemon is unavailable, Orca's bash or zsh integration can override custom arguments and retain login startup. Agent launches, startup commands, and one-off shell choices do not use this argument setting. + ## Windows shell The default shell on Windows is configurable between PowerShell, Command Prompt, and WSL under [Settings → Terminal](/docs/settings). WSL is offered automatically when `wsl.exe --status` succeeds. The **+** dropdown on the tab bar also shows a submenu so you can open a one-off tab in any shell without changing your default. diff --git a/docs/site/package.json b/docs/site/package.json index 89a977c3d40..010405f418e 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -18,21 +18,21 @@ "fumadocs-mdx": "^14.3.1", "fumadocs-ui": "^16.8.4", "lucide-react": "^1.6.0", - "next": "16.3.4", - "react": "19.2.4", - "react-dom": "19.2.4", - "tailwind-merge": "^3.5.0", + "next": "16.3.6", + "react": "19.2.8", + "react-dom": "19.2.8", + "tailwind-merge": "^3.7.0", "tw-animate-css": "^1.4.0", "zod": "^4" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/mdx": "^2.0.13", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/node": "^22.20.4", + "@types/react": "~19.2.18", + "@types/react-dom": "~19.2.7", "eslint": "^9", - "eslint-config-next": "16.3.4", + "eslint-config-next": "16.3.6", "tailwindcss": "^4", "typescript": "^5", "vercel": "59.11.1" @@ -40,7 +40,7 @@ "engines": { "node": "22.x" }, - "packageManager": "pnpm@10.24.0", + "packageManager": "pnpm@10.34.6", "pnpm": { "onlyBuiltDependencies": [ "esbuild", @@ -50,13 +50,21 @@ "overrides": { "@vercel/fun>tar": "7.5.22", "@vercel/fun>@tootallnate/once": "2.0.1", - "@vercel/node>undici": "6.28.0", + "@vercel/node>undici": "6.28.1", "@vercel/python-analysis>js-yaml": "4.3.2", "@vercel/python-analysis>minimatch": "10.2.6", "vercel>smol-toml": "1.8.0", - "vercel>undici": "6.28.0", + "vercel>undici": "6.28.1", "@vercel/python-analysis>smol-toml": "1.8.0", - "@vercel/rust>smol-toml": "1.8.0" + "@vercel/rust>smol-toml": "1.8.0", + "@vercel/sandbox>undici": "7.29.1", + "@vercel/static-config>ajv": "8.18.0", + "@vercel/backends>path-to-regexp": "8.4.0", + "@vercel/fun>path-to-regexp": "8.4.0", + "brace-expansion@<2": "1.1.21", + "brace-expansion@>=4 <5.0.12": "5.0.12", + "@vercel/express>path-to-regexp": "8.4.0", + "@vercel/hono>path-to-regexp": "8.4.0" } } } diff --git a/docs/site/pnpm-lock.yaml b/docs/site/pnpm-lock.yaml index 0d496ad7c6c..c69df84feae 100644 --- a/docs/site/pnpm-lock.yaml +++ b/docs/site/pnpm-lock.yaml @@ -7,13 +7,21 @@ settings: overrides: '@vercel/fun>tar': 7.5.22 '@vercel/fun>@tootallnate/once': 2.0.1 - '@vercel/node>undici': 6.28.0 + '@vercel/node>undici': 6.28.1 '@vercel/python-analysis>js-yaml': 4.3.2 '@vercel/python-analysis>minimatch': 10.2.6 vercel>smol-toml: 1.8.0 - vercel>undici: 6.28.0 + vercel>undici: 6.28.1 '@vercel/python-analysis>smol-toml': 1.8.0 '@vercel/rust>smol-toml': 1.8.0 + '@vercel/sandbox>undici': 7.29.1 + '@vercel/static-config>ajv': 8.18.0 + '@vercel/backends>path-to-regexp': 8.4.0 + '@vercel/fun>path-to-regexp': 8.4.0 + brace-expansion@<2: 1.1.21 + brace-expansion@>=4 <5.0.12: 5.0.12 + '@vercel/express>path-to-regexp': 8.4.0 + '@vercel/hono>path-to-regexp': 8.4.0 importers: @@ -24,28 +32,28 @@ importers: version: 2.1.1 fumadocs-core: specifier: ^16.8.4 - version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) fumadocs-mdx: specifier: ^14.3.1 - version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) fumadocs-ui: specifier: ^16.8.4 - version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3) + version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) lucide-react: specifier: ^1.6.0 - version: 1.26.0(react@19.2.4) + version: 1.26.0(react@19.2.8) next: - specifier: 16.3.4 - version: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.3.6 + version: 16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: - specifier: 19.2.4 - version: 19.2.4 + specifier: 19.2.8 + version: 19.2.8 react-dom: - specifier: 19.2.4 - version: 19.2.4(react@19.2.4) + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) tailwind-merge: - specifier: ^3.5.0 - version: 3.6.0 + specifier: ^3.7.0 + version: 3.7.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -60,20 +68,20 @@ importers: specifier: ^2.0.13 version: 2.0.14 '@types/node': - specifier: ^20 - version: 20.19.43 + specifier: ^22.20.4 + version: 22.20.4 '@types/react': - specifier: ^19 - version: 19.2.17 + specifier: ~19.2.18 + version: 19.2.18 '@types/react-dom': - specifier: ^19 - version: 19.2.3(@types/react@19.2.17) + specifier: ~19.2.7 + version: 19.2.7(@types/react@19.2.18) eslint: specifier: ^9 version: 9.39.5(jiti@2.7.0) eslint-config-next: - specifier: 16.3.4 - version: 16.3.4(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + specifier: 16.3.6 + version: 16.3.6(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) tailwindcss: specifier: ^4 version: 4.3.3 @@ -102,8 +110,8 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.29.7': @@ -140,8 +148,8 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true @@ -149,12 +157,12 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@bytecodealliance/preview2-shim@0.17.6': @@ -605,144 +613,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] + libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] + libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] + libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] + libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] + libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] + libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -804,30 +828,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/keyring-linux-arm64-musl@1.2.0': resolution: {integrity: sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': resolution: {integrity: sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/keyring-linux-x64-gnu@1.2.0': resolution: {integrity: sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/keyring-linux-x64-musl@1.2.0': resolution: {integrity: sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/keyring-win32-arm64-msvc@1.2.0': resolution: {integrity: sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==} @@ -857,56 +886,67 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@16.3.4': - resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@next/eslint-plugin-next@16.3.4': - resolution: {integrity: sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==} + '@next/env@16.3.6': + resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} - '@next/swc-darwin-arm64@16.3.4': - resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} + '@next/eslint-plugin-next@16.3.6': + resolution: {integrity: sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==} + + '@next/swc-darwin-arm64@16.3.6': + resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.4': - resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} + '@next/swc-darwin-x64@16.3.6': + resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.4': - resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} + '@next/swc-linux-arm64-gnu@16.3.6': + resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.4': - resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} + '@next/swc-linux-arm64-musl@16.3.6': + resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] - '@next/swc-linux-x64-gnu@16.3.4': - resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} + '@next/swc-linux-x64-gnu@16.3.6': + resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] - '@next/swc-linux-x64-musl@16.3.4': - resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} + '@next/swc-linux-x64-musl@16.3.6': + resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.4': - resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} + '@next/swc-win32-arm64-msvc@16.3.6': + resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.4': - resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} + '@next/swc-win32-x64-msvc@16.3.6': + resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -978,48 +1018,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.121.0': resolution: {integrity: sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': resolution: {integrity: sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': resolution: {integrity: sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.121.0': resolution: {integrity: sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.121.0': resolution: {integrity: sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.121.0': resolution: {integrity: sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.121.0': resolution: {integrity: sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.121.0': resolution: {integrity: sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==} @@ -1103,48 +1151,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-arm64-musl@0.111.0': resolution: {integrity: sha512-ARyfcMCIxVLDgLf6FQ8Oo1/TFySpnquV+vuSb4SFQZfYDqgMklzwv0NYXxWD0aB6enElyMDs6pQJBzusEKCkOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-transform/binding-linux-ppc64-gnu@0.111.0': resolution: {integrity: sha512-PKpVRrSvBNK3tv9vwxn7Fay+QWZmprPGlEqJcseBJllQc5mFMD4Q/w44chu5iR9ZLsDeSHzmNWrgMLo4J0sP2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-riscv64-gnu@0.111.0': resolution: {integrity: sha512-9bUml6rMgk+8GF5rvNMweFspkzSiCjqpV6HduwiUyexqfGKrmjq9IZOxxvnzkE2RGdQzP507NNDoVNYIoGQYuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-riscv64-musl@0.111.0': resolution: {integrity: sha512-tzGCohGxaeH6KRJjfYZd4mHCoGjCai6N+zZi1Oj+tSDMAAdyvs1dRzYb8PNUGnybCg3Te4M0jLPzWZaSmnKraQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-transform/binding-linux-s390x-gnu@0.111.0': resolution: {integrity: sha512-sRG1KIfZ0ML9ToEygm5aM/5GJeBA05uHlgW3M0Rx/DNWMJhuahLmqWuB02aWSmijndLfEKXLLXIWhvWupRG8lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-x64-gnu@0.111.0': resolution: {integrity: sha512-T0Kmvk+OdlUdABdXlDIf3MQReMzFfC75NEI9x8jxy5pKooACEFg0k0V8gyR3gq4DzbDCfucqFQDWNvSgIopAbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-x64-musl@0.111.0': resolution: {integrity: sha512-EgoutsP3YfqzN8a9vpc9+XLr0bmBl0dA3uOMiP77+exATCPxJBkJErGmQkqk6RtTp5XqX6q6mB45qWQyKk6+pA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-transform/binding-openharmony-arm64@0.111.0': resolution: {integrity: sha512-d8J+ejc0j5WODbVwR/QxFaI65YMwvG0W53vcVCHwa6ja1QI5lpe7sislrefG2EFYgnY47voMRzlXab5d4gEcDw==} @@ -1570,24 +1626,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.1': resolution: {integrity: sha512-UvApLEGholmxw/HIwmUnLq3CwdydbhaHHllvWiCTNbyGom7wTwOtz5OAQbAKZYyiEOeIXZNPkM7nA4Dtng7CLw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.1': resolution: {integrity: sha512-uVctNgZHiGnJx5Fij7wHLhgw4uyZBVi6mykeWKOqE7bVy9Hcxn0fM/IuqdMwk6hXlaf9fFShDTFz2+YejP+x0A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.1': resolution: {integrity: sha512-T6Eg0xWwcxd/MzBcuv4Z37YVbUbJxy5cMNnbIt/Yr99wFwli30O4BPlY8hKeGyn6lWNtU0QioBS46lVzDN38bg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': resolution: {integrity: sha512-PuGZVS2xNJyLADeh2F04b+Cz4NwvpglbtWACgrDOa5YDTEHKwmiTDjoD5eZ9/ptXtcpeFrMqD2H4Zn33KAh1Eg==} @@ -1705,24 +1765,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.3': resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.3': resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.3': resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.3': resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} @@ -1765,6 +1829,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -1795,16 +1862,16 @@ packages: '@types/node@20.11.0': resolution: {integrity: sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==} - '@types/node@20.19.43': - resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1812,63 +1879,63 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@typescript-eslint/eslint-plugin@8.65.0': - resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + '@typescript-eslint/eslint-plugin@8.71.0': + resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.65.0 + '@typescript-eslint/parser': ^8.71.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.65.0': - resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + '@typescript-eslint/parser@8.71.0': + resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.65.0': - resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.65.0': - resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.65.0': - resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + '@typescript-eslint/type-utils@8.71.0': + resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.65.0': - resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.65.0': - resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.65.0': - resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.3': @@ -1913,51 +1980,61 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -2191,8 +2268,8 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.6.3: - resolution: {integrity: sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} @@ -2219,8 +2296,8 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} engines: {node: '>= 0.4'} array.prototype.findlast@1.2.5: @@ -2279,8 +2356,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.12.1: - resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} axobject-query@4.1.0: @@ -2313,27 +2390,27 @@ packages: bare-abort-controller: optional: true - baseline-browser-mapping@2.11.1: - resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + baseline-browser-mapping@2.11.26: + resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} engines: {node: '>=6.0.0'} hasBin: true bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - brace-expansion@1.1.18: - resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.7: - resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + browserslist@4.29.2: + resolution: {integrity: sha512-/u9r8k8ue4ZhHCw9ovDtltpWdXeXzjhdxCGj6vm1RGLPXb8lu33EMdoCHF5Sx8oYBS+Q/FYYNmAqlHncxR3RmA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2360,8 +2437,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + caniuse-lite@1.0.30001813: + resolution: {integrity: sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2545,8 +2622,8 @@ packages: engines: {node: '>=16'} hasBin: true - electron-to-chromium@1.5.396: - resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==} + electron-to-chromium@1.5.440: + resolution: {integrity: sha512-SghDzqdJokdz8zP8YNlvS74+CwLRoUPDGvP/gFA+HrKVw+pOshUAgx8pXR37xl/u16zGae746rFNWvZ/22kU2Q==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -2635,8 +2712,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@16.3.4: - resolution: {integrity: sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==} + eslint-config-next@16.3.6: + resolution: {integrity: sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2823,6 +2900,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3051,6 +3131,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -3172,8 +3255,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.6: - resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + ignore@7.0.10: + resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} engines: {node: '>= 4'} import-fresh@3.3.1: @@ -3227,8 +3310,8 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} is-data-view@1.0.2: @@ -3455,24 +3538,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3783,6 +3870,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -3797,8 +3889,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.3.4: - resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} + next@16.3.6: + resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3853,8 +3945,8 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true - node-releases@2.0.51: - resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} engines: {node: '>=18'} nopt@8.1.0: @@ -3992,12 +4084,8 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-to-regexp@8.2.0: - resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} - engines: {node: '>=16'} - - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.0: + resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==} pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -4059,10 +4147,10 @@ packages: resolution: {integrity: sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==} engines: {node: '>= 0.8'} - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: - react: ^19.2.4 + react: ^19.2.8 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4097,8 +4185,8 @@ packages: '@types/react': optional: true - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} readdirp@4.1.2: @@ -4258,8 +4346,8 @@ packages: setprototypeof@1.1.1: resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==} - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -4350,8 +4438,8 @@ packages: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + string.prototype.matchall@4.1.0: + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} engines: {node: '>= 0.4'} string.prototype.repeat@1.0.0: @@ -4411,8 +4499,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tailwind-merge@3.6.0: - resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -4509,16 +4597,16 @@ packages: resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} engines: {node: '>= 0.4'} - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + typed-array-byte-offset@1.0.5: + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} engines: {node: '>= 0.4'} typed-array-length@1.0.8: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} - typescript-eslint@8.65.0: - resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + typescript-eslint@8.71.0: + resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4542,12 +4630,12 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@6.28.0: - resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + undici@6.28.1: + resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} engines: {node: '>=18.17'} - undici@7.29.0: - resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} engines: {node: '>=20.18.1'} unified@11.0.5: @@ -4585,8 +4673,8 @@ packages: unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -4656,8 +4744,8 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.22: - resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} engines: {node: '>= 0.4'} which@2.0.2: @@ -4755,14 +4843,14 @@ snapshots: '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -4772,10 +4860,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.7': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -4784,7 +4872,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.7 + browserslist: 4.29.2 lru-cache: 5.1.1 semver: 6.3.1 @@ -4792,8 +4880,8 @@ snapshots: '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -4802,7 +4890,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color @@ -4815,31 +4903,31 @@ snapshots: '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 - '@babel/parser@7.29.7': + '@babel/parser@7.29.9': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 @@ -5095,20 +5183,20 @@ snapshots: '@floating-ui/core': 1.8.0 '@floating-ui/utils': 0.2.12 - '@floating-ui/react-dom@2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@floating-ui/dom': 1.8.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) '@floating-ui/utils@0.2.12': {} - '@fuma-translate/react@1.0.2(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@fuma-translate/react@1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 '@fumadocs/tailwind@0.1.1(tailwindcss@4.3.3)': optionalDependencies: @@ -5133,108 +5221,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@isaacs/fs-minipass@4.0.1': @@ -5354,13 +5442,6 @@ snapshots: '@napi-rs/keyring-win32-ia32-msvc': 1.2.0 '@napi-rs/keyring-win32-x64-msvc': 1.2.0 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.3 - optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.10.0 @@ -5368,37 +5449,44 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.3.4': {} + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.4 + optional: true - '@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0))': + '@next/env@16.3.6': {} + + '@next/eslint-plugin-next@16.3.6(eslint@9.39.5(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.3.4': + '@next/swc-darwin-arm64@16.3.6': optional: true - '@next/swc-darwin-x64@16.3.4': + '@next/swc-darwin-x64@16.3.6': optional: true - '@next/swc-linux-arm64-gnu@16.3.4': + '@next/swc-linux-arm64-gnu@16.3.6': optional: true - '@next/swc-linux-arm64-musl@16.3.4': + '@next/swc-linux-arm64-musl@16.3.6': optional: true - '@next/swc-linux-x64-gnu@16.3.4': + '@next/swc-linux-x64-gnu@16.3.6': optional: true - '@next/swc-linux-x64-musl@16.3.4': + '@next/swc-linux-x64-musl@16.3.6': optional: true - '@next/swc-win32-arm64-msvc@16.3.4': + '@next/swc-win32-arm64-msvc@16.3.6': optional: true - '@next/swc-win32-x64-msvc@16.3.4': + '@next/swc-win32-x64-msvc@16.3.6': optional: true '@nodelib/fs.scandir@2.1.5': @@ -5555,352 +5643,352 @@ snapshots: '@radix-ui/primitive@1.1.7': {} - '@radix-ui/react-accordion@1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-accordion@1.2.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collapsible': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collapsible': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-arrow@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-arrow@1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-collapsible@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-collapsible@1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-collection@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-collection@1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-compose-refs@1.1.4(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-compose-refs@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-context@1.2.1(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-context@1.2.1(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-dialog@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-dialog@1.1.21(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-direction@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-direction@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-dismissable-layer@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-dismissable-layer@1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-focus-guards@1.1.5(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-focus-guards@1.1.5(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-focus-scope@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-focus-scope@1.1.14(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-id@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-id@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-navigation-menu@1.2.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-navigation-menu@1.2.20(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-popover@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-popover@1.1.21(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-popper@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-popper@1.3.5(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@floating-ui/react-dom': 2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-arrow': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-rect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.17)(react@19.2.4) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/rect': 1.1.3 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-portal@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-portal@1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-presence@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-presence@1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-primitive@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-primitive@2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-roving-focus@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-roving-focus@1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.2(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-scroll-area@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-scroll-area@1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-slot@1.3.1(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-slot@1.3.1(@types/react@19.2.18)(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-tabs@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-tabs@1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@radix-ui/react-use-callback-ref@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-callback-ref@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-controllable-state@1.2.5(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-controllable-state@1.2.5(@types/react@19.2.18)(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-effect-event@0.0.4(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-effect-event@0.0.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-is-hydrated@0.1.2(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-is-hydrated@0.1.2(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-layout-effect@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-layout-effect@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-previous@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-previous@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-rect@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-rect@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: '@radix-ui/rect': 1.1.3 - react: 19.2.4 + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-size@1.1.3(@types/react@19.2.17)(react@19.2.4)': + '@radix-ui/react-use-size@1.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.4) - react: 19.2.4 + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-visually-hidden@1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@radix-ui/react-visually-hidden@1.2.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) '@radix-ui/rect@1.1.3': {} @@ -6091,6 +6179,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -6121,15 +6214,15 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@20.19.43': + '@types/node@22.20.4': dependencies: undici-types: 6.21.0 - '@types/react-dom@19.2.3(@types/react@19.2.17)': + '@types/react-dom@19.2.7(@types/react@19.2.18)': dependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@types/react@19.2.17': + '@types/react@19.2.18': dependencies: csstype: 3.2.3 @@ -6137,57 +6230,57 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.0 eslint: 9.39.5(jiti@2.7.0) - ignore: 7.0.6 + ignore: 7.0.10 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.0 debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.71.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.65.0': + '@typescript-eslint/scope-manager@8.71.0': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) @@ -6195,14 +6288,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.65.0': {} + '@typescript-eslint/types@8.71.0': {} - '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.71.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/project-service': 8.71.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 @@ -6212,20 +6305,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/utils@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.65.0': + '@typescript-eslint/visitor-keys@8.71.0': dependencies: - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.71.0 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.3': {} @@ -6288,7 +6381,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': @@ -6309,7 +6402,7 @@ snapshots: fs-extra: 11.1.0 get-port: 5.1.1 oxc-transform: 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.0 resolve.exports: 2.0.3 rolldown: 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) srvx: 0.11.16 @@ -6330,7 +6423,7 @@ snapshots: is-buffer: 2.0.5 is-node-process: 1.2.0 throttleit: 2.1.0 - undici: 6.28.0 + undici: 6.28.1 '@vercel/build-utils@14.9.0': dependencies: @@ -6393,7 +6486,7 @@ snapshots: '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) fs-extra: 11.1.0 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.0 ts-morph: 12.0.0 zod: 3.22.4 transitivePeerDependencies: @@ -6424,7 +6517,7 @@ snapshots: micro: 9.3.5-canary.3 ms: 2.1.1 node-fetch: 2.6.7 - path-to-regexp: 8.2.0 + path-to-regexp: 8.4.0 promisepipe: 3.0.0 semver: 7.5.4 stat-mode: 0.3.0 @@ -6474,7 +6567,7 @@ snapshots: '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) fs-extra: 11.1.0 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.0 ts-morph: 12.0.0 zod: 3.22.4 transitivePeerDependencies: @@ -6568,7 +6661,7 @@ snapshots: ts-morph: 12.0.0 tsx: 4.21.0 typescript: 5.9.3 - undici: 6.28.0 + undici: 6.28.1 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -6653,7 +6746,7 @@ snapshots: ms: 2.1.3 picocolors: 1.1.1 tar-stream: 3.1.7 - undici: 7.29.0 + undici: 7.29.1 xdg-app-paths: 5.1.0 zod: 4.4.3 transitivePeerDependencies: @@ -6673,7 +6766,7 @@ snapshots: '@vercel/static-config@3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - ajv: 8.6.3 + ajv: 8.18.0 json-schema-to-ts: 1.6.4 oxc-parser: 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) ts-morph: 12.0.0 @@ -6716,12 +6809,12 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.6.3: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - uri-js: 4.4.1 ansi-styles@4.3.0: dependencies: @@ -6744,14 +6837,14 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - array-includes@3.1.9: + array-includes@3.2.0: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 es-abstract: 1.24.2 es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 + es-shim-unscopables: 1.1.0 is-string: 1.1.1 math-intrinsics: 1.1.0 @@ -6828,7 +6921,7 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.12.1: {} + axe-core@4.13.0: {} axobject-query@4.1.0: {} @@ -6842,18 +6935,18 @@ snapshots: bare-events@2.9.2: {} - baseline-browser-mapping@2.11.1: {} + baseline-browser-mapping@2.11.26: {} bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 - brace-expansion@1.1.18: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -6861,13 +6954,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.7: + browserslist@4.29.2: dependencies: - baseline-browser-mapping: 2.11.1 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.396 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.7) + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001813 + electron-to-chromium: 1.5.440 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.29.2) buffer-crc32@0.2.13: {} @@ -6892,7 +6985,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001806: {} + caniuse-lite@1.0.30001813: {} ccount@2.0.1: {} @@ -7049,7 +7142,7 @@ snapshots: signal-exit: 4.0.2 time-span: 4.0.0 - electron-to-chromium@1.5.396: {} + electron-to-chromium@1.5.440: {} emoji-regex@9.2.2: {} @@ -7127,10 +7220,10 @@ snapshots: string.prototype.trimstart: 1.0.8 typed-array-buffer: 1.0.3 typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 + typed-array-byte-offset: 1.0.5 typed-array-length: 1.0.8 unbox-primitive: 1.1.0 - which-typed-array: 1.1.22 + which-typed-array: 1.1.24 es-define-property@1.0.1: {} @@ -7261,18 +7354,18 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.3.4(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): + eslint-config-next@16.3.6(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0)) + '@next/eslint-plugin-next': 16.3.6(eslint@9.39.5(jiti@2.7.0)) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0)) globals: 16.4.0 - typescript-eslint: 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + typescript-eslint: 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -7284,7 +7377,7 @@ snapshots: eslint-import-resolver-node@0.3.10: dependencies: debug: 3.2.7 - is-core-module: 2.16.2 + is-core-module: 2.17.0 resolve: 2.0.0-next.7 transitivePeerDependencies: - supports-color @@ -7294,31 +7387,31 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) - get-tsconfig: 4.14.0 + get-tsconfig: 4.14.3 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 @@ -7326,9 +7419,9 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 - is-core-module: 2.16.2 + is-core-module: 2.17.0 is-glob: 4.0.3 minimatch: 3.1.5 object.fromentries: 2.0.8 @@ -7338,7 +7431,7 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -7347,10 +7440,10 @@ snapshots: eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)): dependencies: aria-query: 5.3.2 - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.12.1 + axe-core: 4.13.0 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 @@ -7366,7 +7459,7 @@ snapshots: eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.9 eslint: 9.39.5(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.4.3 @@ -7376,7 +7469,7 @@ snapshots: eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)): dependencies: - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.findlast: 1.2.5 array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 @@ -7393,7 +7486,7 @@ snapshots: prop-types: 15.8.1 resolve: 2.0.0-next.7 semver: 6.3.1 - string.prototype.matchall: 4.0.12 + string.prototype.matchall: 4.1.0 string.prototype.repeat: 1.0.0 eslint-scope@8.4.0: @@ -7566,6 +7659,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.8: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -7604,14 +7699,14 @@ snapshots: dependencies: is-callable: 1.2.7 - framer-motion@12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + framer-motion@12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: motion-dom: 12.42.2 motion-utils: 12.39.0 tslib: 2.8.1 optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) fs-extra@11.1.0: dependencies: @@ -7628,7 +7723,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3): + fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -7653,23 +7748,23 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 - '@types/react': 19.2.17 - lucide-react: 1.26.0(react@19.2.4) - next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + '@types/react': 19.2.18 + lucide-react: 1.26.0(react@19.2.8) + next: 16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) js-yaml: 4.3.2 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 @@ -7685,43 +7780,43 @@ snapshots: optionalDependencies: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 - '@types/react': 19.2.17 - next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 + '@types/react': 19.2.18 + next: 16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 transitivePeerDependencies: - supports-color - fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3): + fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: - '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) - '@radix-ui/react-accordion': 1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collapsible': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-dialog': 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-navigation-menu': 1.2.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popover': 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-scroll-area': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.4) - '@radix-ui/react-tabs': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-accordion': 1.2.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collapsible': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': 1.1.21(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-navigation-menu': 1.2.20(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popover': 1.1.21(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-scroll-area': 1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-tabs': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) - lucide-react: 1.26.0(react@19.2.4) - motion: 12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.4) + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + lucide-react: 1.26.0(react@19.2.8) + motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) rehype-raw: 7.0.0 scroll-into-view-if-needed: 3.1.0 shiki: 4.3.1 unist-util-visit: 5.1.0 optionalDependencies: '@types/mdx': 2.0.14 - '@types/react': 19.2.17 - next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/react': 19.2.18 + next: 16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@types/react-dom' @@ -7787,6 +7882,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -7979,7 +8078,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.6: {} + ignore@7.0.10: {} import-fresh@3.3.1: dependencies: @@ -8036,7 +8135,7 @@ snapshots: is-callable@1.2.7: {} - is-core-module@2.16.2: + is-core-module@2.17.0: dependencies: hasown: 2.0.4 @@ -8122,7 +8221,7 @@ snapshots: is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.22 + which-typed-array: 1.1.24 is-weakmap@2.0.2: {} @@ -8197,7 +8296,7 @@ snapshots: jsx-ast-utils@3.3.5: dependencies: - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.flat: 1.3.3 object.assign: 4.1.7 object.values: 1.2.1 @@ -8290,9 +8389,9 @@ snapshots: dependencies: yallist: 4.0.0 - lucide-react@1.26.0(react@19.2.4): + lucide-react@1.26.0(react@19.2.8): dependencies: - react: 19.2.4 + react: 19.2.8 luxon@3.7.2: {} @@ -8758,11 +8857,11 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.18 + brace-expansion: 1.1.21 minimist@1.2.8: {} @@ -8780,13 +8879,13 @@ snapshots: motion-utils@12.39.0: {} - motion@12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + motion@12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - framer-motion: 12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + framer-motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tslib: 2.8.1 optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) mri@1.2.0: {} @@ -8798,35 +8897,37 @@ snapshots: nanoid@3.3.18: {} + nanoid@3.3.19: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} - next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) - next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.3.6(@babel/core@7.29.7)(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.3.4 + '@next/env': 16.3.6 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.11.1 - caniuse-lite: 1.0.30001806 + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001813 postcss: 8.5.23 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.4 - '@next/swc-darwin-x64': 16.3.4 - '@next/swc-linux-arm64-gnu': 16.3.4 - '@next/swc-linux-arm64-musl': 16.3.4 - '@next/swc-linux-x64-gnu': 16.3.4 - '@next/swc-linux-x64-musl': 16.3.4 - '@next/swc-win32-arm64-msvc': 16.3.4 - '@next/swc-win32-x64-msvc': 16.3.4 - sharp: 0.35.4(@types/node@20.19.43) + '@next/swc-darwin-arm64': 16.3.6 + '@next/swc-darwin-x64': 16.3.6 + '@next/swc-linux-arm64-gnu': 16.3.6 + '@next/swc-linux-arm64-musl': 16.3.6 + '@next/swc-linux-x64-gnu': 16.3.6 + '@next/swc-linux-x64-musl': 16.3.6 + '@next/swc-win32-arm64-msvc': 16.3.6 + '@next/swc-win32-x64-msvc': 16.3.6 + sharp: 0.35.5(@types/node@22.20.4) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -8853,7 +8954,7 @@ snapshots: node-gyp-build@4.8.4: {} - node-releases@2.0.51: {} + node-releases@2.0.57: {} nopt@8.1.0: dependencies: @@ -9052,9 +9153,7 @@ snapshots: path-to-regexp@6.3.0: {} - path-to-regexp@8.2.0: {} - - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.0: {} pend@1.2.0: {} @@ -9070,7 +9169,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -9112,41 +9211,41 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - react-dom@19.2.4(react@19.2.4): + react-dom@19.2.8(react@19.2.8): dependencies: - react: 19.2.4 + react: 19.2.8 scheduler: 0.27.0 react-is@16.13.1: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.4): + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): dependencies: - react: 19.2.4 - react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.4) + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.4): + react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.8): dependencies: - react: 19.2.4 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.4) - react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.4) + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.4) - use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.4) + use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.4): + react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.8): dependencies: get-nonce: 1.0.1 - react: 19.2.4 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - react@19.2.4: {} + react@19.2.8: {} readdirp@4.1.2: {} @@ -9288,7 +9387,7 @@ snapshots: resolve@2.0.0-next.7: dependencies: es-errors: 1.3.0 - is-core-module: 2.16.2 + is-core-module: 2.17.0 node-exports-info: 1.6.2 object-keys: 1.1.1 path-parse: 1.0.7 @@ -9397,38 +9496,38 @@ snapshots: setprototypeof@1.1.1: {} - sharp@0.35.4(@types/node@20.19.43): + sharp@0.35.5(@types/node@22.20.4): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 - '@types/node': 20.19.43 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 22.20.4 optional: true shebang-command@2.0.0: @@ -9526,7 +9625,7 @@ snapshots: define-properties: 1.2.1 es-abstract: 1.24.2 - string.prototype.matchall@4.0.12: + string.prototype.matchall@4.1.0: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 @@ -9590,10 +9689,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4): + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8): dependencies: client-only: 0.0.1 - react: 19.2.4 + react: 19.2.8 optionalDependencies: '@babel/core': 7.29.7 @@ -9603,7 +9702,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tailwind-merge@3.6.0: {} + tailwind-merge@3.7.0: {} tailwindcss@4.3.3: {} @@ -9708,13 +9807,12 @@ snapshots: has-proto: 1.2.0 is-typed-array: 1.1.15 - typed-array-byte-offset@1.0.4: + typed-array-byte-offset@1.0.5: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 - has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 @@ -9727,12 +9825,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): + typescript-eslint@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: @@ -9753,9 +9851,9 @@ snapshots: undici-types@6.21.0: {} - undici@6.28.0: {} + undici@6.28.1: {} - undici@7.29.0: {} + undici@7.29.1: {} unified@11.0.5: dependencies: @@ -9830,9 +9928,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - update-browserslist-db@1.2.3(browserslist@4.28.7): + update-browserslist-db@1.3.3(browserslist@4.29.2): dependencies: - browserslist: 4.28.7 + browserslist: 4.29.2 escalade: 3.2.0 picocolors: 1.1.1 @@ -9840,20 +9938,20 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.4): + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8): dependencies: - react: 19.2.4 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.4): + use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.8): dependencies: detect-node-es: 1.1.0 - react: 19.2.4 + react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 uuid@14.0.1: {} @@ -9893,7 +9991,7 @@ snapshots: luxon: 3.7.2 sandbox: 4.1.0 smol-toml: 1.8.0 - undici: 6.28.0 + undici: 6.28.1 uuid: 14.0.1 zod: 4.1.11 optionalDependencies: @@ -9960,7 +10058,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.22 + which-typed-array: 1.1.24 which-collection@1.0.2: dependencies: @@ -9969,7 +10067,7 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-typed-array@1.1.22: + which-typed-array@1.1.24: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.9 diff --git a/docs/site/src/components/docs/SearchDialog.tsx b/docs/site/src/components/docs/SearchDialog.tsx index 36c56dfab65..e0bc8032c15 100644 --- a/docs/site/src/components/docs/SearchDialog.tsx +++ b/docs/site/src/components/docs/SearchDialog.tsx @@ -122,6 +122,16 @@ export default function SearchDialog({ dialogId = 'docs-search-dialog', onClose return query.data }, [query.data]) + const highlightedResults = useMemo(() => { + const highlights = new Map() + for (const result of results) { + if (result.content && !highlights.has(result.content)) { + highlights.set(result.content, renderHighlighted(result.content)) + } + } + return highlights + }, [results]) + const selectedIndex = Math.min(activeIndex, Math.max(results.length - 1, 0)) useEffect(() => { @@ -354,7 +364,10 @@ export default function SearchDialog({ dialogId = 'docs-search-dialog', onClose : 'text-muted-foreground hover:bg-accent hover:text-foreground' )} > - {g.page.content ? renderHighlighted(g.page.content) : g.page.url} + {g.page.content + ? (highlightedResults.get(g.page.content) ?? + renderHighlighted(g.page.content)) + : g.page.url} )}