diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml
index 39cb1465852..8b7801881eb 100644
--- a/.github/actions/install-node-dependencies/action.yml
+++ b/.github/actions/install-node-dependencies/action.yml
@@ -1,5 +1,5 @@
name: Install Node dependencies
-description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
+description: Installs the Node toolchain and repository dependencies for CI jobs.
inputs:
native-runtime:
@@ -10,6 +10,18 @@ inputs:
description: Node.js version override; defaults to the version declared in package.json.
required: false
default: ''
+ persist-native-cache:
+ description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI.
+ required: false
+ default: 'true'
+
+outputs:
+ node-version:
+ description: Resolved Node.js version used for the install.
+ value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
+ native-cache-scope:
+ description: Operating-system image scope used by the native module cache.
+ value: ${{ steps.native-cache-scope.outputs.scope }}
runs:
using: composite
@@ -18,6 +30,7 @@ runs:
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
+ version: 10.24.0
run_install: false
- name: Setup Node.js
@@ -51,7 +64,7 @@ runs:
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
- name: Use external node-gyp
- if: inputs.native-runtime != 'none'
+ if: runner.os == 'Linux' && inputs.native-runtime != 'none'
shell: bash
run: |
npm install -g node-gyp@11.5.0
@@ -74,18 +87,50 @@ runs:
run: |
pnpm install --frozen-lockfile --ignore-scripts
# Job containers can run composite steps from a source mirror without .git.
- git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
+ if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
+ git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
+ fi
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
# Node version, and the patch whose contents the build has to match.
- - name: Restore compiled native modules
+ # Windows extra globs are empty on Linux. No restore-keys: a partial-match key is
+ # an ABI-mismatched build, and ensure-native-runtime would recompile it anyway.
+ # Native addons built on a newer Linux image can require glibc symbols
+ # missing from an older runner/container. ImageOS distinguishes hosted
+ # Windows/macOS images; /etc/os-release also distinguishes Linux containers.
+ - name: Resolve native cache scope
+ id: native-cache-scope
if: inputs.native-runtime != 'none'
+ shell: bash
+ run: |
+ scope="${ImageOS:-$RUNNER_OS}"
+ if [ -r /etc/os-release ]; then
+ . /etc/os-release
+ scope="${ID:-linux}-${VERSION_ID:-unknown}"
+ fi
+ echo "scope=$scope" >> "$GITHUB_OUTPUT"
+
+ - name: Restore compiled native modules
+ if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
uses: actions/cache@v5
with:
- path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
- key: native-modules-${{ runner.os }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch') }}
+ path: |
+ node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
+ node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
+ node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
+ key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
+
+ - name: Restore compiled native modules without saving
+ if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
+ uses: actions/cache/restore@v5
+ with:
+ path: |
+ node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
+ node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
+ node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
+ key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml
index ab1c6ce687b..b24c8a52f2c 100644
--- a/.github/workflows/computer-e2e.yml
+++ b/.github/workflows/computer-e2e.yml
@@ -71,30 +71,17 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- - uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- - uses: pnpm/action-setup@v6
- with:
- run_install: false
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
- # Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
- # permission on Linux runners; node-pty's install fallback then fails
- # before this smoke job can exercise the native package.
- - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
- if: runner.os == 'Linux'
- 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"
- - run: pnpm install --frozen-lockfile
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: node
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
# is no root config) and falls back to the 5s default timeout with no
# Windows worker cap, so the real csc.exe launcher-compile tests time out
# on hosted Windows. Use the shared config so this job matches pnpm test.
- run: >-
pnpm vitest run --config config/vitest.config.ts
- config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
@@ -159,13 +146,9 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- - uses: actions/setup-node@v6
+ - uses: ./.github/actions/install-node-dependencies
with:
- node-version-file: package.json
- - uses: pnpm/action-setup@v6
- with:
- run_install: false
- - run: pnpm install --frozen-lockfile
+ native-runtime: electron
- name: Owner-loss benchmark process cleanup
run: >-
pnpm vitest run
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 24c0ffa0e5b..00739f36350 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -17,6 +17,10 @@ on:
description: JSON array of changed specs; empty runs the full suite
required: false
type: string
+ ssh_source_changed:
+ description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane'
+ required: false
+ type: string
workflow_dispatch:
inputs:
ref:
@@ -40,36 +44,10 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
- # Why: the E2E build compiles native modules via node-gyp. Mirrors the
- # install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
- # errors.
- - name: Install native build tools
- run: sudo apt-get update && sudo apt-get install -y build-essential python3
-
- # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
- # Without that cache every E2E job re-downloaded the whole dependency set.
- - name: Setup pnpm
- uses: pnpm/action-setup@v6
+ # Why: the build's plain-Node daemon smoke load resolves node-pty.
+ - uses: ./.github/actions/install-node-dependencies
with:
- run_install: false
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- cache: pnpm
-
- # Why: this job runs the same pnpm install path as pr.yml's verify
- # job, so it needs the same pinned node-gyp override to avoid pnpm's
- # broken bundled gyp_main.py on Linux.
- - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
- if: runner.os == 'Linux'
- 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
- run: pnpm install --frozen-lockfile
+ native-runtime: node
# Why: building here avoids parallel builds inside Playwright globalSetup;
# paired-browser specs also need the standalone web bundle.
@@ -130,11 +108,11 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
- # Why: pnpm install rebuilds native modules, and those postinstall
- # scripts still need the Linux toolchain even though this shard reuses
- # the prebuilt Electron output.
+ # Why: pnpm install used to rebuild native modules here; the composite
+ # action restores them from cache and only compiles on a miss. The
+ # toolchain is still required for that miss path, and for paired Quick
+ # Open coverage which exercises the resource-bounded host search.
- name: Install native build tools
- # Why: paired Quick Open coverage exercises the resource-bounded host search.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
# Why: Electron on Linux needs an X display even when the app
@@ -143,33 +121,9 @@ jobs:
- name: Install xvfb
run: sudo apt-get install -y xvfb
- # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
- # Without that cache every E2E job re-downloaded the whole dependency set.
- - name: Setup pnpm
- uses: pnpm/action-setup@v6
+ - uses: ./.github/actions/install-node-dependencies
with:
- run_install: false
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- cache: pnpm
-
- # Why: this job runs the same pnpm install path as pr.yml's verify
- # job, so it needs the same pinned node-gyp override to avoid pnpm's
- # broken bundled gyp_main.py on Linux. Gate on runner.os matches
- # release.yml so the invariant "this workaround is Linux-only" is
- # consistent across all three workflows, even though this job
- # currently pins runs-on: ubuntu-latest.
- - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
- if: runner.os == 'Linux'
- 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
- run: pnpm install --frozen-lockfile
+ native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -222,25 +176,9 @@ jobs:
# lane now receives those specs from pr.yml's SSH source mapping.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
- # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
- # Without that cache every E2E job re-downloaded the whole dependency set.
- - name: Setup pnpm
- uses: pnpm/action-setup@v6
+ - uses: ./.github/actions/install-node-dependencies
with:
- run_install: false
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- cache: pnpm
-
- - name: Use external node-gyp to avoid pnpm's bundled copy
- 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
- run: pnpm install --frozen-lockfile
+ native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -287,14 +225,21 @@ jobs:
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
needs: build
+ # Why ssh_source_changed first: this lane used to trigger on SSH source only as a side
+ # effect of one route listing a startup-readiness spec — pruning that spec would have
+ # silently retired the whole lane. The signal is now derived from the SSH routes directly.
+ # The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this
+ # lane, so editing one must still run it here.
if: >-
inputs.test_files == '' ||
+ inputs.ssh_source_changed == 'true' ||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
runs-on: ubuntu-latest
- # Why 35: the parking, retention, startup-exec, and paired parity specs run
- # serially on isolated Electron/SSH fixtures after watcher isolation.
- timeout-minutes: 35
+ # Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average
+ # ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35
+ # — and the sharded lanes already show that a lane which times out is a lane nobody trusts.
+ timeout-minutes: 60
steps:
- name: Checkout
@@ -305,29 +250,9 @@ jobs:
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
- # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
- # Without that cache every E2E job re-downloaded the whole dependency set.
- - name: Setup pnpm
- uses: pnpm/action-setup@v6
+ - uses: ./.github/actions/install-node-dependencies
with:
- run_install: false
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- cache: pnpm
-
- # Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
- # stays consistent across workflows even while this job is ubuntu-latest.
- - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
- if: runner.os == 'Linux'
- 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
- run: pnpm install --frozen-lockfile
+ native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -346,6 +271,14 @@ jobs:
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
+ # Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every
+ # spec below skipped itself while the shard still reported green. Running them on this one
+ # VM pays the fixture image build once instead of ten times, and keeps an SSH regression
+ # legible as an SSH-named failure.
+ - name: Run remaining Docker SSH E2E
+ if: always()
+ run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
+
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
diff --git a/.github/workflows/node-next-compat.yml b/.github/workflows/node-next-compat.yml
new file mode 100644
index 00000000000..eb9de70dab7
--- /dev/null
+++ b/.github/workflows/node-next-compat.yml
@@ -0,0 +1,20 @@
+name: Node next compatibility
+
+on:
+ schedule:
+ # Full future-runtime coverage is useful, but not worth doubling every PR matrix.
+ - cron: '0 10 * * *'
+ workflow_dispatch:
+
+concurrency:
+ group: node-next-compat
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ uses: ./.github/workflows/unit-tests.yml
+ with:
+ node_versions: '["26"]'
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index a256da5112c..338bcda7dbd 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -16,15 +16,29 @@ permissions:
contents: read
jobs:
- # Why: a README/docs-only PR used to start the full matrix (32 test shards,
+ # Why: a README/docs-only PR used to start the full matrix (test shards,
# two package jobs, typecheck, git compat, xterm, shell contracts). Path
# filters on `on.pull_request` would drop the `verify` check entirely; this
# detector keeps verify as the required aggregate and skips the expensive jobs.
+ # Per-job outputs also skip git-compat/xterm/packaging/shell when those
+ # inputs are unchanged; empty diffs fail closed and run everything.
code_paths:
name: detect code-relevant changes
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
+ native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
+ static_analysis: ${{ steps.filter.outputs.static_analysis }}
+ typecheck: ${{ steps.filter.outputs.typecheck }}
+ git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
+ xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
+ shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
+ test: ${{ steps.filter.outputs.test }}
+ orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
+ cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
+ managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
+ package: ${{ steps.filter.outputs.package }}
+ package_windows: ${{ steps.filter.outputs.package_windows }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -48,14 +62,12 @@ jobs:
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
echo "Changed paths:"
printf '%s\n' "$CHANGED"
- SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)"
- echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
- echo "should_run=$SHOULD_RUN"
+ printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
static_analysis:
name: static analysis
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.static_analysis == 'true'
runs-on: ubuntu-latest
steps:
@@ -70,6 +82,8 @@ jobs:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: node
- name: Lint
run: pnpm exec oxlint --format github
@@ -188,7 +202,7 @@ jobs:
typecheck:
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.typecheck == 'true'
runs-on: ubuntu-latest
steps:
@@ -216,7 +230,7 @@ jobs:
git_compatibility:
name: Git compatibility
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.git_compatibility == 'true'
runs-on: ubuntu-latest
steps:
@@ -283,7 +297,7 @@ jobs:
xterm_patch_sync:
name: xterm patch sync
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.xterm_patch_sync == 'true'
runs-on: ubuntu-latest
steps:
@@ -313,7 +327,7 @@ jobs:
shell_contracts:
name: shell contracts
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.shell_contracts == 'true'
runs-on: ubuntu-latest
# Why: this job's cost is almost entirely package download, and a stalled mirror has
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
@@ -429,17 +443,13 @@ jobs:
src/shared/startup-shell-portability.live-shell.test.ts \
src/shared/posix-command-path-lookup.test.ts
- test:
- name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
+ # Cache-key input changes would otherwise make every shard compile the same
+ # native addon concurrently. Prime the supported Node ABI before the matrix fans out.
+ test_native_cache:
+ name: prepare test native cache node 24
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.native_cache_changed == 'true'
runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- node: ['24', '26']
- shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
- shard_total: [16]
steps:
- name: Checkout
@@ -450,39 +460,24 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- node-version: ${{ matrix.node }}
+ node-version: '24'
- - name: Install Electron package binary for tests
- run: node config/scripts/install-electron-package-binary.mjs
+ test:
+ needs: [code_paths, test_native_cache]
+ if: >-
+ always() &&
+ needs.code_paths.outputs.test == 'true' &&
+ (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
+ uses: ./.github/workflows/unit-tests.yml
+ with:
+ node_versions: '["24"]'
- - name: Test shard
- run: |
- pnpm exec vitest run --config config/vitest.config.ts \
- --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
- --exclude=src/main/daemon/shell-ready.test.ts \
- --exclude=src/main/daemon/node-pty-fd-leak.test.ts \
- --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
- --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
- --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
- --exclude=src/main/shell-startup-feature-channel.test.ts \
- --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
- --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
- --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
- --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
- --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
- --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
- --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
- --exclude=src/shared/startup-shell-portability.live-shell.test.ts \
- --exclude=src/shared/posix-command-path-lookup.test.ts \
- --exclude=tests/e2e/cross-version-wire/** \
- --shard=${{ matrix.shard }}/${{ matrix.shard_total }}
-
- # Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix
- # would pay for it 32 times to run one file in whichever shard it landed in.
+ # 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.should_run == 'true'
+ if: needs.code_paths.outputs.orcad_browser == 'true'
runs-on: ubuntu-latest
steps:
@@ -518,7 +513,7 @@ jobs:
cross-version-wire:
name: cross-version wire compatibility
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.cross-version-wire == 'true'
runs-on: ubuntu-latest
steps:
@@ -550,7 +545,7 @@ jobs:
managed_hook_node18:
name: managed hooks on Node 18
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.managed_hook_node18 == 'true'
runs-on: ubuntu-latest
steps:
@@ -575,7 +570,7 @@ jobs:
package:
name: package
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.package == 'true'
runs-on: ubuntu-latest
steps:
@@ -647,7 +642,7 @@ jobs:
package_windows:
name: package (windows)
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.package_windows == 'true'
runs-on: windows-2022
timeout-minutes: 30
@@ -657,17 +652,6 @@ jobs:
with:
persist-credentials: false
- - name: Setup pnpm
- uses: pnpm/action-setup@v6
- with:
- run_install: false
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- cache: pnpm
-
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
@@ -678,18 +662,22 @@ jobs:
restore-keys: |
electron-builder-windows-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
+ # Why persist-native-cache false: this job later rebuilds the same path for
+ # Electron. A post-job save would store the Electron ABI under the Node key.
+ - uses: ./.github/actions/install-node-dependencies
+ id: deps
+ with:
+ native-runtime: node
+ persist-native-cache: 'false'
- # Why: node-pty prefers its upstream prebuild, which does not contain
- # Orca's Windows patch, so the job-object exports would be absent and the
- # suite below would test an unpatched binary. build_from_source removes
- # the prebuild, and the package's postinstall restores the ConPTY runtime
- # files that a bare node-gyp rebuild would miss.
- - name: Rebuild node-pty from patched source
- env:
- npm_config_build_from_source: 'true'
- run: pnpm rebuild node-pty
+ - name: Save compiled Node native modules
+ uses: actions/cache/save@v5
+ with:
+ path: |
+ node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
+ node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
+ node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
+ key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Test Windows-specific boundaries
run: >-
@@ -724,9 +712,26 @@ jobs:
# Why the :parallel variant: identical to build:release except the three
# electron-vite targets overlap instead of running back to back. The Linux package
# job already packages and smoke-tests an AppImage built that way.
+ - name: Cache Windows CLI launcher
+ uses: actions/cache@v5
+ with:
+ path: native/windows-cli-launcher/.build
+ key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }}
+
- name: Build package inputs
+ env:
+ ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1'
run: pnpm run build:release:parallel
+ - name: Restore compiled Electron native modules
+ uses: actions/cache@v5
+ with:
+ path: |
+ node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
+ node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
+ node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
+ key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
+
- name: Prepare Electron native runtime
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
@@ -754,6 +759,7 @@ jobs:
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
test_files: ${{ steps.filter.outputs.test_files }}
+ ssh_source_changed: ${{ steps.filter.outputs.ssh_source_changed }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -776,6 +782,11 @@ jobs:
# authorities, exclusions, and sentinels without evaluating workflow shell.
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
+ # Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a
+ # spec name surviving in a route's list. Same routes, so the two cannot drift.
+ SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)"
+ echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
+ echo "SSH source changed: $SSH_SOURCE_CHANGED"
if [ "$TEST_FILES_JSON" != '[]' ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Changed E2E specs: $TEST_FILES_JSON"
@@ -794,6 +805,7 @@ jobs:
uses: ./.github/workflows/e2e.yml
with:
test_files: ${{ needs.e2e-paths.outputs.test_files }}
+ ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
verify:
if: always()
@@ -807,6 +819,7 @@ jobs:
- shell_contracts
- test
- orcad_browser
+ - cross-version-wire
- managed_hook_node18
- package
- package_windows
@@ -826,57 +839,63 @@ jobs:
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 }}
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
+ TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
+ GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
+ XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
+ SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }}
TEST: ${{ needs.test.result }}
+ TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }}
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
+ ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }}
+ CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }}
+ CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }}
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
+ MANAGED_HOOK_NODE18_SHOULD_RUN: ${{ needs.code_paths.outputs.managed_hook_node18 }}
PACKAGE: ${{ needs.package.result }}
+ PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
+ PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }}
run: |
if [ "$CODE_PATHS" != "success" ]; then
exit 1
fi
+ if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
+ exit 1
+ fi
if [ "$SHOULD_RUN" != "true" ]; then
echo "Docs-only change; expensive PR checks skipped."
- if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
- exit 1
- fi
- for result in \
- "$STATIC_ANALYSIS" \
- "$TYPECHECK" \
- "$GIT_COMPATIBILITY" \
- "$XTERM_PATCH_SYNC" \
- "$SHELL_CONTRACTS" \
- "$TEST" \
- "$ORCAD_BROWSER" \
- "$MANAGED_HOOK_NODE18" \
- "$PACKAGE" \
- "$PACKAGE_WINDOWS"; do
- if [ "$result" != "skipped" ]; then
- exit 1
- fi
- done
- exit 0
fi
- # Require success when the PR has code-relevant changes
- for result in \
- "$CODE_PATHS" \
- "$STATIC_ANALYSIS" \
- "$ROOT_DIRECTORY_GUARD" \
- "$TYPECHECK" \
- "$GIT_COMPATIBILITY" \
- "$XTERM_PATCH_SYNC" \
- "$SHELL_CONTRACTS" \
- "$TEST" \
- "$ORCAD_BROWSER" \
- "$MANAGED_HOOK_NODE18" \
- "$PACKAGE" \
- "$PACKAGE_WINDOWS"; do
- if [ "$result" != "success" ]; then
- exit 1
+ failed=0
+ check_job() {
+ local name="$1" result="$2" should="$3"
+ if [ "$should" = "true" ]; then
+ if [ "$result" != "success" ]; then
+ echo "$name: expected success, got $result"
+ failed=1
+ fi
+ else
+ if [ "$result" != "skipped" ]; then
+ echo "$name: expected skipped, got $result"
+ failed=1
+ fi
fi
- done
+ }
+ # 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 git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
+ check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
+ check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
+ check_job test "$TEST" "$TEST_SHOULD_RUN"
+ check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN"
+ check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN"
+ check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
+ check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
+ check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN"
+ exit "$failed"
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
new file mode 100644
index 00000000000..9906b569324
--- /dev/null
+++ b/.github/workflows/unit-tests.yml
@@ -0,0 +1,59 @@
+name: Unit tests
+
+on:
+ workflow_call:
+ inputs:
+ node_versions:
+ description: JSON array of Node.js major versions to test.
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ node: ${{ fromJSON(inputs.node_versions) }}
+ shard: [1, 2, 3, 4, 5, 6, 7, 8]
+ shard_total: [8]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: node
+ node-version: ${{ matrix.node }}
+
+ - name: Install Electron package binary for tests
+ run: node config/scripts/install-electron-package-binary.mjs
+
+ - name: Test shard
+ run: |
+ pnpm exec vitest run --config config/vitest.config.ts \
+ --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
+ --exclude=src/main/daemon/shell-ready.test.ts \
+ --exclude=src/main/daemon/node-pty-fd-leak.test.ts \
+ --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
+ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
+ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
+ --exclude=src/main/shell-startup-feature-channel.test.ts \
+ --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
+ --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
+ --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
+ --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
+ --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
+ --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
+ --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
+ --exclude=src/shared/startup-shell-portability.live-shell.test.ts \
+ --exclude=src/shared/posix-command-path-lookup.test.ts \
+ --exclude=tests/e2e/cross-version-wire/** \
+ --shard=${{ matrix.shard }}/${{ matrix.shard_total }}
diff --git a/README.md b/README.md
index 8d0b9e79653..8de0ff8d2b9 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) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,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) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Download APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
+- **Android:** [Download APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
---
diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt
index 664bb4c28b3..75e536a2198 100644
--- a/config/max-lines-baseline.txt
+++ b/config/max-lines-baseline.txt
@@ -2,17 +2,13 @@
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
-inline src/cli/handlers/automations.ts
-inline src/cli/help.ts
inline src/main/agent-hooks/server.ts
inline src/main/automations/external-manager.ts
-inline src/main/automations/hermes-cron-output.ts
inline src/main/browser/agent-browser-bridge.ts
inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-manager.ts
inline src/main/browser/cdp-bridge.ts
inline src/main/claude-accounts/runtime-auth-service.ts
-inline src/main/cli/wsl-cli-installer.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex/hook-service.ts
@@ -23,14 +19,7 @@ inline src/main/index.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/worktree-remote.ts
-inline src/main/keybindings/keybinding-file.ts
-inline src/main/linear/issues.ts
-inline src/main/memory/collector.ts
-inline src/main/ports/advertised-url-watcher.ts
-inline src/main/ports/local-workspace-port-scanner.ts
-inline src/main/project-groups/nested-repo-discovery.ts
inline src/main/providers/local-pty-provider.ts
-inline src/main/rate-limits/claude-pty.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.ts
@@ -52,7 +41,6 @@ inline src/relay/git-handler.ts
inline src/relay/pty-handler.ts
inline src/relay/workspace-space-scan.ts
inline src/renderer/src/components/GitLabItemDialog.tsx
-inline src/renderer/src/components/JiraIssueWorkspace.tsx
inline src/renderer/src/components/LinearItemDrawer.tsx
inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
@@ -61,15 +49,12 @@ inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
-inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
-inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
-inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
@@ -82,40 +67,25 @@ inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
inline src/renderer/src/runtime/runtime-file-client.ts
-inline src/renderer/src/runtime/runtime-linear-client.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-runtime-session.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.ts
-inline src/renderer/src/store/slices/diffComments.ts
-inline src/renderer/src/store/slices/hosted-review.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/ui.ts
-inline src/shared/automation-schedules.ts
-inline src/shared/commit-message-agent-spec.ts
-inline src/shared/constants.ts
inline src/shared/keybindings.ts
-inline src/shared/marine-creatures.ts
inline src/shared/telemetry-events.ts
-inline src/shared/text-search.ts
-inline tests/e2e/helpers/terminal.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/index.tsx
mobile-config app/h/*/session/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/h/*/tasks.tsx
mobile-config app/index.tsx
-mobile-config app/pair-scan.tsx
mobile-config app/troubleshoot.tsx
mobile-config scripts/mock-server.ts
-mobile-config scripts/repro-terminal-colors.ts
-mobile-config scripts/repro-worktree-startup-stream.ts
mobile-config src/browser/MobileBrowserPane.tsx
-mobile-config src/components/CustomKeyModal.tsx
mobile-config src/components/NewWorktreeModal.tsx
-mobile-config src/components/mobile-rich-markdown-editor-html.ts
-mobile-config src/terminal/terminal-accessory-keys.ts
mobile-config src/terminal/terminal-webview-html.ts
mobile-config src/transport/rpc-client.ts
diff --git a/config/patches/@vscode__windows-process-tree@0.8.0.patch b/config/patches/@vscode__windows-process-tree@0.8.0.patch
index 8e6e8e86e25..65cf2be5ce4 100644
--- a/config/patches/@vscode__windows-process-tree@0.8.0.patch
+++ b/config/patches/@vscode__windows-process-tree@0.8.0.patch
@@ -1,26 +1,31 @@
diff --git a/binding.gyp b/binding.gyp
-index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
+index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644
--- a/binding.gyp
+++ b/binding.gyp
-@@ -3,7 +3,7 @@
+@@ -3,7 +3,6 @@
{
"target_name": "windows_process_tree",
"dependencies": [
- "= statSync(sourcePath).mtimeMs
}
function defaultOutputPath(projectRoot) {
@@ -58,3 +41,47 @@ function readArg(name) {
const index = process.argv.indexOf(name)
return index !== -1 ? process.argv[index + 1] : undefined
}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ if (process.platform !== 'win32') {
+ // Why: electron-builder treats a skipped native build like success and can
+ // continue toward a Windows package whose declared orca.exe does not exist.
+ throw new Error(
+ 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
+ )
+ }
+
+ const repoRoot = resolve(import.meta.dirname, '../..')
+ const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
+ const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
+ const compilerPath = findFrameworkCompiler(process.env)
+
+ if (!compilerPath) {
+ throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
+ }
+
+ mkdirSync(dirname(outputPath), { recursive: true })
+ if (
+ shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
+ reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
+ })
+ ) {
+ console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
+ process.exit(0)
+ }
+ const result = spawnSync(
+ compilerPath,
+ ['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
+ { cwd: repoRoot, stdio: 'inherit' }
+ )
+
+ if (result.signal) {
+ process.kill(process.pid, result.signal)
+ }
+ if (result.error) {
+ throw result.error
+ }
+ if (result.status !== 0) {
+ process.exit(result.status ?? 1)
+ }
+}
diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs
index 4bb219a5687..82332897715 100644
--- a/config/scripts/build-windows-cli-launcher.test.mjs
+++ b/config/scripts/build-windows-cli-launcher.test.mjs
@@ -5,12 +5,15 @@ import {
mkdtempSync,
readFileSync,
rmSync,
+ statSync,
+ utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
+import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
@@ -37,6 +40,33 @@ function itWindows(name, test) {
}
describe('Windows CLI launcher', () => {
+ it('reuses a compiled launcher that is at least as new as the C# source', () => {
+ const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
+ try {
+ const sourcePath = join(root, 'OrcaCliLauncher.cs')
+ const outputPath = join(root, '.build', 'orca.exe')
+ mkdirSync(join(root, '.build'))
+ writeFileSync(sourcePath, 'source\n')
+ writeFileSync(outputPath, 'binary\n')
+ const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
+ utimesSync(outputPath, later, later)
+
+ expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
+ writeFileSync(sourcePath, 'changed\n')
+ const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
+ utimesSync(sourcePath, sourceLater, sourceLater)
+ expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
+ expect(
+ shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
+ ).toBe(true)
+ expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
+ false
+ )
+ } finally {
+ removeFixtureTree(root)
+ }
+ })
+
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs
index 01045eadfbf..3e43beb359e 100644
--- a/config/scripts/build-windows-process-tree-relay-addon.mjs
+++ b/config/scripts/build-windows-process-tree-relay-addon.mjs
@@ -26,9 +26,11 @@ import {
mkdirSync,
openSync,
readFileSync,
- readSync
+ readSync,
+ writeFileSync
} from 'node:fs'
-import { join, resolve } from 'node:path'
+import { createRequire } from 'node:module'
+import { dirname, join, resolve } from 'node:path'
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
const ROOT = resolve(import.meta.dirname, '..', '..')
@@ -67,13 +69,16 @@ function assertPatchApplied() {
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
- if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) {
+ if (bindingGyp.includes('node_addon_api.gyp')) {
throw new Error(
- 'binding.gyp still uses require("node-addon-api").targets. That path is ' +
- 'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' +
+ 'binding.gyp still depends on node_addon_api.gyp. pnpm and node-gyp rewrite that ' +
+ 'project path incorrectly on Windows. ' +
'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
+ if (!bindingGyp.includes('"include_dirs": ["deps/node-addon-api"]')) {
+ throw new Error('binding.gyp does not use the staged node-addon-api headers.')
+ }
const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
if (processCc.includes('process_count < 1024')) {
throw new Error(
@@ -83,6 +88,58 @@ function assertPatchApplied() {
}
}
+// pnpm can materialize this CRLF package without applying its patch. Repair the
+// load-bearing build settings before node-gyp so the release build stays safe.
+function applyWindowsProcessTreeBuildFixes() {
+ const bindingPath = join(PACKAGE_DIR, 'binding.gyp')
+ const processPath = join(PACKAGE_DIR, 'src', 'process.cc')
+ const nodeAddonApiDir = dirname(
+ createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')
+ )
+ const stagedHeaderDir = join(PACKAGE_DIR, 'deps', 'node-addon-api')
+ let bindingGyp = readFileSync(bindingPath, 'utf8')
+ let processCc = readFileSync(processPath, 'utf8')
+ const originalBinding = bindingGyp
+ const originalProcess = processCc
+
+ for (const dynamicDependency of [
+ String.raw` {
)
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
- 'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
@@ -126,10 +125,14 @@ describe('computer-use e2e workflow', () => {
const job = workflow.jobs['mac-native-owner-smoke']
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
+ const install = job.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
expect(job.if).toBe("github.event_name == 'pull_request'")
expect(job['runs-on']).toBe('macos-15')
expect(checkout.with['persist-credentials']).toBe(false)
+ expect(install.with['native-runtime']).toBe('electron')
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
const cleanupRun = runs.find((run) =>
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs
index b570ac0e7ae..424b671b11e 100644
--- a/config/scripts/ensure-native-runtime.mjs
+++ b/config/scripts/ensure-native-runtime.mjs
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, readFileSync } from 'node:fs'
import { release } from 'node:os'
-import { basename, resolve } from 'node:path'
+import { basename, dirname, resolve } from 'node:path'
const require = createRequire(import.meta.url)
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
@@ -67,7 +67,12 @@ function ensureNodeRuntime() {
if (!initial.ok) {
printCheckError(initial)
}
- runPnpm(['rebuild', 'node-pty'])
+ const failedModules = initial.failures.map((failure) => failure.moduleName)
+ const rebuildModules = [
+ 'node-pty',
+ ...failedModules.filter((moduleName) => moduleName !== 'node-pty')
+ ]
+ rebuildNodeRuntimeModules(rebuildModules)
verifyNodeRuntimeAfterRebuild()
return
}
@@ -77,7 +82,7 @@ function ensureNodeRuntime() {
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
)
printCheckError(initial)
- runPnpm(['rebuild', ...failedModules])
+ rebuildNodeRuntimeModules(failedModules)
verifyNodeRuntimeAfterRebuild()
}
@@ -312,14 +317,10 @@ function getPatchedNodePtyRebuildReason() {
return null
}
- // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
- // patch only lands in the source-built build/Release artifacts.
+ // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
+ // Windows patches only land in the source-built build/Release artifacts.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
- const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
- // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
- if (process.platform === 'darwin') {
- artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
- }
+ const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir)
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -329,11 +330,24 @@ function getPatchedNodePtyRebuildReason() {
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
}
-function requiresPatchedNodePtySourceBuild() {
+function patchedNodePtyArtifactPaths(nodePtyDir) {
if (process.platform === 'win32') {
- return false
+ const releaseDir = resolve(nodePtyDir, 'build', 'Release')
+ return [
+ resolve(releaseDir, 'conpty.node'),
+ ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename))
+ ]
}
+ const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
+ // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
+ if (process.platform === 'darwin') {
+ artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
+ }
+ return artifactPaths
+}
+
+function requiresPatchedNodePtySourceBuild() {
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
if (!existsSync(nodePtyPatchPath)) {
return false
@@ -351,16 +365,32 @@ function getWindowsBuildNumber() {
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
}
-function runPnpm(args) {
+function rebuildNodeRuntimeModules(moduleNames) {
+ for (const moduleName of moduleNames) {
+ const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
+ console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
+ runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
+ if (moduleName === 'node-pty' && process.platform === 'win32') {
+ runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
+ }
+ }
+}
+
+function runPnpm(args, { cwd = projectDir } = {}) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+ const env =
+ process.platform === 'linux' && args.includes('node-gyp')
+ ? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
+ : process.env
const result = spawnSync(command, args, {
- cwd: projectDir,
+ cwd,
stdio: 'inherit',
- shell: process.platform === 'win32'
+ shell: process.platform === 'win32',
+ env
})
if (result.error || result.status !== 0) {
- console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
+ console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
if (result.error) {
console.error(formatError(result.error))
}
diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs
index d1ea104b863..ea6e876e619 100644
--- a/config/scripts/ensure-native-runtime.test.mjs
+++ b/config/scripts/ensure-native-runtime.test.mjs
@@ -29,6 +29,7 @@ describe('ensure-native-runtime', () => {
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir)
+ writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
@@ -42,7 +43,11 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
- expect(log).toContain('pnpm rebuild node-pty\n')
+ expect(log).toContain('pnpm exec node-gyp rebuild\n')
+ expect(log).toContain(join('node_modules', 'node-pty'))
+ if (process.platform === 'linux') {
+ expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
+ }
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
@@ -52,6 +57,41 @@ describe('ensure-native-runtime', () => {
}
})
+ it.skipIf(process.platform !== 'win32')(
+ 'rebuilds other failed Windows addons with patched node-pty',
+ () => {
+ 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')
+ copyFileSync(sourceScriptPath, scriptPath)
+ writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
+ writeNodePtyPatchFile(projectDir)
+ writeFakePnpm(binDir)
+
+ const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
+ cwd: projectDir,
+ encoding: 'utf8',
+ env: envWithPrependedPath(binDir, {
+ ORCA_NATIVE_TEST_LOG: logPath,
+ ORCA_NATIVE_TEST_MARKER: markerPath
+ })
+ })
+
+ 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).toContain(join('node_modules', 'node-pty'))
+ expect(log).toContain(join('node_modules', 'windows-native-registry'))
+ } finally {
+ rmSync(projectDir, { recursive: true, force: true })
+ }
+ }
+ )
+
it.skipIf(process.platform === 'win32')(
'rebuilds patched node-pty artifacts even when the Node load check passes',
() => {
@@ -80,7 +120,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 rebuild node-pty\n')
+ expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -114,7 +154,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 rebuild node-pty\n')
+ expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -148,7 +188,7 @@ 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 rebuild node-pty')
+ expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -178,9 +218,15 @@ function envWithPrependedPath(binDir, extraEnv) {
}
}
-function writeFakeNativeModules(projectDir) {
+function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
+ writeFileSync(
+ join(nodePtyDir, 'package.json'),
+ '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
+ )
+ mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
+ writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -197,15 +243,29 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
if (!markerExists) {
throw new Error('ABI mismatch sentinel')
}
+ return {
+ dir: '../build/Release/',
+ module: {
+ listJobProcessIds() {},
+ terminateJob() {},
+ assignCurrentProcessToJob() {}
+ }
+ }
}
`
)
- writeFakeWindowsRegistry(projectDir)
+ writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
}
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
+ writeFileSync(
+ join(nodePtyDir, 'package.json'),
+ '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
+ )
+ mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
+ writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -218,23 +278,40 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
const dir = ${JSON.stringify(nativeDir)} ??
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
- return { dir, module: {} }
+ return {
+ dir,
+ module: {
+ listJobProcessIds: () => [],
+ terminateJob: () => true,
+ assignCurrentProcessToJob: () => true
+ }
+ }
}
`
)
writeFakeWindowsRegistry(projectDir)
}
-function writeFakeWindowsRegistry(projectDir) {
+function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
if (process.platform !== 'win32') {
return
}
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
mkdirSync(registryDir, { recursive: true })
writeFileSync(
- join(registryDir, 'index.js'),
- 'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
+ join(registryDir, 'package.json'),
+ '{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n'
)
+ const markerGate = requiresMarker
+ ? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }`
+ : ''
+ writeFileSync(
+ join(registryDir, 'index.js'),
+ `exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n`
+ )
+ const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
+ mkdirSync(processTreeDir, { recursive: true })
+ writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
}
function writeNodePtyPatchFile(projectDir) {
@@ -245,6 +322,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
+ if (process.platform === 'win32') {
+ writeFileSync(join(buildDir, 'conpty.node'), '')
+ mkdirSync(join(buildDir, 'conpty'), { recursive: true })
+ writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
+ writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
+ return
+ }
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
@@ -260,6 +344,15 @@ function writeFakePnpm(binDir) {
const { appendFileSync, writeFileSync } = require('node:fs')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${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\`
+)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
`
)
diff --git a/config/scripts/install-node-dependencies-action.test.mjs b/config/scripts/install-node-dependencies-action.test.mjs
index d989221c11a..a90747513a1 100644
--- a/config/scripts/install-node-dependencies-action.test.mjs
+++ b/config/scripts/install-node-dependencies-action.test.mjs
@@ -47,6 +47,33 @@ function executeInstallScript(fixture) {
}
describe('install-node-dependencies action', () => {
+ it('skips the lockfile diff when a job container has no Git metadata', () => {
+ const fixture = createFixture()
+ try {
+ rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
+
+ const result = executeInstallScript(fixture)
+ expect(result.status, result.stderr || result.stdout).toBe(0)
+ expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
+ } finally {
+ rmSync(fixture.root, { recursive: true, force: true })
+ }
+ })
+
+ it('skips the lockfile diff for a bare repository', () => {
+ const fixture = createFixture()
+ try {
+ rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
+ expect(run('git', ['init', '--bare', '-q'], { cwd: fixture.workspace }).status).toBe(0)
+
+ const result = executeInstallScript(fixture)
+ expect(result.status, result.stderr || result.stdout).toBe(0)
+ expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
+ } finally {
+ rmSync(fixture.root, { recursive: true, force: true })
+ }
+ })
+
it.each([
['package.json', '{"name":"changed"}\n'],
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs
index 372218d903c..64b2f96505a 100644
--- a/config/scripts/package-electron-runtime-contract.test.mjs
+++ b/config/scripts/package-electron-runtime-contract.test.mjs
@@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => {
expect(copyStep.run).toContain('git add "$CASK_PATH"')
})
- it('installs the Electron package binary in PR checks without changing native module ABI', () => {
- const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
- const parsedWorkflow = parse(prWorkflow)
- const installStep = parsedWorkflow.jobs.test.steps.find(
+ it('installs the Electron package binary in the shared unit-test workflow', () => {
+ const unitTestWorkflow = parse(
+ readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8')
+ )
+ const installStep = unitTestWorkflow.jobs.test.steps.find(
(step) => step.name === 'Install Electron package binary for tests'
)
diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs
index d19eee52454..c51b1c18a5c 100644
--- a/config/scripts/pr-code-change-scope.mjs
+++ b/config/scripts/pr-code-change-scope.mjs
@@ -16,6 +16,196 @@ const DOCS_ONLY_FILES = new Set([
const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/']
+export const PR_CHECK_JOBS = [
+ 'static_analysis',
+ 'typecheck',
+ 'git_compatibility',
+ 'xterm_patch_sync',
+ 'shell_contracts',
+ 'test',
+ 'orcad_browser',
+ 'cross-version-wire',
+ 'managed_hook_node18',
+ 'package',
+ 'package_windows'
+]
+
+const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
+
+const GLOBAL_FORCE_PREFIXES = [
+ '.github/workflows/pr.yml',
+ '.github/actions/install-node-dependencies/',
+ 'config/scripts/pr-code-change-scope'
+]
+
+const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml'])
+
+const GIT_COMPAT_PREFIXES = [
+ 'src/shared/git-',
+ 'src/shared/review-head-tracking-ref',
+ 'src/main/git/',
+ 'src/relay/git-',
+ 'config/scripts/git-binary-compatibility'
+]
+
+const XTERM_PREFIXES = [
+ 'config/patches/xterm-upstream.json',
+ 'config/patches/@xterm',
+ 'config/patches/xterm-src/',
+ 'config/scripts/regenerate-xterm-patches'
+]
+
+const SHELL_PREFIXES = [
+ 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec',
+ 'src/main/daemon/shell-ready',
+ 'src/main/daemon/daemon-bash-shell-ready',
+ 'src/main/daemon/daemon-shell-ready-wrapper',
+ 'src/main/daemon/node-pty-fd-leak',
+ 'src/main/providers/local-pty-shell-ready',
+ 'src/main/providers/__tests__/shell-ready-framework-example',
+ 'src/main/pty/',
+ 'src/main/shell-templates',
+ 'src/main/shell-startup-',
+ 'src/main/shell-wrapper-',
+ 'src/main/terminal-history-fish',
+ 'src/main/zsh-',
+ 'src/renderer/src/components/terminal-pane/fish-color-scheme',
+ 'src/shared/fish-',
+ 'src/shared/pty-reply-echo-shapes',
+ 'src/shared/startup-shell-portability',
+ 'src/shared/posix-command-path-lookup',
+ 'config/patches/node-pty@',
+ 'config/scripts/ensure-native-runtime',
+ 'config/scripts/node-pty-job-ownership'
+]
+
+const ORCAD_BROWSER_PREFIXES = [
+ 'src/main/orcad/external-chromium-',
+ 'src/main/orcad/orcad-browser-provider',
+ 'src/main/orcad/orcad-agent-browser-binary',
+ 'src/main/orcad/electron-serve-browser-process'
+]
+
+const CROSS_VERSION_WIRE_PREFIXES = [
+ 'tests/e2e/cross-version-wire/',
+ 'src/shared/protocol-version',
+ 'src/shared/terminal-stream-protocol',
+ 'src/shared/browser-client-host-protocol',
+ 'src/shared/browser-network-tunnel-protocol',
+ 'src/shared/browser-client-host-placement',
+ 'src/main/runtime/rpc/dispatcher',
+ 'src/main/runtime/rpc/methods/browser-tab-create-schema',
+ 'src/main/runtime/rpc/methods/terminal',
+ 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
+]
+
+const MANAGED_HOOK_PREFIXES = [
+ 'config/scripts/smoke-managed-hook-runtime-node18',
+ 'config/scripts/build-relay',
+ 'src/relay/',
+ 'src/shared/agent-hook',
+ 'src/main/agent-hooks/'
+]
+
+const NATIVE_RUNTIME_PREFIXES = [
+ 'config/scripts/ensure-native-runtime',
+ 'config/scripts/rebuild-native-deps',
+ 'config/scripts/node-pty-job-ownership',
+ 'config/scripts/electron-builder-native-rebuild',
+ 'config/patches/node-pty@',
+ 'config/patches/@vscode__windows-process-tree'
+]
+
+const NATIVE_CACHE_FILES = new Set([
+ 'package.json',
+ 'pnpm-lock.yaml',
+ '.github/actions/install-node-dependencies/action.yml',
+ 'config/scripts/ensure-native-runtime.mjs',
+ 'config/scripts/rebuild-native-deps.mjs'
+])
+
+const NATIVE_CACHE_PREFIXES = [
+ 'config/patches/node-pty@',
+ 'config/patches/@vscode__windows-process-tree'
+]
+
+const SHARED_PACKAGE_PREFIXES = [
+ 'electron.vite.config.ts',
+ 'config/electron-builder',
+ 'config/packaged-runtime',
+ 'config/build-plugins/',
+ 'config/scripts/build-',
+ 'config/scripts/smoke-packaged',
+ 'config/scripts/install-electron-package-binary',
+ 'config/scripts/verify-packaged',
+ 'config/scripts/verify-linux-glibc',
+ 'config/scripts/run-electron-vite',
+ 'skills/',
+ 'skill-guides/',
+ 'resources/build/',
+ 'resources/onboarding/',
+ 'resources/plugins/',
+ 'resources/skills/',
+ ...NATIVE_RUNTIME_PREFIXES
+]
+
+const LINUX_PACKAGE_PREFIXES = [
+ ...SHARED_PACKAGE_PREFIXES,
+ 'native/computer-use-linux/',
+ 'resources/linux/',
+ 'config/scripts/run-headless-serve'
+]
+
+const WINDOWS_PACKAGE_PREFIXES = [
+ ...SHARED_PACKAGE_PREFIXES,
+ 'native/windows-cli-launcher/',
+ 'native/computer-use-windows/',
+ 'resources/win32/',
+ 'config/scripts/build-windows-cli-launcher',
+ 'config/scripts/windows-pty-native-capability',
+ 'tests/tools/windows-pty-native-capability-smoke/'
+]
+
+const LINUX_PACKAGE_TESTS = [
+ 'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts',
+ 'src/main/browser/browser-route-tcp-egress.electron.test.ts',
+ 'src/main/browser/browser-route-webrtc-egress.electron.test.ts',
+ 'src/main/browser/browser-route-h3-egress.electron.test.ts',
+ 'src/main/browser/browser-route-dns-prefetch.electron.test.ts'
+]
+
+const WINDOWS_PACKAGE_TESTS = [
+ ...LINUX_PACKAGE_TESTS,
+ 'config/scripts/rebuild-native-deps.test.mjs',
+ 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
+ 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
+ 'src/shared/child-process/windows-command-line.win32.test.ts',
+ 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
+ 'src/main/windows/windows-pty-job.win32.test.ts',
+ 'src/main/windows/windows-host-job.win32.test.ts',
+ 'src/main/wsl/wsl-runner.test.ts',
+ 'src/main/wsl/wsl-guest-environment.test.ts',
+ 'src/main/wsl/wsl-invocation-boundary.test.ts',
+ 'src/main/wsl/wsl-executable-path.win32.test.ts',
+ 'src/main/wsl/wsl-w1-w3-contract.test.ts',
+ 'src/shared/source-scan/source-tree-scan.test.ts',
+ 'src/main/cli/wsl-cli-powershell-boundary.test.ts',
+ 'src/main/cursor/hook-service.test.ts',
+ 'src/main/orca-profiles/profile-index-store.test.ts',
+ 'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
+ 'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
+ 'src/shared/secure-file-fsync-flags.test.ts',
+ 'src/main/ipc/pty-codex-account-attribution.test.ts',
+ 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts'
+]
+
+const DESKTOP_IRRELEVANT_PREFIXES = [
+ 'mobile/',
+ '.github/workflows/mobile.yml',
+ '.github/workflows/mobile-ios-release.yml',
+ '.github/workflows/mobile-android-release.yml'
+]
+
export function isDocsOnlyPath(file) {
if (DOCS_ONLY_FILES.has(file)) {
return true
@@ -32,10 +222,91 @@ export function shouldRunPrChecks(changedFiles) {
if (changedFiles.length === 0) {
return true
}
- return changedFiles.some((file) => !isDocsOnlyPath(file))
+ return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
+}
+
+export function classifyPrJobs(changedFiles) {
+ const emptyDiff = changedFiles.length === 0
+ const shouldRun = shouldRunPrChecks(changedFiles)
+ const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
+ const jobs = Object.fromEntries(
+ PR_CHECK_JOBS.map((job) => [
+ job,
+ shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
+ ])
+ )
+ return {
+ should_run: shouldRun,
+ native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
+ ...jobs
+ }
+}
+
+function jobDetector(job) {
+ switch (job) {
+ case 'git_compatibility':
+ return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
+ case 'xterm_patch_sync':
+ return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
+ case 'shell_contracts':
+ return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES))
+ case 'orcad_browser':
+ return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES))
+ case 'cross-version-wire':
+ return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES))
+ case 'managed_hook_node18':
+ return (files) => files.some((file) => matchesPrefix(file, MANAGED_HOOK_PREFIXES))
+ case 'package':
+ return (files) => files.some(isLinuxPackagePath)
+ case 'package_windows':
+ return (files) => files.some(isWindowsPackagePath)
+ default:
+ return () => true
+ }
+}
+
+function isLinuxPackagePath(file) {
+ return LINUX_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, LINUX_PACKAGE_PREFIXES)
+}
+
+function isWindowsPackagePath(file) {
+ return WINDOWS_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, WINDOWS_PACKAGE_PREFIXES)
+}
+
+function isProductBundlePath(file, extraPrefixes) {
+ if (isTestFile(file)) {
+ return false
+ }
+ if (file.startsWith('src/')) {
+ return true
+ }
+ return matchesPrefix(file, extraPrefixes)
+}
+
+function isTestFile(file) {
+ return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/')
+}
+
+function isDesktopIrrelevantPath(file) {
+ return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
+}
+
+function isNativeCacheInputPath(file) {
+ return NATIVE_CACHE_FILES.has(file) || matchesPrefix(file, NATIVE_CACHE_PREFIXES)
+}
+
+function isGlobalForcePath(file) {
+ return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
+}
+
+function matchesPrefix(file, prefixes) {
+ return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
- process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n')
+ const classification = classifyPrJobs(files)
+ for (const [name, value] of Object.entries(classification)) {
+ process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
+ }
}
diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs
index f71d6c42934..ea83796e6cb 100644
--- a/config/scripts/pr-code-change-scope.test.mjs
+++ b/config/scripts/pr-code-change-scope.test.mjs
@@ -1,13 +1,18 @@
+import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
-import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs'
+import {
+ classifyPrJobs,
+ isDocsOnlyPath,
+ PR_CHECK_JOBS,
+ shouldRunPrChecks
+} from './pr-code-change-scope.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
-const gatedIf = "needs.code_paths.outputs.should_run == 'true'"
const expensiveJobs = [
'static_analysis',
'typecheck',
@@ -15,12 +20,34 @@ const expensiveJobs = [
'xterm_patch_sync',
'shell_contracts',
'test',
+ 'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
]
+const ALWAYS_ON = ['static_analysis', 'typecheck', 'test']
+
+function expectedJobs(overrides, { alwaysOn = true } = {}) {
+ return Object.fromEntries(
+ PR_CHECK_JOBS.map((job) => [
+ job,
+ (alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job])
+ ])
+ )
+}
+
+function expectClassification(files, overrides) {
+ const result = classifyPrJobs(files)
+ const shouldRun = shouldRunPrChecks(files)
+ expect(result.should_run).toBe(shouldRun)
+ expect(result).toMatchObject({
+ should_run: shouldRun,
+ ...expectedJobs(overrides, { alwaysOn: shouldRun })
+ })
+}
+
describe('docs-only path classification', () => {
it('treats the WeChat README PR files as docs-only', () => {
expect(
@@ -54,9 +81,170 @@ describe('docs-only path classification', () => {
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
expect(shouldRunPrChecks([])).toBe(true)
})
+
+ it('does not start desktop PR Checks for mobile-only diffs', () => {
+ expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false)
+ })
})
-describe('PR Checks docs-only skip wiring', () => {
+describe('per-job path classification', () => {
+ it('runs every expensive job on an empty diff rather than skipping by accident', () => {
+ const result = classifyPrJobs([])
+ expect(result.should_run).toBe(true)
+ for (const job of PR_CHECK_JOBS) {
+ expect(result[job], job).toBe(true)
+ }
+ })
+
+ it('skips every expensive job for docs-only diffs', () => {
+ expectClassification(['README.md', 'docs/readme/README.zh-CN.md'], {})
+ })
+
+ it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => {
+ expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], {
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs Git compatibility only when git capability inputs change', () => {
+ expectClassification(['src/shared/git-capability-cache.ts'], {
+ git_compatibility: true,
+ package: true,
+ package_windows: true
+ })
+ expectClassification(['src/shared/git-binary-compatibility.test.ts'], {
+ git_compatibility: true
+ })
+ })
+
+ it('runs xterm patch sync only when xterm inputs change', () => {
+ expectClassification(['config/patches/xterm-upstream.json'], {
+ xterm_patch_sync: true
+ })
+ expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
+ xterm_patch_sync: true
+ })
+ })
+
+ it('runs native package jobs only for the platform that ships the changed native', () => {
+ expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], {
+ package_windows: true
+ })
+ expectClassification(['native/computer-use-linux/runtime.py'], {
+ package: true
+ })
+ expectClassification(['native/computer-use-macos/Package.swift'], {})
+ })
+
+ it('runs shell contracts when live-shell inputs change', () => {
+ expectClassification(['src/main/daemon/shell-ready.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs shell contracts when wrapper templates or live-shell fixtures change', () => {
+ expectClassification(['src/main/shell-templates.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
+ for (const file of [
+ 'src/main/orcad/external-chromium-browser-session.ts',
+ 'src/main/orcad/external-chromium-command-arguments.ts',
+ 'src/main/orcad/external-chromium-tab-registry.ts',
+ 'src/main/orcad/external-chromium-tab-projection.ts'
+ ]) {
+ expectClassification([file], {
+ orcad_browser: true,
+ package: true,
+ package_windows: true
+ })
+ }
+ expectClassification(['src/main/orcad/orcad-native-preflight.ts'], {
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs cross-version wire checks for every working-tree wire module', () => {
+ for (const file of [
+ 'src/shared/protocol-version.ts',
+ 'src/shared/terminal-stream-protocol.ts',
+ 'src/main/runtime/rpc/dispatcher.ts',
+ 'src/main/runtime/rpc/methods/browser-tab-create-schema.ts',
+ 'src/main/runtime/rpc/methods/terminal.ts',
+ 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
+ ]) {
+ expectClassification([file], {
+ 'cross-version-wire': true,
+ package: true,
+ package_windows: true
+ })
+ }
+ expectClassification(
+ ['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'],
+ { 'cross-version-wire': true }
+ )
+ })
+
+ it('runs workflow-self-change and lockfile diffs as force-all', () => {
+ const result = classifyPrJobs(['.github/workflows/pr.yml'])
+ expect(result.should_run).toBe(true)
+ for (const job of PR_CHECK_JOBS) {
+ expect(result[job], job).toBe(true)
+ }
+ expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
+ })
+
+ it('primes native caches only when their immutable inputs change', () => {
+ expect(classifyPrJobs([]).native_cache_changed).toBe(true)
+ expect(classifyPrJobs(['README.md']).native_cache_changed).toBe(false)
+ expect(classifyPrJobs(['src/main/index.ts']).native_cache_changed).toBe(false)
+ for (const file of [
+ 'package.json',
+ 'pnpm-lock.yaml',
+ '.github/actions/install-node-dependencies/action.yml',
+ 'config/scripts/ensure-native-runtime.mjs',
+ 'config/scripts/rebuild-native-deps.mjs',
+ 'config/patches/node-pty@1.1.0.patch'
+ ]) {
+ expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true)
+ }
+ })
+
+ it('keeps unit-test-only diffs out of packaging', () => {
+ expectClassification(['src/main/git/git-status.test.ts'], {
+ git_compatibility: true
+ })
+ })
+
+ it('emits GitHub output pairs from the shipped CLI', () => {
+ const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
+ cwd: projectDir,
+ encoding: 'utf8',
+ input: 'config/patches/xterm-upstream.json\n'
+ })
+ expect(result.status, result.stderr).toBe(0)
+ expect(result.stdout).toContain('should_run=true\n')
+ expect(result.stdout).toContain('xterm_patch_sync=true\n')
+ expect(result.stdout).toContain('git_compatibility=false\n')
+ expect(result.stdout).toContain('package=false\n')
+ expect(result.stdout).toContain('test=true\n')
+ })
+})
+
+describe('PR Checks skip wiring', () => {
it('classifies the PR range with a tested script and expands renames', () => {
const classify = prWorkflow.jobs.code_paths.steps.find(
(step) => step.name === 'Classify changed paths'
@@ -65,9 +253,12 @@ describe('PR Checks docs-only skip wiring', () => {
expect(classify.run).toContain('--no-renames')
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
- expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
- '${{ steps.filter.outputs.should_run }}'
- )
+ expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
+ for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) {
+ expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
+ `\${{ steps.filter.outputs.${jobName} }}`
+ )
+ }
})
it('keeps the cheap root-directory guard on docs-only PRs', () => {
@@ -75,11 +266,26 @@ describe('PR Checks docs-only skip wiring', () => {
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
})
- it('skips expensive jobs unless the detector says the PR has code', () => {
- for (const jobName of expensiveJobs) {
+ 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(['code_paths'])
- expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf)
+ expect(prWorkflow.jobs[jobName].if, jobName).toBe(
+ `needs.code_paths.outputs.${jobName} == 'true'`
+ )
}
+ expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache'])
+ expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
+ expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'")
+ expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'")
+ expect(prWorkflow.jobs.test_native_cache.needs).toEqual(['code_paths'])
+ expect(prWorkflow.jobs.test_native_cache.if).toBe(
+ "needs.code_paths.outputs.native_cache_changed == 'true'"
+ )
+ expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined()
+ const primerInstall = prWorkflow.jobs.test_native_cache.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+ expect(primerInstall.with['node-version']).toBe('24')
})
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
@@ -89,19 +295,23 @@ describe('PR Checks docs-only skip wiring', () => {
)
})
- it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => {
+ it('lets verify pass skipped jobs the classifier turned off', () => {
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
- expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"')
- const docsOnlyBranch = verifyStep.run.slice(
- 0,
- verifyStep.run.indexOf('# Require success when the PR has code-relevant changes')
- )
- expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]')
- expect(docsOnlyBranch).not.toContain('"$result" != "success"')
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
+ expect(verifyStep.run).toContain('# Require success when the PR has code-relevant changes')
+ expect(verifyStep.run).toContain('expected skipped')
+ expect(verifyStep.run).toContain('expected success')
+ for (const job of prWorkflow.jobs.verify.needs) {
+ if (job === 'code_paths' || job === 'root_directory_guard') {
+ continue
+ }
+ const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
+ expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
+ expect(verifyStep.run).toContain(`"$${envVar}"`)
+ }
})
})
diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs
index 2c1243d04fa..8cd622b300c 100644
--- a/config/scripts/pr-e2e-gate-contract.test.mjs
+++ b/config/scripts/pr-e2e-gate-contract.test.mjs
@@ -1,9 +1,14 @@
-import { existsSync, readFileSync } from 'node:fs'
+import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parse as parseJsonc } from 'jsonc-parser'
import { describe, expect, it } from 'vitest'
import { parse as parseYaml } from 'yaml'
-import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
+import {
+ hasSshSourceChange,
+ PR_E2E_SOURCE_ROUTES,
+ selectPrE2eSpecs,
+ SSH_SOURCE_ROUTE_IDS
+} from './pr-e2e-source-routing.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
@@ -100,17 +105,19 @@ describe('PR E2E gate contract', () => {
// job without adding it to the strict loop fails here instead of silently
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
- // Why lastIndexOf: the docs-only branch has its own loop that allows skipped.
const successMarker = '# Require success when the PR has code-relevant changes'
- const successLoop = verifyStep.run.slice(
- verifyStep.run.indexOf(successMarker),
- verifyStep.run.lastIndexOf('done')
- )
+ const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker))
expect(successLoop.length).toBeGreaterThan(0)
+ expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
+ expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
for (const job of prWorkflow.jobs.verify.needs) {
- const envVar = job.toUpperCase()
+ const envVar = job.replaceAll('-', '_').toUpperCase()
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
+ if (job === 'code_paths' || job === 'root_directory_guard') {
+ continue
+ }
expect(successLoop).toContain(`"$${envVar}"`)
+ expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
}
})
@@ -157,6 +164,18 @@ describe('PR E2E gate contract', () => {
expect(sshDockerRunner).toContain("'electron-headful'")
})
+ it('reuses the composite install action instead of duplicating pnpm setup', () => {
+ const installFor = (jobName) =>
+ e2eWorkflow.jobs[jobName].steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+
+ expect(installFor('build').with['native-runtime']).toBe('node')
+ for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
+ expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
+ }
+ })
+
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
@@ -195,6 +214,8 @@ describe('PR E2E gate contract', () => {
'src/main/providers/ssh-',
'src/main/ipc/pty',
'src/relay/',
+ 'src/shared/ssh-',
+ 'src/renderer/src/store/slices/direct-ssh-',
'src/renderer/src/components/terminal-pane/remote-runtime-'
]
for (const authority of sshSourceAuthorities) {
@@ -203,10 +224,24 @@ describe('PR E2E gate contract', () => {
)
}
+ // Why named files rather than prefixes: these seams are single modules, and a prefix
+ // here would route their unrelated neighbours.
+ for (const file of [
+ 'src/main/runtime/public-ssh-state.ts',
+ 'src/renderer/src/startup/ssh-startup-reconnect.ts',
+ 'src/renderer/src/store/slices/ssh.ts'
+ ]) {
+ expect(selectPrE2eSpecs([file]), file).toContain(
+ 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
+ )
+ }
+
const mappedSpecs = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
]
@@ -235,6 +270,140 @@ describe('PR E2E gate contract', () => {
expect(changedInstall.run).toContain('openssh-client')
})
+ it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
+ // Why by name: none of these carry "ssh", so the SSH authorities above never reach them
+ // — a closed-tab tombstone and a dropped default-tabs marker both shipped through it.
+ for (const file of [
+ 'src/renderer/src/hooks/remote-workspace-session-merge.ts',
+ 'src/main/ipc/remote-workspace-snapshot-normalization.ts',
+ 'src/renderer/src/lib/worktree-initial-terminal-seeding.ts',
+ 'src/renderer/src/lib/worktree-default-terminal-tabs.ts',
+ 'src/shared/remote-workspace-session-projection.ts',
+ 'src/renderer/src/components/terminal/initial-terminal.ts'
+ ]) {
+ const specs = selectPrE2eSpecs([file])
+ expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts')
+ expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts')
+ }
+
+ expect(
+ selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
+ ).toEqual([])
+ })
+
+ it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => {
+ // The behavioural half of the invariant, and the part that actually matters: an SSH source
+ // edit is recognised as one, through the same routes that select the specs.
+ for (const file of [
+ 'src/main/ssh/connection.ts',
+ 'src/relay/pty-handler.ts',
+ 'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts',
+ 'src/renderer/src/hooks/remote-workspace-session-merge.ts',
+ 'src/main/ipc/remote-workspace-snapshot-normalization.ts'
+ ]) {
+ expect(hasSshSourceChange([file]), file).toBe(true)
+ }
+ for (const file of [
+ 'src/main/git/git-status.ts',
+ 'src/renderer/src/components/tab-bar/BrowserTab.tsx',
+ 'src/main/ssh/connection.test.ts'
+ ]) {
+ expect(hasSshSourceChange([file]), file).toBe(false)
+ }
+
+ // Why: the signal must stay derived from the routes. A route id that no longer exists would
+ // silently narrow it to nothing.
+ for (const id of SSH_SOURCE_ROUTE_IDS) {
+ expect(
+ PR_E2E_SOURCE_ROUTES.map((route) => route.id),
+ id
+ ).toContain(id)
+ }
+
+ // Why text and not structure: a job `if:` is only ever available as a string. The strongest
+ // available assertion is that the source signal is its own disjunct, so the lane no longer
+ // depends on a spec name surviving in a route's spec list.
+ const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
+ expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||")
+
+ expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string')
+ expect(prWorkflow.jobs['e2e-paths'].outputs.ssh_source_changed).toBe(
+ '${{ steps.filter.outputs.ssh_source_changed }}'
+ )
+ expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe(
+ '${{ needs.e2e-paths.outputs.ssh_source_changed }}'
+ )
+ expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source')
+ expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED')
+ })
+
+ it('gives every Docker-gated SSH spec a lane that runs it', () => {
+ // Why this shape: the sharded lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec
+ // that no runner names runs nowhere and still reports green — the silent skip this file
+ // exists to prevent. Asserting reachability rather than a literal keeps that true when
+ // the lanes move.
+ // Why these two are exempt: each needs something CI cannot give it, recorded in
+ // run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage.
+ const unreachableSpecs = new Set([
+ 'tests/e2e/ssh-docker-relay-perf.spec.ts',
+ 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
+ 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
+ ])
+ // Why comments are stripped: this file's own runner lists the two exempt specs by name in a
+ // prose comment. A substring scan over raw text would count any spec merely *discussed* in a
+ // runner as claimed by it -- the silent skip this assertion exists to catch, re-entering
+ // through the documentation.
+ const stripComments = (text) =>
+ text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
+ const laneRunners = [
+ 'run-ssh-docker-e2e.mjs',
+ 'run-ssh-docker-watcher-isolation-e2e.mjs',
+ 'run-ssh-docker-terminal-parking-e2e.mjs'
+ ].map((file) => stripComments(readFileSync(join(projectDir, 'config/scripts', file), 'utf8')))
+
+ // Why a comparison and not the bare name: preview and demo specs cite the flag in a
+ // "how to run me" comment without gating on it. Why a regex rather than one literal: an
+ // equally-valid spelling (double quotes, or a `!==` guard) would escape a fixed-string scan
+ // and the spec would silently leave the contract.
+ const dockerGateExpression = /ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/
+ const dockerGatedSpecs = readdirSync(join(projectDir, 'tests/e2e'))
+ .filter((file) => file.endsWith('.spec.ts'))
+ .map((file) => `tests/e2e/${file}`)
+ .filter((spec) => dockerGateExpression.test(readFileSync(join(projectDir, spec), 'utf8')))
+ expect(dockerGatedSpecs.length).toBeGreaterThan(0)
+
+ const unclaimed = dockerGatedSpecs.filter(
+ (spec) => !unreachableSpecs.has(spec) && !laneRunners.some((runner) => runner.includes(spec))
+ )
+ expect(
+ unclaimed,
+ `Docker-gated specs claimed by no lane runner: ${unclaimed.join(', ')}`
+ ).toEqual([])
+
+ // Why: an exemption that outlives its spec would quietly excuse a real gap.
+ for (const spec of unreachableSpecs) {
+ expect(dockerGatedSpecs, spec).toContain(spec)
+ // Why also assert absence from every runner: `unreachableSpecs` short-circuits the
+ // unclaimed check above, so a spec could be documented as exempt while a runner still
+ // invokes it -- an exemption that reads as coverage removal but changes nothing, and a
+ // lane that stays red for a reason the file says it excluded.
+ for (const runner of laneRunners) {
+ expect(runner.includes(spec), `${spec} is exempt but still invoked by a lane runner`).toBe(
+ false
+ )
+ }
+ }
+
+ const laneStep = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].steps.find(
+ (step) => step.name === 'Run remaining Docker SSH E2E'
+ )
+ expect(laneStep.run).toContain('test:e2e:ssh-docker')
+ // Why: the added serial tests, several budgeting 4-10 minutes each, do not fit the old 35.
+ expect(
+ e2eWorkflow.jobs['ssh-docker-watcher-isolation']['timeout-minutes']
+ ).toBeGreaterThanOrEqual(60)
+ })
+
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs
index 04f570f0094..108a4d5ab10 100644
--- a/config/scripts/pr-e2e-source-routing.mjs
+++ b/config/scripts/pr-e2e-source-routing.mjs
@@ -18,12 +18,32 @@ export const PR_E2E_SOURCE_ROUTES = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
],
+ // Why the store/startup/shared additions: the SSH-named authorities stop at the main
+ // process and the pane component, but the reconnect ledgers and retained-payload
+ // admission that decide whether a pane rebinds live in the renderer store.
matches: (file) =>
isProductSource(file) &&
- /^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
+ /^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
+ file
+ )
+ },
+ {
+ // Why a sibling route rather than more paths on ssh-terminal-source: these modules carry
+ // no "ssh" in their names, and only the two restore specs gate them. Folding them in
+ // would run the whole SSH terminal list for a tab-tombstone edit.
+ id: 'ssh-workspace-session-restore',
+ specs: [
+ 'tests/e2e/ssh-cold-activation-restore.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts'
+ ],
+ matches: (file) =>
+ isProductSource(file) &&
+ /^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test(
file
)
},
@@ -131,6 +151,18 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
return [...specs].sort((left, right) => left.localeCompare(right))
}
+/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */
+export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore']
+
+// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to
+// trigger only because one route happened to list a startup-readiness spec, so pruning that
+// spec would have silently retired the lane. Two lists that must agree is how that drifted.
+export function hasSshSourceChange(changedPaths) {
+ return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some(
+ (route) => changedPaths.some(route.matches)
+ )
+}
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
let input = ''
process.stdin.setEncoding('utf8')
@@ -138,6 +170,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
input += chunk
}
const changedPaths = input.split(/\r?\n/).filter(Boolean)
- const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
- process.stdout.write(`${JSON.stringify(specs)}\n`)
+ if (process.argv.includes('--ssh-source')) {
+ process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
+ } else {
+ const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
+ process.stdout.write(`${JSON.stringify(specs)}\n`)
+ }
}
diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs
index 42540f6bcb4..29f532b9436 100644
--- a/config/scripts/pr-workflow-parallelism.test.mjs
+++ b/config/scripts/pr-workflow-parallelism.test.mjs
@@ -3,6 +3,8 @@ import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
+const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
+const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
const dependencyAction = parse(
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
)
@@ -47,22 +49,35 @@ describe('PR workflow parallelism', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
- it('shards the general test suite across Node 24 and Node 26', () => {
- expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
- expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
- Array.from({ length: 16 }, (_, index) => index + 1)
+ it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => {
+ const sharedTest = unitTestWorkflow.jobs.test
+ const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
+ const installStep = sharedTest.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
)
- expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
- const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
- const installStep = workflow.jobs.test.steps.find(
+ const primerInstall = workflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
+ expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
+ expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
+ expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
+ expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26'])
+ expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
+ expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
+ expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
+ expect(sharedTest.strategy.matrix.shard).toEqual(
+ Array.from({ length: 8 }, (_, index) => index + 1)
+ )
+ expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
+ expect(primerInstall.with['native-runtime']).toBe('node')
+ expect(primerInstall.with['node-version']).toBe('24')
+ expect(workflow.jobs.test.needs).toContain('test_native_cache')
})
it('runs real-shell coverage once outside the general shards', () => {
@@ -208,6 +223,8 @@ describe('PR workflow parallelism', () => {
expect(pnpmIndex).toBeLessThan(nodeIndex)
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
+ const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
+ expect(steps[pnpmIndex].with.version).toBe(packageManagerVersion)
expect(steps[nodeIndex].with.cache).toBe('pnpm')
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
@@ -232,22 +249,24 @@ describe('PR workflow parallelism', () => {
workflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
+ const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
- for (const jobName of [
- 'static_analysis',
- 'typecheck',
- 'git_compatibility',
- 'xterm_patch_sync'
- ]) {
+ for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
+ expect(installFor('static_analysis').with['native-runtime']).toBe('node')
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
- expect(installFor('test').with['native-runtime']).toBe('node')
+ expect(sharedTestInstall.with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
+ expect(installFor('package_windows').with['native-runtime']).toBe('node')
+ expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
+ expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
expect(
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
- ).toBe("inputs.native-runtime != 'none'")
+ ).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
@@ -295,14 +314,42 @@ describe('PR workflow parallelism', () => {
// overwritten and one after the rebuild would never save a hit.
expect(installIndex).toBeLessThan(cacheIndex)
expect(cacheIndex).toBeLessThan(prepareIndex)
- expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
+ expect(steps[cacheIndex].if).toBe(
+ "inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
+ )
+ const restoreOnly = steps.find(
+ (step) => step.name === 'Restore compiled native modules without saving'
+ )
+ expect(restoreOnly.if).toBe(
+ "inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
+ )
+ expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
// Native artifacts are ABI-bound: a key missing either dimension serves a build
// that cannot load, and ensure-native-runtime would recompile it anyway.
- expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
- expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
- expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
- // No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
- expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
+ for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
+ expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
+ expect(cacheStep.with.key).toContain('${{ runner.os }}')
+ expect(cacheStep.with.key).toContain('${{ runner.arch }}')
+ expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version')
+ expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
+ expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
+ expect(cacheStep.with.key).toContain(
+ 'config/patches/@vscode__windows-process-tree@0.8.0.patch'
+ )
+ expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
+ expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
+ expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
+ expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
+ expect(cacheStep.with.path).toContain('windows-native-registry@')
+ expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
+ expect(cacheStep.with['restore-keys']).toBeUndefined()
+ }
+ const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
+ expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
+ expect(cacheScope.run).toContain('/etc/os-release')
+ expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
+ '${{ steps.native-cache-scope.outputs.scope }}'
+ )
})
it('reuses TypeScript incremental state across typecheck runs', () => {
@@ -346,6 +393,7 @@ describe('PR workflow parallelism', () => {
'shell_contracts',
'test',
'orcad_browser',
+ 'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
@@ -359,5 +407,7 @@ describe('PR workflow parallelism', () => {
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
+ expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
+ expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
})
})
diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs
index 78e25d3634c..4eca4bac33a 100644
--- a/config/scripts/rebuild-native-deps.mjs
+++ b/config/scripts/rebuild-native-deps.mjs
@@ -401,14 +401,23 @@ function getPatchedNodePtyRebuildReason() {
return null
}
- // Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
- // load successfully in Electron while missing the patched fd/error handling.
+ // Why: Orca patches node-pty's native Unix spawn path and Windows job-object
+ // exports; upstream prebuilds can load while missing those patches.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
- const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
- // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
- if (process.platform === 'darwin') {
- artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
- }
+ const artifactPaths =
+ rebuildPlatform === 'win32'
+ ? [
+ resolve(nodePtyDir, 'build', 'Release', 'conpty.node'),
+ ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) =>
+ resolve(nodePtyDir, 'build', 'Release', 'conpty', filename)
+ )
+ ]
+ : [
+ resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
+ ...(osPlatform() === 'darwin'
+ ? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')]
+ : [])
+ ]
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -422,9 +431,6 @@ function requiresPatchedNodePtySourceBuild() {
if (!onlyModules.includes('node-pty')) {
return false
}
- if (rebuildPlatform === 'win32') {
- return false
- }
if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) {
return false
}
diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs
index b158ee57f21..5ebc39dfcaa 100644
--- a/config/scripts/rebuild-native-deps.test.mjs
+++ b/config/scripts/rebuild-native-deps.test.mjs
@@ -586,6 +586,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
+ if (process.platform === 'win32') {
+ writeFileSync(join(buildDir, 'conpty.node'), '')
+ mkdirSync(join(buildDir, 'conpty'), { recursive: true })
+ writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
+ writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
+ return
+ }
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs
new file mode 100644
index 00000000000..a723a9a6ad0
--- /dev/null
+++ b/config/scripts/run-ssh-docker-e2e.mjs
@@ -0,0 +1,95 @@
+import { spawnSync } from 'node:child_process'
+
+const rawExtraArgs = process.argv.slice(2)
+const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
+const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+const env = {
+ ...process.env,
+ ORCA_E2E_SSH_DOCKER: '1',
+ ORCA_E2E_WEB_CLIENT: '1'
+}
+
+// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
+const spawnOptions = {
+ stdio: 'inherit',
+ env,
+ shell: process.platform === 'win32'
+}
+
+const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
+
+if (runtime.status !== 0) {
+ process.exit(runtime.status ?? 1)
+}
+
+// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane
+// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading
+// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion.
+//
+// Deliberately absent, and therefore still covered by no CI trigger:
+// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would
+// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in
+// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at
+// all. Recorded as a real gap, not as coverage living somewhere else.
+// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
+// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
+// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying:
+// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 /
+// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate
+// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above.
+// (b) it is ROTTED: four call sites are out of date against terminal.ts's current
+// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane
+// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
+// semantic decisions (which ptyId to capture, which split direction) that change
+// what the repro measures. Tracked in stablyai/orca#16764.
+//
+// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
+// grep-inverts away.
+//
+// Known gaps in SSH e2e coverage, recorded here because nothing else names them:
+// - The job that runs this is still called `ssh-docker-watcher-isolation`, though watcher
+// isolation is now one spec of many. Renaming it changes the GitHub check name and can
+// break required-check config, so the name understates the job on purpose.
+// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on
+// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment
+// has the exact wiring to flip it, and the gate contract asserts the current state.
+// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere
+// and are not Docker-gated, which puts them outside this file's contract:
+// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER)
+// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER)
+// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH)
+// ssh-localhost (ORCA_E2E_SSH_LOCALHOST)
+// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E)
+// Runner scripts for the first four sit unused in package.json; no workflow calls them.
+const result = spawnSync(
+ pnpm,
+ [
+ 'exec',
+ 'playwright',
+ 'test',
+ 'tests/e2e/pty-input-write-queue-ssh.spec.ts',
+ 'tests/e2e/ssh-ai-vault-session-history.spec.ts',
+ 'tests/e2e/ssh-cold-activation-restore.spec.ts',
+ 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
+ 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-external-image-preview.spec.ts',
+ 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts',
+ 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts',
+ 'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
+ 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
+ 'tests/e2e/ssh-restart-tab-accumulation.spec.ts',
+ 'tests/e2e/ssh-skill-installation.spec.ts',
+ 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
+ '--config',
+ 'tests/playwright.config.ts',
+ '--project',
+ 'electron-headless',
+ '--project',
+ 'electron-headful',
+ '--workers=1',
+ ...extraArgs
+ ],
+ spawnOptions
+)
+
+process.exit(result.status ?? 1)
diff --git a/config/scripts/skills-cli-package-workflow.test.mjs b/config/scripts/skills-cli-package-workflow.test.mjs
index 489437dd61f..07f7beb46ac 100644
--- a/config/scripts/skills-cli-package-workflow.test.mjs
+++ b/config/scripts/skills-cli-package-workflow.test.mjs
@@ -14,6 +14,7 @@ describe('packaged skills CLI PR gates', () => {
expect(job['runs-on']).toBe('windows-2022')
expect(buildStep.run).toBe('pnpm run build:release:parallel')
+ expect(buildStep.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.run).toContain('electron-builder')
expect(packageStep.run).toContain('--dir')
diff --git a/config/scripts/windows-process-tree-gyp-path.test.mjs b/config/scripts/windows-process-tree-gyp-path.test.mjs
index 5a3aa465737..e2e650384ec 100644
--- a/config/scripts/windows-process-tree-gyp-path.test.mjs
+++ b/config/scripts/windows-process-tree-gyp-path.test.mjs
@@ -9,26 +9,30 @@ const PATCH = readFileSync(
'utf8'
)
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
-const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
+const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
describe('windows-process-tree node-addon-api gyp path', () => {
- it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => {
- expect(PATCH).toContain(
- `+ " {
+ expect(PATCH).not.toContain('+ "../../node-addon-api')
+ expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],')
+ expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],')
+ const buildScript = readFileSync(
+ join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
+ 'utf8'
)
- const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
- expect(bindingGyp).toContain(ABSOLUTE_GYP)
- expect(bindingGyp).not.toContain("require('node-addon-api').targets")
- expect(
- readFileSync(
- join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
- 'utf8'
- )
- ).toContain(ABSOLUTE_GYP)
+ expect(buildScript).toContain(
+ "for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h'])"
+ )
+ expect(buildScript).toContain("import { createRequire } from 'node:module'")
+ expect(buildScript).toContain("import { dirname, join, resolve } from 'node:path'")
+ expect(buildScript).toContain(
+ "createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')"
+ )
+ expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.')
})
it('resolves node_addon_api.gyp to a real file from the package directory', () => {
- const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], {
+ const resolved = execFileSync(process.execPath, ['-p', RESOLVED_GYP], {
cwd: PACKAGE_DIR,
encoding: 'utf8'
}).trim()
diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs
index 9a6b8b285cf..e1742a43bb0 100644
--- a/config/scripts/windows-pty-native-capability-workflow.test.mjs
+++ b/config/scripts/windows-pty-native-capability-workflow.test.mjs
@@ -28,8 +28,14 @@ describe('packaged Windows PTY native capability routing', () => {
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
const job = workflow.jobs.package_windows
- const sourceRebuild = job.steps.find(
- (step) => step.name === 'Rebuild node-pty from patched source'
+ const install = job.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+ const nodeCacheSave = job.steps.find(
+ (step) => step.name === 'Save compiled Node native modules'
+ )
+ const electronCache = job.steps.find(
+ (step) => step.name === 'Restore compiled Electron native modules'
)
const build = job.steps.find((step) => step.name === 'Build package inputs')
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
@@ -37,10 +43,22 @@ describe('packaged Windows PTY native capability routing', () => {
const verify = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
+ const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8')
- expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
- expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
+ expect(install.with['native-runtime']).toBe('node')
+ expect(install.with['persist-native-cache']).toBe('false')
+ expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
+ expect(nodeCacheSave.with.key).toContain('-node-node')
+ expect(electronCache.with.key).toContain('-electron-node')
+ for (const cache of [nodeCacheSave, electronCache]) {
+ expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
+ expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
+ expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
+ }
+ expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']")
+ expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')")
expect(build.run).toBe('pnpm run build:release:parallel')
+ expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
expect(workflow.jobs.verify.needs).toContain('package_windows')
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index d604b83bf97..49201fa15cc 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-
@@ -227,7 +227,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.44/app-release.apk)
+- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md
index b015c26449a..c9bb161fe8e 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) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -235,7 +235,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) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android :** [Télécharger l'APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
+- **Android :** [Télécharger l'APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index 3b1992448fe..cc589be1cc5 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.44/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.46/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,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.44/app-release.apk)
+- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 6e53e590463..1de67052fcb 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) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [APK 0.0.44 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
+- **Android:** [APK 0.0.46 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/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 9f86dc2cfa3..970fcf6c8a0 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) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,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) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Baixar APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
+- **Android:** [Baixar APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 0c619ea260d..2e11413aa2a 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.44/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.46/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)