mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # mobile/src/browser/MobileBrowserPane.tsx
This commit is contained in:
@@ -112,7 +112,6 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable
|
||||
|
||||
The scheduled 2x/day RC cron in [`release-rc.yml`](../../actions/workflows/release-rc.yml) is independent and continues to run automatically from `main`.
|
||||
|
||||
|
||||
## Release Channels
|
||||
|
||||
The public Homebrew cask tracks stable desktop releases:
|
||||
|
||||
@@ -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
|
||||
@@ -73,18 +86,51 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
git diff --exit-code package.json pnpm-lock.yaml
|
||||
# Job containers can run composite steps from a source mirror without .git.
|
||||
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'
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
name: Adhoc macOS Dev Build
|
||||
name: Adhoc macOS + Windows Dev Build
|
||||
|
||||
# Why: lets anyone cut a signed macOS build of an unlanded branch so the team can
|
||||
# actually run an experimental feature for a few days, instead of reasoning about
|
||||
# it from a diff. Hourly covers main; this covers everything that is not main yet.
|
||||
# Why: lets anyone cut installable macOS and Windows builds of a trusted repo ref
|
||||
# so the team can run an experimental feature instead of reasoning from a diff.
|
||||
# Hourly covers current main; this covers experiments and on-demand validation.
|
||||
#
|
||||
# Deliberately narrow scope, same trade as hourly:
|
||||
# - macOS only. Other platforms keep using RC/stable.
|
||||
# Deliberately narrow scope:
|
||||
# - macOS and Windows desktop installers. Linux keeps using RC/stable.
|
||||
# - No tests, no lint, no e2e. PR CI and release-cut remain the gates.
|
||||
# - Signed AND notarized, exactly like a release. macOS anchors a notarized
|
||||
# app's TCC grants on identifier + team rather than on its cdhash, so those
|
||||
# grants survive an update; an unnotarized build reads as a new client and
|
||||
# silently loses file access under Documents/Desktop/Downloads.
|
||||
# - macOS is signed and notarized so TCC grants survive updates.
|
||||
# - Windows is unsigned; the published release notes explain the one-time
|
||||
# SmartScreen/manual-install requirement.
|
||||
#
|
||||
# Artifacts publish to stablyai/orca-adhoc — separate from both orca and
|
||||
# orca-hourly. Separate from orca because the main repo's releases atom feed
|
||||
@@ -311,7 +310,6 @@ jobs:
|
||||
including back to Stable, works in-app from there."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Publish adhoc macOS artifacts
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -308,7 +308,6 @@ jobs:
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Publish daily macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -230,10 +230,23 @@ jobs:
|
||||
shell: bash
|
||||
run: node config/scripts/verify-dev-channel-packaging.mjs --channel="$CHANNEL" --platform=win32
|
||||
|
||||
# Why here and not in build:relay: only a Windows runner can compile it, and
|
||||
# arm64 cross-compiles from this same x64 agent. Runs before the 20-minute
|
||||
# build so a runner image missing the MSVC ARM64 cross toolset fails in
|
||||
# seconds with MSB8020 naming the component, rather than deep into packaging.
|
||||
- name: Build Windows process-table addon for the relay
|
||||
shell: bash
|
||||
run: |
|
||||
node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64
|
||||
node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64
|
||||
|
||||
- name: Build app
|
||||
shell: bash
|
||||
run: pnpm build:release
|
||||
env:
|
||||
# Fail the build rather than ship a relay that silently falls back to
|
||||
# the PowerShell scan on every Windows SSH host.
|
||||
ORCA_REQUIRE_RELAY_NATIVE_ADDONS: 'x64,arm64'
|
||||
# Why unset ORCA_BUILD_IDENTITY: telemetry's transport gate accepts only
|
||||
# 'stable' or 'rc', so leaving it unset keeps dev builds silent — which
|
||||
# is correct for unvetted artifacts. Same as the mac dev channels.
|
||||
|
||||
+36
-102
@@ -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,12 @@ 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
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 zsh
|
||||
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
|
||||
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
|
||||
@@ -142,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
|
||||
@@ -221,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
|
||||
@@ -286,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
|
||||
@@ -304,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
|
||||
@@ -345,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
|
||||
|
||||
@@ -74,6 +74,7 @@ jobs:
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run golden E2E tests on macOS
|
||||
@@ -87,6 +88,7 @@ jobs:
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run golden E2E tests on Windows
|
||||
@@ -97,6 +99,7 @@ jobs:
|
||||
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
||||
pnpm run --if-present test:e2e:workspace-session-golden
|
||||
pnpm run --if-present test:e2e:windows-fresh-startup-golden
|
||||
pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
|
||||
if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) {
|
||||
pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
|
||||
}
|
||||
|
||||
@@ -290,7 +290,6 @@ jobs:
|
||||
including back to Stable, works in-app from there."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Publish hourly macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -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"]'
|
||||
+192
-133
@@ -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
|
||||
@@ -425,20 +439,17 @@ jobs:
|
||||
src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
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
|
||||
@@ -449,36 +460,60 @@ 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
|
||||
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
|
||||
# would pay for it on every shard to run one file in whichever shard it landed in.
|
||||
orcad_browser:
|
||||
name: orcad browser provider
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.orcad_browser == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Why no native-runtime: the provider drives the prebuilt agent-browser binary
|
||||
# shipped in node_modules and never touches node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why the runner's Google Chrome and not its chromium: Ubuntu 24.04 only ships an
|
||||
# AppArmor userns profile for the Chrome .deb, so chromium dies with "No usable
|
||||
# sandbox" and the provider passes no --no-sandbox. Why fail instead of skip: an
|
||||
# unset ORCA_BROWSER_EXECUTABLE is exactly how this test went uncovered for so long.
|
||||
- name: Resolve Chrome for the browser provider
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chrome="$(command -v google-chrome || command -v google-chrome-stable || true)"
|
||||
if [ -z "$chrome" ]; then
|
||||
echo "::error::No Google Chrome on the runner; the browser provider test would silently skip."
|
||||
exit 1
|
||||
fi
|
||||
"$chrome" --version
|
||||
echo "ORCA_BROWSER_EXECUTABLE=$chrome" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Test external Chromium browser provider
|
||||
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/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 }}
|
||||
src/main/orcad/external-chromium-browser-process.integration.test.ts
|
||||
|
||||
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:
|
||||
@@ -501,13 +536,16 @@ jobs:
|
||||
|
||||
# A path filter that matches nothing exits 1 ("No test files found"), so this
|
||||
# lane cannot report success while running zero tests.
|
||||
- name: Old/new client and server terminal journey
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
- name: Old/new client and server compatibility journeys
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
|
||||
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:
|
||||
@@ -532,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:
|
||||
@@ -555,6 +593,15 @@ jobs:
|
||||
with:
|
||||
native-runtime: electron
|
||||
|
||||
- name: Test Linux Electron lifecycle boundary
|
||||
run: >-
|
||||
xvfb-run --auto-servernum pnpm exec vitest run --config config/vitest.config.ts
|
||||
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
|
||||
|
||||
- name: Build package inputs
|
||||
run: |
|
||||
status=0
|
||||
@@ -595,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
|
||||
|
||||
@@ -605,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:
|
||||
@@ -626,25 +662,36 @@ 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: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
config/scripts/rebuild-native-deps.test.mjs
|
||||
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
|
||||
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
|
||||
@@ -654,17 +701,37 @@ jobs:
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -673,6 +740,9 @@ jobs:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
|
||||
|
||||
- name: Smoke packaged Windows PTY native capability
|
||||
run: pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe
|
||||
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked
|
||||
|
||||
@@ -689,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
|
||||
@@ -707,39 +778,15 @@ jobs:
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
|
||||
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
|
||||
if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then
|
||||
TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)"
|
||||
fi
|
||||
# Why: the Docker-SSH specs only ever ran when someone edited a spec, so four
|
||||
# pane-restore regressions shipped from SSH source edits that touched no test.
|
||||
# Unit tests are excluded because they prove the same source without Docker.
|
||||
# ssh-startup-exec-readiness.spec.ts is listed to route e2e.yml's dedicated
|
||||
# ssh-docker-watcher-isolation lane, which filters it out of changed-e2e.
|
||||
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(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))'; then
|
||||
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
|
||||
'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-startup-exec-readiness.spec.ts' \
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' | sort -u)"
|
||||
fi
|
||||
# Why: same lesson as the SSH specs above. The IME e2e suite asserts real pty bytes and
|
||||
# real overlay geometry, but only ran when someone edited a spec, so every IME fix in the
|
||||
# 1.4.18x window shipped without it - including one whose diagnosis hardware later refuted.
|
||||
# config/patches is listed because the terminal's input handling lives in the xterm patch.
|
||||
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(config/patches/|src/renderer/src/components/terminal-pane/(terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))'; then
|
||||
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
|
||||
'tests/e2e/terminal-cjk-ime-committed-text.spec.ts' \
|
||||
'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts' \
|
||||
'tests/e2e/terminal-ime-exact-byte.spec.ts' \
|
||||
'tests/e2e/terminal-korean-composing-chord-order.spec.ts' \
|
||||
'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts' \
|
||||
'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts' \
|
||||
'tests/e2e/terminal-korean-preedit-visibility.spec.ts' | sort -u)"
|
||||
fi
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
|
||||
# Source routes are executable contracts so a test can prove exact
|
||||
# authorities, exclusions, and sentinels without evaluating workflow shell.
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
|
||||
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"
|
||||
@@ -758,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()
|
||||
@@ -770,6 +818,8 @@ jobs:
|
||||
- xterm_patch_sync
|
||||
- shell_contracts
|
||||
- test
|
||||
- orcad_browser
|
||||
- cross-version-wire
|
||||
- managed_hook_node18
|
||||
- package
|
||||
- package_windows
|
||||
@@ -789,54 +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" \
|
||||
"$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" \
|
||||
"$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"
|
||||
|
||||
@@ -34,8 +34,7 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
|
||||
@@ -844,6 +844,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore golden test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
tests/e2e/golden-source-control-open-diff.spec.ts \
|
||||
tests/e2e/golden-terminal-file-link.spec.ts
|
||||
|
||||
- name: Install native build tools
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
@@ -946,6 +956,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
@@ -978,13 +998,26 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Install Ubuntu 20.04 prerequisites
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Trust the checked-out workspace in the job container
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
@@ -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 }}
|
||||
+5
-2
@@ -17,6 +17,9 @@ src/**/*.d.ts
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
# Why: the trailing-slash form matches directories only, so a node_modules SYMLINK (how agent
|
||||
# worktrees share an install) slipped past a bulk `git add` and got committed — twice.
|
||||
node_modules
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
@@ -95,13 +98,13 @@ docs/**
|
||||
!docs/readme/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/agent-skill-sharing-implementation-checklist.md
|
||||
!docs/agent-skill-sharing-installation-plan.md
|
||||
!docs/ai-vault-process-isolation-plan.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
!docs/reference/git-compatibility.md
|
||||
!docs/reference/headless-linux-server.md
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/macos-press-and-hold.md
|
||||
!docs/reference/orcad-operations.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/windows-process-enumeration.md
|
||||
!docs/reference/wsl-runner-verification.md
|
||||
|
||||
@@ -7,9 +7,15 @@ All UI work — layout, color, typography, spacing, component selection, UX beha
|
||||
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
|
||||
|
||||
# Style
|
||||
## Concise/Brief Non-obviosu comments ONLY
|
||||
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
* BE CONCISE. 1 LINE if possible
|
||||
|
||||
## Reuse Before Reimplementing
|
||||
|
||||
Before writing new logic at any scale — a function, component, IPC channel, state store, or whole subsystem/flow — check whether an existing implementation already does the job (or nearly does). Extend or generalize it instead of building a parallel version; only write from scratch when nothing fits. Keep the check proportionate: a quick search for trivial code, a real one before building anything substantial.
|
||||
|
||||
## Concise/Brief Non-obvious Comments ONLY
|
||||
|
||||
- DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
- BE CONCISE. 1 LINE if possible
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
@@ -21,7 +27,14 @@ Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuf
|
||||
|
||||
## Type Declarations: Prefer `.ts` Over `.d.ts`
|
||||
|
||||
# Verifying Changes
|
||||
|
||||
- **Typecheck**: `pnpm tc` (or `tc:node` / `tc:cli` / `tc:web`)
|
||||
- **Test**: `pnpm test [path/to/file.test.ts]`
|
||||
- **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format`
|
||||
|
||||
# Considerations
|
||||
|
||||
## Worktree Safety
|
||||
|
||||
Always use the primary working directory (the worktree) for all file reads and edits. Never follow absolute paths from subagent results that point to the main repo.
|
||||
|
||||
@@ -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)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -262,6 +262,7 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
|
||||
</p>
|
||||
|
||||
## Signed Builds
|
||||
|
||||
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
|
||||
|
||||
## License
|
||||
|
||||
@@ -24,8 +24,7 @@ const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'parcel-watcher-process-entry',
|
||||
'computer-sidecar',
|
||||
'wsl-transcript-fs-process-entry',
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'codex/codex-app-server-grant-entry'
|
||||
'agent-hooks/managed-agent-hook-controls'
|
||||
] as const
|
||||
|
||||
// Entries executed as worker threads of the main process. Electron's module is
|
||||
|
||||
@@ -14,6 +14,9 @@ const {
|
||||
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
|
||||
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
|
||||
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
|
||||
const {
|
||||
verifyPackagedNodePtyJobOwnership
|
||||
} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs')
|
||||
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
|
||||
|
||||
// Why: dev-channel builds must carry the *release* identity — same bundle id,
|
||||
@@ -266,6 +269,13 @@ module.exports = {
|
||||
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
|
||||
const hostArchEnum = archEnumByNodeArch[process.arch]
|
||||
const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4
|
||||
if (context.electronPlatformName === 'win32') {
|
||||
if (process.platform === 'win32' && canExecuteTargetArch) {
|
||||
verifyPackagedNodePtyJobOwnership(resourcesDir)
|
||||
} else {
|
||||
console.log('[verify-packaged-node-pty] skipped cross-platform or cross-arch package')
|
||||
}
|
||||
}
|
||||
verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, {
|
||||
executeCommands: canExecuteTargetArch
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"src/main/ports/port-scan-command-worker-entry.ts",
|
||||
"src/main/ipc/parcel-watcher-process-entry.ts",
|
||||
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
|
||||
"src/main/codex/codex-app-server-grant-entry.ts",
|
||||
"src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"src/main/claude-accounts/keychain.ts",
|
||||
"src/renderer/src/main.tsx",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
[
|
||||
{
|
||||
"filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts",
|
||||
"kind": "object-property:title",
|
||||
"text": "Nightly #1",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts",
|
||||
"kind": "object-property:label",
|
||||
"text": "Hermes",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
|
||||
"kind": "jsx-attribute:label",
|
||||
|
||||
@@ -2,191 +2,90 @@
|
||||
# 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/handlers/orchestration.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/browser/grab-guest-script.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/claude-accounts/service.ts
|
||||
inline src/main/cli/cli-installer.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/config-toml-trust.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
inline src/main/daemon/daemon-init.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.ts
|
||||
inline src/main/daemon/daemon-server.ts
|
||||
inline src/main/daemon/pty-subprocess.ts
|
||||
inline src/main/git/repo.ts
|
||||
inline src/main/git/runner.ts
|
||||
inline src/main/git/status.ts
|
||||
inline src/main/git/worktree.ts
|
||||
inline src/main/github/pr-refresh-coordinator.ts
|
||||
inline src/main/github/project-view.ts
|
||||
inline src/main/github/project-view/mutations.ts
|
||||
inline src/main/github/work-item-details.ts
|
||||
inline src/main/gitlab/client.ts
|
||||
inline src/main/index.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/github.ts
|
||||
inline src/main/ipc/pty.ts
|
||||
inline src/main/ipc/repos.ts
|
||||
inline src/main/ipc/ssh.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/ipc/worktrees.ts
|
||||
inline src/main/jira/issues.ts
|
||||
inline src/main/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/linear/projects.ts
|
||||
inline src/main/memory/collector.ts
|
||||
inline src/main/opencode/hook-service.ts
|
||||
inline src/main/persistence/loading-store/store.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/providers/ssh-git-provider.ts
|
||||
inline src/main/rate-limits/claude-fetcher.ts
|
||||
inline src/main/rate-limits/claude-pty.ts
|
||||
inline src/main/rate-limits/codex-fetcher.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
inline src/main/runtime/orca-runtime-files.ts
|
||||
inline src/main/runtime/orca-runtime-git.ts
|
||||
inline src/main/runtime/orca-runtime.test.ts
|
||||
inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/rpc/methods/terminal.ts
|
||||
inline src/main/runtime/runtime-rpc.ts
|
||||
inline src/main/source-control/hosted-review-creation.ts
|
||||
inline src/main/speech/model-manager.ts
|
||||
inline src/main/speech/stt-service.ts
|
||||
inline src/main/ssh/ssh-channel-multiplexer.ts
|
||||
inline src/main/ssh/ssh-connection.ts
|
||||
inline src/main/ssh/ssh-relay-deploy.ts
|
||||
inline src/main/ssh/ssh-relay-session.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.ts
|
||||
inline src/main/updater.ts
|
||||
inline src/main/window/attach-main-window-services.ts
|
||||
inline src/main/window/createMainWindow.ts
|
||||
inline src/main/workspace-space-analysis.ts
|
||||
inline src/preload/index.ts
|
||||
inline src/relay/dispatcher.ts
|
||||
inline src/relay/external-automations-handler.ts
|
||||
inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/relay.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/LinearIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
inline src/renderer/src/components/UpdateCard.tsx
|
||||
inline src/renderer/src/components/WorktreeJumpPalette.tsx
|
||||
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/EditorContent.tsx
|
||||
inline src/renderer/src/components/editor/IpynbViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectCell.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectPicker.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
|
||||
inline src/renderer/src/components/linear-project-view-surfaces.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/ChecksPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
|
||||
inline src/renderer/src/components/settings/AccountsPane.tsx
|
||||
inline src/renderer/src/components/settings/AgentsPane.tsx
|
||||
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
|
||||
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
|
||||
inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.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
|
||||
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
|
||||
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
|
||||
inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.ts
|
||||
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
|
||||
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-git-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/github.ts
|
||||
inline src/renderer/src/store/slices/hosted-review.ts
|
||||
inline src/renderer/src/store/slices/jira.ts
|
||||
inline src/renderer/src/store/slices/linear.ts
|
||||
inline src/renderer/src/store/slices/repos.ts
|
||||
inline src/renderer/src/store/slices/tabs.ts
|
||||
inline src/renderer/src/store/slices/terminals.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/renderer/src/store/slices/workspace-cleanup.ts
|
||||
inline src/renderer/src/web/web-preload-api.ts
|
||||
inline src/renderer/src/web/web-runtime-client.ts
|
||||
inline src/shared/agent-hook-listener.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/remote-runtime-client.ts
|
||||
inline src/shared/runtime-types.ts
|
||||
inline src/shared/source-control-ai.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
|
||||
|
||||
@@ -348,6 +348,35 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
return
|
||||
}
|
||||
|
||||
// Why delete only conpty.node: node-pty's loader tries build/Release, then
|
||||
// build/Debug, then prebuilds/<platform>-<arch>, swallowing every failure in
|
||||
// between. Only the source build carries Orca's job-object exports, so an ABI
|
||||
// mismatch or an AV quarantine of build/Release/conpty.node would silently
|
||||
// fall through to the UNPATCHED prebuild -- teardown back to guessing by PID
|
||||
// ancestry, with no error anywhere.
|
||||
//
|
||||
// Why NOT the whole prebuilds/ tree: Orca's own patch deletes the
|
||||
// `conpty_console_list` and winpty `pty` gyp targets, so a Windows source
|
||||
// build emits conpty.node and nothing else. conpty_console_list.node,
|
||||
// pty.node, winpty.dll and winpty-agent.exe exist ONLY here. Removing them
|
||||
// silently kills console-membership probing (the forked agent throws at
|
||||
// require, and its caller resolves null with silent: true), and removes the
|
||||
// winpty backend that node-pty still selects below Windows build 18309.
|
||||
//
|
||||
// Why the arch check: a cross-arch package copies the host's build/Release,
|
||||
// so its mere presence does not mean it matches electronArch -- deleting the
|
||||
// target-arch prebuild would then remove the only loadable binary.
|
||||
if (
|
||||
electronPlatformName === 'win32' &&
|
||||
electronArch === process.arch &&
|
||||
existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'))
|
||||
) {
|
||||
const prebuildDir = join(nodePtyDir, 'prebuilds', `win32-${electronArch}`)
|
||||
for (const staleFallback of ['conpty.node', 'conpty.pdb']) {
|
||||
rmSync(join(prebuildDir, staleFallback), { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
if (allowedPrebuildPrefix) {
|
||||
pruneNodePtyNativeDirectories(
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
diff --git a/binding.gyp b/binding.gyp
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -16,9 +16,6 @@
|
||||
@@ -3,7 +3,6 @@
|
||||
{
|
||||
"target_name": "windows_process_tree",
|
||||
"dependencies": [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
],
|
||||
"conditions": [
|
||||
['OS=="win"', {
|
||||
@@ -15,14 +14,13 @@
|
||||
"src/process_commandline.cc"
|
||||
],
|
||||
"include_dirs": [],
|
||||
- "include_dirs": [],
|
||||
+ "include_dirs": ["deps/node-addon-api"],
|
||||
+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],
|
||||
"libraries": [ 'psapi.lib' ],
|
||||
- "msvs_configuration_attributes": {
|
||||
- "SpectreMitigation": "Spectre"
|
||||
- },
|
||||
- "msvs_configuration_attributes": {
|
||||
- "SpectreMitigation": "Spectre"
|
||||
- },
|
||||
"msvs_settings": {
|
||||
"VCCLCompilerTool": {
|
||||
+ "ExceptionHandling": 1,
|
||||
"AdditionalOptions": [
|
||||
"/guard:cf",
|
||||
"/sdl",
|
||||
diff --git a/src/process.cc b/src/process.cc
|
||||
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
|
||||
--- a/src/process.cc
|
||||
@@ -20,7 +34,7 @@ index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5
|
||||
process_info.push_back(std::move(pinfo));
|
||||
process_count++;
|
||||
}
|
||||
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
|
||||
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
|
||||
+ } while (Process32Next(snapshot_handle, &process_entry));
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,8 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
|
||||
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -5,9 +5,6 @@
|
||||
@@ -1,13 +1,18 @@
|
||||
{
|
||||
'target_defaults': {
|
||||
'dependencies': [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
+ # Orca: GCC 9 (Ubuntu 20.04 floor) accepts C++20 as gnu++2a, but rejects
|
||||
+ # the newer gnu++20 spelling emitted by Node 24's gyp flags.
|
||||
+ 'cflags_cc!': [
|
||||
+ '-std=gnu++20'
|
||||
+ ],
|
||||
+ 'cflags_cc': [
|
||||
+ '-std=gnu++2a'
|
||||
+ ],
|
||||
'conditions': [
|
||||
['OS=="win"', {
|
||||
- 'msvs_configuration_attributes': {
|
||||
@@ -12,6 +25,39 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc83
|
||||
'msvs_settings': {
|
||||
'VCCLCompilerTool': {
|
||||
'AdditionalOptions': [
|
||||
@@ -42,32 +39,6 @@
|
||||
'-lshlwapi'
|
||||
],
|
||||
},
|
||||
- {
|
||||
- 'target_name': 'conpty_console_list',
|
||||
- 'sources' : [
|
||||
- 'src/win/conpty_console_list.cc'
|
||||
- ],
|
||||
- },
|
||||
- {
|
||||
- 'target_name': 'pty',
|
||||
- 'include_dirs' : [
|
||||
- '<!(node -p "require(\'node-addon-api\').include_dir")',
|
||||
- 'deps/winpty/src/include',
|
||||
- ],
|
||||
- # Disabled due to winpty
|
||||
- 'msvs_disabled_warnings': [ 4506, 4530 ],
|
||||
- 'dependencies' : [
|
||||
- 'deps/winpty/src/winpty.gyp:winpty-agent',
|
||||
- 'deps/winpty/src/winpty.gyp:winpty',
|
||||
- ],
|
||||
- 'sources' : [
|
||||
- 'src/win/winpty.cc',
|
||||
- 'src/win/path_util.cc'
|
||||
- ],
|
||||
- 'libraries': [
|
||||
- '-lshlwapi'
|
||||
- ],
|
||||
- }
|
||||
]
|
||||
}, { # OS!="win"
|
||||
'targets': [
|
||||
@@ -88,6 +85,16 @@
|
||||
'libraries!': [
|
||||
'-lutil'
|
||||
|
||||
+3099
-207
File diff suppressed because one or more lines are too long
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build one node-pty prebuilt for the CURRENT platform/arch/libc and file it in orcad's
|
||||
* prebuilds matrix, so a deployment target needs no C/C++ toolchain.
|
||||
*
|
||||
* node-pty is the only ABI-sensitive native module orcad requires. It is also PATCHED in
|
||||
* this repo (config/patches/node-pty@1.1.0.patch), and that patch is the glibc-floor fix:
|
||||
* `.symver` pins on openpty/forkpty/pthread_sigmask plus the `--no-as-needed` ldflags that
|
||||
* keep libutil/libpthread in DT_NEEDED. An upstream prebuilt has none of it and reproduces
|
||||
* #9902. So the matrix is compiled from patched sources here, and this script refuses to
|
||||
* run if the patch is not in the tree it is about to compile.
|
||||
*
|
||||
* orcad pins its own Node runtime, so the ABI dimension is fixed and the matrix varies
|
||||
* only platform/arch/libc:
|
||||
* linux-x64-glibc, linux-arm64-glibc, linux-x64-musl, linux-arm64-musl,
|
||||
* darwin-x64, darwin-arm64
|
||||
*
|
||||
* CI runs this once per slot, each inside the container that owns that libc/arch, and
|
||||
* merges the resulting `out/orcad/prebuilds` trees. `--slot=<name>` forces the label so
|
||||
* the glibc/musl distinction is recorded from the container rather than detected.
|
||||
*
|
||||
* Usage:
|
||||
* node config/scripts/build-orcad-prebuilds.mjs [--slot=linux-x64-musl]
|
||||
* node config/scripts/build-orcad-prebuilds.mjs --require-slots # release gate
|
||||
*/
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const ROOT = join(import.meta.dirname, '..', '..')
|
||||
const PREBUILDS_DIR = join(ROOT, 'out', 'orcad', 'prebuilds')
|
||||
|
||||
/** Every slot a shipped matrix must fill. The single source of truth for the matrix. */
|
||||
export const MATRIX_SLOTS = [
|
||||
'linux-x64-glibc',
|
||||
'linux-arm64-glibc',
|
||||
'linux-x64-musl',
|
||||
'linux-arm64-musl',
|
||||
'darwin-x64',
|
||||
'darwin-arm64'
|
||||
]
|
||||
|
||||
/**
|
||||
* Why the report header and not `ldd`: `glibcVersionRuntime` is present only when the
|
||||
* process is linked against glibc, and musl images have no `ldd` worth parsing.
|
||||
*/
|
||||
export function detectLibc(platform = process.platform, header = readReportHeader()) {
|
||||
if (platform !== 'linux') {
|
||||
return 'none'
|
||||
}
|
||||
return header && typeof header === 'object' && 'glibcVersionRuntime' in header ? 'glibc' : 'musl'
|
||||
}
|
||||
|
||||
function readReportHeader() {
|
||||
try {
|
||||
return process.report?.getReport?.()?.header
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function slotName(argv = process.argv, platform = process.platform, arch = process.arch) {
|
||||
const forced = argv.find((arg) => arg.startsWith('--slot='))
|
||||
if (forced) {
|
||||
return forced.slice('--slot='.length)
|
||||
}
|
||||
const libc = detectLibc(platform)
|
||||
return libc === 'none' ? `${platform}-${arch}` : `${platform}-${arch}-${libc}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch is what holds the Ubuntu 20.04 floor. Compiling without it produces a binary
|
||||
* that loads fine on the build host and dies on the target — the exact failure the matrix
|
||||
* exists to prevent, now baked into a shipped artifact instead of a first-connect error.
|
||||
*/
|
||||
export function assertNodePtyPatchApplied(nodePtyDir) {
|
||||
const bindingGyp = readFileSync(join(nodePtyDir, 'binding.gyp'), 'utf8')
|
||||
const ptySource = readFileSync(join(nodePtyDir, 'src', 'unix', 'pty.cc'), 'utf8')
|
||||
const missing = []
|
||||
if (!bindingGyp.includes('--no-as-needed,-l:libutil.so.1')) {
|
||||
missing.push("binding.gyp is missing the '--no-as-needed,-l:libutil.so.1' ldflag")
|
||||
}
|
||||
if (!ptySource.includes('.symver openpty,openpty@')) {
|
||||
missing.push('src/unix/pty.cc is missing the .symver glibc pins')
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
'[orcad-prebuilds] refusing to build: config/patches/node-pty@1.1.0.patch is not applied.',
|
||||
...missing.map((line) => ` - ${line}`),
|
||||
'A prebuilt compiled without it will not load on Ubuntu 20.04 (see',
|
||||
'docs/reference/linux-glibc-compatibility.md and #9902). Run `pnpm install` to apply patches.'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function readManifest(prebuildsDir) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why merge rather than overwrite: CI builds one slot per container and merges the trees.
|
||||
* A manifest that records only the last slot would erase every other container's record,
|
||||
* and `--require-slots` would then reject a complete matrix.
|
||||
*/
|
||||
export function mergeManifest(existing, next) {
|
||||
const slots = new Set([...(existing?.slots ?? []), next.slot])
|
||||
return {
|
||||
module: 'node-pty',
|
||||
version: next.version,
|
||||
nodeAbi: next.nodeAbi,
|
||||
slots: [...slots].sort()
|
||||
}
|
||||
}
|
||||
|
||||
function nodePtyDir() {
|
||||
return dirname(require.resolve('node-pty/package.json'))
|
||||
}
|
||||
|
||||
function compileNodePty(dir) {
|
||||
const built = join(dir, 'build', 'Release', 'pty.node')
|
||||
if (existsSync(built)) {
|
||||
console.log(`[orcad-prebuilds] reusing existing build at ${built}`)
|
||||
return built
|
||||
}
|
||||
console.log('[orcad-prebuilds] compiling node-pty from patched source ...')
|
||||
const result = spawnSync(
|
||||
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
['node-gyp', 'rebuild'],
|
||||
{
|
||||
cwd: dir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`)
|
||||
}
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`[orcad-prebuilds] node-gyp succeeded but ${built} is missing`)
|
||||
}
|
||||
return built
|
||||
}
|
||||
|
||||
function requireSlots() {
|
||||
const manifest = readManifest(PREBUILDS_DIR)
|
||||
const have = new Set(manifest?.slots ?? [])
|
||||
const missing = MATRIX_SLOTS.filter((slot) => !have.has(slot))
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`[orcad-prebuilds] matrix incomplete — missing ${missing.join(', ')}. ` +
|
||||
'Hosts on those slots fall back to a source build and need a C/C++ toolchain.'
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(`[orcad-prebuilds] matrix complete — ${MATRIX_SLOTS.length} slots`)
|
||||
}
|
||||
|
||||
function build() {
|
||||
const dir = nodePtyDir()
|
||||
assertNodePtyPatchApplied(dir)
|
||||
const slot = slotName()
|
||||
const slotDir = join(PREBUILDS_DIR, slot)
|
||||
mkdirSync(slotDir, { recursive: true })
|
||||
|
||||
const builtBinary = compileNodePty(dir)
|
||||
copyFileSync(builtBinary, join(slotDir, 'pty.node'))
|
||||
console.log(`[orcad-prebuilds] stored ${slot}/pty.node`)
|
||||
|
||||
// Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper,
|
||||
// so a slot without it installs cleanly and then fails ENOENT the first time a user
|
||||
// opens a terminal. Windows has no spawn-helper.
|
||||
if (process.platform !== 'win32') {
|
||||
const helperSource = join(dirname(builtBinary), 'spawn-helper')
|
||||
if (!existsSync(helperSource)) {
|
||||
throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`)
|
||||
}
|
||||
copyFileSync(helperSource, join(slotDir, 'spawn-helper'))
|
||||
console.log(`[orcad-prebuilds] stored ${slot}/spawn-helper`)
|
||||
}
|
||||
|
||||
// The static floor gate, applied to the artifact we are about to ship rather than only
|
||||
// to the packaged desktop app. objdump is Linux-only, which is where the floor lives.
|
||||
if (process.platform === 'linux') {
|
||||
const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs')
|
||||
verifyLinuxGlibcFloor(slotDir)
|
||||
}
|
||||
|
||||
const manifest = mergeManifest(readManifest(PREBUILDS_DIR), {
|
||||
slot,
|
||||
version: require('node-pty/package.json').version,
|
||||
nodeAbi: process.versions.modules
|
||||
})
|
||||
writeFileSync(join(PREBUILDS_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
console.log(
|
||||
`[orcad-prebuilds] manifest: node-pty ${manifest.version}, ABI ${manifest.nodeAbi}, slots ${manifest.slots.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('build-orcad-prebuilds.mjs')) {
|
||||
if (process.argv.includes('--require-slots')) {
|
||||
requireSlots()
|
||||
} else {
|
||||
build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertNodePtyPatchApplied,
|
||||
detectLibc,
|
||||
MATRIX_SLOTS,
|
||||
mergeManifest,
|
||||
readManifest,
|
||||
slotName
|
||||
} from './build-orcad-prebuilds.mjs'
|
||||
|
||||
const PATCHED_BINDING_GYP =
|
||||
"'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
|
||||
const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);'
|
||||
|
||||
const dirs = []
|
||||
const stage = (bindingGyp, ptyCc) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-src-'))
|
||||
dirs.push(dir)
|
||||
mkdirSync(join(dir, 'src', 'unix'), { recursive: true })
|
||||
writeFileSync(join(dir, 'binding.gyp'), bindingGyp)
|
||||
writeFileSync(join(dir, 'src', 'unix', 'pty.cc'), ptyCc)
|
||||
return dir
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('assertNodePtyPatchApplied', () => {
|
||||
it('accepts a tree with both halves of the glibc-floor fix', () => {
|
||||
expect(() =>
|
||||
assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, PATCHED_PTY_CC))
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('refuses to build when the ldflags half is missing', () => {
|
||||
// The .symver pins alone let gcc's --as-needed drop libutil/libpthread from
|
||||
// DT_NEEDED, which loads on the build host and fails on Ubuntu 20.04 — #9902 again,
|
||||
// this time baked into a shipped prebuilt.
|
||||
expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", PATCHED_PTY_CC))).toThrow(
|
||||
/--no-as-needed,-l:libutil\.so\.1/
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to build when the .symver pins are missing', () => {
|
||||
expect(() => assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, '// upstream'))).toThrow(
|
||||
/\.symver glibc pins/
|
||||
)
|
||||
})
|
||||
|
||||
it('names the patch and the doc so the fix is findable', () => {
|
||||
expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", '// upstream'))).toThrow(
|
||||
/config\/patches\/node-pty@1\.1\.0\.patch/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slot naming', () => {
|
||||
it('covers every platform orcad ships to', () => {
|
||||
expect([...MATRIX_SLOTS].sort()).toEqual([
|
||||
'darwin-arm64',
|
||||
'darwin-x64',
|
||||
'linux-arm64-glibc',
|
||||
'linux-arm64-musl',
|
||||
'linux-x64-glibc',
|
||||
'linux-x64-musl'
|
||||
])
|
||||
})
|
||||
|
||||
it('lets CI force the label so the container decides glibc vs musl', () => {
|
||||
// Detection inside a container that happens to run a differently-linked Node would
|
||||
// file the build under the wrong slot. The forced label must beat detection outright,
|
||||
// so assert against one detection could never produce for this platform/arch.
|
||||
expect(slotName(['node', 'x', '--slot=linux-x64-glibc'], 'linux', 'arm64')).toBe(
|
||||
'linux-x64-glibc'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the libc dimension off Linux', () => {
|
||||
expect(slotName([], 'darwin', 'arm64')).toBe('darwin-arm64')
|
||||
})
|
||||
|
||||
it('reads glibc from the report header and musl from its absence', () => {
|
||||
expect(detectLibc('linux', { glibcVersionRuntime: '2.31' })).toBe('glibc')
|
||||
expect(detectLibc('linux', {})).toBe('musl')
|
||||
expect(detectLibc('darwin', { glibcVersionRuntime: '2.31' })).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeManifest', () => {
|
||||
it('accumulates slots across the per-container CI runs that build them', () => {
|
||||
// Overwriting would erase every other container's record, and the release gate would
|
||||
// then reject a matrix that is actually complete.
|
||||
const first = mergeManifest(null, { slot: 'linux-x64-glibc', version: '1.1.0', nodeAbi: '127' })
|
||||
const second = mergeManifest(first, {
|
||||
slot: 'linux-arm64-musl',
|
||||
version: '1.1.0',
|
||||
nodeAbi: '127'
|
||||
})
|
||||
|
||||
expect(second.slots).toEqual(['linux-arm64-musl', 'linux-x64-glibc'])
|
||||
expect(second).toMatchObject({ module: 'node-pty', version: '1.1.0', nodeAbi: '127' })
|
||||
})
|
||||
|
||||
it('does not duplicate a slot rebuilt twice', () => {
|
||||
const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' })
|
||||
expect(
|
||||
mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots
|
||||
).toEqual(['darwin-arm64'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('readManifest', () => {
|
||||
it('returns null instead of throwing when no matrix has been built', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-manifest-'))
|
||||
dirs.push(dir)
|
||||
expect(readManifest(dir)).toBeNull()
|
||||
})
|
||||
})
|
||||
+226
-13
@@ -7,14 +7,45 @@
|
||||
* the only ones that statically import `node:sqlite`, so dropping them is what keeps
|
||||
* the host Node floor at 18 instead of 22.5+.
|
||||
*/
|
||||
import { fork, spawnSync } from 'node:child_process'
|
||||
import { build } from 'esbuild'
|
||||
import { mkdirSync, rmSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { arch, platform, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import {
|
||||
ORCAD_VERSION,
|
||||
ORCAD_VERSION_FILENAME,
|
||||
orcadArtifactFilenames
|
||||
} from '../../src/shared/orcad-artifacts.ts'
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..', '..')
|
||||
const OUT_DIR = join(ROOT, 'out', 'orcad')
|
||||
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
|
||||
// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher
|
||||
// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath`
|
||||
// looks for it in the app root. A deployment has no desktop out/main to fall back to.
|
||||
const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts')
|
||||
const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
|
||||
// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process,
|
||||
// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every
|
||||
// orcad restart would SIGKILL every running terminal.
|
||||
const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts')
|
||||
const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js')
|
||||
const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
|
||||
const OUT_FILE = join(OUT_DIR, 'orcad.js')
|
||||
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
|
||||
const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME)
|
||||
|
||||
// Native addons must exist on the host; they cannot be bundled.
|
||||
// `electron` is external so a residual import fails loudly at require() time rather
|
||||
@@ -48,6 +79,36 @@ const externalNativeAddons = {
|
||||
|
||||
rmSync(OUT_DIR, { recursive: true, force: true })
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
|
||||
}
|
||||
|
||||
/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source
|
||||
* directory under `outdir`, and both children must land flat beside orcad.js — that is where
|
||||
* their runtime resolvers look for them. */
|
||||
function buildForkedChild(entryPoint, outfile) {
|
||||
return build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile,
|
||||
external: EXTERNAL,
|
||||
plugins: [externalNativeAddons],
|
||||
metafile: true,
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
define: { 'process.env.NODE_ENV': '"production"' },
|
||||
logLevel: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
const childResults = await Promise.all([
|
||||
buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
|
||||
buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE)
|
||||
])
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [ENTRY],
|
||||
@@ -55,7 +116,7 @@ const result = await build({
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(OUT_DIR, 'orcad.js'),
|
||||
outfile: OUT_FILE,
|
||||
external: EXTERNAL,
|
||||
plugins: [jsoncParserEsm, externalNativeAddons],
|
||||
metafile: true,
|
||||
@@ -65,32 +126,184 @@ const result = await build({
|
||||
logLevel: 'error'
|
||||
})
|
||||
|
||||
const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint)
|
||||
const output = Object.values(result.metafile.outputs).find(
|
||||
(o) => o.entryPoint === 'src/main/orcad/main.ts'
|
||||
)
|
||||
// Why check `original` and not just `path`: when electron is bundleable, esbuild
|
||||
// rewrites `path` to the resolved file under node_modules and the naive check passes
|
||||
// while the package is very much in the bundle.
|
||||
const electronImporters = new Set()
|
||||
for (const [file, info] of Object.entries(result.metafile.inputs)) {
|
||||
for (const imported of info.imports ?? []) {
|
||||
const specifier = imported.original ?? imported.path
|
||||
if (specifier === 'electron' || specifier.startsWith('electron/')) {
|
||||
electronImporters.add(file)
|
||||
// Why both metafiles: the forked children ship in the same deployment and run under the
|
||||
// same plain Node. A daemon-entry that reached electron would fail at fork time, on the
|
||||
// path whose whole point is that terminals survive.
|
||||
function collectImporters(metafiles, matches) {
|
||||
const importers = new Set()
|
||||
for (const metafile of metafiles) {
|
||||
for (const [file, info] of Object.entries(metafile.inputs)) {
|
||||
for (const imported of info.imports ?? []) {
|
||||
if (matches(imported.original ?? imported.path)) {
|
||||
importers.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return importers
|
||||
}
|
||||
|
||||
const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)]
|
||||
const electronImporters = collectImporters(
|
||||
metafiles,
|
||||
(specifier) => specifier === 'electron' || specifier.startsWith('electron/')
|
||||
)
|
||||
const sqliteImporters = collectImporters(metafiles, (specifier) => specifier === 'node:sqlite')
|
||||
|
||||
const graphErrors = []
|
||||
if (electronImporters.size > 0) {
|
||||
console.error(
|
||||
`[build-orcad] ${electronImporters.size} module(s) in the bundle import electron:
|
||||
${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
|
||||
graphErrors.push(
|
||||
`${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters]
|
||||
.map((file) => ` - ${file}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
if (sqliteImporters.size > 0) {
|
||||
graphErrors.push(
|
||||
`${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters]
|
||||
.map((file) => ` - ${file}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (graphErrors.length > 0) {
|
||||
console.error(`[build-orcad] ${graphErrors.join('\n')}`)
|
||||
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
|
||||
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
|
||||
// install the PTY controller. Once orcad ships, it should become a ratchet entry
|
||||
// point so the two numbers cannot drift.
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
// Why smoke-load and not just read the metafile: the import scan proves no module
|
||||
// *names* electron, but a graph can still fail to resolve under plain Node — a
|
||||
// dynamic require, a missing native, a top-level throw. The plain-node-entry-guard
|
||||
// smoke-loads its entries for exactly this reason, and orcad cannot join that guard
|
||||
// because it is an esbuild artifact rather than a rollup input.
|
||||
// Why an exit code and not a message match: these bundles are minified onto one line, so
|
||||
// Node's uncaught-exception report echoes that whole line — which contains every string
|
||||
// literal in the bundle. A crash therefore "matches" any expected message, and a textual
|
||||
// assertion passes against a bundle that never loaded.
|
||||
const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000
|
||||
})
|
||||
const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}`
|
||||
if (smoke.error || smoke.signal || smoke.status !== 0) {
|
||||
console.error(
|
||||
`[build-orcad] the bundle did not load under plain Node.\n` +
|
||||
`Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` +
|
||||
`signal=${smoke.signal ?? 'none'} ` +
|
||||
`error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
// Why require + parseArgs and not a real daemon: requiring the bundle evaluates every
|
||||
// top-level import, and calling its exported argv parser proves the entry's own code is
|
||||
// there rather than a graph that merely resolved. Booting one would need a socket, a
|
||||
// token and a PTY — `smoke:orcad-terminal` does that end to end, through orcad.
|
||||
// The verdict is carried by the exit code for the same minification reason as above.
|
||||
const daemonSmoke = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`const mod = require(${JSON.stringify(DAEMON_OUT_FILE)})\n` +
|
||||
`if (typeof mod.parseArgs !== 'function') { process.exit(3) }\n` +
|
||||
`try { mod.parseArgs([]); process.exit(4) } catch { process.exit(0) }`
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000,
|
||||
env: { ...process.env, ORCA_DAEMON_ENTRY_LOAD_CHECK: '1' }
|
||||
}
|
||||
)
|
||||
const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}`
|
||||
if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) {
|
||||
console.error(
|
||||
`[build-orcad] the daemon child did not load under plain Node.\n` +
|
||||
`Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` +
|
||||
`signal=${daemonSmoke.signal ?? 'none'} ` +
|
||||
`error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
const watcherFailure = await smokeLoadWatcherChild()
|
||||
if (watcherFailure) {
|
||||
console.error(
|
||||
`[build-orcad] the watcher child did not run under plain Node.\n${watcherFailure}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on
|
||||
// this string, so two different builds carrying one version would share a directory — and an
|
||||
// already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale
|
||||
// bytes while reporting the new version.
|
||||
if (process.exitCode !== 1) {
|
||||
const hash = createHash('sha256')
|
||||
for (const filename of orcadArtifactFilenames()) {
|
||||
const artifactPath = join(OUT_DIR, filename)
|
||||
if (!existsSync(artifactPath)) {
|
||||
throw new Error(
|
||||
`orcad declares ${filename} in ORCAD_ARTIFACTS but never emitted it. Add the build ` +
|
||||
'step, or drop it from src/shared/orcad-artifacts.ts.'
|
||||
)
|
||||
}
|
||||
hash.update(readFileSync(artifactPath))
|
||||
}
|
||||
const fullVersion = `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}`
|
||||
writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion)
|
||||
console.log(
|
||||
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.`
|
||||
`[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork the shipped watcher child and drive one message through it.
|
||||
*
|
||||
* Why a real fork and not existsSync: the file being present says nothing about whether
|
||||
* its graph resolves under plain Node, and this child is only ever reached through
|
||||
* `fork()` at runtime — a broken one degrades silently to in-process watching.
|
||||
* `subscribe-started` is acked before the native module is touched, so this passes on a
|
||||
* build machine with no compiled @parcel/watcher.
|
||||
*/
|
||||
async function smokeLoadWatcherChild() {
|
||||
const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-'))
|
||||
const child = fork(WATCHER_OUT_FILE, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += String(chunk)
|
||||
})
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
resolve(`No 'subscribe-started' ack within 30s.\n${stderr.slice(0, 2000)}`)
|
||||
}, 30_000)
|
||||
const settle = (failure) => {
|
||||
clearTimeout(timer)
|
||||
resolve(failure)
|
||||
}
|
||||
child.on('message', (message) => {
|
||||
if (message?.op === 'subscribe-started') {
|
||||
child.disconnect()
|
||||
}
|
||||
})
|
||||
child.on('error', (error) => settle(`fork failed: ${error.message}`))
|
||||
// Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code
|
||||
// or a signal here is a load failure rather than a clean teardown.
|
||||
child.on('exit', (code, signal) =>
|
||||
settle(code === 0 ? null : `exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`)
|
||||
)
|
||||
child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} })
|
||||
})
|
||||
} finally {
|
||||
rmSync(probeDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import { join } from 'node:path'
|
||||
import {
|
||||
RELAY_BUILD_PLATFORMS,
|
||||
RELAY_VERSION_FILENAME,
|
||||
RELAY_WINDOWS_PROCESS_TREE_FILENAME,
|
||||
relayOptionalArtifactFilenames,
|
||||
isWindowsRelayPlatform,
|
||||
relayArtifactFilenames
|
||||
} from '../../src/shared/relay-artifacts.ts'
|
||||
@@ -55,6 +57,38 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
|
||||
'relay-assets',
|
||||
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
|
||||
)
|
||||
// Written by build-windows-process-tree-relay-addon.mjs, which only runs on a
|
||||
// Windows machine.
|
||||
const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree')
|
||||
|
||||
// Which Windows arches must have the addon, as a comma-separated list ('all' for
|
||||
// every arch). Per-arch rather than a flag because arm64 needs the MSVC ARM64
|
||||
// cross toolset, an optional VS component: where it is absent that relay should
|
||||
// fall back to the scan, not fail the release the x64 relay is riding on.
|
||||
const REQUIRED_ADDON_ARCHES = (process.env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS ?? '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
function stageWindowsProcessTreeAddon(platform, outDir) {
|
||||
if (!isWindowsRelayPlatform(platform)) {
|
||||
return
|
||||
}
|
||||
const arch = platform.slice('win32-'.length)
|
||||
const source = join(WINDOWS_PROCESS_TREE_BUILD_DIR, arch, RELAY_WINDOWS_PROCESS_TREE_FILENAME)
|
||||
if (!existsSync(source)) {
|
||||
if (REQUIRED_ADDON_ARCHES.includes(arch) || REQUIRED_ADDON_ARCHES.includes('all')) {
|
||||
throw new Error(
|
||||
`Relay ${platform} needs ${source}. Run: node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${arch} (Windows only).`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
`Relay ${platform}: no ${RELAY_WINDOWS_PROCESS_TREE_FILENAME}; relay will use the PowerShell scan.`
|
||||
)
|
||||
return
|
||||
}
|
||||
copyFileSync(source, join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME))
|
||||
}
|
||||
|
||||
// Why: lets the packaging contract test build into a temp tree instead of
|
||||
// clobbering a developer's out/relay or racing tests that read it.
|
||||
@@ -92,6 +126,7 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
|
||||
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
|
||||
)
|
||||
}
|
||||
stageWindowsProcessTreeAddon(platform, outDir)
|
||||
|
||||
await build({
|
||||
entryPoints: [WATCHER_ENTRY],
|
||||
@@ -172,12 +207,25 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
|
||||
}
|
||||
hash.update(readFileSync(artifactPath))
|
||||
}
|
||||
// Why hashed only when present: a relay carrying the native addon answers
|
||||
// differently from one that falls back to the scan, so the two must not share
|
||||
// an immutable directory -- but a build without it is still valid.
|
||||
for (const filename of relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))) {
|
||||
const artifactPath = join(outDir, filename)
|
||||
if (existsSync(artifactPath)) {
|
||||
hash.update(readFileSync(artifactPath))
|
||||
}
|
||||
}
|
||||
const contentHash = hash.digest('hex').slice(0, 12)
|
||||
|
||||
// Close the loop: an artifact emitted here but absent from the manifest would
|
||||
// ship unhashed and unprobed — exactly how the WSL helper went missing.
|
||||
const emitted = readdirSync(outDir).filter((name) => name !== RELAY_VERSION_FILENAME)
|
||||
const undeclared = emitted.filter((name) => !expected.includes(name))
|
||||
const declared = [
|
||||
...expected,
|
||||
...relayOptionalArtifactFilenames(isWindowsRelayPlatform(platform))
|
||||
]
|
||||
const undeclared = emitted.filter((name) => !declared.includes(name))
|
||||
if (undeclared.length > 0) {
|
||||
throw new Error(
|
||||
`Relay ${platform} emitted undeclared artifacts: ${undeclared.join(', ')}. ` +
|
||||
@@ -194,11 +242,12 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
|
||||
// so a single platform-independent bundle suffices; it ships inside the
|
||||
// Windows app via the same out/relay extraResources mapping.
|
||||
{
|
||||
const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
|
||||
const wslHookEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
|
||||
const wslBrowserNetworkEntry = join(ROOT, 'src', 'relay', 'wsl-browser-network-relay.ts')
|
||||
const outDir = join(OUT_ROOT, 'wsl')
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
await build({
|
||||
entryPoints: [wslEntry],
|
||||
entryPoints: [wslHookEntry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
@@ -214,6 +263,27 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
|
||||
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
|
||||
console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`)
|
||||
|
||||
await build({
|
||||
entryPoints: [wslBrowserNetworkEntry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'wsl-browser-network-relay.js'),
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
const browserNetworkContent = readFileSync(join(outDir, 'wsl-browser-network-relay.js'))
|
||||
const browserNetworkHash = createHash('sha256')
|
||||
.update(browserNetworkContent)
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
writeFileSync(join(outDir, '.browser-network-version'), `${RELAY_VERSION}+${browserNetworkHash}`)
|
||||
console.log(`Built WSL browser network relay → ${outDir}/wsl-browser-network-relay.js`)
|
||||
}
|
||||
|
||||
console.log('Relay build complete.')
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
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 })
|
||||
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)
|
||||
export function shouldReuseCompiledWindowsCliLauncher(
|
||||
outputPath,
|
||||
sourcePath,
|
||||
{ reuseCached = false } = {}
|
||||
) {
|
||||
if (!existsSync(outputPath)) {
|
||||
return false
|
||||
}
|
||||
// Why reuseCached: Actions cache keys already hash the C# source, but restore
|
||||
// does not preserve mtimes, so a hit would look stale and recompile anyway.
|
||||
if (reuseCached) {
|
||||
return true
|
||||
}
|
||||
return statSync(outputPath).mtimeMs >= 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Compile `@vscode/windows-process-tree` for a relay host.
|
||||
*
|
||||
* The relay is deployed to machines with no compiler, and this addon cannot be
|
||||
* npm-installed there: it carries a binding.gyp, so npm rebuilds from source and
|
||||
* the build wants Spectre-mitigated libraries even where MSVC is present. The
|
||||
* binary inside the published tarball loads, but predates our patch and still
|
||||
* caps enumeration at 1024 processes -- a busy host then gets a truncated table
|
||||
* missing its own pid, which reads as "unavailable" only under load.
|
||||
*
|
||||
* So we compile it here, from the patched source pnpm already materialized, and
|
||||
* ship the result as a relay artifact. Windows arm64 cross-compiles from an x64
|
||||
* runner, so both arches come off one Windows job.
|
||||
*
|
||||
* Node headers, not Electron: the relay runs under the host's own `node`. The
|
||||
* addon is N-API, so one build serves every Node the remote might have.
|
||||
*
|
||||
* node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
closeSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
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, '..', '..')
|
||||
const PACKAGE_DIR = join(ROOT, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
const SUPPORTED_ARCHES = ['x64', 'arm64']
|
||||
|
||||
/** PE `IMAGE_FILE_HEADER.Machine` values, so a cross-build cannot silently emit host arch. */
|
||||
const PE_MACHINE = { x64: 0x8664, arm64: 0xaa64 }
|
||||
|
||||
function parseArgs(argv) {
|
||||
const arch = argv.find((a) => a.startsWith('--arch='))?.slice('--arch='.length) ?? process.arch
|
||||
const outDir = argv.find((a) => a.startsWith('--out='))?.slice('--out='.length)
|
||||
if (!SUPPORTED_ARCHES.includes(arch)) {
|
||||
throw new Error(`--arch must be one of ${SUPPORTED_ARCHES.join(', ')}; got ${arch}`)
|
||||
}
|
||||
return {
|
||||
arch,
|
||||
outDir: outDir ? resolve(outDir) : join(ROOT, '.build', 'windows-process-tree', arch)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to build unpatched source.
|
||||
*
|
||||
* Each hunk fails differently: Spectre dies outright, the 1024-process cap
|
||||
* succeeds and lies, and `.targets` is cwd-relative so pnpm's nested layout
|
||||
* makes node-gyp miss node_addon_api.gyp on Windows. Checking the source
|
||||
* rather than trusting the install is what stops a silently unpatched tree
|
||||
* from being shipped as if it were patched.
|
||||
*/
|
||||
function assertPatchApplied() {
|
||||
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
|
||||
if (bindingGyp.includes('SpectreMitigation')) {
|
||||
throw new Error(
|
||||
'binding.gyp still requests SpectreMitigation. pnpm did not apply ' +
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (bindingGyp.includes('node_addon_api.gyp')) {
|
||||
throw new Error(
|
||||
'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(
|
||||
'src/process.cc still caps enumeration at 1024 processes. pnpm did not apply ' +
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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`<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except`,
|
||||
String.raw`<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except`,
|
||||
'../../node-addon-api/node_addon_api.gyp:node_addon_api_except'
|
||||
]) {
|
||||
bindingGyp = bindingGyp.replace(`"${dynamicDependency}",`, '')
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"include_dirs": []',
|
||||
'"include_dirs": ["deps/node-addon-api"],\n "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"]'
|
||||
)
|
||||
if (!bindingGyp.includes('"ExceptionHandling": 1')) {
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"VCCLCompilerTool": {',
|
||||
'"VCCLCompilerTool": {\n "ExceptionHandling": 1,'
|
||||
)
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
/\r?\n\s*"msvs_configuration_attributes": \{\s*"SpectreMitigation": "Spectre"\s*\},?/s,
|
||||
''
|
||||
)
|
||||
processCc = processCc.replace(/process_count < 1024 && /, '')
|
||||
|
||||
if (bindingGyp !== originalBinding) {
|
||||
writeFileSync(bindingPath, bindingGyp)
|
||||
}
|
||||
if (processCc !== originalProcess) {
|
||||
writeFileSync(processPath, processCc)
|
||||
}
|
||||
mkdirSync(stagedHeaderDir, { recursive: true })
|
||||
for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h']) {
|
||||
copyFileSync(join(nodeAddonApiDir, header), join(stagedHeaderDir, header))
|
||||
}
|
||||
if (bindingGyp !== originalBinding || processCc !== originalProcess) {
|
||||
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the PE machine field, so an arm64 request cannot ship an x64 binary. */
|
||||
function readPeMachine(binaryPath) {
|
||||
const fd = openSync(binaryPath, 'r')
|
||||
try {
|
||||
const header = Buffer.alloc(4)
|
||||
readSync(fd, header, 0, 4, 0x3c)
|
||||
const peOffset = header.readUInt32LE(0)
|
||||
const machine = Buffer.alloc(2)
|
||||
readSync(fd, machine, 0, 2, peOffset + 4)
|
||||
return machine.readUInt16LE(0)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { arch, outDir } = parseArgs(process.argv.slice(2))
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error(
|
||||
`This addon only builds on Windows; running on ${process.platform}. ` +
|
||||
'Relay builds elsewhere simply omit it and fall back to the CIM scan.'
|
||||
)
|
||||
}
|
||||
if (!existsSync(PACKAGE_DIR)) {
|
||||
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
|
||||
}
|
||||
applyWindowsProcessTreeBuildFixes()
|
||||
assertPatchApplied()
|
||||
|
||||
console.log(`[windows-process-tree] building ${arch} from ${PACKAGE_DIR}`)
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), 'rebuild', `--arch=${arch}`],
|
||||
{ cwd: PACKAGE_DIR, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
const built = join(PACKAGE_DIR, 'build', 'Release', 'windows_process_tree.node')
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`node-gyp reported success but ${built} is missing.`)
|
||||
}
|
||||
const machine = readPeMachine(built)
|
||||
if (machine !== PE_MACHINE[arch]) {
|
||||
throw new Error(
|
||||
`Built binary is machine 0x${machine.toString(16)}, expected 0x${PE_MACHINE[arch].toString(16)} for ${arch}. ` +
|
||||
'node-gyp ignored --arch; a relay would get a binary its host cannot load.'
|
||||
)
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
const staged = join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME)
|
||||
copyFileSync(built, staged)
|
||||
console.log(`[windows-process-tree] ${arch} -> ${staged}`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(`[windows-process-tree] ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -139,7 +139,99 @@ function diagnosticLineRange(root, filename, span) {
|
||||
return { start: startLine, end: startLine + (highlighted.match(/\n/g)?.length ?? 0) }
|
||||
}
|
||||
|
||||
export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = process.cwd()) {
|
||||
// Why: a file-splitting refactor makes every line of the new module an "added"
|
||||
// line, so pre-existing lint debt in code that merely MOVED starts failing the
|
||||
// changed-lines gate. The only way to satisfy it is to edit the moved code,
|
||||
// which is exactly what a behavior-preserving refactor must not do. So a
|
||||
// diagnostic is exempt when its highlighted lines already existed, verbatim and
|
||||
// contiguous, somewhere in the base revision of the files this change touches.
|
||||
function normalizeSourceLine(line) {
|
||||
return line.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function collectBaseLineBlocks(root, comparisonBase, files = null) {
|
||||
// Why: in a split, the moved code's base text lives in the ORIGINAL file, which is
|
||||
// often deleted or renamed away. Deleted paths never reach the changed-file list
|
||||
// (it filters to ACMRTUB), so read every path the diff touches, deletions included.
|
||||
const paths =
|
||||
files ??
|
||||
splitNullDelimited(runGit(root, ['diff', '--name-only', '-z', comparisonBase, '--'])).filter(
|
||||
(file) => SOURCE_FILE_PATTERN.test(file)
|
||||
)
|
||||
const blocks = []
|
||||
for (const file of paths) {
|
||||
const result = spawnSync('git', ['show', `${comparisonBase}:${file}`], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
if (result.status !== 0 || typeof result.stdout !== 'string') {
|
||||
continue
|
||||
}
|
||||
blocks.push(
|
||||
result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map(normalizeSourceLine)
|
||||
.filter((line) => line !== '')
|
||||
)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
export function isMovedCode(highlightedLines, baseBlocks) {
|
||||
const needle = highlightedLines.map(normalizeSourceLine).filter((line) => line !== '')
|
||||
if (needle.length === 0) {
|
||||
return false
|
||||
}
|
||||
// Why a near-match rather than an exact contiguous one: a split moves a block
|
||||
// verbatim but a diagnostic's span often reaches past it — most commonly to a
|
||||
// hook dependency array, which legitimately grows when closure variables become
|
||||
// props. Requiring every line to match would report the moved body as new. So:
|
||||
// the block must still start at the same line in the base and appear IN ORDER,
|
||||
// and nearly all of it must be present. Genuinely new code shares neither the
|
||||
// anchor nor the ordering, so it stays reported.
|
||||
const MIN_COVERAGE = 0.9
|
||||
return baseBlocks.some((rawHaystack) => {
|
||||
const haystack = rawHaystack.map(normalizeSourceLine).filter((line) => line !== '')
|
||||
for (let start = 0; start < haystack.length; start += 1) {
|
||||
if (haystack[start] !== needle[0]) {
|
||||
continue
|
||||
}
|
||||
let matched = 1
|
||||
let cursor = start + 1
|
||||
for (let index = 1; index < needle.length && cursor < haystack.length; index += 1) {
|
||||
while (cursor < haystack.length && haystack[cursor] !== needle[index]) {
|
||||
cursor += 1
|
||||
}
|
||||
if (cursor < haystack.length) {
|
||||
matched += 1
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
if (matched / needle.length >= MIN_COVERAGE) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function diagnosticHighlightedLines(root, filename, span) {
|
||||
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
|
||||
const source = readFileSync(absolutePath, 'utf8').split(/\r?\n/)
|
||||
const range = diagnosticLineRange(root, filename, span)
|
||||
if (range === null) {
|
||||
return []
|
||||
}
|
||||
return source.slice(range.start - 1, range.end)
|
||||
}
|
||||
|
||||
export function diagnosticTouchesAddedLines(
|
||||
diagnostic,
|
||||
rangesByFile,
|
||||
root = process.cwd(),
|
||||
baseBlocks = []
|
||||
) {
|
||||
const file = normalizedDiagnosticPath(root, diagnostic.filename)
|
||||
const ranges = rangesByFile.get(file)
|
||||
if (!ranges) {
|
||||
@@ -147,7 +239,13 @@ export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = pro
|
||||
}
|
||||
return (diagnostic.labels ?? []).some((label) => {
|
||||
const lineRange = diagnosticLineRange(root, diagnostic.filename, label.span)
|
||||
return lineRange !== null && overlapsAddedLines(lineRange.start, lineRange.end, ranges)
|
||||
if (lineRange === null || !overlapsAddedLines(lineRange.start, lineRange.end, ranges)) {
|
||||
return false
|
||||
}
|
||||
return !isMovedCode(
|
||||
diagnosticHighlightedLines(root, diagnostic.filename, label.span),
|
||||
baseBlocks
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -193,10 +291,12 @@ export function main(
|
||||
return 0
|
||||
}
|
||||
|
||||
const baseBlocks = collectBaseLineBlocks(root, comparisonBase)
|
||||
|
||||
let failures = 0
|
||||
for (const scan of OXLINT_SCANS) {
|
||||
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root)
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks)
|
||||
)
|
||||
for (const diagnostic of diagnostics) {
|
||||
printDiagnostic(diagnostic, root)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
OXLINT_SCANS,
|
||||
diagnosticTouchesAddedLines,
|
||||
isMovedCode,
|
||||
overlapsAddedLines,
|
||||
parseAddedLineRanges
|
||||
} from './check-changed-code-quality.mjs'
|
||||
@@ -52,3 +53,54 @@ describe('changed-code quality line matching', () => {
|
||||
expect(scan.args).not.toContain('--disable-nested-config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('moved-code exemption', () => {
|
||||
it('treats a verbatim contiguous block from the base as moved', () => {
|
||||
const base = [['const a = 1', 'items.map((item, index) => (', 'key={index}', '))']]
|
||||
expect(isMovedCode(['items.map((item, index) => (', 'key={index}', '))'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores indentation and whitespace changes from the move', () => {
|
||||
const base = [[' items.map((item, index) => (', ' key={index}']]
|
||||
expect(isMovedCode(['items.map((item, index) => (', 'key={index}'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not exempt a genuinely new violation', () => {
|
||||
const base = [['const a = 1', 'const b = 2']]
|
||||
expect(isMovedCode(['rows.map((row, i) => <td key={i} />)'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not exempt a block that is only partly present in the base', () => {
|
||||
const base = [['doThing()', 'unrelated()']]
|
||||
expect(isMovedCode(['doThing()', 'newlyAddedSideEffect()'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a few lines appended inside the moved block', () => {
|
||||
// A split commonly grows a hook dependency array when closure variables
|
||||
// become props; the moved body around it is still moved.
|
||||
const body = Array.from({ length: 20 }, (_, i) => `line${i}()`)
|
||||
const base = [body]
|
||||
const moved = [...body.slice(0, 19), 'newDep,', body[19]]
|
||||
expect(isMovedCode(moved, base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not exempt when the anchor line is absent from the base', () => {
|
||||
const base = [['doThing()', 'filler()', 'other()']]
|
||||
expect(isMovedCode(['brandNewCall()', 'doThing()', 'other()'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not exempt when most of the block is absent from the base', () => {
|
||||
const base = [['keep0()', 'keep1()', 'unrelated()']]
|
||||
const mostlyNew = ['keep0()', ...Array.from({ length: 18 }, (_, i) => `fresh${i}()`)]
|
||||
expect(isMovedCode(mostlyNew, base)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores blank lines when matching', () => {
|
||||
const base = [['a()', 'b()']]
|
||||
expect(isMovedCode(['a()', '', 'b()'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('never exempts an empty highlight', () => {
|
||||
expect(isMovedCode(['', ' '], [['a()']])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,7 +34,11 @@ const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt')
|
||||
// itself and the RPC server that fronts it.
|
||||
const ENTRY_POINTS = [
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'),
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts')
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts'),
|
||||
// Why orcad too: it imports ipc/pty directly to install the PTY controller, so its
|
||||
// graph is strictly larger than the two runtime entries. Measuring only those let the
|
||||
// two numbers drift — the gate would read zero while the shipped artifact regressed.
|
||||
path.join(ROOT, 'src', 'main', 'orcad', 'main.ts')
|
||||
]
|
||||
|
||||
// Native addons and electron cannot be bundled; externalising them is what the
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('client-hosted browser package coverage', () => {
|
||||
it('bundles the WSL browser-network relay with its version stamp', () => {
|
||||
const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
|
||||
|
||||
expect(relayBuild).toContain("outfile: join(outDir, 'wsl-browser-network-relay.js')")
|
||||
expect(relayBuild).toContain("join(outDir, '.browser-network-version')")
|
||||
})
|
||||
|
||||
it('runs client-hosted Electron lifecycle coverage on native package hosts', () => {
|
||||
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
|
||||
const parsedWorkflow = parse(prWorkflow)
|
||||
const linuxStep = parsedWorkflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Test Linux Electron lifecycle boundary'
|
||||
)
|
||||
const windowsStep = parsedWorkflow.jobs.package_windows.steps.find(
|
||||
(step) => step.name === 'Test Windows-specific boundaries'
|
||||
)
|
||||
|
||||
const required = [
|
||||
'browser-client-page-renderer-lifecycle',
|
||||
'browser-route-webrtc-egress',
|
||||
'browser-route-tcp-egress',
|
||||
'browser-route-h3-egress',
|
||||
'browser-route-dns-prefetch'
|
||||
].map((name) => `src/main/browser/${name}.electron.test.ts`)
|
||||
|
||||
expect(linuxStep.run).toContain('xvfb-run --auto-servernum')
|
||||
for (const file of required) {
|
||||
expect(linuxStep.run).toContain(file)
|
||||
expect(windowsStep.run).toContain(file)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -76,7 +76,6 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
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')
|
||||
|
||||
@@ -238,7 +238,11 @@ async function main() {
|
||||
throw new Error('daemon readiness did not publish the expected PID ownership record')
|
||||
}
|
||||
log('PID ownership record matches the ready daemon')
|
||||
if (!existsSync(socketPath)) {
|
||||
const endpointPublished =
|
||||
process.platform === 'win32'
|
||||
? (await probeEndpoint(socketPath)) === 'connected'
|
||||
: existsSync(socketPath)
|
||||
if (!endpointPublished) {
|
||||
throw new Error('daemon did not publish its endpoint at the canonical socket path')
|
||||
}
|
||||
log('endpoint published at the canonical socket path')
|
||||
|
||||
@@ -102,6 +102,10 @@ describe('Electron Vite output contract', () => {
|
||||
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
|
||||
})
|
||||
|
||||
it('bundles validation dependencies used by the sandboxed preload', () => {
|
||||
expect(electronViteConfig.preload?.build?.externalizeDeps?.exclude).toContain('zod')
|
||||
})
|
||||
|
||||
it('exits when a static import fails before source error guards load', () => {
|
||||
const processMock = new EventEmitter() as EventEmitter & {
|
||||
exit: (code: number) => void
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
const NODE_PTY_PATCH = readFileSync(
|
||||
new URL('../patches/node-pty@1.1.0.patch', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const PATCHED = {
|
||||
dir: 'build/Release/',
|
||||
module: {
|
||||
listJobProcessIds: () => [],
|
||||
terminateJob: () => true,
|
||||
assignCurrentProcessToJob: () => true
|
||||
}
|
||||
}
|
||||
const PREBUILD = {
|
||||
dir: 'prebuilds/win32-x64/',
|
||||
module: {
|
||||
startProcess: () => {},
|
||||
connect: () => {},
|
||||
resize: () => {},
|
||||
clear: () => {},
|
||||
kill: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('assertNodePtyJobOwnership', () => {
|
||||
it('keeps node-addon-api project paths absolute during Windows source builds', () => {
|
||||
expect(NODE_PTY_PATCH).toContain(
|
||||
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the unchanged Windows helper and fallback on their upstream prebuilds', () => {
|
||||
expect(NODE_PTY_PATCH).toContain("- 'target_name': 'conpty_console_list'")
|
||||
expect(NODE_PTY_PATCH).toContain("- 'target_name': 'pty'")
|
||||
})
|
||||
|
||||
it('rejects the prebuild that shipped without the job exports', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
|
||||
).toThrow(/listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
|
||||
})
|
||||
|
||||
it('names where the bad native came from, so the fix is obvious', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
|
||||
).toThrow(/prebuilds\/win32-x64/)
|
||||
})
|
||||
|
||||
it('accepts a source build carrying the patch', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PATCHED })
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['non-Windows hosts', { platform: 'darwin', nativeName: 'pty' }],
|
||||
['the pre-ConPTY winpty backend', { platform: 'win32', nativeName: 'pty' }]
|
||||
])('stays out of the way on %s', (_case, spec) => {
|
||||
expect(() => assertNodePtyJobOwnership({ ...spec, native: PREBUILD })).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -4,9 +4,10 @@ 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')
|
||||
const scriptPath = import.meta.filename
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const runtime = readRuntimeArg()
|
||||
@@ -66,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
|
||||
}
|
||||
@@ -76,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()
|
||||
}
|
||||
|
||||
@@ -277,6 +283,7 @@ function loadNodePtyNativeModule() {
|
||||
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
|
||||
const native = loadNativeModule(nativeName)
|
||||
assertNodePtyWindowsConptyRuntime(native?.dir)
|
||||
assertNodePtyJobOwnership({ nativeName, native })
|
||||
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) {
|
||||
throw new Error(
|
||||
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
|
||||
@@ -310,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) {
|
||||
@@ -327,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
|
||||
@@ -349,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))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
|
||||
const sourceNodePtyJobOwnershipPath = fileURLToPath(
|
||||
new URL('./node-pty-job-ownership.cjs', import.meta.url)
|
||||
)
|
||||
|
||||
describe('ensure-native-runtime', () => {
|
||||
it('rechecks Node native modules in fresh child processes after rebuilding', () => {
|
||||
@@ -26,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'], {
|
||||
@@ -39,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$/)
|
||||
@@ -49,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',
|
||||
() => {
|
||||
@@ -77,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 })
|
||||
}
|
||||
@@ -111,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 })
|
||||
}
|
||||
@@ -145,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 })
|
||||
}
|
||||
@@ -156,6 +199,10 @@ describe('ensure-native-runtime', () => {
|
||||
function mkTempProject() {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-'))
|
||||
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
|
||||
copyFileSync(
|
||||
sourceNodePtyJobOwnershipPath,
|
||||
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
|
||||
)
|
||||
return projectDir
|
||||
}
|
||||
|
||||
@@ -171,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(
|
||||
@@ -190,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(
|
||||
@@ -211,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) {
|
||||
@@ -238,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'), '')
|
||||
@@ -253,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')
|
||||
`
|
||||
)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8'))
|
||||
const installScript = action.runs.steps.find((step) => step.name === 'Install dependencies').run
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return spawnSync(command, args, { encoding: 'utf8', ...options })
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-install-node-action-'))
|
||||
const workspace = join(root, 'checkout')
|
||||
const detachedCwd = join(root, 'action cwd')
|
||||
const bin = join(root, 'bin')
|
||||
mkdirSync(workspace)
|
||||
mkdirSync(detachedCwd)
|
||||
mkdirSync(bin)
|
||||
|
||||
for (const directory of [workspace, detachedCwd]) {
|
||||
writeFileSync(join(directory, 'package.json'), '{"name":"fixture"}\n')
|
||||
writeFileSync(join(directory, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
|
||||
}
|
||||
|
||||
expect(run('git', ['init', '-q'], { cwd: workspace }).status).toBe(0)
|
||||
expect(run('git', ['add', 'package.json', 'pnpm-lock.yaml'], { cwd: workspace }).status).toBe(0)
|
||||
|
||||
const pnpm = join(bin, 'pnpm')
|
||||
writeFileSync(pnpm, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(pnpm, 0o755)
|
||||
return { bin, detachedCwd, root, workspace }
|
||||
}
|
||||
|
||||
function executeInstallScript(fixture) {
|
||||
return run('bash', ['-c', installScript], {
|
||||
cwd: fixture.detachedCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_WORKSPACE: fixture.workspace,
|
||||
PATH: `${fixture.bin}${delimiter}${process.env.PATH}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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']
|
||||
])('rejects a changed %s when the composite step cwd is detached', (file, contents) => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
const clean = executeInstallScript(fixture)
|
||||
expect(clean.status, clean.stderr || clean.stdout).toBe(0)
|
||||
|
||||
writeFileSync(join(fixture.workspace, file), contents)
|
||||
const dirty = executeInstallScript(fixture)
|
||||
const output = `${dirty.stdout}\n${dirty.stderr}`
|
||||
expect(dirty.status).toBe(1)
|
||||
expect(output).toContain(`diff --git a/${file} b/${file}`)
|
||||
expect(output).not.toContain('diff --git a/package.json b/pnpm-lock.yaml')
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -37,7 +37,13 @@ const RELOCALIZED_GENERIC_TERMS = [
|
||||
|
||||
export const JA_PHRASE_FIXES = [
|
||||
...RELOCALIZED_GENERIC_TERMS,
|
||||
{ pattern: /エージェント/g, replacement: 'Agent', whenEnIncludes: 'agent' },
|
||||
{
|
||||
pattern: /エージェント/g,
|
||||
replacement: 'Agent',
|
||||
whenEnIncludes: 'agent',
|
||||
// Skills filters and metadata are Japanese UI labels, not agent product prose.
|
||||
skipKeyPrefixes: ['auto.components.skills.']
|
||||
},
|
||||
{ pattern: /解雇/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
|
||||
{ pattern: /却下/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
|
||||
{ pattern: /代理人/g, replacement: 'Agent', whenEnIncludes: 'agent' },
|
||||
|
||||
@@ -79,6 +79,16 @@ describe('locale-translation-policy ja relocalization', () => {
|
||||
expect(ja('Open the browser', 'ブラウザーを開く')).toBe('ブラウザを開く')
|
||||
})
|
||||
|
||||
it('preserves translated Japanese Skills labels', () => {
|
||||
expect(
|
||||
ja(
|
||||
'Filter by agent',
|
||||
'エージェントで絞り込み',
|
||||
'auto.components.skills.SkillsPage.filterProvider'
|
||||
)
|
||||
).toBe('エージェントで絞り込み')
|
||||
})
|
||||
|
||||
it('leaves ranges and token samples out of the ellipsis rule', () => {
|
||||
expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較')
|
||||
})
|
||||
|
||||
@@ -351,12 +351,15 @@ function phraseFixMatchesEnglish(enValue, fix) {
|
||||
return enValue.toLowerCase().includes(fix.whenEnIncludes.toLowerCase())
|
||||
}
|
||||
|
||||
function applyPhraseFixes(enValue, localeValue, locale) {
|
||||
function applyPhraseFixes(enValue, localeValue, locale, key = '') {
|
||||
let result = localeValue
|
||||
for (const fix of LOCALE_PHRASE_FIXES[locale] ?? []) {
|
||||
if (!phraseFixMatchesEnglish(enValue, fix)) {
|
||||
continue
|
||||
}
|
||||
if (fix.skipKeyPrefixes?.some((prefix) => key.startsWith(prefix))) {
|
||||
continue
|
||||
}
|
||||
result = result.replace(fix.pattern, fix.replacement)
|
||||
}
|
||||
return result
|
||||
@@ -367,7 +370,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
|
||||
if (keyOverride) {
|
||||
// Why: exact key overrides can still carry stale MT output, so glossary repairs remain the final gate.
|
||||
let result = applyBrandMistranslationFixes(enValue, keyOverride, locale, key)
|
||||
result = applyPhraseFixes(enValue, result, locale)
|
||||
result = applyPhraseFixes(enValue, result, locale, key)
|
||||
if (['zh', 'ja', 'ko'].includes(locale)) {
|
||||
result = applyCjkLatinTermSpacing(result, locale)
|
||||
}
|
||||
@@ -377,7 +380,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
|
||||
const valueOverride = LOCALE_VALUE_OVERRIDES[locale]?.[enValue]
|
||||
if (valueOverride) {
|
||||
let result = applyBrandMistranslationFixes(enValue, valueOverride, locale, key)
|
||||
result = applyPhraseFixes(enValue, result, locale)
|
||||
result = applyPhraseFixes(enValue, result, locale, key)
|
||||
if (['zh', 'ja', 'ko'].includes(locale)) {
|
||||
result = applyCjkLatinTermSpacing(result, locale)
|
||||
}
|
||||
@@ -398,7 +401,7 @@ export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
|
||||
}
|
||||
|
||||
result = applyBrandMistranslationFixes(enValue, result, locale, key)
|
||||
result = applyPhraseFixes(enValue, result, locale)
|
||||
result = applyPhraseFixes(enValue, result, locale, key)
|
||||
if (['zh', 'ja', 'ko'].includes(locale)) {
|
||||
result = applyCjkLatinTermSpacing(result, locale)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'use strict'
|
||||
|
||||
const NODE_PTY_JOB_EXPORTS = ['listJobProcessIds', 'terminateJob', 'assignCurrentProcessToJob']
|
||||
|
||||
function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
|
||||
if (platform !== 'win32' || nativeName !== 'conpty') {
|
||||
return
|
||||
}
|
||||
const exported = native?.module ?? native
|
||||
const missing = NODE_PTY_JOB_EXPORTS.filter((name) => typeof exported?.[name] !== 'function')
|
||||
if (missing.length === 0) {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`node-pty's conpty native is missing ${missing.join(', ')}.`,
|
||||
`Resolved from: ${native?.dir ?? 'unknown'}`,
|
||||
'That build cannot own a PTY tree, so terminatePtyJob degrades to "unavailable"',
|
||||
'and pane teardown falls back to guessing by PID ancestry.',
|
||||
'Rebuild node-pty from source so config/patches/node-pty@1.1.0.patch applies.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { assertNodePtyJobOwnership }
|
||||
@@ -17,11 +17,11 @@ describe('Electron runtime package contract', () => {
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
|
||||
expect(patch).toContain('readonly clearModelGeneration: number')
|
||||
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
|
||||
expect(patch).toContain('this.clearModelGeneration++')
|
||||
expect(patch).toContain('this._atlas._clearModelGeneration||0')
|
||||
expect(patch.match(/\^\(\?:\[1-8\]\\d\{2\}\|900\)\$/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps root postinstall as the single Electron binary install owner', () => {
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { prunePackagedNodePty } = require('../packaged-runtime-node-modules.cjs')
|
||||
|
||||
/**
|
||||
* node-pty's loader tries build/Release, then build/Debug, then
|
||||
* prebuilds/<platform>-<arch>, swallowing failures in between. Only the source
|
||||
* build carries Orca's job-object exports, so leaving the prebuilt conpty.node
|
||||
* beside it means an ABI mismatch or an AV quarantine silently downgrades the
|
||||
* app to a binary that cannot own a PTY tree.
|
||||
*
|
||||
* The siblings must survive: Orca's patch deletes the `conpty_console_list` and
|
||||
* winpty `pty` gyp targets, so those binaries exist nowhere but here.
|
||||
*/
|
||||
describe('prunePackagedNodePty: the Windows conpty fallback', () => {
|
||||
let resources
|
||||
const HOST_ARCH = process.arch
|
||||
|
||||
const nodePty = () => join(resources, 'node_modules', 'node-pty')
|
||||
const write = (relative) => {
|
||||
const target = join(nodePty(), relative)
|
||||
mkdirSync(join(target, '..'), { recursive: true })
|
||||
writeFileSync(target, 'x')
|
||||
}
|
||||
const prebuilt = (name, arch = HOST_ARCH) => join(nodePty(), 'prebuilds', `win32-${arch}`, name)
|
||||
|
||||
/** What a real packaged win32 prebuilds/<arch> directory holds. */
|
||||
const SIBLINGS = ['conpty_console_list.node', 'pty.node', 'winpty.dll', 'winpty-agent.exe']
|
||||
|
||||
const seedWindowsTree = (arch = HOST_ARCH) => {
|
||||
write(join('build', 'Release', 'conpty.node'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'conpty.dll'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'OpenConsole.exe'))
|
||||
write(join('prebuilds', `win32-${arch}`, 'conpty.node'))
|
||||
for (const sibling of SIBLINGS) {
|
||||
write(join('prebuilds', `win32-${arch}`, sibling))
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resources = mkdtempSync(join(tmpdir(), 'orca-prune-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(resources, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('removes the stale conpty fallback when the source build is present', () => {
|
||||
seedWindowsTree()
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
|
||||
|
||||
expect(existsSync(join(nodePty(), 'build', 'Release', 'conpty.node'))).toBe(true)
|
||||
expect(existsSync(prebuilt('conpty.node'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(SIBLINGS)('keeps %s, which exists nowhere else in a packaged build', (sibling) => {
|
||||
// Orca's patch removes the conpty_console_list and winpty gyp targets, so a
|
||||
// Windows source build never produces these. Deleting them kills console
|
||||
// membership silently and breaks PTY spawn below Windows build 18309.
|
||||
seedWindowsTree()
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
|
||||
|
||||
expect(existsSync(prebuilt(sibling))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the fallback when there is no source build to prefer', () => {
|
||||
write(join('prebuilds', `win32-${HOST_ARCH}`, 'conpty.node'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'conpty.dll'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'OpenConsole.exe'))
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
|
||||
|
||||
expect(existsSync(prebuilt('conpty.node'))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the fallback on a cross-arch package, where build/Release is the host arch', () => {
|
||||
// electron-builder --win --arm64 on an x64 host copies an x64
|
||||
// build/Release; deleting the arm64 prebuild would remove the only binary
|
||||
// the shipped app could load.
|
||||
const target = HOST_ARCH === 'arm64' ? 'x64' : 'arm64'
|
||||
seedWindowsTree(target)
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', target)
|
||||
|
||||
expect(existsSync(prebuilt('conpty.node', target))).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['darwin', 'arm64'],
|
||||
['linux', 'x64']
|
||||
])('leaves %s prebuilds alone', (platform, arch) => {
|
||||
write(join('build', 'Release', 'conpty.node'))
|
||||
write(join('prebuilds', `${platform}-${arch}`, 'pty.node'))
|
||||
|
||||
prunePackagedNodePty(resources, platform, arch)
|
||||
|
||||
expect(existsSync(join(nodePty(), 'prebuilds', `${platform}-${arch}`, 'pty.node'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
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 } from 'yaml'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
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 = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
const reliabilityManifest = parseJsonc(
|
||||
readFileSync(join(projectDir, 'config/reliability-gates.jsonc'), 'utf8')
|
||||
)
|
||||
const playwrightConfig = readFileSync(join(projectDir, 'tests/playwright.config.ts'), 'utf8')
|
||||
const sshDockerRunner = readFileSync(
|
||||
join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'),
|
||||
'utf8'
|
||||
@@ -21,6 +32,47 @@ const verifyStep = prWorkflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
|
||||
/** The route that sends a change to the two-Electron restart-survival spec. */
|
||||
const restartSurvivalRoute = PR_E2E_SOURCE_ROUTES.find(
|
||||
(route) => route.id === 'client-hosted-browser.restart-survival'
|
||||
)
|
||||
|
||||
describe('restart-survival E2E routing', () => {
|
||||
// Every file below carries behavior the restart spec is the only test that exercises end to end.
|
||||
it.each([
|
||||
'src/main/runtime/orca-runtime.ts',
|
||||
'src/main/runtime/orca-runtime-browser.ts',
|
||||
'src/main/runtime/client-hosted-page-reconciliation-window.ts',
|
||||
'src/main/runtime/runtime-browser-client-page-adoption.ts',
|
||||
'src/main/runtime/runtime-browser-client-page-recovery.ts',
|
||||
'src/main/runtime/browser-host-client-page-adoption.ts',
|
||||
'src/main/runtime/browser-host-page-reconciliation-orchestration.ts',
|
||||
'src/main/runtime/rpc/methods/browser-client-host.ts',
|
||||
'src/main/browser/browser-client-host-authority-replacement-wait.ts',
|
||||
'src/main/browser/paired-runtime-browser-client-host-composition.ts',
|
||||
'src/renderer/src/runtime/web-session-tabs-sync.ts',
|
||||
'src/renderer/src/runtime/host-session-snapshot-authority.ts',
|
||||
'src/renderer/src/runtime/restored-client-hosted-browser-host-attach.ts',
|
||||
'src/renderer/src/store/slices/runtime-status.ts',
|
||||
'src/shared/runtime-types.ts',
|
||||
'src/shared/browser-client-host-protocol.ts'
|
||||
])('routes %s', (path) => {
|
||||
expect(restartSurvivalRoute.matches(path)).toBe(true)
|
||||
})
|
||||
|
||||
// The pattern is deliberately not "anything under src": routing every PR at a two-Electron spec
|
||||
// is the cost the filter exists to avoid.
|
||||
it.each([
|
||||
'src/main/git/git-status.ts',
|
||||
'src/renderer/src/components/tab-bar/BrowserTab.tsx',
|
||||
'src/main/terminal/pty-manager.ts',
|
||||
// The status/types entries name whole files, not a suffix any longer name may end with.
|
||||
'src/shared/computer-use-runtime-types.ts'
|
||||
])('does not route %s', (path) => {
|
||||
expect(restartSurvivalRoute.matches(path)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PR E2E gate contract', () => {
|
||||
it('keeps E2E advisory while the suite is red on main', () => {
|
||||
// Why: pin the deliberate choice so it reads as intentional rather than as
|
||||
@@ -53,24 +105,33 @@ 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} }}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('selects modified Playwright specs without running deleted tests', () => {
|
||||
expect(filterStep.run).toContain('--diff-filter=AMCR')
|
||||
expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'")
|
||||
expect(filterStep.run).toContain('config/scripts/pr-e2e-source-routing.mjs')
|
||||
expect(filterStep.run).not.toContain('tests/playwright\\.')
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'tests/e2e/active-view-restart-restore.spec.ts',
|
||||
'tests/e2e/deleted.spec.ts.bak',
|
||||
'tests/e2e/global-teardown.unit.test.ts'
|
||||
])
|
||||
).toEqual(['tests/e2e/active-view-restart-restore.spec.ts'])
|
||||
})
|
||||
|
||||
it('uses one runner for changed specs and keeps full runs sharded', () => {
|
||||
@@ -89,6 +150,7 @@ describe('PR E2E gate contract', () => {
|
||||
expect(changedRun.run).toContain(
|
||||
'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"'
|
||||
)
|
||||
expect(playwrightConfig).toContain('retries: 0')
|
||||
})
|
||||
|
||||
it('keeps startup-exec live parity in the isolated SSH lane', () => {
|
||||
@@ -102,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) =>
|
||||
@@ -121,7 +195,7 @@ describe('PR E2E gate contract', () => {
|
||||
]
|
||||
|
||||
for (const file of dedicatedWorkflows) {
|
||||
const workflow = parse(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
|
||||
const workflow = parseYaml(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
|
||||
expect(workflow.on.pull_request, file).toBeUndefined()
|
||||
}
|
||||
})
|
||||
@@ -138,23 +212,41 @@ describe('PR E2E gate contract', () => {
|
||||
const sshSourceAuthorities = [
|
||||
'src/main/ssh/',
|
||||
'src/main/providers/ssh-',
|
||||
'src/main/ipc/(ssh-|pty)',
|
||||
'src/main/ipc/pty',
|
||||
'src/relay/',
|
||||
'src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty)'
|
||||
'src/shared/ssh-',
|
||||
'src/renderer/src/store/slices/direct-ssh-',
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-'
|
||||
]
|
||||
for (const authority of sshSourceAuthorities) {
|
||||
expect(filterStep.run).toContain(authority)
|
||||
expect(selectPrE2eSpecs([`${authority}routing.ts`])).toContain(
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
|
||||
)
|
||||
}
|
||||
|
||||
// Why named files rather than prefixes: these seams are single modules, and a prefix
|
||||
// here would route their unrelated neighbours.
|
||||
for (const file of [
|
||||
'src/main/runtime/public-ssh-state.ts',
|
||||
'src/renderer/src/startup/ssh-startup-reconnect.ts',
|
||||
'src/renderer/src/store/slices/ssh.ts'
|
||||
]) {
|
||||
expect(selectPrE2eSpecs([file]), file).toContain(
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
|
||||
)
|
||||
}
|
||||
|
||||
const mappedSpecs = [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
]
|
||||
for (const spec of mappedSpecs) {
|
||||
expect(filterStep.run).toContain(`'${spec}'`)
|
||||
expect(selectPrE2eSpecs(['src/main/ssh/connection.ts'])).toContain(spec)
|
||||
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
|
||||
// Why: a spec that stops reading the flag would silently run without Docker.
|
||||
if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') {
|
||||
@@ -162,8 +254,7 @@ describe('PR E2E gate contract', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: unit tests next to the mapped sources prove the same code without Docker.
|
||||
expect(filterStep.run).toContain("grep -Ev '\\.test\\.tsx?$'")
|
||||
expect(selectPrE2eSpecs(['src/main/ssh/connection.test.ts'])).toEqual([])
|
||||
|
||||
// Why: startup readiness is filtered out of changed-e2e, so listing it is only
|
||||
// meaningful while it still routes the dedicated Docker lane.
|
||||
@@ -179,10 +270,237 @@ 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')
|
||||
expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts')
|
||||
expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types')
|
||||
expect(selectPrE2eSpecs(['src/shared/ephemeral-vm-recipes.ts'])).toEqual([
|
||||
'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('routes P0 sentinels from their causal sources', () => {
|
||||
const cases = [
|
||||
[
|
||||
'src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx',
|
||||
'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'
|
||||
],
|
||||
['src/main/runtime/orca-runtime-files.ts', 'tests/e2e/paired-quick-open-large-tree.spec.ts'],
|
||||
[
|
||||
'src/renderer/src/runtime/sync-runtime-graph.ts',
|
||||
'tests/e2e/host-parked-pane-remote-viewer.spec.ts'
|
||||
],
|
||||
[
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts',
|
||||
'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'
|
||||
],
|
||||
[
|
||||
'src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts',
|
||||
'tests/e2e/paired-remote-pane-layout-retry.spec.ts'
|
||||
]
|
||||
]
|
||||
for (const [source, spec] of cases) {
|
||||
expect(selectPrE2eSpecs([source]), source).toEqual([spec])
|
||||
expect(selectPrE2eSpecs([source.replace(/\.tsx?$/, '.test.ts')]), source).toEqual([])
|
||||
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
|
||||
}
|
||||
const quickCommandSpec = 'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'
|
||||
for (const source of [
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts'
|
||||
]) {
|
||||
expect(selectPrE2eSpecs([source]), source).toContain(quickCommandSpec)
|
||||
expect(selectPrE2eSpecs([source.replace(/\.ts$/, '.test.ts')]), source).not.toContain(
|
||||
quickCommandSpec
|
||||
)
|
||||
}
|
||||
for (const source of [
|
||||
'src/main/ipc/rg-availability.ts',
|
||||
'src/shared/ripgrep-process-availability.ts'
|
||||
]) {
|
||||
expect(selectPrE2eSpecs([source]), source).toEqual([
|
||||
'tests/e2e/paired-quick-open-large-tree.spec.ts'
|
||||
])
|
||||
}
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'src/main/runtime/orca-runtime-files.ts',
|
||||
'tests/e2e/paired-quick-open-large-tree.spec.ts'
|
||||
])
|
||||
).toEqual(['tests/e2e/paired-quick-open-large-tree.spec.ts'])
|
||||
expect(selectPrE2eSpecs(['src/renderer/src/components/FileExplorer.tsx'])).toEqual([])
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts'
|
||||
])
|
||||
).toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts')
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts'
|
||||
])
|
||||
).not.toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts')
|
||||
expect(selectPrE2eSpecs(['src/main/ipc/pty.ts'])).not.toContain(
|
||||
'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps source-routed sentinels registered to their reliability gates', () => {
|
||||
const routedGateIds = [
|
||||
'terminal-startup.quick-command-pre-bind-recovery',
|
||||
'quick-open.paired-host-path-search',
|
||||
'terminal-session.host-cold-park-stream-continuity',
|
||||
'terminal-provider.ssh-remote-reattach-contract',
|
||||
'terminal-session.remote-pane-layout-retry'
|
||||
]
|
||||
for (const gateId of routedGateIds) {
|
||||
const route = PR_E2E_SOURCE_ROUTES.find((candidate) => candidate.id === gateId)
|
||||
const gate = reliabilityManifest.gates.find((candidate) => candidate.id === gateId)
|
||||
expect(route, gateId).toBeDefined()
|
||||
expect(gate, gateId).toMatchObject({ maturity: 'experimental', protection: 'partial' })
|
||||
for (const spec of route.specs) {
|
||||
expect(gate.testFiles, gateId).toContain(spec)
|
||||
expect(
|
||||
gate.commands.some((command) => command.includes(spec)),
|
||||
gateId
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const isProductSource = (file) => !/\.test\.tsx?$/.test(file)
|
||||
|
||||
export const PR_E2E_SOURCE_ROUTES = [
|
||||
{
|
||||
id: 'ephemeral-vm-runtime.rollback-readable-sidecar',
|
||||
specs: ['tests/e2e/ephemeral-vm-provisioned-root.spec.ts'],
|
||||
matches: (file) =>
|
||||
/^(?:src\/main\/ephemeral-vm-(?:runtime-(?:service|provisioning-persistence)|failed-start-cleanup)|src\/shared\/(?:ephemeral-vm-runtime-(?:store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'ssh-terminal-source',
|
||||
specs: [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-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\/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
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-input.ime-and-synthetic-forwarding',
|
||||
specs: [
|
||||
'tests/e2e/terminal-cjk-ime-committed-text.spec.ts',
|
||||
'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts',
|
||||
'tests/e2e/terminal-ime-exact-byte.spec.ts',
|
||||
'tests/e2e/terminal-korean-composing-chord-order.spec.ts',
|
||||
'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts',
|
||||
'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts',
|
||||
'tests/e2e/terminal-korean-preedit-visibility.spec.ts'
|
||||
],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:config\/patches\/|src\/renderer\/src\/components\/terminal-pane\/(?:terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-startup.quick-command-pre-bind-recovery',
|
||||
specs: ['tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/renderer\/src\/components\/tab-bar\/TabBarQuickCommandsMenu\.tsx|src\/renderer\/src\/hooks\/use-terminal-quick-command-hosts\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:pty-connection|pty-transport|terminal-pty-pre-spawn-e2e-barrier)\.ts|src\/renderer\/src\/components\/terminal-pane\/pty-connection\/(?:connect-pane-pty|fresh-spawn-start|pane-pty-visibility-bind|pty-input-recovery)\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:TerminalPane|use-terminal-pane-lifecycle)\.tsx?|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'quick-open.paired-host-path-search',
|
||||
specs: ['tests/e2e/paired-quick-open-large-tree.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/main\/ipc\/(?:filesystem-(?:list-files|search-file-paths)|rg-availability)\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/relay\/(?:fs-handler(?:-install-rg|-list-files|-ripgrep-fallback)?|fs-list-files-fallback-chain)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability)\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-session.host-cold-park-stream-continuity',
|
||||
specs: ['tests/e2e/host-parked-pane-remote-viewer.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/renderer\/src\/components\/terminal-pane\/(?:terminal-hidden-view-parking|terminal-tab-park-candidates|terminal-tab-activation-order|terminal-parked-pty-watcher|terminal-parked-tab-watchers|terminal-parked-watcher-registry)\.ts|src\/renderer\/src\/runtime\/sync-runtime-graph\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-provider.ssh-remote-reattach-contract',
|
||||
specs: ['tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
!file.endsWith('-test-harness.ts') &&
|
||||
/^(?:src\/renderer\/src\/components\/terminal-pane\/remote-runtime-pty-transport(?:-[a-z0-9-]+)?\.ts|src\/renderer\/src\/runtime\/remote-runtime-terminal-multiplexer\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-session.remote-pane-layout-retry',
|
||||
specs: ['tests/e2e/paired-remote-pane-layout-retry.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/renderer\/src\/components\/terminal-pane\/(?:remote-pane-layout-push|TerminalPane)\.tsx?|src\/renderer\/src\/lib\/terminal-layout-equality\.ts|src\/renderer\/src\/runtime\/web-session-tabs-sync\.ts|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
// Why: the host's row for a client-rendered page only exists across two real Electron
|
||||
// apps, so this spec is the only gate on it. The high-churn seams it also rides
|
||||
// (ipc/runtime, useIpcEvents, preload) are left out deliberately: routing on those runs a
|
||||
// two-app e2e on most PRs, and their client-hosted share is already covered by the
|
||||
// main-process integration test.
|
||||
id: 'client-hosted-browser.host-strip',
|
||||
specs: ['tests/e2e/paired-client-hosted-browser-host-strip.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^src\/.*(?:[Cc]lient-?[Hh]osted-?[Bb]rowser|BrowserPaneOverlayLayer)/.test(file)
|
||||
},
|
||||
{
|
||||
// Why a second, wider pattern: restart survival breaks from seams that never say
|
||||
// "client-hosted" - page adoption, the host lease/reconciliation plan, the session-tab
|
||||
// snapshot the client culls rows against. orca-runtime.ts is included despite its churn: it
|
||||
// publishes the snapshot flag the client holds its rows on, and no narrower path names that
|
||||
// seam.
|
||||
id: 'client-hosted-browser.restart-survival',
|
||||
specs: ['tests/e2e/paired-client-hosted-browser-restart-survival.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime(?:-browser)?\.ts|\/runtime-(?:status|types)\.ts)/.test(
|
||||
file
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
|
||||
const specs = new Set(changedPaths.filter((file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file)))
|
||||
for (const route of PR_E2E_SOURCE_ROUTES) {
|
||||
const matchedFiles = changedPaths.filter(route.matches)
|
||||
if (matchedFiles.length === 0) {
|
||||
continue
|
||||
}
|
||||
route.specs.forEach((spec) => specs.add(spec))
|
||||
reportRoute(`[pr-e2e] ${route.id}: ${route.specs.join(', ')}`)
|
||||
}
|
||||
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')
|
||||
for await (const chunk of process.stdin) {
|
||||
input += chunk
|
||||
}
|
||||
const changedPaths = input.split(/\r?\n/).filter(Boolean)
|
||||
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`)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
@@ -256,7 +275,9 @@ describe('PR workflow parallelism', () => {
|
||||
// resolution can never legitimately change anything.
|
||||
expect(dependencyInstall.run).toContain('--frozen-lockfile')
|
||||
expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile')
|
||||
expect(dependencyInstall.run).toContain('git diff --exit-code package.json pnpm-lock.yaml')
|
||||
expect(dependencyInstall.run).toContain(
|
||||
'git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml'
|
||||
)
|
||||
expect(dependencyInstall.run).toContain('--ignore-scripts')
|
||||
expect(dependencyInstall.run).not.toContain('--os=')
|
||||
expect(dependencyInstall.run).not.toContain('--cpu=')
|
||||
@@ -293,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', () => {
|
||||
@@ -343,6 +392,8 @@ describe('PR workflow parallelism', () => {
|
||||
'xterm_patch_sync',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'cross-version-wire',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
@@ -352,5 +403,11 @@ describe('PR workflow parallelism', () => {
|
||||
)
|
||||
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
|
||||
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
|
||||
// Why assert this one too: the browser provider test skips itself without
|
||||
// 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"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -475,9 +481,14 @@ function loadNativeModule(moduleName) {
|
||||
}
|
||||
if (moduleName === 'node-pty') {
|
||||
projectRequire('node-pty')
|
||||
const { assertNodePtyJobOwnership } = projectRequire(
|
||||
'./config/scripts/node-pty-job-ownership.cjs'
|
||||
)
|
||||
const { loadNativeModule } = projectRequire('node-pty/lib/utils')
|
||||
const native = loadNativeModule(getNodePtyNativeModuleName())
|
||||
const nativeName = getNodePtyNativeModuleName()
|
||||
const native = loadNativeModule(nativeName)
|
||||
assertNodePtyWindowsConptyRuntime(native.dir)
|
||||
assertNodePtyJobOwnership({ nativeName, native })
|
||||
if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) {
|
||||
throw new Error(
|
||||
'node-pty resolved to ' +
|
||||
|
||||
@@ -18,6 +18,9 @@ const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', impo
|
||||
const sourceInstallScriptPath = fileURLToPath(
|
||||
new URL('./install-electron-package-binary.mjs', import.meta.url)
|
||||
)
|
||||
const sourceNodePtyJobOwnershipPath = fileURLToPath(
|
||||
new URL('./node-pty-job-ownership.cjs', import.meta.url)
|
||||
)
|
||||
|
||||
describe('rebuild-native-deps Electron install fallback', () => {
|
||||
it('continues non-strict postinstall when Electron retry download fails', () => {
|
||||
@@ -135,6 +138,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
|
||||
writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' })
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
writeFakeWindowsProcessTree(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, process.arch)
|
||||
|
||||
const result = runRebuildScript(projectDir, {
|
||||
@@ -189,6 +193,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
|
||||
writeFakeLoadableNodePty(projectDir)
|
||||
writeFakeWindowsProcessTree(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, process.arch)
|
||||
|
||||
const result = runRebuildScript(projectDir, {
|
||||
@@ -207,6 +212,36 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'rebuilds a loadable ConPTY native that lacks Orca job ownership',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
|
||||
writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false })
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
writeFakeWindowsProcessTree(projectDir)
|
||||
|
||||
const result = runRebuildScript(projectDir, {
|
||||
ORCA_REBUILD_TEST_LOG: rebuildLogPath,
|
||||
npm_config_platform: 'win32',
|
||||
npm_config_arch: process.arch
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('Rebuilding failed native modules: node-pty')
|
||||
expect(result.stdout).toContain('missing listJobProcessIds')
|
||||
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
|
||||
expect(rebuildCall.onlyModules).toEqual(['node-pty'])
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rebuilds when Electron can load node-pty but patched build artifacts are missing',
|
||||
() => {
|
||||
@@ -305,6 +340,10 @@ function mkTempProject() {
|
||||
sourceInstallScriptPath,
|
||||
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
|
||||
)
|
||||
copyFileSync(
|
||||
sourceNodePtyJobOwnershipPath,
|
||||
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
|
||||
)
|
||||
return projectDir
|
||||
}
|
||||
|
||||
@@ -495,7 +534,10 @@ function writeFakeNodePtyConptyPayload(projectDir, arch) {
|
||||
writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`)
|
||||
}
|
||||
|
||||
function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) {
|
||||
function writeFakeLoadableNodePty(
|
||||
projectDir,
|
||||
{ nativeDir = 'prebuilds/pty', ownsPtyJob = true } = {}
|
||||
) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
@@ -503,7 +545,19 @@ function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } =
|
||||
join(nodePtyDir, 'lib', 'utils.js'),
|
||||
`
|
||||
exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
return { dir: ${JSON.stringify(nativeDir)}, module: { nativeName } }
|
||||
return {
|
||||
dir: ${JSON.stringify(nativeDir)},
|
||||
module: {
|
||||
nativeName,
|
||||
...(${JSON.stringify(ownsPtyJob)}
|
||||
? {
|
||||
listJobProcessIds() {},
|
||||
terminateJob() {},
|
||||
assignCurrentProcessToJob() {}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -518,6 +572,12 @@ function writeFakeWindowsRegistry(projectDir) {
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeWindowsProcessTree(projectDir) {
|
||||
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) {
|
||||
mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true })
|
||||
writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n')
|
||||
@@ -526,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'), '')
|
||||
|
||||
@@ -434,6 +434,20 @@ describe('check-mode reporting', () => {
|
||||
// These run without network or a build, so ordinary `pnpm test` catches the two
|
||||
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
|
||||
describe('committed xterm patch artifacts', () => {
|
||||
it('normalizes Chromium font-weight serialization in every WebGL runtime copy', async () => {
|
||||
const patch = await readFile(
|
||||
path.join(REPO_ROOT, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
|
||||
'utf8'
|
||||
)
|
||||
expect(patch).not.toMatch(/desiredWeight !== '400'|orcaProbeWeight!=="400"/)
|
||||
expect(
|
||||
patch.match(/(?:actualWeightToken === 'normal'|orcaActualWeightToken==="normal")/g)
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
patch.match(/(?:actualWeight !== desiredWeight|orcaActualWeight!==orcaProbeWeight)/g)
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('records the lockfile hash pnpm derives from the patch file', async () => {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
|
||||
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
|
||||
|
||||
@@ -10,6 +10,17 @@ const releaseWorkflow = parse(
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
|
||||
describe('release E2E dispatch contract', () => {
|
||||
it('validates immutable tags with the current golden test harness', () => {
|
||||
const restoreStep = releaseWorkflow.jobs['terminal-rendering-golden'].steps.find(
|
||||
(step) => step.name === 'Restore golden test harness from the workflow ref'
|
||||
)
|
||||
|
||||
expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restoreStep.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restoreStep.run).toContain('golden-source-control-open-diff.spec.ts')
|
||||
expect(restoreStep.run).toContain('golden-terminal-file-link.spec.ts')
|
||||
})
|
||||
|
||||
it('dispatches tag-scoped E2E only after publication', () => {
|
||||
const dispatchJob = releaseWorkflow.jobs['post-release-e2e']
|
||||
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch tag-scoped E2E')
|
||||
|
||||
@@ -190,8 +190,10 @@ function summarizeMemory(snapshot: MemorySnapshot): {
|
||||
app: MemorySnapshot['app']
|
||||
host: MemorySnapshot['host']
|
||||
processMemoryMetric: MemorySnapshot['processMemoryMetric']
|
||||
processCommitMetric: MemorySnapshot['processCommitMetric']
|
||||
totalCpu: number
|
||||
totalMemory: number
|
||||
totalPrivateMemory: MemorySnapshot['totalPrivateMemory']
|
||||
worktreeCount: number
|
||||
sessionCount: number
|
||||
worktreeMemory: number
|
||||
@@ -208,8 +210,10 @@ function summarizeMemory(snapshot: MemorySnapshot): {
|
||||
app: snapshot.app,
|
||||
host: snapshot.host,
|
||||
processMemoryMetric: snapshot.processMemoryMetric,
|
||||
processCommitMetric: snapshot.processCommitMetric,
|
||||
totalCpu: snapshot.totalCpu,
|
||||
totalMemory: snapshot.totalMemory,
|
||||
totalPrivateMemory: snapshot.totalPrivateMemory,
|
||||
worktreeCount: snapshot.worktrees.length,
|
||||
sessionCount: snapshot.worktrees.reduce(
|
||||
(total, worktree) => total + worktree.sessions.length,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1',
|
||||
ORCA_E2E_LOCAL_SSH_BROWSER: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/local-ssh-browser-routing.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -0,0 +1,42 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1',
|
||||
ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -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)
|
||||
@@ -18,11 +18,14 @@
|
||||
* - a paired client can list worktrees and create a terminal,
|
||||
* - a command run in that terminal produces its output,
|
||||
* - the server exits when asked.
|
||||
* Optional `--browser` assertions:
|
||||
* - create, navigate, evaluate, and screenshot through the selected host provider.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import process from 'node:process'
|
||||
|
||||
@@ -274,6 +277,50 @@ async function main() {
|
||||
throw new Error('worktree.create succeeded but worktree.show cannot resolve it')
|
||||
}
|
||||
log(`server resolves ${shown.id}`)
|
||||
if (process.argv.includes('--browser')) {
|
||||
const status = orca(pairingCode, ['status'])
|
||||
if (!status?.runtime?.capabilities?.includes('browser.headless.v1')) {
|
||||
throw new Error(
|
||||
`status omitted browser.headless.v1: ${JSON.stringify(status?.runtime?.capabilities)}`
|
||||
)
|
||||
}
|
||||
const fixturePath = join(userDataDir, 'browser-smoke.html')
|
||||
writeFileSync(
|
||||
fixturePath,
|
||||
'<!doctype html><title>Orcad Browser Smoke</title><main>browser-ready</main>'
|
||||
)
|
||||
const browserPageId = orca(pairingCode, [
|
||||
'tab',
|
||||
'create',
|
||||
'--worktree',
|
||||
created.id,
|
||||
'--url',
|
||||
'about:blank'
|
||||
])?.browserPageId
|
||||
if (!browserPageId) {
|
||||
throw new Error('browser.tabCreate returned no browser page id')
|
||||
}
|
||||
const targetFlags = ['--worktree', created.id, '--page', browserPageId]
|
||||
const targetUrl = pathToFileURL(fixturePath).href
|
||||
const navigation = orca(pairingCode, ['goto', ...targetFlags, '--url', targetUrl])
|
||||
if (navigation?.url !== targetUrl || navigation?.title !== 'Orcad Browser Smoke') {
|
||||
throw new Error(`browser.goto returned the wrong page: ${JSON.stringify(navigation)}`)
|
||||
}
|
||||
const evaluated = orca(pairingCode, [
|
||||
'eval',
|
||||
...targetFlags,
|
||||
'--expression',
|
||||
'document.querySelector("main")?.textContent'
|
||||
])
|
||||
if (evaluated?.result !== 'browser-ready') {
|
||||
throw new Error(`browser.eval returned ${JSON.stringify(evaluated)}`)
|
||||
}
|
||||
const screenshot = orca(pairingCode, ['screenshot', ...targetFlags])
|
||||
if (screenshot?.format !== 'png' || typeof screenshot.data !== 'string' || !screenshot.data) {
|
||||
throw new Error('browser.screenshot returned no PNG data')
|
||||
}
|
||||
log('browser navigate/evaluate/screenshot round trip OK')
|
||||
}
|
||||
|
||||
const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal
|
||||
if (!terminal?.handle) {
|
||||
|
||||
@@ -44,6 +44,48 @@ describe('skill-sharing release workflow', () => {
|
||||
expect(command).toContain('src/shared/skill-bundle-install-contract.test.ts')
|
||||
})
|
||||
|
||||
it('installs the archive tool required by Electron on the Linux floor', () => {
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const prerequisites = stepNamed(linux, 'Install Ubuntu 20.04 prerequisites')
|
||||
|
||||
expect(prerequisites.run).toMatch(/apt-get install[^\n]*\bunzip\b/)
|
||||
})
|
||||
|
||||
it('trusts only the checked-out workspace before container git operations', () => {
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const trustWorkspace = stepNamed(linux, 'Trust the checked-out workspace in the job container')
|
||||
const restoreHarness = stepNamed(
|
||||
linux,
|
||||
'Restore skill-sharing test harness from the workflow ref'
|
||||
)
|
||||
const safeDirectoryCommands = linux.steps
|
||||
.filter((step) => typeof step.run === 'string' && step.run.includes('safe.directory'))
|
||||
.map((step) => step.run)
|
||||
|
||||
expect(trustWorkspace.run).toBe('git config --global --add safe.directory "$GITHUB_WORKSPACE"')
|
||||
expect(linux.steps.indexOf(trustWorkspace)).toBeLessThan(linux.steps.indexOf(restoreHarness))
|
||||
expect(safeDirectoryCommands).toEqual([
|
||||
'git config --global --add safe.directory "$GITHUB_WORKSPACE"'
|
||||
])
|
||||
})
|
||||
|
||||
it('validates immutable tags with the current skill-sharing test harness', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
'skill-sharing-linux-floor-release-gate'
|
||||
]) {
|
||||
const restore = stepNamed(
|
||||
workflow.jobs[jobName],
|
||||
'Restore skill-sharing test harness from the workflow ref'
|
||||
)
|
||||
|
||||
expect(restore.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restore.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restore.run).toContain('skill-freshness-inventory.test.ts')
|
||||
expect(restore.run).toContain('skill-provider-runtime-roots.test.ts')
|
||||
}
|
||||
})
|
||||
|
||||
it('archives bounded machine-readable evidence from every platform', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const { createRequire } = require('node:module')
|
||||
const { join } = require('node:path')
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
|
||||
function loadPackagedConpty(resourcesDir) {
|
||||
const packagedRequire = createRequire(join(resourcesDir, 'package.json'))
|
||||
const { loadNativeModule } = packagedRequire('./node_modules/node-pty/lib/utils')
|
||||
return loadNativeModule('conpty')
|
||||
}
|
||||
|
||||
function verifyPackagedNodePtyJobOwnership(resourcesDir, options = {}) {
|
||||
const platform = options.platform ?? process.platform
|
||||
if (platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const native = (options.loadNative ?? loadPackagedConpty)(resourcesDir)
|
||||
assertNodePtyJobOwnership({ platform, nativeName: 'conpty', native })
|
||||
if (!native.dir.replace(/\\/g, '/').includes('build/Release/')) {
|
||||
throw new Error(`Packaged node-pty resolved to ${native.dir}; expected patched build/Release`)
|
||||
}
|
||||
console.log('[verify-packaged-node-pty] OK — packaged ConPTY owns process trees')
|
||||
}
|
||||
|
||||
module.exports = { verifyPackagedNodePtyJobOwnership }
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
verifyPackagedNodePtyJobOwnership
|
||||
} = require('./verify-packaged-node-pty-job-ownership.cjs')
|
||||
|
||||
const PATCHED = {
|
||||
dir: '../build/Release/',
|
||||
module: {
|
||||
listJobProcessIds: () => [],
|
||||
terminateJob: () => true,
|
||||
assignCurrentProcessToJob: () => true
|
||||
}
|
||||
}
|
||||
|
||||
describe('verifyPackagedNodePtyJobOwnership', () => {
|
||||
it('accepts the packaged patched ConPTY binding', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => PATCHED
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a packaged upstream prebuild', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => ({ dir: '../prebuilds/win32-x64/', module: {} })
|
||||
})
|
||||
).toThrow(/missing listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
|
||||
})
|
||||
|
||||
it('requires the patched source-build directory', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => ({ ...PATCHED, dir: '../prebuilds/win32-x64/' })
|
||||
})
|
||||
).toThrow(/expected patched build\/Release/)
|
||||
})
|
||||
|
||||
it('does not load Windows natives for other targets', () => {
|
||||
const loadNative = vi.fn()
|
||||
verifyPackagedNodePtyJobOwnership('resources', { platform: 'linux', loadNative })
|
||||
expect(loadNative).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const PATCH = readFileSync(
|
||||
join(projectDir, 'config/patches/@vscode__windows-process-tree@0.8.0.patch'),
|
||||
'utf8'
|
||||
)
|
||||
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
|
||||
|
||||
describe('windows-process-tree node-addon-api gyp path', () => {
|
||||
it('stages headers without a pnpm-sensitive gyp dependency', () => {
|
||||
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'
|
||||
)
|
||||
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', RESOLVED_GYP], {
|
||||
cwd: PACKAGE_DIR,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
expect(isAbsolute(resolved)).toBe(true)
|
||||
expect(existsSync(resolved)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
|
||||
describe('packaged Windows PTY native capability routing', () => {
|
||||
it('runs the packaged executable immediately after the unpacked app is built', () => {
|
||||
const job = workflow.jobs.package_windows
|
||||
const packageIndex = job.steps.findIndex((step) => step.name === 'Package unpacked app')
|
||||
const smokeIndex = job.steps.findIndex(
|
||||
(step) => step.name === 'Smoke packaged Windows PTY native capability'
|
||||
)
|
||||
const smoke = job.steps[smokeIndex]
|
||||
|
||||
expect(job['runs-on']).toBe('windows-2022')
|
||||
expect(smokeIndex).toBe(packageIndex + 1)
|
||||
expect(smoke.run).toBe(
|
||||
'pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe'
|
||||
)
|
||||
expect(smoke.if).toBeUndefined()
|
||||
expect(smoke['continue-on-error']).toBeUndefined()
|
||||
expect(packageJson.scripts['smoke:windows-pty-native-capability']).toBe(
|
||||
'node tests/tools/windows-pty-native-capability-smoke/run.mjs'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
|
||||
const job = workflow.jobs.package_windows
|
||||
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')
|
||||
const packageStep = job.steps.find((step) => step.name === 'Package unpacked app')
|
||||
const verify = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8')
|
||||
|
||||
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')
|
||||
expect(verify.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}')
|
||||
expect(verify.run).toContain('"$PACKAGE_WINDOWS"')
|
||||
})
|
||||
|
||||
it('keeps the native probe event-based, scoped, and runnable in packaged Node mode', () => {
|
||||
const driver = readFileSync('tests/tools/windows-pty-native-capability-smoke/run.mjs', 'utf8')
|
||||
const probe = readFileSync(
|
||||
'tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs',
|
||||
'utf8'
|
||||
)
|
||||
const source = `${driver}\n${probe}`
|
||||
|
||||
expect(driver).toContain("ELECTRON_RUN_AS_NODE: '1'")
|
||||
expect(source).not.toMatch(/\b(?:sleep|tasklist|taskkill)\b/i)
|
||||
expect(source).not.toContain('maxRetries')
|
||||
expect(source).not.toContain('retryDelay')
|
||||
expect(source).not.toContain("from 'node:child_process'")
|
||||
expect(source).not.toContain("require('node:child_process')")
|
||||
expect(probe).toContain("'System32', 'wscript.exe'")
|
||||
expect(probe).toContain('real-orca-detached-launcher.vbs')
|
||||
expect(probe).not.toMatch(/cmd\.exe|start "" \/b/i)
|
||||
expect(probe).toContain('native.terminateJob(target._pty, target.pid)')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@
|
||||
"../src/shared/**/*",
|
||||
"../src/main/agent-state-file-reader.ts",
|
||||
"../src/main/agent-hooks/hook-stdin-contract.ts",
|
||||
"../src/main/agent-hooks/hook-post-command.ts",
|
||||
"../src/main/agent-hooks/hook-config-write-path.ts",
|
||||
"../src/main/agent-hooks/hooks-json-read.ts",
|
||||
"../src/main/agent-hooks/installer-utils.ts",
|
||||
@@ -30,8 +31,6 @@
|
||||
"../src/main/codex/codex-app-server-capability-cache.ts",
|
||||
"../src/main/codex/codex-app-server-capability-signal.ts",
|
||||
"../src/main/codex/codex-app-server-client.ts",
|
||||
"../src/main/codex/codex-app-server-grant-bridge.ts",
|
||||
"../src/main/codex/codex-app-server-grant-envelope.ts",
|
||||
"../src/main/codex/codex-app-server-session.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
@@ -40,13 +39,16 @@
|
||||
"../src/main/codex/codex-config-settings-upsert.ts",
|
||||
"../src/main/codex/codex-home-paths.ts",
|
||||
"../src/main/codex/codex-managed-home-resource-copy-marker.ts",
|
||||
"../src/main/codex/codex-managed-trust-grant-plan.ts",
|
||||
"../src/main/codex/codex-path-observation.ts",
|
||||
"../src/main/codex/codex-hook-identity.ts",
|
||||
"../src/main/codex/codex-hook-trust-grant.ts",
|
||||
"../src/main/codex/codex-managed-trust-reconciliation.ts",
|
||||
"../src/main/codex/codex-process-exit-deadline.ts",
|
||||
"../src/main/codex/codex-state-db.ts",
|
||||
"../src/main/codex/codex-trust-identity.ts",
|
||||
"../src/main/codex/codex-trust-config-rollback.ts",
|
||||
"../src/main/codex/codex-trust-config-mutation-queue.ts",
|
||||
"../src/main/codex/codex-trust-grant-telemetry.ts",
|
||||
"../src/main/codex/codex-trust-grant-host.ts",
|
||||
"../src/main/codex/codex-trust-grant-ledger.ts",
|
||||
@@ -58,10 +60,16 @@
|
||||
"../src/main/codex/config-settings-promotion.ts",
|
||||
"../src/main/codex/config-settings-promotion-write-target.ts",
|
||||
"../src/main/codex/config-sync-stall.ts",
|
||||
"../src/main/codex/config-toml-atomic-write.ts",
|
||||
"../src/main/codex/config-toml-deprecated-hook-flag.ts",
|
||||
"../src/main/codex/config-toml-hook-trust-blocks.ts",
|
||||
"../src/main/codex/config-toml-hook-trust-edit.ts",
|
||||
"../src/main/codex/config-toml-hook-trust-read.ts",
|
||||
"../src/main/codex/config-toml-key-path.ts",
|
||||
"../src/main/codex/config-toml-line-scan.ts",
|
||||
"../src/main/codex/config-toml-project-trust.ts",
|
||||
"../src/main/codex/config-toml-runtime-owned-sections.ts",
|
||||
"../src/main/codex/config-toml-syntax.ts",
|
||||
"../src/main/codex/config-toml-trust.ts",
|
||||
"../src/main/codex/hook-service.ts",
|
||||
"../src/main/codex/hook-trust-promotion.ts",
|
||||
@@ -75,9 +83,18 @@
|
||||
"../src/main/copilot/copilot-managed-script.ts",
|
||||
"../src/main/copilot/copilot-remote-hook-install.ts",
|
||||
"../src/main/copilot/hook-service.ts",
|
||||
"../src/main/cursor/hook-events.ts",
|
||||
"../src/main/cursor/hook-script.ts",
|
||||
"../src/main/cursor/hook-service.ts",
|
||||
"../src/main/droid/hook-service.ts",
|
||||
"../src/main/gemini/hook-service.ts",
|
||||
"../src/main/grok/grok-hook-config.ts",
|
||||
"../src/main/grok/grok-hook-config-cleanup.ts",
|
||||
"../src/main/grok/grok-hook-config-file.ts",
|
||||
"../src/main/grok/grok-hook-owners.ts",
|
||||
"../src/main/grok/grok-hook-remote-install.ts",
|
||||
"../src/main/grok/grok-hook-script.ts",
|
||||
"../src/main/grok/grok-hook-symlink-cleanup-marker.ts",
|
||||
"../src/main/grok/hook-service.ts",
|
||||
"../src/main/grok/windows-grok-hook-script.ts",
|
||||
"../src/main/devin/hook-settings.ts",
|
||||
|
||||
+14
-14
@@ -137,20 +137,20 @@ Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI
|
||||
|
||||
When a control has multiple plausible primitives, use this fork:
|
||||
|
||||
| You want… | Reach for | Don't use |
|
||||
| ------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------- |
|
||||
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
|
||||
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
|
||||
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
|
||||
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
|
||||
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
|
||||
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
| You want… | Reach for | Don't use |
|
||||
| ------------------------------------------------------------ | ------------------------------------------ | ------------------------------------- |
|
||||
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
|
||||
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
|
||||
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
|
||||
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
|
||||
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
|
||||
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
|
||||
If you find yourself styling around a primitive (`<Popover>` to act like a `<Dialog>`, or vice versa), stop and reconsider — the focus-management semantics differ and a future contributor will be misled by the mismatch.
|
||||
|
||||
|
||||
@@ -314,8 +314,6 @@ Windows/WSL run and native-Windows staging lifecycle; this does not treat WSL se
|
||||
identical to macOS. The user-driven signed-in desktop and real-host production journey, supported
|
||||
Windows SSH, and the quarantine lifecycle deletion remain. The shared staging data plane is asleep.
|
||||
|
||||
Source plan: [Agent skill sharing and installation plan](./agent-skill-sharing-installation-plan.md).
|
||||
|
||||
This checklist turns the architecture plan into ordered implementation and release work. A checked
|
||||
item means evidence exists in code, tests, reviewed infrastructure, or release documentation; it
|
||||
does not mean the surrounding phase is complete.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,596 +0,0 @@
|
||||
# AI Vault process isolation architecture and implementation plan
|
||||
|
||||
Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete.
|
||||
|
||||
Last updated: 2026-08-09.
|
||||
|
||||
## Decision summary
|
||||
|
||||
AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary.
|
||||
|
||||
The target has three rules:
|
||||
|
||||
1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process.
|
||||
2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data.
|
||||
3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer.
|
||||
|
||||
This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified.
|
||||
|
||||
The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal.
|
||||
|
||||
## Goals
|
||||
|
||||
- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host.
|
||||
- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active.
|
||||
- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time.
|
||||
- The service can be restarted without restarting the app, runtime, relay, or terminals.
|
||||
- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible.
|
||||
- Mixed-version remote peers continue to work.
|
||||
- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity.
|
||||
- Rewriting the scanner or changing session discovery semantics during the process migration.
|
||||
- Moving xterm into another renderer.
|
||||
- Adding a process per window, worktree, repository, or SSH request.
|
||||
- Removing the old-relay compatibility fallback in the same change.
|
||||
- Making pagination a public wire dependency before mixed-version fallback behavior exists.
|
||||
|
||||
## Current architecture and coupling
|
||||
|
||||
| Path | Current execution | Remaining coupling |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main |
|
||||
| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host |
|
||||
| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process |
|
||||
| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay |
|
||||
| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm |
|
||||
|
||||
The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API.
|
||||
|
||||
The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign.
|
||||
|
||||
## Feature-parity contract
|
||||
|
||||
The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics.
|
||||
|
||||
| Capability | Required behavior after migration | Owner |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters |
|
||||
| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today |
|
||||
| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection |
|
||||
| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router |
|
||||
| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them |
|
||||
| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation |
|
||||
| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing |
|
||||
| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate |
|
||||
| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate |
|
||||
| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result |
|
||||
| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration |
|
||||
| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success |
|
||||
| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling |
|
||||
| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports |
|
||||
|
||||
Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow.
|
||||
|
||||
No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list.
|
||||
|
||||
## Target topology
|
||||
|
||||
```text
|
||||
Desktop / runtime host
|
||||
|
||||
Integrated renderer
|
||||
├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon
|
||||
└─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process
|
||||
│
|
||||
└─ host merge + compatibility routing
|
||||
|
||||
SSH execution host
|
||||
|
||||
Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar
|
||||
└────────────────── PTY handler/event loop
|
||||
```
|
||||
|
||||
The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar.
|
||||
|
||||
## Process ownership
|
||||
|
||||
- Desktop: one Vault service per Electron main process, shared by every window and workspace.
|
||||
- Orca runtime: one Vault service per runtime host process, shared by all connected clients.
|
||||
- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests.
|
||||
- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration.
|
||||
- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop.
|
||||
|
||||
## Service responsibilities
|
||||
|
||||
The service owns:
|
||||
|
||||
- Source discovery and filesystem metadata reads.
|
||||
- Transcript streaming and parsing for all supported agents.
|
||||
- OpenCode SQLite reads and its nested worker lifecycle.
|
||||
- In-memory and persisted session parse caches.
|
||||
- The bounded title index and transcript title resolution.
|
||||
- On-demand Claude/OMP subagent listing and full first-user-prompt extraction.
|
||||
- Scan parse concurrency and cooperative cancellation.
|
||||
- Result construction, deduplication, sorting, and internal paging.
|
||||
- Per-request metrics that contain counts and timings, not session content.
|
||||
|
||||
The router owns:
|
||||
|
||||
- Electron IPC, runtime RPC, or relay method registration.
|
||||
- Authentication and connection ownership already enforced by that transport.
|
||||
- Public argument validation and execution-host selection.
|
||||
- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration.
|
||||
- Multi-host result merging.
|
||||
- Request coalescing and mapping caller cancellation to the service request.
|
||||
- Validation of service responses before publishing them across a public boundary.
|
||||
- Compatibility fallback selection.
|
||||
- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation.
|
||||
|
||||
The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication.
|
||||
|
||||
## Internal process protocol
|
||||
|
||||
Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol.
|
||||
|
||||
Parent to service:
|
||||
|
||||
```ts
|
||||
type ServiceRequest =
|
||||
| { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions }
|
||||
| { type: 'request'; id: number; operation: 'scan'; options: ScanOptions }
|
||||
| { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] }
|
||||
| { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest }
|
||||
| { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest }
|
||||
| { type: 'invalidate'; paths: string[]; generation: number }
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'shutdown' }
|
||||
```
|
||||
|
||||
Service to parent:
|
||||
|
||||
```ts
|
||||
type ServiceResponse =
|
||||
| { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities }
|
||||
| { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics }
|
||||
| { type: 'result'; id: number; operation: 'titles'; value: TitlesResult }
|
||||
| { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult }
|
||||
| { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult }
|
||||
| { type: 'invalidated'; generation: number }
|
||||
| { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean }
|
||||
| { type: 'fatal'; code: ServiceErrorCode; message: string }
|
||||
```
|
||||
|
||||
Protocol rules:
|
||||
|
||||
- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch.
|
||||
- Use monotonically increasing safe-integer request IDs scoped to the child lifetime.
|
||||
- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth.
|
||||
- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes.
|
||||
- Coalesce equivalent scans. Replace an older queued background scan with the newest one.
|
||||
- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does.
|
||||
- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy.
|
||||
- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck.
|
||||
- Continue returning the last good renderer result when a refresh fails.
|
||||
- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state.
|
||||
- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented.
|
||||
|
||||
The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers.
|
||||
|
||||
## Lifecycle and supervision
|
||||
|
||||
### Startup
|
||||
|
||||
- Start lazily on the first scan or title request.
|
||||
- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader.
|
||||
- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows.
|
||||
- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready.
|
||||
- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child.
|
||||
|
||||
### Steady state
|
||||
|
||||
- Reuse the process and caches across calls.
|
||||
- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency.
|
||||
- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal.
|
||||
- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive.
|
||||
- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper.
|
||||
|
||||
### Faults and restart
|
||||
|
||||
- Reject the active request on exit, disconnect, malformed protocol, or timeout.
|
||||
- Retry queued work in a new process only when it is safe and has not been cancelled.
|
||||
- Back off at 250 ms, 1 second, then 5 seconds.
|
||||
- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt.
|
||||
- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely.
|
||||
|
||||
## Resource and quality-of-service policy
|
||||
|
||||
The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness.
|
||||
|
||||
- CPU: below-normal process priority, scan concurrency eight, one scan at a time.
|
||||
- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry.
|
||||
- Queue: 16 calls, coalescing, background replacement, explicit priority.
|
||||
- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior.
|
||||
- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve.
|
||||
- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending.
|
||||
- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap.
|
||||
|
||||
Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation.
|
||||
|
||||
## Renderer publication
|
||||
|
||||
`ai-vault-session-refresh.ts` should stop applying a completed result immediately.
|
||||
|
||||
Add `ai-vault-session-publication-gate.ts` with this behavior:
|
||||
|
||||
1. Cache the validated result immediately so another caller can reuse it.
|
||||
2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`.
|
||||
3. Otherwise retain only the newest pending result and wait for quiet.
|
||||
4. Publish after 1 second even if input continues, using a transition and one bounded page.
|
||||
5. Cancel a pending publication when the scope, host, or component request token changes.
|
||||
|
||||
Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel.
|
||||
|
||||
A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization.
|
||||
|
||||
## Desktop and runtime implementation
|
||||
|
||||
Create these concrete modules:
|
||||
|
||||
- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown.
|
||||
- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy.
|
||||
- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions.
|
||||
- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution.
|
||||
- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes.
|
||||
- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy.
|
||||
|
||||
Reuse the scanner and cache modules without moving their business logic. Replace imports in:
|
||||
|
||||
- `src/main/ai-vault/cached-session-list.ts`
|
||||
- `src/main/ai-vault/session-title-resolver.ts`
|
||||
- `src/main/ipc/ai-vault-subagent-list.ts`
|
||||
- `src/main/ai-vault/session-first-user-prompt-read.ts`
|
||||
- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete
|
||||
- `src/main/ipc/ai-vault.ts`
|
||||
- `src/main/runtime/rpc/methods/ai-vault.ts`
|
||||
|
||||
Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period.
|
||||
|
||||
Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration.
|
||||
|
||||
## SSH relay sidecar implementation
|
||||
|
||||
Create:
|
||||
|
||||
- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader.
|
||||
- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter.
|
||||
- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports.
|
||||
|
||||
Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process.
|
||||
|
||||
`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness.
|
||||
|
||||
If the sidecar cannot start or crashes:
|
||||
|
||||
- Return a normal `AiVaultListResult` containing a host issue and no new sessions.
|
||||
- Do not scan inline in the relay.
|
||||
- Keep all PTYs, reconnect state, and other relay methods available.
|
||||
- Allow the next manual refresh to cross the restart circuit breaker once.
|
||||
|
||||
Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals.
|
||||
|
||||
## Remote compatibility
|
||||
|
||||
The first implementation adds no public method, stream opcode, required field, or changed result meaning.
|
||||
|
||||
- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar.
|
||||
- New desktop with current relay: calls the same methods and receives the same result shape.
|
||||
- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback.
|
||||
- Old desktop with new relay: unknown internal sidecar details never cross the wire.
|
||||
|
||||
Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability.
|
||||
|
||||
The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision.
|
||||
|
||||
## Folder workspaces, WSL, and paths
|
||||
|
||||
- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata.
|
||||
- Continue using `path.join` and the existing host-platform path adapters.
|
||||
- Keep Windows batch and shell selection unrelated to terminal-shell preference.
|
||||
- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows.
|
||||
- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths.
|
||||
- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript.
|
||||
- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The service is a performance and reliability boundary, not a privilege boundary.
|
||||
|
||||
- It has the same OS user and must access the same agent stores.
|
||||
- Do not expose its IPC channel to renderer code or the network.
|
||||
- The parent passes validated options; the service validates again before filesystem access.
|
||||
- Do not log prompts, titles, transcript paths, environment values, or raw service messages.
|
||||
- Preserve symlink and allowed-root checks for title reads.
|
||||
- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further.
|
||||
|
||||
## Packaging
|
||||
|
||||
Desktop packaging changes:
|
||||
|
||||
- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`.
|
||||
- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`.
|
||||
- Reuse the existing unpacked entry-path pattern used by other forked processes.
|
||||
- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down.
|
||||
|
||||
Relay packaging changes:
|
||||
|
||||
- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture.
|
||||
- Include it in `.version` content hashing and deploy completeness assertions.
|
||||
- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together.
|
||||
- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier.
|
||||
|
||||
## Observability
|
||||
|
||||
Add spans and counters for:
|
||||
|
||||
- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit.
|
||||
- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls.
|
||||
- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count.
|
||||
- Parent/child serialization duration and serialized bytes.
|
||||
- Child RSS and heap-used snapshots at request completion.
|
||||
- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count.
|
||||
- Relay sidecar failures separately from PTY and relay-loop health.
|
||||
|
||||
Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs.
|
||||
|
||||
## Verification matrix
|
||||
|
||||
The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable.
|
||||
|
||||
| Layer | Required coverage |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts |
|
||||
| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ |
|
||||
| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush |
|
||||
| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown |
|
||||
| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP |
|
||||
| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior |
|
||||
| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions |
|
||||
| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state |
|
||||
| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency |
|
||||
| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata |
|
||||
|
||||
The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
| Failure | User-visible Vault behavior | Terminal behavior |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
||||
| Service fails to start | Keep last result and show a retryable host issue | Unchanged |
|
||||
| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged |
|
||||
| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged |
|
||||
| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged |
|
||||
| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged |
|
||||
| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available |
|
||||
| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged |
|
||||
| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains |
|
||||
|
||||
## Benchmark methodology
|
||||
|
||||
The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home.
|
||||
|
||||
Each iteration:
|
||||
|
||||
1. Keeps the Vault panel mounted in both arms.
|
||||
2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home.
|
||||
3. Alternates arm order to reduce warm-up bias.
|
||||
4. Runs paced raw-mode terminal echo typing as the control.
|
||||
5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment.
|
||||
6. Verifies the newest seeded title is visible, proving the full scan and UI path completed.
|
||||
|
||||
The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key.
|
||||
|
||||
Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory.
|
||||
|
||||
## Current measurements
|
||||
|
||||
Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work.
|
||||
|
||||
Pre-implementation parity baseline:
|
||||
|
||||
- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts.
|
||||
- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests.
|
||||
- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes.
|
||||
|
||||
### Representative pass
|
||||
|
||||
Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | -------------------: | ------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms |
|
||||
| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms |
|
||||
| Worst timer drift | 14.2 ms | 24.5 ms |
|
||||
| Worst frame gap | 9.4 ms | 24.9 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 |
|
||||
| Refresh duration p50 / max | — | 103.5 / 204.2 ms |
|
||||
|
||||
There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration.
|
||||
|
||||
### Stress pass
|
||||
|
||||
Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | ------------------: | -------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms |
|
||||
| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms |
|
||||
| Worst timer drift | 9.3 ms | 43.6 ms |
|
||||
| Worst frame gap | 9.4 ms | 50.0 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 240 | 0 / 240 |
|
||||
| Refresh duration p50 / max | — | 276.3 / 453.5 ms |
|
||||
|
||||
The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer.
|
||||
|
||||
### Measurement limits
|
||||
|
||||
- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior.
|
||||
- The benchmark covers the local service path, not the currently unisolated SSH relay path.
|
||||
- Raw shell echo is a stable latency probe, not a full-screen TUI workload.
|
||||
- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost.
|
||||
|
||||
## Post-implementation A/B
|
||||
|
||||
Run the same build with the rollout switch off and on, alternating launch order:
|
||||
|
||||
```bash
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment
|
||||
```
|
||||
|
||||
Run at least five representative and five stress passes on:
|
||||
|
||||
- macOS Apple Silicon.
|
||||
- Windows x64 with a folder workspace and a WSL-backed source present.
|
||||
- Ubuntu 20.04-compatible x64 packaging/runtime.
|
||||
- A 4-core or smaller machine or constrained CI runner.
|
||||
|
||||
Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy.
|
||||
|
||||
## Implemented result
|
||||
|
||||
The implementation now matches the target topology:
|
||||
|
||||
- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback.
|
||||
- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry.
|
||||
- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue.
|
||||
- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes.
|
||||
- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second.
|
||||
- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker.
|
||||
|
||||
Local validation on 2026-08-09:
|
||||
|
||||
- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully.
|
||||
- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane.
|
||||
- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone.
|
||||
- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled.
|
||||
|
||||
Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence.
|
||||
|
||||
| Metric | Worker control | Worker refresh | Process control | Process refresh |
|
||||
| --------------------- | -------------: | -------------: | --------------: | --------------: |
|
||||
| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms |
|
||||
| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms |
|
||||
| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms |
|
||||
| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms |
|
||||
| Long tasks over 50 ms | 0 | 0 | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 |
|
||||
| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms |
|
||||
|
||||
The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness.
|
||||
|
||||
Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Correctness:
|
||||
|
||||
- Zero missing terminal echoes.
|
||||
- The latest seeded Vault session is visibly published in every treatment arm.
|
||||
- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner.
|
||||
- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics.
|
||||
|
||||
Performance:
|
||||
|
||||
- Treatment parse p95 is no more than 10 ms above its paired control.
|
||||
- Treatment render p95 is no more than 15 ms above its paired control.
|
||||
- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms.
|
||||
- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run.
|
||||
- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes.
|
||||
|
||||
Isolation:
|
||||
|
||||
- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon.
|
||||
- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods.
|
||||
- The service respects queue, timeout, idle, and heap bounds.
|
||||
- Packaged app and relay artifacts contain and launch the correct entry for every supported platform.
|
||||
|
||||
## Phased implementation plan
|
||||
|
||||
### Phase 0 — measurement harness (completed in this change)
|
||||
|
||||
- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes.
|
||||
- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`.
|
||||
- Capture representative and stress baselines.
|
||||
|
||||
Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes.
|
||||
|
||||
### Phase 1 — desktop and runtime service behind a switch
|
||||
|
||||
- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor.
|
||||
- Reuse current scanner/cache implementations.
|
||||
- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled.
|
||||
- Keep the worker path as the disabled-switch fallback.
|
||||
- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution.
|
||||
- Add E2E fault injection that kills the service during active terminal typing.
|
||||
|
||||
Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets.
|
||||
|
||||
### Phase 2 — make the desktop/runtime process the default
|
||||
|
||||
- Enable the process by default in development, E2E, and canary builds.
|
||||
- Collect process RSS, restart, timeout, and latency telemetry without content.
|
||||
- Validate Windows, Linux, WSL, and constrained-runner results.
|
||||
- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle.
|
||||
|
||||
Exit: no unexplained crash or timeout increase and all acceptance gates green.
|
||||
|
||||
### Phase 3 — SSH relay sidecar
|
||||
|
||||
- Add the relay sidecar entry and supervisor.
|
||||
- Remove scanner/parser/cache imports from the relay handler.
|
||||
- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test.
|
||||
- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests.
|
||||
- Make inline relay scanning impossible; sidecar failure returns a host issue.
|
||||
|
||||
Exit: remote typing and fault gates green; current public relay methods unchanged.
|
||||
|
||||
### Phase 4 — renderer quiet publication
|
||||
|
||||
- Add the shared quiet-publication gate and React transition.
|
||||
- Retain only the newest pending result per scope/host.
|
||||
- Add supersession, starvation timeout, unmount, and rapid-host-switch tests.
|
||||
- Re-run the Electron benchmark with large result counts and active filtering/grouping.
|
||||
|
||||
Exit: no Vault-attributable renderer long task over 50 ms in the representative gate.
|
||||
|
||||
### Phase 5 — hardening and cleanup
|
||||
|
||||
- Remove the worker implementation after one stable release if the kill switch was unused.
|
||||
- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry.
|
||||
- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate.
|
||||
- Document operational recovery and service diagnostics.
|
||||
|
||||
Exit: kill switch and dead worker code removed only after evidence supports it.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data.
|
||||
- Renderer: disable quiet publication and apply the validated complete result directly.
|
||||
- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails.
|
||||
- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion.
|
||||
|
||||
## Confidence and remaining questions
|
||||
|
||||
Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation.
|
||||
|
||||
The remaining implementation questions are deliberately narrow:
|
||||
|
||||
1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on.
|
||||
2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry.
|
||||
3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets.
|
||||
4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop.
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 27m">
|
||||
<title>downloads: 27m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 31m">
|
||||
<title>downloads: 31m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">27m</text>
|
||||
<text x="90" y="14">27m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">31m</text>
|
||||
<text x="90" y="14">31m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -1,442 +0,0 @@
|
||||
# Mobile Relay UX — Investigation Findings & Fix Plan
|
||||
|
||||
Scope: phone-side presentation/state-machine issues behind three reported symptoms on Android over
|
||||
the cloud relay. The relay protocol and server-side assignment are healthy; nothing here changes
|
||||
desktop or relay-server code. All file references are in `mobile/` of this worktree.
|
||||
|
||||
## 1. Symptom → root-cause summary
|
||||
|
||||
| # | Symptom | Root cause (verified) |
|
||||
|---|---------|----------------------|
|
||||
| S1 | Resume lands on an empty "Host" page, grey dot | Bare cross-stack `router.push` into a cold nested host navigator resolves to the host index route **without the `hostId` param**; every screen below then runs with `hostId: undefined` |
|
||||
| S2 | Tapping a healthy relay host shows grey 1–2s before green | Every screen focus funnels into the network-handoff recovery path, which **suspends the healthy relay session** (publishes `disconnected`) and re-dials; the re-dial is invisible because `migrateTo` binds new-session state only after authentication |
|
||||
| S3 | Relay-forced pairing looks dead ~5–10s | The pairing relay path has **no log sink** (only direct-path entries reach the "Pairing log"), and post-pairing the app dials the unreachable LAN endpoint for up to 12s before relay recovery is even eligible |
|
||||
|
||||
## 2. Verified end-to-end causal chains
|
||||
|
||||
### S1 — Resume dead-ends on the host index page
|
||||
|
||||
1. Home renders the Resume card only once `hostStates[lastVisited.hostId] === 'connected'`
|
||||
(`app/index.tsx:488`); over relay that is seconds after the host list paints, and the card
|
||||
inserts **above** the Tasks card in the same footer (`app/index.tsx:733-780`) — a layout shift
|
||||
under the thumb.
|
||||
2. Tap → bare `router.push(createMobileSessionHref(...))` (`app/index.tsx:740-746`) targeting
|
||||
`/h/[hostId]/session/[worktreeId]`.
|
||||
3. With the `h` group cold (cold start, or host never visited this session), Expo Router resolves
|
||||
the push to the host stack's **index route with no `hostId` param**. This exact failure mode is
|
||||
documented twice in-repo ("cold Expo deep links resolve to index" —
|
||||
`src/transport/host-edit-navigation.ts:52`, `src/tasks/mobile-task-navigation.ts:90`) and is the
|
||||
root cause named by PR #12001.
|
||||
4. `app/h/_layout.tsx:64` reads `hostId` via `useGlobalSearchParams` → `undefined`.
|
||||
`HostProtocolGate` gets `hostId: undefined`; `useHostClient(undefined)` returns
|
||||
`state: 'disconnected'` (`src/transport/client-context.tsx:344`) → **grey dot**.
|
||||
5. The host index screen renders the fallback title `'Host'` (`app/h/[hostId]/index.tsx:821`), and
|
||||
every fetch no-ops on `!client || connState !== 'connected'`
|
||||
(`app/h/[hostId]/index.tsx:298,364,418,520`) → **empty list**. The Filter/Recent/Repo chips are
|
||||
static toolbar UI, so the page looks "real" but dead.
|
||||
6. "Sometimes": a warm host stack resolves the same push correctly, so the bug is intermittent by
|
||||
navigation history.
|
||||
|
||||
Corrections to the preliminary sweep: the empty page is primarily the missing `hostId` param, not
|
||||
the connection-gated fetches or the cold 30s worktree cache (those matter only when landing *with*
|
||||
a valid `hostId`, e.g. the mistap-strand case). Also, the Resume target "validation" is weaker than
|
||||
it looks: `getCachedWorktrees` is seeded from the persisted home snapshot at hydration
|
||||
(`app/index.tsx:264-277`) and the 30s TTL is stamped at seed time (`src/cache/worktree-cache.ts:20`),
|
||||
so a worktree deleted while the phone was off still passes until a live `worktree.ps` overwrites it.
|
||||
|
||||
**Fix**: PR #12001 ("open the Resume workspace through a mounted host stack") routes Resume through
|
||||
the same mount-then-replace mechanism Tasks uses, extracted to `src/navigation/host-stack-navigation.ts`.
|
||||
Reviewed and validated per Jinwoo; **merged to main as `7948e46db855`** after final validation
|
||||
(see §4, F0). Residual S1 items it does not cover: bare notification/accounts/deep-link pushes (F4),
|
||||
catalog validation + not-found bounce (F7), Resume-card layout shift (F8), gate unmount hazard (F9).
|
||||
|
||||
### S2 — grey blink when focusing a healthy relay host
|
||||
|
||||
1. Every focus of the host screen fires `notifyForeground()`
|
||||
(`app/h/[hostId]/index.tsx:512-517`, deliberately empty deps).
|
||||
2. `openHostLogicalClient` wraps that into `endpointLifecycle.setForeground(true)`
|
||||
(`src/transport/host-logical-client.ts:31-33`); the lifecycle forwards without dedupe
|
||||
(`src/transport/mobile-endpoint-lifecycle.ts:62-64`).
|
||||
3. `MobileEndpointSupervisor.setForeground(true)` computes `wasForeground = true` and calls
|
||||
`RelayReconnectController.handleForeground` (`src/transport/mobile-endpoint-supervisor.ts:115-119`).
|
||||
4. `handleForeground` with `wasForeground && state === 'connected'` **suspends the healthy session**
|
||||
(`src/transport/mobile-relay-reconnect-controller.ts:53-60`). `suspendActiveRelay` early-returns
|
||||
unless the active path is `'relay'` (`:77-84`) — which is why LAN hosts never blink.
|
||||
5. `suspendActiveSession` closes the physical session, disposes all subscriptions, and publishes
|
||||
`'disconnected'` (`src/transport/stable-logical-rpc-client.ts:164-180`) → grey dot
|
||||
(`src/components/StatusDot.tsx:11`), worktree queries blocked.
|
||||
6. `onRetry()` → `recoverRelay()` → `openRelay` + `migrateTo`. During the dial the logical state
|
||||
**stays 'disconnected'**: `migrateTo` only binds the new session's state after
|
||||
`waitForAuthenticated` resolves (`src/transport/stable-logical-rpc-client.ts:188,213`), and the
|
||||
dialing session's own `connecting`/`handshaking` publishes (`src/transport/mobile-relay-rpc-session.ts:43,74`)
|
||||
fire with no listeners attached. Grey persists the full 1–2s (happy path has no artificial
|
||||
delays; any failure adds ≥250ms full-jitter backoff, `src/transport/mobile-relay-retry-delays.ts:3-5`).
|
||||
7. `migrateTo` completes → `'connected'` → green; subscriptions replay; gated fetches rerun.
|
||||
|
||||
Second trigger for the same path: OS network-revival nudges call `notifyForeground()` on every live
|
||||
client (`src/transport/client-context.tsx:286-292`, `src/transport/connection-revival-triggers.ts`)
|
||||
— any Wi-Fi↔cellular transition or came-online event grey-blinks every connected relay host.
|
||||
|
||||
Design context: the suspend-on-repeat-foreground is pinned by the supervisor test as the
|
||||
network-handoff half-open case (`src/transport/mobile-endpoint-supervisor.test.ts:~160-197`). The
|
||||
asymmetry is that **direct sockets probe instead of tearing down** — `notifyForeground` on a
|
||||
connected direct client runs an activity probe that detects a half-open socket in ≤8s
|
||||
(`src/transport/rpc-client.ts:1119-1124`) — while relay sessions have a no-op `notifyForeground`
|
||||
(`src/transport/mobile-relay-rpc-session.ts:106`) and the supervisor's only tool is
|
||||
suspend-then-redial. There is also an in-repo make-before-break precedent: lease rotation calls
|
||||
`recoverRelay(forceReplacement = true)` and migrates a **live** session with zero visible blink
|
||||
(`src/transport/mobile-endpoint-supervisor.ts:56-59,146,249`).
|
||||
|
||||
Divergent mount defaults (secondary): home renders `hostStates[id] ?? 'connecting'` (amber,
|
||||
`app/index.tsx:707`) while `getState()`/`useHostClient` return `'disconnected'` (grey) for a
|
||||
missing store entry (`src/transport/client-context.tsx:221,344`) — so host screens flash grey
|
||||
during the async client acquire (Keychain read) that home never shows.
|
||||
|
||||
### S3 — silent 5–10s relay-forced pairing
|
||||
|
||||
Pairing phase:
|
||||
|
||||
1. `pair-confirm.tsx` / `pair-scan.tsx` pass `connectOptions.onLog` into `startPreProfilePairing`
|
||||
(`app/pair-confirm.tsx:91-99`, `app/pair-scan.tsx:135-143`).
|
||||
2. The coordinator threads it **only to the direct candidate**
|
||||
(`src/transport/pre-profile-pairing-coordinator.ts:152-157`). The relay candidate
|
||||
(`:161-187`) gets nothing: `connectMobileRelayForPairing` has no log parameter at all
|
||||
(`src/transport/mobile-relay-physical-client.ts:22-30`), nor do the director resolution,
|
||||
journal writes, or the recovery loop in `src/transport/pairing-relay-candidate.ts`.
|
||||
3. With LAN unreachable, the visible "Pairing log" shows only the direct dial stalling toward its
|
||||
12s connect timeout while the relay path does the real work silently: cell WebSocket + E2EE
|
||||
handshake + `pairing.provisionRelay` + `pairing.getEndpoints` + credential-bundle write
|
||||
(`pre-profile-pairing-coordinator.ts:206-233`). The error copy even says "see log below for
|
||||
where it stalled" (`app/pair-confirm.tsx:138`) — the log cannot show it.
|
||||
4. Un-logged waits in the relay recovery loop: each of up to 3 attempts wraps a 5s director
|
||||
resolution (`src/transport/mobile-relay-invite-director.ts:16`) plus full-jitter sleeps capped
|
||||
at 100/200/400ms (`src/transport/pairing-relay-candidate.ts:58-59,70-71`) — worst case ~15s of
|
||||
silence. (Correction: the preliminary "~3×2s of backoff" was wrong; the sleeps are small, the
|
||||
director resolves dominate.) The relay E2EE layer itself has **no timers**: a pairing relay
|
||||
request is unbounded except the screen's 25s cap (`app/pair-confirm.tsx:27`).
|
||||
5. Pairing logs also never reach `connectionLogStore` (single producer:
|
||||
`src/transport/client-context.tsx:132`), so the Connection Log screen shows nothing about a
|
||||
pairing that just failed.
|
||||
|
||||
Post-pairing phase:
|
||||
|
||||
6. `pair-confirm` calls `closeHost(hostId)` then replaces to `/h/<id>`
|
||||
(`app/pair-confirm.tsx:118-123`).
|
||||
7. The destination re-acquires a client asynchronously (grey `'disconnected'` default during the
|
||||
Keychain read, `src/transport/client-context.tsx:221`), then dials the **LAN endpoint first**
|
||||
(`src/transport/host-logical-client.ts:12`) — amber for up to `CONNECT_TIMEOUT_MS = 12s`
|
||||
(`src/transport/rpc-client.ts:126`) on a black-holed LAN.
|
||||
8. Relay recovery cannot start earlier: `needsRecovery` treats `connecting`/`handshaking` as live
|
||||
progress (`src/transport/mobile-relay-reconnect-controller.ts:73-75`), checked at supervisor
|
||||
start and on every retry (`src/transport/mobile-endpoint-supervisor.ts:106,146`).
|
||||
9. When the direct dial finally fails, the relay dial runs invisibly (same `migrateTo` mechanism
|
||||
as S2) → green. Worst case with a director resolution failure and grace-credential retry:
|
||||
~29–58s under the old session's labels.
|
||||
10. The "Orca Relay" path label only renders once `state === 'connected'`
|
||||
(`src/components/MobileHostCard.tsx:23,47`) — the user learns the phone is using relay only
|
||||
after the wait ends, and `classifyConnection` has no relay-aware branch
|
||||
(`src/transport/connection-health.ts:45-100`).
|
||||
|
||||
## 3. Anti-pattern sweep
|
||||
|
||||
### (a) Uncoordinated deep pushes into `/h` from outside the host stack
|
||||
|
||||
Coordinated today (mount-then-replace): host edit (`src/transport/host-edit-navigation.ts`) and
|
||||
Tasks (`src/tasks/mobile-task-navigation.ts`). Note host-edit's predicate is weaker — it checks the
|
||||
root route only and can fire its `replace` while the nested stack is still gated/unmounted; Tasks
|
||||
proves the nested stack exists (`mountedHostStack`, `mobile-task-navigation.ts:53-70`).
|
||||
|
||||
Bare pushes remaining (host stack plausibly cold at each):
|
||||
|
||||
| Call site | Target | Cold scenario |
|
||||
|---|---|---|
|
||||
| `app/_layout.tsx:127` via `src/notifications/notification-routing.ts:58,64` | `/h/<id>/session/<wt>` or `/h/<id>` | **Coldest path** — `getLastNotificationResponse()` after launch from a killed app, plus the warm listener |
|
||||
| `app/index.tsx:740-746` (Resume) | `/h/[hostId]/session/[worktreeId]` | Fixed by PR #12001 |
|
||||
| `app/index.tsx:835` (Account-usage card) | `/h/<id>/accounts` | Home is the root route |
|
||||
| `orca://` deep links (scheme in `app.json:9`, no linking config) | any `/h/...` | Default filesystem linking, zero coordination; `app/_layout.tsx:52-58` only intercepts pairing codes |
|
||||
| `app/h/[hostId]/history/[worktreeId].tsx:15`, `pr/[worktreeId].tsx:16` | redirect to source-control | A cold deep link to these hits the same cold-navigator resolution first |
|
||||
|
||||
Shallow index-only pushes (`app/index.tsx:723,803`, onboarding/pair flows) don't need coordination.
|
||||
No `<Link>`, `navigationRef`, or `router.navigate` anywhere in `mobile/`.
|
||||
|
||||
Related hazard: `HostProtocolGate` **unmounts the mounted HostStack mid-connect** for a first-visit
|
||||
host — stack mounts while `connecting`, is replaced by a spinner when `status.get` goes in flight
|
||||
(`statusPending` true only when connected: `src/transport/host-status-gates.ts:111`), then remounts
|
||||
(`src/components/HostProtocolGate.tsx:34-44`). A deep navigation that resolved into the first mount
|
||||
can be destroyed by the gate cycle.
|
||||
|
||||
### (b) Surfaces that render grey 'disconnected' during expected transients
|
||||
|
||||
Store defaults: every read API on the canonical store defaults to `'disconnected'` for a missing
|
||||
entry — `getState` (`src/transport/client-context.tsx:221`), `useHostClient` seed/re-seed/unbound
|
||||
fallback (`:343-345,377,393`). Exactly one call site defaults to amber instead: the home screen's
|
||||
`hostStates[id] ?? 'connecting'` (`app/index.tsx:707,903`), whose reconciliation effect also
|
||||
refuses to write `'disconnected'` for a never-tracked host (`:378-394`) — home already solved
|
||||
locally what every other surface gets wrong.
|
||||
|
||||
The grey window is not one frame: `openEntry` awaits `loadHosts()` (a Keychain/SecureStore pass)
|
||||
**before** inserting the store entry (`client-context.tsx:87-161`, insert at `:153`), so a cold
|
||||
start or deep link into `/h/[hostId]` shows grey for the whole Keychain latency. The physical
|
||||
client is not the cause — it already reports `'connecting'` synchronously by the time `connect()`
|
||||
returns (`rpc-client.ts:302,967`). Additionally, `forceReconnect` deletes the entry then awaits the
|
||||
async reopen (`client-context.tsx:201-218`), so **every Retry button drives the UI grey before
|
||||
amber**.
|
||||
|
||||
Surfaces that show grey / "disconnected" copy for a healthy host during these transients (all via
|
||||
`useHostClient`): host header dot (`app/h/[hostId]/index.tsx:819`); host toolbar + FAB disabled
|
||||
(`:850-860,941-1000,1063-1078,1209`); the workspace list body renders **nothing at all** for
|
||||
`disconnected` — `selectHostWorkspaceListState` falls through to `null`, not even a spinner
|
||||
(`src/worktree/host-workspace-list-state.ts:17-24`); tasks header dot + "Connect to a host" empty
|
||||
state (`app/h/[hostId]/tasks.tsx:8681,8661-8663`); session dot (no `verdict` prop at all,
|
||||
`session/[worktreeId].tsx:4434`) and the literal "Disconnected" chip (`:4246-4255`); native-chat
|
||||
composer lock (`src/session/MobileNativeChatView.tsx:427-432`); source-control / git-history /
|
||||
diff-review / file-explorer / agent-history "Waiting for desktop…" states; the connection-log
|
||||
screen prints the raw enum (`app/connection-log.tsx:113-117`); home's Resume/Accounts/Tasks/Quick
|
||||
Action gates all read `=== 'connected'`; voice settings goes fully inert
|
||||
(`app/voice-settings.tsx:50-55`). Counter-examples that behave well: home host card, the accounts
|
||||
screen ("Connecting to {host}…" + cached snapshot, `app/h/[hostId]/accounts.tsx:366-370`).
|
||||
|
||||
Deliberate transients that publish `'disconnected'` while healthy work proceeds: relay suspend on
|
||||
focus/network nudges (S2); background suspend (`src/transport/mobile-endpoint-supervisor.ts:123` —
|
||||
correct per billing, but state stays grey through the entire foreground re-dial rather than
|
||||
flipping to `'connecting'`); post-migration cleanup (`:251-253`); `closeHost` during the
|
||||
pair-confirm handoff (`src/transport/client-context.tsx:83`); three open-failure paths
|
||||
(`:107,114,135`).
|
||||
|
||||
Destructive companion pattern — state flips don't just recolor, they **wipe loaded data**:
|
||||
`host-status-gates.ts:32,100-112` wipes cached host capabilities on every disconnect;
|
||||
`tasks.tsx:2774-2800` resets the whole screen's hydration and force-closes ~15 sheets;
|
||||
`session/[worktreeId].tsx:2043,2432-2439,3713-3716` clears diff comments/capability flags/agent
|
||||
lists; the PR sidebar hides entirely (`src/session/use-mobile-pr-branch-context.ts:59-66` →
|
||||
`use-mobile-pr-sidebar-controller.ts:113-118`); git history blanks rows on the **reconnect** branch
|
||||
(`src/source-control/MobileGitHistoryList.tsx:63-68`); the repo cache survives disconnect but is
|
||||
wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333`); the worktree cache is read
|
||||
only at mount/hostId change (`app/h/[hostId]/index.tsx:129,330`), never on reconnect, so a >30s
|
||||
entry means an empty remount.
|
||||
|
||||
Same bug class in a second enum: `workspaceSshStatusLabel` defaults a `null` SSH status to
|
||||
"Disconnected" (`src/tasks/workspace-ssh-gate.ts:14-37`, rendered in `NewWorktreeModal.tsx:863`
|
||||
and `tasks.tsx:10970`).
|
||||
|
||||
### (c) Invisible relay establishment phases
|
||||
|
||||
- `connectMobileRelayRpcSession` (normal relay connects) has **no onLog** — the entire relay
|
||||
session lifecycle emits nothing (`src/transport/mobile-relay-rpc-session.ts:30-39`); the
|
||||
supervisor logs only coarse post-hoc lines (`mobile-endpoint-supervisor.ts:185-188,261`).
|
||||
- `migrateTo` structurally discards the dialing session's `connecting`/`handshaking` states
|
||||
(`src/transport/stable-logical-rpc-client.ts:182-223,267-299`).
|
||||
- Direct→relay upgrade path has no sink at all (`src/transport/mobile-endpoint-lifecycle.ts:49-58`,
|
||||
`mobile-relay-direct-upgrade-controller.ts:19`).
|
||||
- Pairing relay path fully silent (S3 above); pairing logs never reach `connectionLogStore`.
|
||||
- Path label ("Orca Relay") gated on `connected` (`src/components/MobileHostCard.tsx:47`);
|
||||
`classifyConnection` collapses `connecting`/`handshaking`/`reconnecting` and has no relay branch.
|
||||
- Regression suite for connect-label stalls exists for the direct path only
|
||||
(`src/transport/cellular-connecting-label-stall.test.ts`); no relay equivalent.
|
||||
|
||||
## 4. Fix plan
|
||||
|
||||
Ordered by felt-flakiness-removed per unit risk. All fixes are phone-local; none change the wire
|
||||
protocol, so every old/new phone × old/new desktop pairing keeps working unless noted.
|
||||
|
||||
### F0 (S1, quick win) — land PR #12001 ✅ MERGED
|
||||
|
||||
Squash-merged to main as `7948e46db855` (2026-08-04) after validation: CI fully green; drift check
|
||||
against current main clean (only overlap, #12575, touches different regions of `app/index.tsx` and
|
||||
auto-merges); full mobile suite (411 files, 3110 tests) passed on a local merge of main into the PR
|
||||
branch. The PR routes Resume through the shared mount-then-replace mechanism
|
||||
(`src/navigation/host-stack-navigation.ts`) and adds a source-guard test against reintroducing the
|
||||
bare push. This branch has since been fast-forwarded onto that merge, and F4 builds on the
|
||||
extracted module.
|
||||
Backward compat: navigation-only, none.
|
||||
Residuals tracked as F4/F7/F8/F9.
|
||||
|
||||
### F1 (S2, quick win) — stop suspending a healthy relay on focus ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: split the nudge reasons that today all funnel into `setForeground(true)`:
|
||||
|
||||
- Screen-focus nudge (`app/h/[hostId]/index.tsx:515`): must not suspend. For the relay path, either
|
||||
no-op (state changes already drive the UI) or run a cheap liveness probe (an RPC with a short
|
||||
budget) and only enter recovery on failure — mirroring the direct path's activity probe.
|
||||
- Network-change / app-resume nudges: keep half-open protection, but **verify by replacement**
|
||||
instead of break-before-make: call the existing `recoverRelay(forceReplacement = true)` path
|
||||
(proven by lease rotation) so `migrateTo` swaps sessions with the dot staying green; only if the
|
||||
replacement dial fails, fall back to `suspendActiveRelay` so a genuinely dead link stops lying
|
||||
green and the retry loop re-arms (plain `recoverRelay` early-returns while the stale state is
|
||||
still `'connected'`, so the fallback suspend is required for convergence).
|
||||
|
||||
Files: `src/transport/mobile-relay-reconnect-controller.ts` (`handleForeground`),
|
||||
`src/transport/mobile-endpoint-supervisor.ts` (thread a nudge reason; failure-path suspend),
|
||||
`src/transport/mobile-endpoint-lifecycle.ts`, `src/transport/host-logical-client.ts` (reason-tagged
|
||||
`notifyForeground`), optionally `src/transport/rpc-client.ts` type for the reason parameter.
|
||||
Risk: PEER_DROPPED/LIMIT_EXCEEDED churn if replacement dials overlap — reuse the existing
|
||||
`shouldDefer` cooldown; billed duplicate socket for the overlap window (lease rotation already
|
||||
accepts this). Half-open regression risk is covered by the fallback suspend.
|
||||
Tests: split `mobile-endpoint-supervisor.test.ts:~160-197` into (focus nudge → no suspend, dot
|
||||
stays green) and (network handoff → replacement dial; failure → suspend + cooldown). Keep the
|
||||
background-suspend test unchanged.
|
||||
|
||||
### F2 (S2/S3, quick win) — unify mount defaults to 'connecting' ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: `getState(hostId)` returns `'connecting'` when the host is known (primed profile or
|
||||
pending open) and no entry exists yet; `'disconnected'` only for unknown/closed hosts. Aligns every
|
||||
host screen with home's `?? 'connecting'`. Two companion changes in the same class:
|
||||
- `forceReconnect` should notify `'connecting'` (or insert a placeholder entry) instead of leaving
|
||||
the deleted-entry window grey (`src/transport/client-context.tsx:201-218`) — every Retry button
|
||||
currently drives the UI grey before amber.
|
||||
- Optionally have `openEntry` insert a `'connecting'` placeholder before the Keychain read so the
|
||||
cold-start gap (`client-context.tsx:87-153`) is amber too.
|
||||
|
||||
**Required interaction fix**: `app/h/[hostId]/index.tsx:738-741` falls back to
|
||||
`lastKnownWorktrees` only for `disconnected | reconnecting | auth-failed`; `connecting`/
|
||||
`handshaking` fall through to the live (empty on fresh mount) array. Flipping the default without
|
||||
extending that predicate would silently disable the stale-list fallback and blank the list —
|
||||
extend it to every not-connected state (or key it on "no live fetch has succeeded this mount").
|
||||
Files: `src/transport/client-context.tsx` (`getState`, `useHostClient`, `forceReconnect`,
|
||||
`openEntry`), `app/h/[hostId]/index.tsx` (fallback predicate),
|
||||
`src/worktree/host-workspace-list-state.ts` (render a spinner for the not-connected states instead
|
||||
of `null`).
|
||||
Risk: a permanently unreachable host now shows amber briefly before the verdict system escalates —
|
||||
acceptable; `classifyConnection` already owns escalation. Audit the §3(b) "wipe" sites for any that
|
||||
key on `'disconnected'` specifically.
|
||||
Tests: `client-context.test.ts` known-vs-unknown host defaults + forceReconnect state sequence;
|
||||
host screen test for the stale-list fallback under `'connecting'`.
|
||||
|
||||
### F3 (S3, quick win) — give the pairing relay path a log sink ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: add an optional `onLog` to `connectMobileRelayForPairing`,
|
||||
`createRecoveringPairingRelayCandidate`, and `resolvePairingInviteThroughDirector`; thread
|
||||
`connectOptions.onLog` from the coordinator to the relay candidate; emit phase lines ("relay:
|
||||
resolving director…", "relay: cell connected", "relay: E2EE handshake…", "relay: authenticated",
|
||||
"relay: installing credential…"). Optionally also append pairing logs into `connectionLogStore`
|
||||
under the resolved host id so the Connection Log screen has a record post-pairing.
|
||||
Files: `src/transport/mobile-relay-physical-client.ts`, `pairing-relay-candidate.ts`,
|
||||
`mobile-relay-invite-director.ts`, `pre-profile-pairing-coordinator.ts`.
|
||||
Risk: none (additive, phone-local). Old desktops: unaffected — logging only.
|
||||
Tests: coordinator test asserting relay-path log entries arrive through `connectOptions.onLog`;
|
||||
extend `pairing-relay-candidate.test.ts` for per-attempt lines.
|
||||
|
||||
### F4 (S1 class, quick win after F0) — coordinate the remaining bare deep pushes
|
||||
|
||||
Approach: route notification taps (`app/_layout.tsx:127` + `src/notifications/notification-routing.ts`)
|
||||
and the Account-usage card (`app/index.tsx:835`) through `src/navigation/host-stack-navigation.ts`
|
||||
once #12001 lands; migrate host-edit onto the same stricter mechanism (#12001's own noted
|
||||
follow-up). `orca://` deep links can follow later via a route-level guard.
|
||||
Risk: notification cold-start ordering (push before root nav ready) — the mechanism already
|
||||
tolerates that by waiting for state commits.
|
||||
Tests: reuse the `host-stack-navigation.test.ts` harness for a notification-shaped target.
|
||||
|
||||
### F5 (S2/S3, deeper) — make relay dials visible through `migrateTo`
|
||||
|
||||
Approach: while the logical client is `suspended`/`'disconnected'`, have `migrateTo` forward the
|
||||
dialing session's state publishes (`connecting`/`handshaking`) to `publishState`, unbinding on
|
||||
success (normal bind takes over) or failure (restore `'disconnected'`). Guard: never downgrade a
|
||||
still-`'connected'` previous session (make-before-break migrations must stay green). Follow-on UI:
|
||||
show the path being dialed ("Connecting via Orca Relay…") by exposing the pending path, and let
|
||||
`MobileHostCard`/`classifyConnection` render it while not yet connected.
|
||||
Files: `src/transport/stable-logical-rpc-client.ts` (+ its test), `src/transport/connection-health.ts`,
|
||||
`src/components/MobileHostCard.tsx`, `src/transport/mobile-connection-path-label.ts`.
|
||||
Risk: state-ordering regressions in the pinned stable-client and connecting-label suites; keep the
|
||||
forwarding strictly gated on suspended/disconnected.
|
||||
Tests: add a relay-path analog of `cellular-connecting-label-stall.test.ts`; stable-client cases:
|
||||
forwarded states during suspended dial, no forwarding during live-session replacement, failure
|
||||
restores `'disconnected'`.
|
||||
|
||||
### F6 (S3, deeper) — happy-eyeballs relay start post-pairing
|
||||
|
||||
Approach: when a relay credential bundle exists and the direct dial has not authenticated within a
|
||||
short grace (2–3s), start the relay dial in parallel instead of waiting for the 12s direct failure;
|
||||
first authenticated path wins via the existing `migrateTo`/hysteresis machinery. Scope initially to
|
||||
the first connect after pairing (or hosts whose last success was relay) to avoid pointless relay
|
||||
sockets on healthy LANs.
|
||||
Files: `src/transport/mobile-endpoint-supervisor.ts` (start/needsRecovery gating), possibly a
|
||||
phone-local `HostProfile` hint field (no protocol impact; old desktops never present `relay`, so
|
||||
the path is naturally guarded).
|
||||
Risk: racing direct is exactly what `needsRecovery`'s design avoids — needs the mutex
|
||||
(`operationInFlight`) audit and dwell/hysteresis respect; billed relay data on LANs if scoped too
|
||||
broadly.
|
||||
Tests: supervisor fake-timer cases: black-holed LAN converges in ~3-5s; healthy LAN never opens a
|
||||
relay socket; relay loser closed after direct wins.
|
||||
|
||||
### F7 (S1, deeper) — catalog-validate resume targets + not-found bounce
|
||||
|
||||
Approach: (1) on Resume tap with the host connected, validate the target against the freshest
|
||||
`worktree.ps` result (not the snapshot-seeded cache); if absent, open the host index instead.
|
||||
(2) In the session screen, once connected and the catalog is known, bounce unknown `worktreeId`s
|
||||
(exempting `folder:` and floating-workspace sentinels, `app/h/[hostId]/session/[worktreeId].tsx:852-854`)
|
||||
to the host index with a notice. (3) Use the validating reader in
|
||||
`src/worktree/last-visited-worktree-repo.ts` on home instead of the raw `JSON.parse`
|
||||
(`app/index.tsx:315-322`), and import the storage-key constant at both literal call sites.
|
||||
Risk: false bounces during slow catalog loads — only bounce on a *confirmed* fresh catalog miss.
|
||||
Tests: repo tests for the validating reader on home; session-screen bounce cases incl. sentinel
|
||||
exemptions.
|
||||
|
||||
### F8 (S1 aggravator, cheap) — stop the Resume/Tasks layout shift
|
||||
|
||||
Approach: reserve the Resume card's slot (fixed-height placeholder or render-below-Tasks) so its
|
||||
late arrival cannot move the Tasks card under the thumb; alternatively render the card immediately
|
||||
from the snapshot in a disabled state until the host connects.
|
||||
Files: `app/index.tsx` footer.
|
||||
Risk: none.
|
||||
Tests: render test asserting footer order/height stability across `resumeWorktree` arrival.
|
||||
|
||||
### F9 (S1 class, deeper) — HostProtocolGate should not unmount a mounted stack
|
||||
|
||||
Approach: once the HostStack has mounted for a host, keep it mounted and overlay the pending
|
||||
spinner instead of replacing children, preserving in-flight nested navigation; keep the hard
|
||||
replace only for the `blocked` verdict.
|
||||
Files: `src/components/HostProtocolGate.tsx`.
|
||||
Risk: the gate exists so child routes don't call too-new RPCs while compatibility is unknown — an
|
||||
overlay must still block interaction until resolved; verify child mount effects don't fire gated
|
||||
RPCs pre-verdict before choosing overlay vs. current behavior.
|
||||
Tests: gate test asserting no unmount across `statusPending` for an already-mounted host.
|
||||
|
||||
### F10 (S2 class, deeper) — stop wiping loaded data on transient state flips
|
||||
|
||||
Approach: audit the §3(b) destructive-clear sites and make each preserve data across a
|
||||
not-`'connected'` blip, clearing only on host change or explicit sign-out. Top offenders by felt
|
||||
impact: git history blanking rows on the reconnect branch
|
||||
(`src/source-control/MobileGitHistoryList.tsx:63-68` — refetch without `setRows(null)`); the repo
|
||||
cache wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333` — keep last-good on
|
||||
error); the diff-review "ready-state preserved" branch that is dead code because it sits after the
|
||||
early return (`src/session/use-mobile-diff-review-controller.ts:85-89`); host capability wipe
|
||||
(`src/transport/host-status-gates.ts:32`); the tasks-screen full re-hydration
|
||||
(`app/h/[hostId]/tasks.tsx:2774-2800`); worktree-cache re-read on reconnect, not only at mount
|
||||
(`app/h/[hostId]/index.tsx:129,330`).
|
||||
Risk: showing stale data as if live — pair each preservation with the existing staleness verdicts
|
||||
rather than inventing new indicators. The in-repo reference pattern is
|
||||
`src/worktree/home-worktree-info.ts:27-47`: counts older than a 10min TTL render as
|
||||
"Last known: N worktrees" instead of being dropped, and `markHomeWorktreeCatalogUnavailable`
|
||||
preserves proven counts across a failed refresh, flagging only `staleCounts`.
|
||||
Tests: per-surface "data survives disconnect→reconnect" cases; F1 largely removes the *trigger*
|
||||
(suspend blips), so this is hardening, not the primary fix.
|
||||
|
||||
## 5. Backward compatibility (old/new phone × old/new desktop)
|
||||
|
||||
- Every fix above is phone-app-local; no RPC methods, close codes, credential formats, or pairing
|
||||
steps change. Old phones against any desktop are untouched (they don't have the code).
|
||||
- New phone + old desktop without relay support: `host.relay` is absent → F1/F5/F6 relay paths
|
||||
never activate; pairing keeps the existing `method_not_found` downgrade
|
||||
(`src/transport/pre-profile-pairing-coordinator.ts:210-217`); F3 logging is inert (no relay
|
||||
candidate is created).
|
||||
- New phone + old desktop with relay: all paths use existing RPCs (`status.get`,
|
||||
`pairing.provisionRelay`, resume confirm) — no new calls introduced. F1's replacement dial reuses
|
||||
the same resume-credential flow lease rotation already exercises against production desktops.
|
||||
- F6's profile hint (if added) is a phone-local persisted field; absent values behave as today.
|
||||
|
||||
## 6. Constants appendix (verified)
|
||||
|
||||
| Constant | Value | Where |
|
||||
|---|---|---|
|
||||
| Direct connect timeout | 12s | `src/transport/rpc-client.ts:126` |
|
||||
| Direct handshake timeout | 5s | `rpc-client.ts:127` |
|
||||
| Direct reconnect ladder | 0.5→60s, give up 12, trickle 90s | `rpc-client.ts:113-117` |
|
||||
| `migrateTo` auth timeout | 12s | `src/transport/stable-logical-rpc-client.ts:182` |
|
||||
| Relay backoff | 250ms floor, 500ms base, 30s ceiling, full jitter | `src/transport/mobile-relay-retry-delays.ts:3-5` |
|
||||
| Host-offline relay retry | 5–15s | `mobile-relay-retry-delays.ts:7-8` |
|
||||
| Gate reprobe cadence | 60s→15min | `mobile-relay-retry-delays.ts:13-14` |
|
||||
| Director resolve timeout | 5s (invite & resume) | `mobile-relay-invite-director.ts:16`, `mobile-relay-resume-director.ts:21` |
|
||||
| Pairing relay recovery | ≤3 attempts × (5s director + ≤100/200/400ms jitter) | `src/transport/pairing-relay-candidate.ts:42,58-71` |
|
||||
| Pairing overall cap | 25s | `app/pair-confirm.tsx:27` |
|
||||
| Relay E2EE layer timers | none | `mobile-relay-e2ee-link.ts`, `mobile-e2ee-v2-*.ts` |
|
||||
| Worktree cache TTL | 30s from write/seed | `src/cache/worktree-cache.ts:12` |
|
||||
| Direct activity probe (foreground) | detects half-open ≤8s | `rpc-client.ts:1119-1124` |
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,13 +33,14 @@ authority.
|
||||
|
||||
## Current Capabilities
|
||||
|
||||
| Capability | Preferred behavior | Compatibility behavior |
|
||||
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 |
|
||||
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
|
||||
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
|
||||
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
|
||||
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
|
||||
| Capability | Preferred behavior | Compatibility behavior |
|
||||
| --------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fetch-no-write-fetch-head` | Fetch a private rebase ref without changing worktree-local `FETCH_HEAD` | Serialize all Orca fetch/pull operations per worktree Git directory before Git 2.29 |
|
||||
| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 |
|
||||
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
|
||||
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
|
||||
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
|
||||
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
|
||||
|
||||
## Why Not `simple-git`
|
||||
|
||||
@@ -54,7 +55,7 @@ capability problem.
|
||||
## CI Contract
|
||||
|
||||
PR checks run the capability contract against real Git 2.25.5, 2.38.1, and
|
||||
2.49.1 binaries. This spans the core-workflow baseline, the transitional
|
||||
2.49.1 binaries. This spans the pre-2.29 serialized `FETCH_HEAD` fallback, the transitional
|
||||
`merge-tree --write-tree` behavior before `--merge-base`, and current Git.
|
||||
|
||||
Keep the unit tests alongside that matrix. They cover concurrent probes,
|
||||
|
||||
@@ -17,23 +17,59 @@ differ on other Debian-derived releases.
|
||||
|
||||
## Ubuntu and Debian prerequisites
|
||||
|
||||
Install the AppImage runtime dependency and Xvfb:
|
||||
Install the CLI tools, Xvfb, and the shared libraries Electron links against.
|
||||
A minimal server or container image ships none of the Electron libraries, and
|
||||
`orca serve` then fails before Electron starts:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl file jq xvfb zlib1g-dev
|
||||
sudo apt-get install -y \
|
||||
curl file jq xvfb zlib1g-dev ca-certificates git \
|
||||
libgtk-3-0t64 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libgbm1 libasound2t64 \
|
||||
libxtst6 libcups2t64 libdrm2 libxkbcommon0 libpango-1.0-0 libcairo2 libatspi2.0-0t64 \
|
||||
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxrender1 libx11-xcb1 \
|
||||
libxcb-dri3-0 libxss1
|
||||
```
|
||||
|
||||
On Ubuntu 22.04, install `libfuse2` to execute the AppImage through FUSE. On
|
||||
Ubuntu 24.04 and Debian, the equivalent package may be `libfuse2t64`. FUSE is
|
||||
That command is for Ubuntu 24.04 and newer and Debian 13 and newer. Those
|
||||
releases carried out the 64-bit `time_t` transition, which renamed six of the
|
||||
packages with a `t64` suffix. On Ubuntu 20.04, Ubuntu 22.04, and Debian 12,
|
||||
substitute the unsuffixed names:
|
||||
|
||||
- `libgtk-3-0t64` becomes `libgtk-3-0`
|
||||
- `libatk1.0-0t64` becomes `libatk1.0-0`
|
||||
- `libatk-bridge2.0-0t64` becomes `libatk-bridge2.0-0`
|
||||
- `libasound2t64` becomes `libasound2`
|
||||
- `libcups2t64` becomes `libcups2`
|
||||
- `libatspi2.0-0t64` becomes `libatspi2.0-0`
|
||||
|
||||
The other names are identical on every supported release. The substitution is not
|
||||
symmetric, so use the list that matches the release. A `t64` name on Ubuntu 20.04,
|
||||
Ubuntu 22.04, or Debian 12 fails immediately with `E: Unable to locate package
|
||||
libgtk-3-0t64`. In the other direction the old names mostly still resolve, because
|
||||
each renamed package declares `Provides:` its unsuffixed name — except `libasound2`
|
||||
on Ubuntu 24.04, where `liboss4-salsa-asound2` in `universe` claims that name too.
|
||||
apt will not choose between two providers and exits with `E: Package 'libasound2'
|
||||
has no installation candidate`, which aborts the entire install line and leaves none
|
||||
of the libraries installed.
|
||||
|
||||
On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through
|
||||
FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain
|
||||
`libfuse2` name also resolves there because nothing else provides it. FUSE is
|
||||
optional: without it, use the AppImage's supported extraction path:
|
||||
|
||||
```bash
|
||||
cd /opt/orca
|
||||
./orca-linux.AppImage --appimage-extract
|
||||
sudo chmod -R a+rX /opt/orca/squashfs-root
|
||||
/opt/orca/squashfs-root/AppRun serve --port 6768
|
||||
```
|
||||
|
||||
The `chmod` is required whenever the extraction runs as a different user than
|
||||
the server: `--appimage-extract` creates `squashfs-root` as `drwx------` owned by
|
||||
the extracting user, so anyone else — including a dedicated service user — cannot
|
||||
even traverse it, and the run fails before Electron starts.
|
||||
|
||||
Docker commonly has no FUSE device. Use `--appimage-extract` once or
|
||||
`--appimage-extract-and-run`; neither requires a privileged container. The
|
||||
extract-and-run wrapper can print extracted paths before Orca starts, so
|
||||
@@ -144,8 +180,15 @@ AppImage, but must not be able to replace it or the rollback artifacts.
|
||||
sudo useradd --system --create-home --shell /usr/sbin/nologin orca
|
||||
sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage
|
||||
sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage
|
||||
# Only if you ran --appimage-extract: extraction leaves squashfs-root root-only.
|
||||
sudo chmod -R a+rX /opt/orca/squashfs-root
|
||||
```
|
||||
|
||||
The last line matters because the two halves of this guide combine badly without
|
||||
it. `--appimage-extract` writes `squashfs-root` as `drwx------ root root`, so the
|
||||
`orca` service user cannot read or traverse the extracted tree and the unit fails
|
||||
at startup. `chmod 755 /opt/orca` alone does not reach into it.
|
||||
|
||||
For most hosts, one `orca serve` service is enough because Orca starts Xvfb on
|
||||
display `:99` when no display exists:
|
||||
|
||||
@@ -834,11 +877,12 @@ refuse to run there and print the command to run on the machine you want.
|
||||
- GPU or DRI warnings on a VPS: keep `LIBGL_ALWAYS_SOFTWARE=1` in the service
|
||||
environment.
|
||||
- Chromium sandbox errors: confirm the service is running as the non-root
|
||||
`orca` user and that `/opt/orca` is readable by that user.
|
||||
`orca` user and that `/opt/orca` is readable by that user, including
|
||||
`/opt/orca/squashfs-root` if you extracted the AppImage.
|
||||
- Clients cannot connect: make sure `--pairing-address` is an address reachable
|
||||
from the client, and make sure firewalls allow the selected `--port`.
|
||||
- Journal shows `Another Orca instance is already running for this userData
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
`RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the
|
||||
owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or
|
||||
keep it and leave the unit down), then run
|
||||
@@ -857,4 +901,7 @@ refuse to run there and print the command to run on the machine you want.
|
||||
`sudo systemctl reset-failed orca-serve.service` first.
|
||||
- Diagnosing other missing libraries: extract the AppImage without launching it
|
||||
with `./orca-linux.AppImage --appimage-extract`, then run
|
||||
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
|
||||
`ldd squashfs-root/orca-ide` to list any shared libraries the host is missing.
|
||||
The Electron binary is `orca-ide`, not `orca`; `ldd` on a path that does not
|
||||
exist prints nothing and exits cleanly, which reads as a clean result in
|
||||
exactly the situation where you are hunting a missing library.
|
||||
|
||||
@@ -25,11 +25,11 @@ broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/99
|
||||
The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved
|
||||
several long-stable functions into libc under brand-new symbol versions:
|
||||
|
||||
| Symbol | New version | node-pty use |
|
||||
| ----------------- | ------------- | ----------------------- |
|
||||
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
|
||||
| `openpty` | `GLIBC_2.34` | allocate the pty |
|
||||
| `forkpty` | `GLIBC_2.34` | fork the shell |
|
||||
| Symbol | New version | node-pty use |
|
||||
| ----------------- | ------------ | ----------------------- |
|
||||
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
|
||||
| `openpty` | `GLIBC_2.34` | allocate the pty |
|
||||
| `forkpty` | `GLIBC_2.34` | fork the shell |
|
||||
|
||||
Electron itself (glibc 2.25) and the other bundled native modules
|
||||
(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under
|
||||
@@ -82,6 +82,32 @@ libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore
|
||||
needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the
|
||||
app itself still launches on stock 20.04.
|
||||
|
||||
**3. Check before loading, on hosts that ship without a compiler (`orcad`).**
|
||||
The two gates above protect the packaged desktop app, where the binary is built and
|
||||
verified by the same pipeline. `orcad` is deployed to hosts Orca never built on, so it
|
||||
adds a runtime precondition
|
||||
([`src/main/orcad/node-pty-precondition.ts`](../../src/main/orcad/node-pty-precondition.ts)),
|
||||
run from `main.ts` before anything requires `node-pty`. It loads the addon in a **child
|
||||
process**, so a binary the loader refuses — or one that aborts outright — is data rather
|
||||
than this process's death, and the operator gets a sentence naming the host's libc, its
|
||||
Node ABI, its prebuild slot and the command to run. A proven-unloadable binary exits 78
|
||||
(`EX_CONFIG`) instead of reaching the `require`; a probe that never answered is reported
|
||||
as unverifiable and boots anyway, because a silent probe is not evidence. Whatever it
|
||||
finds is published in `status.get`'s `degradations[]` under `terminal_unavailable`.
|
||||
|
||||
**4. Ship the binary, built from patched sources.**
|
||||
[`config/scripts/build-orcad-prebuilds.mjs`](../../config/scripts/build-orcad-prebuilds.mjs)
|
||||
(`pnpm run build:orcad-prebuilds`, after `build:orcad`) compiles node-pty for the current
|
||||
host and files it under `out/orcad/prebuilds/<slot>/`, where a slot is
|
||||
`linux-{x64,arm64}-{glibc,musl}` or `darwin-{x64,arm64}`. libc is part of the slot name
|
||||
because node-pty's own loader falls back to `prebuilds/<platform>-<arch>` and cannot tell
|
||||
glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`.
|
||||
The script refuses to compile a tree where `config/patches/node-pty@1.1.0.patch` is not
|
||||
applied: without the patch the prebuilt is a #9902 crash shipped as an artifact rather
|
||||
than a first-connect error. CI runs it once per slot inside the matching container
|
||||
(`--slot=` forces the label), merges the trees, and `--require-slots` fails a release with
|
||||
a hole in the matrix.
|
||||
|
||||
## Adding or upgrading a native dependency
|
||||
|
||||
- Prefer packages that ship prebuilt binaries compiled against an old toolchain
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# macOS press-and-hold and key repeat
|
||||
|
||||
macOS opens the accent picker when a key is held unless an application opts out in its preferences
|
||||
domain. That prevents held keys from repeating in terminal applications such as vim. On the first
|
||||
eligible launch, Orca writes:
|
||||
|
||||
```sh
|
||||
defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false
|
||||
```
|
||||
|
||||
The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and
|
||||
non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect
|
||||
on the next launch.
|
||||
|
||||
## Precedence and decision record
|
||||
|
||||
Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a
|
||||
user choice and preserved. Only an unset key receives the `false` default.
|
||||
|
||||
The decision is stored once in
|
||||
`<userData>/macos-press-and-hold-default.json`. An `applied` or
|
||||
`kept-user-preference` decision prevents future launches from touching the domain again.
|
||||
Probe and write failures remain retryable so a transient failure does not permanently disable the
|
||||
fix.
|
||||
|
||||
`defaults read <domain> <key>` is used instead of
|
||||
`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an
|
||||
explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures,
|
||||
timeouts, and other exit statuses leave the preference alone.
|
||||
|
||||
## Restoring the accent picker
|
||||
|
||||
Set the preference explicitly, then restart Orca:
|
||||
|
||||
```sh
|
||||
defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true
|
||||
```
|
||||
|
||||
After Orca has recorded its one-time decision, deleting the key also restores the macOS default
|
||||
without Orca recreating it:
|
||||
|
||||
```sh
|
||||
defaults delete com.stablyai.orca ApplePressAndHoldEnabled
|
||||
```
|
||||
|
||||
Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that
|
||||
domain instead when applicable.
|
||||
|
||||
## Reverting
|
||||
|
||||
Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert
|
||||
must also arrange to delete the key for users who ran an affected build.
|
||||
|
||||
## Test coverage
|
||||
|
||||
Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership,
|
||||
record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins
|
||||
the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Running orcad
|
||||
|
||||
`orcad` is the Orca runtime served from plain Node. This is the contract between it and
|
||||
whatever supervises it: what it binds, what it owns on disk, who restarts what, and what its
|
||||
readiness payload actually proves.
|
||||
|
||||
Design background: `docs/design/shipping-orcad.html` §00c and §04.
|
||||
|
||||
## Two long-lived processes, not one
|
||||
|
||||
A deployment is **orcad** plus **the terminal daemon**.
|
||||
|
||||
| | orcad | terminal daemon |
|
||||
| ---------- | -------------------------------- | ------------------------------------- |
|
||||
| Started by | the supervisor | orcad, detached |
|
||||
| Owns | RPC, git, worktrees, persistence | every local PTY |
|
||||
| Lifetime | one supervised run | **outlives orcad** |
|
||||
| Endpoint | `ws://<bind>:<port>` | `<data-root>/daemon/daemon-v<N>.sock` |
|
||||
|
||||
The daemon outliving orcad is the property the whole peer model is recommended for
|
||||
(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime
|
||||
restart, so a restart, an update or a rollback does not destroy running work. Everything
|
||||
below exists to keep that true.
|
||||
|
||||
**Consequence for supervision:** orcad's shutdown path calls `disconnectDaemon()`, never
|
||||
`shutdownDaemon()`. A supervisor that reaps orcad's whole process group — systemd's
|
||||
`KillMode=control-group` — kills the daemon too and turns every restart back into data loss.
|
||||
Use `KillMode=mixed` (the default) or `process`, and never `--send-sigkill` on the group.
|
||||
|
||||
## Bind policy
|
||||
|
||||
`--bind <literal-ip>`, **default `127.0.0.1`**.
|
||||
|
||||
Only literal IPs are accepted; hostnames are refused because DNS would decide which
|
||||
interface got bound. `localhost` maps to `127.0.0.1`. `0.0.0.0` / `::` are the explicit
|
||||
opt-ins to network reach, and the startup log says so on every launch.
|
||||
|
||||
The bind is **pinned**, not defaulted. Two things widen the desktop's listener on their own —
|
||||
`orca serve`'s wide default, and a startup where some device has connected before — and an
|
||||
unattended host's exposure must be exactly what the operator asked for on every launch. A
|
||||
mobile pairing offer, which normally rebinds to all interfaces, is refused while the bind is
|
||||
pinned to loopback and reports `network_exposure_failed` rather than advertising an endpoint
|
||||
nothing can reach.
|
||||
|
||||
Under the shipping design a client reaches a remote orcad over an SSH local port-forward, so
|
||||
loopback is the correct default and the pairing credential travels over SSH.
|
||||
|
||||
## Data root and the instance lock
|
||||
|
||||
The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`.
|
||||
|
||||
Before the profile index or the store is touched, orcad takes `<data-root>/orcad.lock`.
|
||||
It refuses to start when:
|
||||
|
||||
| Code | Meaning |
|
||||
| -------------------------------------- | ------------------------------------------------------------- |
|
||||
| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
|
||||
| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
|
||||
| `orcad_instance_lock_held` | another live orcad owns this root |
|
||||
| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
|
||||
| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
|
||||
|
||||
A root that is merely too permissive and that we own is tightened to `0700` rather than
|
||||
refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal
|
||||
is a private root, and refusing when we could just fix it helps nobody. We refuse when the
|
||||
permissions are not ours to fix. Windows is exempt from the owner and mode checks: ACLs are
|
||||
not expressible as a POSIX mode, and `statSync().mode` there reports a synthesized one.
|
||||
|
||||
A dead holder's record is reclaimed (PID plus process start time, so a recycled PID does not
|
||||
read as alive). A record belonging to a different identity is never reclaimed.
|
||||
|
||||
**The lock scopes one role — who is the runtime.** It deliberately says nothing about the
|
||||
daemon, which lives under `<data-root>/daemon` and fences its own endpoint with its own PID
|
||||
record. A lock that asked "is any process using this root" would refuse exactly the restarts
|
||||
a live daemon makes worthwhile.
|
||||
|
||||
## Supervision
|
||||
|
||||
### Who supervises orcad
|
||||
|
||||
An external supervisor (systemd, launchd, a process manager). orcad conforms to it:
|
||||
|
||||
- **Readiness.** One JSON line on stdout (`--json`), `type: "orca_server_ready"`, published
|
||||
after the listener is bound and the daemon verdict is in. There is no separate readiness
|
||||
socket; the line is the signal. Set the supervisor's start timeout generously — the daemon
|
||||
launch has its own retries and can take tens of seconds on a cold host.
|
||||
- **Shutdown.** `SIGTERM` or `SIGINT` starts a graceful stop. A **second** signal exits
|
||||
immediately with code 1 rather than being swallowed — a supervisor's second signal means
|
||||
its first deadline elapsed, and waiting silently is what turns a stop into a `SIGKILL`,
|
||||
the one teardown that skips the daemon handoff. orcad also imposes its own 15s deadline
|
||||
and exits 1, so the failure stays attributable instead of arriving as an unlogged kill.
|
||||
- **Exit codes.**
|
||||
|
||||
| Code | Meaning | Supervisor should |
|
||||
| ---- | ------------------------------------------------------------ | -------------------- |
|
||||
| 0 | clean shutdown | restart per policy |
|
||||
| 1 | startup or shutdown failure | restart with backoff |
|
||||
| 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
|
||||
|
||||
78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data
|
||||
root owned by someone else is a restart-spin, not a recovery.
|
||||
|
||||
- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract
|
||||
to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes
|
||||
its own NDJSON lifecycle log to `<data-root>/logs/daemon.log` (suppressed by
|
||||
`ORCA_DIAGNOSTICS_DISABLED=1`). Rotation of that file is not implemented — see
|
||||
[What is not covered](#what-is-not-covered).
|
||||
|
||||
### orcad supervising the daemon
|
||||
|
||||
- **Launch.** Forked detached from `daemon-entry.js` beside `orcad.js`, with its own PID
|
||||
record, token and socket under `<data-root>/daemon`.
|
||||
- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not
|
||||
replaced, unless it is unhealthy, foreign, or built from a superseded bundle _and_ owns no
|
||||
live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers
|
||||
to live work.
|
||||
- **Restart.** The adapter respawns the daemon on death, transparently to callers.
|
||||
- **Crash-loop containment.** At most **5 launches per 60s rolling window** per orcad run;
|
||||
past that, launches are refused with `daemon_crash_loop` and terminals fail with that
|
||||
message instead of the process forking forever. The window slides, so a repaired host
|
||||
recovers without restarting orcad. An operator-initiated daemon restart clears it — that
|
||||
is the deliberate "try again".
|
||||
- **No macOS login-session watch.** That watch retires the daemon when the spawning GUI login
|
||||
session dies. An orcad daemon must survive its SSH session ending.
|
||||
- **Shutdown.** orcad never stops the daemon. A daemon that was never adopted retires itself
|
||||
after its adoption window; an adopted one stays resident (see Decommissioning).
|
||||
|
||||
### Decommissioning
|
||||
|
||||
The daemon outliving orcad is deliberate, so stopping orcad does **not** leave the host with
|
||||
zero Orca processes. A daemon that has been adopted stays resident after its runtime
|
||||
disconnects — that is what makes the next start a reattach rather than a cold restore. To
|
||||
retire a host completely, stop orcad and then stop the daemon named by
|
||||
`health.terminalDaemon.pid`, or delete the data root and let the endpoint go stale.
|
||||
|
||||
## Health
|
||||
|
||||
The readiness payload carries a `health` object:
|
||||
|
||||
```
|
||||
buildHash sha256 (16 hex) of the running orcad bundle — build identity that a version
|
||||
string cannot give, so a rollback that did not replace the file is visible
|
||||
buildVersion ORCA_VERSION
|
||||
nodeVersion / nodeAbi process.versions.node / .modules — the ABI native addons must match
|
||||
platform / arch / pid
|
||||
terminalDaemon:
|
||||
state live | degraded | absent
|
||||
ownsFreshSessions whether NEW terminals are daemon-owned, i.e. survive an orcad restart
|
||||
pid the live daemon's pid, from its own PID record
|
||||
buildVersion the build the LIVE daemon was forked from (may legitimately predate
|
||||
this orcad after an update — reporting orcad's version for both would
|
||||
hide exactly that)
|
||||
entryPath / protocolVersion
|
||||
selfTest { ok, coverage, verdict, durationMs }
|
||||
```
|
||||
|
||||
### What the self-test proves
|
||||
|
||||
`selfTest` runs `checkDaemonHealth` against the daemon's socket. It is green only when the
|
||||
daemon **opened its socket, completed the protocol handshake, and ran `ptySpawnHealth` — a
|
||||
real short-lived PTY spawned inside the daemon's own process**. It therefore spans both
|
||||
processes: orcad drives it, the daemon performs it, the verdict crosses the socket.
|
||||
|
||||
- `coverage: 'pty-spawn'` — the full round trip above.
|
||||
- `coverage: 'handshake'` — **win32 only**, where `checkPtySpawnHealth` returns without
|
||||
spawning anything. A green verdict there covers the handshake and nothing more. It is
|
||||
reported separately rather than folded into `ok` so nobody reads it as a PTY round trip.
|
||||
|
||||
`state` is `live` only when the self-test passed **and** `ownsFreshSessions` is true. A
|
||||
daemon that answers but has fallen back to local spawning for new terminals is `degraded`,
|
||||
because those terminals die with orcad. A daemon that answered and then failed its spawn
|
||||
probe is also `degraded`, not `absent`: it still holds live sessions, and calling those
|
||||
exited would be the verdict `ssh-execution-boundary.md` forbids guessing.
|
||||
|
||||
## What is not covered
|
||||
|
||||
Named here so nothing reads as implemented that is not:
|
||||
|
||||
- **A continuous health endpoint.** `health` is published once, in the readiness payload. A
|
||||
supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the same
|
||||
`collectOrcadHealth()`; that surface does not exist yet.
|
||||
- **libc slot.** §04 asks for it in the health payload. It belongs to the native strategy
|
||||
(plan item 5), which owns libc detection; there is no honest value to publish until then.
|
||||
- **`degradations[]`.** Plan item 2's contract, not this one.
|
||||
- **Credential administration** (list / revoke / rotate devices, expiring pending offers,
|
||||
structured security logging) — §04, not delivered here.
|
||||
- **Pinned-port fail-closed.** A pinned `--port` still falls back to an OS-assigned port on
|
||||
conflict.
|
||||
- **Reconciling `webClientUrl` with reachability** under the loopback default.
|
||||
- **State-schema rollback rules.**
|
||||
- **Daemon log rotation.** `<data-root>/logs/daemon.log` grows unbounded.
|
||||
@@ -113,7 +113,7 @@ getting that wrong turns a skew into a false "nothing is blocked".
|
||||
unverifiable, the pane was unreadable, or the agent probe did not answer in time.
|
||||
|
||||
A new client against an old host sees the field absent, which is why absence must read as
|
||||
*unknown* and never as *not waiting*. Collapsing absent into `null` at any hop — including a
|
||||
_unknown_ and never as _not waiting_. Collapsing absent into `null` at any hop — including a
|
||||
convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable
|
||||
from a healthy idle worker, which is the exact failure the field exists to remove.
|
||||
|
||||
@@ -138,3 +138,53 @@ error payload and read that instead. An old host omits it and the message match
|
||||
covers them; once hosts that send it are the floor, the message match can be deleted
|
||||
rather than lived with at its ~10 call sites. Narrowing `isENOENT` back to `.code`
|
||||
without doing this reinstates the bug — the transport has already overwritten it.
|
||||
|
||||
## Known hazard: clients ignore host-published failure fields on client-placed pages
|
||||
|
||||
`RuntimeMobileSessionBrowserTab` — the browser tab a host publishes on the session-tab sync
|
||||
channel — permits `placement`, `loadError` and `certificateFailure` together. But for a tab
|
||||
whose `placement.kind` is `'client'` the engine runs in the client's own app: the failure is
|
||||
raised by the local guest webview, and the host has no view of it (`RuntimeBrowserClientPage`,
|
||||
what the registry actually publishes from, carries neither field). Clients from
|
||||
this version on therefore refuse host ownership of both records for client-placed pages
|
||||
(`web-session-tabs-sync.ts`, the `placement?.kind !== 'client'` carve-outs) — without that,
|
||||
each metadata snapshot deletes the locally recorded failure and the page's failure overlay
|
||||
disappears mid-navigation.
|
||||
|
||||
The hazard is forward-facing and Rule 3 shaped. A host that later starts publishing
|
||||
`loadError` or `certificateFailure` for a client-placed page reaches these clients as content
|
||||
they silently drop, so the host would see no error and no effect. Publishing it has to be
|
||||
capability-gated, with the carve-out narrowed to clients that did not negotiate the
|
||||
capability. Note the cross-version harness does not exercise the session-tab sync channel, so
|
||||
nothing fails if this is forgotten — this note is the only record.
|
||||
|
||||
A related carve-out covers `title`, `url`, `loading`, `canGoBack` and `canGoForward`
|
||||
(`resolveMirroredBrowserPageContent`), and for those the hazard is already live rather than
|
||||
forward-facing: the host does publish them, from a `RuntimeBrowserClientPage` it can only learn
|
||||
about second-hand through the client's own `browser.clientHost.pageMetadata` calls. Its copy
|
||||
therefore starts at the registry defaults (`'Browser'`, the create-time url), and while those
|
||||
publishes are failing it never leaves them.
|
||||
|
||||
That copy is not simply behind, though, and a client must not treat it as such. When a lease
|
||||
reattaches, the host refreshes the page from the client host's own inventory
|
||||
(`runtime-browser-client-page-recovery.ts`), which reads the live guest — so it can be strictly
|
||||
fresher than a local row whose pane is unmounted and whose metadata publisher was disposed with
|
||||
it. A client that ignores the host url is relying on its own guest to re-answer on remount,
|
||||
which `ClientHostedBrowserPagePane`'s mount-time `syncNavigation` is what makes true.
|
||||
|
||||
These five are therefore refused only by the client whose guest actually runs the page:
|
||||
`placement.browserHostClientId` is compared against this client's own host id
|
||||
(`readBrowserClientHostId`). Main stamps that id into the guest-hosting window's
|
||||
`additionalArguments` at creation, and the preload reads it back out of its own argv — the answer
|
||||
has to be there before the first snapshot is interpreted, which is earlier than any IPC handler a
|
||||
renderer could wait on. Every other viewer — a second desktop, the web client, which installs no
|
||||
page renderer at all, the dashboard pop-out, which is deliberately left unstamped — keeps tracking
|
||||
the host, which is the only reason a mirrored viewer shows anything but its first snapshot
|
||||
forever. Improving what a _second_ client sees still means fixing the publish, not the carve-out;
|
||||
the carve-out no longer stands in the way of it.
|
||||
|
||||
The two failure fields above are deliberately left on the looser `placement?.kind !== 'client'`
|
||||
predicate. It is unobservable today — the host publishes neither field for a client-placed page at
|
||||
all, so a mirror has nothing to take either way. If the capability-gated publish this section
|
||||
anticipates ever lands, narrow them the same way rather than by placement kind: a mirror should
|
||||
take a failure it cannot otherwise see, and only the hosting client should refuse it.
|
||||
|
||||
@@ -28,11 +28,11 @@ Three corrections to the first draft:
|
||||
verified. The ssh2 proxy-spawn at `:697` is effectively unreachable. Good news for migration, and
|
||||
the first draft's motivating example was simply wrong.
|
||||
- **Agent forwarding was overstated.** `agentForward` is gated on the user's `ForwardAgent yes`
|
||||
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent *auth*, whose
|
||||
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent _auth_, whose
|
||||
signatures bind the session id and cannot be replayed onward. The risk applies to users who opted
|
||||
into `ForwardAgent`, not everyone.
|
||||
- **Credential theft was understated, and the relay claim was backwards.** `isAgentFallbackError`
|
||||
treats *any* auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
|
||||
treats _any_ auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
|
||||
publickey walks the user to the password prompt (`ssh-connection.ts:844`) and the private-key
|
||||
**passphrase** prompt (`:834`), and `cachedPassword` is replayed without prompting on every
|
||||
reconnect (`:709`). Meanwhile the relay upload matters less than assumed — the attacker already
|
||||
@@ -65,7 +65,7 @@ Two consequences to own rather than discover:
|
||||
|
||||
There is no `ssh`-only way out of this: `-F /dev/null` does NOT invert the exclusion, it reports
|
||||
built-in defaults, so a probe built on it looks permissive on every machine. Verified against
|
||||
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — *could*
|
||||
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — _could_
|
||||
the site config be restricting host keys — where anything ambiguous (unreadable, an unresolvable
|
||||
`Include`, the directive present at all) keeps the refusal. Only a site config that demonstrably
|
||||
says nothing about host keys clears it, which is what stops the rule punishing every devcontainer,
|
||||
@@ -130,16 +130,16 @@ connect, training them to dismiss the one warning that matters.
|
||||
|
||||
> **Corrected against a live client.** The premise above is wrong about OpenSSH, though the
|
||||
> conclusion survives. `check_key_in_hostkeys` is not type-scoped at all: ANY non-marker entry for
|
||||
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — known_hosts
|
||||
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — `known_hosts`
|
||||
> holding only `ssh-rsa` against an ed25519-only server prints `IDENTIFICATION HAS CHANGED` and
|
||||
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the *situation* via
|
||||
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the _situation_ via
|
||||
> `order_hostkeyalgs`, and hard-fails when the situation arises anyway. Our split into `mismatch`
|
||||
> and `unknown-type-known-host` therefore only chooses the wording — both refuse, which is ssh's
|
||||
> action. What the ordering below buys us is what it buys ssh: the situation mostly never arises.
|
||||
|
||||
**But scoping alone is a downgrade vector, and this is the correction that most changes the design.**
|
||||
OpenSSH is safe here only because `order_hostkeyalgs()` reorders the client's proposed host-key
|
||||
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the *client's* order
|
||||
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the _client's_ order
|
||||
priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first
|
||||
regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a
|
||||
friendly first-contact prompt instead of a hard failure.
|
||||
@@ -147,7 +147,7 @@ friendly first-contact prompt instead of a hard failure.
|
||||
Therefore: **set ssh2's `algorithms.serverHostKey` to lead with the key types already known for that
|
||||
host.** Type scoping without algorithm ordering is not a safe design.
|
||||
|
||||
And when the presented type is unknown *while other types are known for this host*, that is
|
||||
And when the presented type is unknown _while other types are known for this host_, that is
|
||||
`unknown-type-known-host` — never a plain TOFU prompt. It must say we already hold a different key
|
||||
for this host.
|
||||
|
||||
@@ -193,7 +193,7 @@ means nothing is known.
|
||||
|
||||
### D5. Recovery must not live in the failure dialog
|
||||
|
||||
A "forget this host key" button *in* the mismatch dialog is D4's rejected "trust anyway" with one
|
||||
A "forget this host key" button _in_ the mismatch dialog is D4's rejected "trust anyway" with one
|
||||
extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it
|
||||
shows the stored fingerprint so the user is choosing knowingly.
|
||||
|
||||
@@ -201,7 +201,7 @@ Offer it only when **our** store is what disagreed; when `known_hosts` disagrees
|
||||
record cannot unblock the connect. Messages, written to avoid naming internals:
|
||||
|
||||
> **Ours disagreed** — "The host key for `build-01` changed since you last connected from Orca. If you
|
||||
> rebuilt or reprovisioned this machine, this is expected." → *Forget the saved key* / *Cancel*
|
||||
> rebuilt or reprovisioned this machine, this is expected." → _Forget the saved key_ / _Cancel_
|
||||
|
||||
> **`known_hosts` disagreed** — "The host key for `build-01` does not match the entry in
|
||||
> `~/.ssh/known_hosts`. `ssh` and `git` will refuse this host too. Run `ssh-keygen -R build-01`." →
|
||||
@@ -239,7 +239,7 @@ machines, two targets can name one machine, and a re-created target must not los
|
||||
The store is a **dedicated file**, not the main persistence blob (`persistence.ts:7088`): a settings
|
||||
restore or rollback must not silently reset trust. Accept and mismatch events are logged.
|
||||
|
||||
`hostKeyFingerprint` is now security-relevant *and* wire-relevant — it is an isolation namespace sent
|
||||
`hostKeyFingerprint` is now security-relevant _and_ wire-relevant — it is an isolation namespace sent
|
||||
to the host (`ssh-relay-session.ts:1298`, `managed-hook-owner-identity.ts:187`). It is `undefined` on
|
||||
the system transport, so **no trust logic may key off it**, and its format must not change (see
|
||||
Traps).
|
||||
@@ -247,11 +247,11 @@ Traps).
|
||||
## Phasing — ship the defence before the dialog
|
||||
|
||||
Review made the case that the riskiest part of this change is not the security model but the modal.
|
||||
Startup restore fires eager connects for *all* previously-active targets in parallel (`App.tsx:1041`)
|
||||
Startup restore fires eager connects for _all_ previously-active targets in parallel (`App.tsx:1041`)
|
||||
with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs
|
||||
outliving the timeout that already deferred them. Runtime-owned ephemeral VMs
|
||||
(`ephemeral-vm-runtime-ssh.ts:31`) dial a freshly provisioned host with a brand-new key on every
|
||||
launch. Paired-web connects run on the *host desktop* (`runtime/rpc/methods/ssh.ts:32`), so the
|
||||
launch. Paired-web connects run on the _host desktop_ (`runtime/rpc/methods/ssh.ts:32`), so the
|
||||
dialog would open on someone else's screen while the web user watches a spinner.
|
||||
|
||||
**Phase 1 — no new modal.** Consult `known_hosts` + our store. `match` connects. `unknown` persists
|
||||
@@ -301,10 +301,11 @@ Each of these makes the fix silently do nothing. All confirmed in our tree.
|
||||
## Scope
|
||||
|
||||
**In scope, corrected:** IPv6 literals and `[host]:port` bracket parsing. Review was right that this
|
||||
is a *parser* requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
|
||||
is a _parser_ requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
|
||||
`unknown`, which is the prompt-training harm D3 exists to avoid.
|
||||
|
||||
**Out of scope, with consequences stated:**
|
||||
|
||||
- **`CheckHostIP`** — OpenSSH defaults it off; we form candidates from the hostname only.
|
||||
- **WSL** — `src/main/ssh/` has no WSL awareness; a distro's `known_hosts` is unreachable, so WSL
|
||||
users get first-contact treatment for hosts they already verified.
|
||||
@@ -337,7 +338,7 @@ verified by running an OpenSSH 10.2p1 client against a real `sshd` on `127.0.0.1
|
||||
its verdict:
|
||||
|
||||
- **The bare-host fallback pass never reports a change.** With `StrictHostKeyChecking=accept-new`, a
|
||||
bare line holding a *different* key, dialed on a non-default port, made ssh connect and append a
|
||||
bare line holding a _different_ key, dialed on a non-default port, made ssh connect and append a
|
||||
new `[127.0.0.1]:2222` line — first contact, no `IDENTIFICATION HAS CHANGED`. Reporting `mismatch`
|
||||
on that pass would refuse hosts ssh connects to happily, and would have looked like the cautious
|
||||
choice.
|
||||
@@ -355,16 +356,16 @@ subtly wrong.**
|
||||
Fixed after review:
|
||||
|
||||
1. **Our own store was type-downgradable.** The inline lookup filtered by key type first and could
|
||||
only answer match/mismatch/unknown, so a record of a *different* type read as `unknown`. D3's
|
||||
only answer match/mismatch/unknown, so a record of a _different_ type read as `unknown`. D3's
|
||||
downgrade, applied to the records we create ourselves. Stored types now also feed the algorithm
|
||||
ordering — without that the guard is only half present.
|
||||
2. **We keyed on the Orca label.** `ssh -G` echoes its own argument back as `hostname` when no Host
|
||||
block matches, so for a manual target `resolved.hostname` *is* the label — the one name D2
|
||||
block matches, so for a manual target `resolved.hostname` _is_ the label — the one name D2
|
||||
forbids. We consulted no entries at all.
|
||||
3. **A refused key still walked the credential ladder.** ssh2 reports a denial as a generic auth
|
||||
failure, so we went on to prompt for the passphrase and hand it to the host we had just refused.
|
||||
Rejections are now a typed error recognised before any fallback.
|
||||
4. **Fail-closed nearly became fail-always.** "No readable known_hosts" counted a *missing* file the
|
||||
4. **Fail-closed nearly became fail-always.** "No readable `known_hosts`" counted a _missing_ file the
|
||||
same as an unreadable one, so a profile that had never connected — everyone's first run — would
|
||||
have been refused, and the suite passed only because dev machines have a `known_hosts`.
|
||||
5. **Ephemeral runtimes were refused for a policy they cannot satisfy.** The carve-out sat below the
|
||||
@@ -404,7 +405,7 @@ tooltip, and the terminal reconnect overlay never asked for it at all. Still ope
|
||||
`getPublicSshState`, so a paired-web client always sees exactly `SSH connection unavailable`.
|
||||
Pre-existing, but it makes the Phase 2 dialog message unreachable there without a change. Note the
|
||||
redaction is not web-only: any target owned by a paired runtime environment is redacted, so a
|
||||
*desktop* user viewing a remote-Orca-server-owned host gets the same generic string.
|
||||
_desktop_ user viewing a remote-Orca-server-owned host gets the same generic string.
|
||||
- **RPC fail-fast** becomes load-bearing the moment the dialog exists (see Phasing).
|
||||
|
||||
**Known gaps that Phase 1 accepts, listed so they are choices and not surprises:**
|
||||
|
||||
@@ -15,7 +15,10 @@ since removed.
|
||||
table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`.
|
||||
|
||||
```ts
|
||||
import { readWindowsProcessTable, readWindowsProcessTableFresh } from '../windows/windows-process-table'
|
||||
import {
|
||||
readWindowsProcessTable,
|
||||
readWindowsProcessTableFresh
|
||||
} from '../windows/windows-process-table'
|
||||
```
|
||||
|
||||
- `readWindowsProcessTable()` — shared TTL cache. Use for anything periodic.
|
||||
@@ -31,15 +34,153 @@ survived its own teardown (#9045).
|
||||
|
||||
Measured on Windows 11 with 1050 processes (p50 / p95):
|
||||
|
||||
| | p50 | p95 |
|
||||
| --- | --- | --- |
|
||||
| pid + ppid + name | 15.9 ms | 17.5 ms |
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
| | p50 | p95 |
|
||||
| -------------------------------- | ------- | ------- |
|
||||
| pid + ppid + name | 15.9 ms | 17.5 ms |
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
|
||||
Those CIM numbers are from a 1050-process host. The scan scales with process
|
||||
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
|
||||
**4.8 MiB** of JSON, against the fallback's 3 s and 8 MiB limits. Both limits
|
||||
match the pre-#15749 reader, so relay hosts are at parity rather than newly at
|
||||
risk — but the headroom is roughly 2x on time and 1.7x on bytes, not the ~4x the
|
||||
706 ms figure implies. On overflow the output is truncated, the JSON fails to
|
||||
parse, and the read rejects, so a busy host loses the table rather than
|
||||
receiving a wrong one.
|
||||
|
||||
## When a read wedges
|
||||
|
||||
The vendored reader pushes every callback onto a module-global queue and drains
|
||||
that queue only when the request holding its `requestInProgress` latch
|
||||
completes. If a Toolhelp32 snapshot never comes back — an EDR hook, a restricted
|
||||
token, a worker that dies — the latch is stuck for the life of the process and
|
||||
every later call parks another closure in that queue.
|
||||
|
||||
Two guards, and they work together:
|
||||
|
||||
- a **3 s deadline** on each read, so a caller gets a rejection instead of a
|
||||
promise that never settles;
|
||||
- a **sticky wedge**: once a read misses its deadline and has not called back,
|
||||
the module refuses every further read until that read's callback fires.
|
||||
|
||||
The wedge used to be a 30 s cooldown that let one probe through per window. That
|
||||
bounded the _rate_ of new callbacks but not the total: a permanently wedged
|
||||
reader retained one more closure every 30 s for as long as the app ran, and each
|
||||
probe also blocked its caller for the full 3 s deadline first. Gating on the
|
||||
outstanding read instead bounds retention at exactly one callback, and gives up
|
||||
nothing on recovery — a probe queued behind the latch could never have observed
|
||||
recovery anyway, whereas the stuck callback firing is the drain itself. On the
|
||||
relay's bare addon, which has no queue of its own, it is also what keeps Orca
|
||||
from re-entering `CreateToolhelp32Snapshot` while a call is still running.
|
||||
|
||||
That last part is not just tidiness. The addon runs each read as a
|
||||
`Napi::AsyncWorker`, so a wedged read holds a libuv threadpool slot for good. On
|
||||
the relay the JS queue is not there to absorb the retries, so one probe per
|
||||
window would have pinned all four default threads inside ~2 minutes — hanging
|
||||
every async `fs` and DNS call in that process, not only the process table.
|
||||
|
||||
A wedge does **not** engage the PowerShell fallback; see the next section for
|
||||
why only absence does.
|
||||
|
||||
## The relay has no binding, and falls back
|
||||
|
||||
Relay deployment installs only `node-pty` and `@parcel/watcher` on the remote
|
||||
host (`RELAY_NATIVE_DEPS` in `src/main/ssh/ssh-relay-deploy.ts`), so a Windows
|
||||
machine used as an SSH host has no `@vscode/windows-process-tree` at all. It is
|
||||
not added there on purpose. Both ways of installing it fail, and both were
|
||||
checked on a real Windows SSH host with 1486 processes:
|
||||
|
||||
**Installing it normally rebuilds from source, and that build fails.** The
|
||||
tarball carries a `binding.gyp`, so npm runs `node-gyp rebuild` regardless of
|
||||
what is already compiled inside it. On a host that _already had_ MSVC Build
|
||||
Tools 2022 installed, that build still failed:
|
||||
|
||||
```
|
||||
error MSB8040: Spectre-mitigated libraries are required for this project.
|
||||
```
|
||||
|
||||
That is the requirement the `binding.gyp` hunk of our patch deletes, and the
|
||||
patch cannot reach a remote host — pnpm patches do not cross SSH. Relay deploy
|
||||
would then break outright rather than degrade: `installNativeDeps` throws on
|
||||
failure, and the toolchain-skip retry is gated to Linux.
|
||||
|
||||
**Skipping the build and using the shipped binary returns a truncated table.**
|
||||
Contrary to what this file used to claim, the published 0.8.0 tarball _does_
|
||||
contain `build/Release/windows_process_tree.node` — an MSVC build directory that
|
||||
looks accidentally published (`.obj` and `.tlog` files ship with it). It is
|
||||
N-API, so it loads on any modern Node. But it predates our patch and still has
|
||||
the `process_count < 1024` cap, so on that 1486-process host:
|
||||
|
||||
```
|
||||
LOADED OK
|
||||
rows=1024
|
||||
selfPid=21964 present=false
|
||||
```
|
||||
|
||||
Exactly 1024 rows, with the querying process itself among the missing. The
|
||||
self-presence guard rejects that, so the fallback engages anyway — but only on
|
||||
hosts busy enough to cross the cap. That is worse than no binding at all: it
|
||||
works on a quiet machine and fails silently under load, which is precisely the
|
||||
shape of bug that survives testing.
|
||||
|
||||
So the constraint is not that no binary exists to ship. It is that the only
|
||||
binary available to ship is the broken one, and building the good one needs a
|
||||
toolchain the remote does not have.
|
||||
|
||||
Instead, `windows-process-table.ts` falls back to
|
||||
`readWindowsProcessRowsWithCim` (`windows-process-table-cim-scan.ts`), the
|
||||
`Get-CimInstance` scan this module replaced. The gate is deliberately narrow:
|
||||
|
||||
- it engages **only** when the module cannot be required, never when a loaded
|
||||
module fails, wedges, or returns an unreadable table — a present-but-failing
|
||||
reader must not silently start forking a shell at the caller's poll rate;
|
||||
- a fallback that also fails still rejects, so "unavailable" never degrades into
|
||||
"nothing is running";
|
||||
- the scan applies the same self-presence guard as the native path.
|
||||
|
||||
`src/main/ssh/relay-native-dependency-coverage.test.ts` asserts that every
|
||||
native addon reachable from the relay entry is either installed on relay hosts
|
||||
or listed there with the reason its absence is safe. That test exists because
|
||||
#15749 shipped this gap: the relay tests injected a fake module through
|
||||
`__setWindowsProcessTreeLoaderForTests`, so nothing exercised the real require.
|
||||
|
||||
## Shipping the native reader to a relay anyway
|
||||
|
||||
The scan is the floor, not the destination: it costs ~1.4 s and a `powershell.exe`
|
||||
where the addon costs ~57 ms. Release builds therefore compile the addon and ship
|
||||
it as an optional relay artifact.
|
||||
|
||||
`config/scripts/build-windows-process-tree-relay-addon.mjs` builds it from the
|
||||
source pnpm has already patched, on a Windows runner, and refuses to run if
|
||||
any patch hunk is missing — the Spectre hunk fails loudly, the 1024-process
|
||||
hunk fails _silently_, and the relative gyp path dies at configure on Windows.
|
||||
The source is checked rather than the install trusted. It also reads the PE
|
||||
machine field of the output, because a cross-build that quietly emitted host
|
||||
arch would ship a binary the target cannot load.
|
||||
|
||||
Windows arm64 cross-compiles from the x64 runner — verified on real hardware,
|
||||
producing `IMAGE_FILE_MACHINE_ARM64` (0xaa64) against x64's 0x8664. It needs the
|
||||
optional _MSVC v143 ARM64 build tools_ component; without it node-gyp fails with
|
||||
`MSB8020`, which is why the addon build runs before the long packaging step.
|
||||
`ORCA_REQUIRE_RELAY_NATIVE_ADDONS` is a per-arch list so a future arch can be
|
||||
added best-effort before it is promoted to required.
|
||||
|
||||
`windows-process-table.ts` binds the bare addon directly rather than the package
|
||||
wrapper. That wrapper adds only a queue over `getProcessList`, and that queue is
|
||||
the wedge described above — it latches a module-global `requestInProgress` with
|
||||
no try/catch. This module already holds a single-flight and a deadline, so going
|
||||
straight to the addon drops the duplicate.
|
||||
|
||||
The artifact is optional in `RELAY_ARTIFACTS`: hashed when present, so a relay
|
||||
carrying it never shares an immutable directory with one that does not, and
|
||||
never probed, because requiring a file only a Windows build machine can produce
|
||||
would make a correct relay read as MISSING and redeploy forever. A relay built
|
||||
on any other OS keeps using the scan.
|
||||
|
||||
## Why the package is patched
|
||||
|
||||
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries two hunks.
|
||||
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries three hunks.
|
||||
|
||||
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
|
||||
libraries, which Orca's Windows build agents do not install. `node-pty` is
|
||||
@@ -49,6 +190,11 @@ Measured on Windows 11 with 1050 processes (p50 / p95):
|
||||
1024 and the querying process was itself among the 27 missing. A truncated
|
||||
snapshot silently hides the descendants a teardown is trying to reap — the
|
||||
exact failure the native path exists to remove.
|
||||
3. **Absolute `node-addon-api` gyp path.** `require('node-addon-api').targets`
|
||||
is cwd-relative. node-gyp on Windows evaluates it from the pnpm store
|
||||
realpath, then loads the relative path from the `node_modules` symlink, so
|
||||
`node_addon_api.gyp` resolves outside the repo and hourly Windows builds
|
||||
die at configure. `node-pty` is patched the same way for the same reason.
|
||||
|
||||
The typings claim `commandLine` is truncated at 512 characters. Measured, it is
|
||||
not: the longest observed on a real host was 26,059.
|
||||
@@ -73,6 +219,13 @@ time to prove a PID has not been recycled — daemon identity, managed-hook
|
||||
ownership, and CPU accounting in the memory collector — still reads it through
|
||||
its own query. Those callers are not migrated.
|
||||
|
||||
Committed private bytes have no equivalent either, and the one memory value the
|
||||
snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores
|
||||
`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the
|
||||
second reason `windows-process-resource-collector.ts` still runs its own
|
||||
`Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time
|
||||
counters in the same pass. Migrating it to the native table would cost both.
|
||||
|
||||
Start time is a proxy for identity, not identity. The durable answer for the
|
||||
process trees Orca itself spawns is an inherited handle: a job object names the
|
||||
tree Orca created, so no start-time comparison is needed. Those readers should
|
||||
@@ -108,7 +261,7 @@ The per-PTY job deliberately does **not** set
|
||||
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Measured on Windows 11: with that flag,
|
||||
releasing the handle when the shell exits also kills whatever the user left
|
||||
running, so typing `exit` in a pane reaped a `start /b` server that used to
|
||||
survive. The job exists to make an *explicit* teardown exact, not to redefine
|
||||
survive. The job exists to make an _explicit_ teardown exact, not to redefine
|
||||
what a clean exit means.
|
||||
|
||||
Reaping a dead daemon's shells (#9195, #10415) is therefore a **second, nested
|
||||
@@ -133,7 +286,7 @@ kill-on-close job on the app, which is exactly what the crash-survival
|
||||
guarantee forbids.
|
||||
|
||||
Once the shell exits, node-pty drops its handle record and closes the job, so a
|
||||
terminated tree reports `null` rather than `[]`. Null means *unverifiable* in
|
||||
terminated tree reports `null` rather than `[]`. Null means _unverifiable_ in
|
||||
the sense of [`ssh-execution-boundary.md`](./ssh-execution-boundary.md) — no job
|
||||
support, not a ConPTY, or no longer tracked. It is never evidence that
|
||||
processes died.
|
||||
@@ -176,8 +329,6 @@ the Windows CI job rebuilds from source before running the win32 suites.
|
||||
it is true before asserting anything else, so an unpatched binary fails loudly
|
||||
instead of passing every case vacuously. That guard is what caught this.
|
||||
|
||||
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
|
||||
exempts win32, on the premise that the patch is Unix-only. That premise is now
|
||||
false, but lifting the exemption also needs `pnpm rebuild` to force a source
|
||||
build — otherwise the assertion fires and the remedy does not fix it. Left as a
|
||||
follow-up rather than changed blind.
|
||||
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` now covers
|
||||
win32 as well, and `pnpm rebuild node-pty` sets `npm_config_build_from_source`
|
||||
so the patched source build actually replaces the upstream prebuild.
|
||||
|
||||
@@ -25,7 +25,7 @@ scripts:
|
||||
|
||||
## Why the script declares it, not the terminal preference
|
||||
|
||||
`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
|
||||
`terminalWindowsShell` says which shell _interactive terminals_ open in. It says nothing about the
|
||||
language a project's setup script is written in. Deriving the runner from it had two consequences:
|
||||
|
||||
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
|
||||
|
||||
@@ -35,7 +35,7 @@ whichever poller arrives first afterwards pays a full-fleet `git worktree list`
|
||||
fan-out. The many high-frequency callers — `listTerminals` without a selector,
|
||||
`showTerminal`, `getWorktreePs`, `listManagedWorktrees`,
|
||||
`resolveWorktreeSelector`, orchestration authority refresh — only determine
|
||||
*who* pays, not *how often*. Steady-state subprocess volume is therefore
|
||||
_who_ pays, not _how often_. Steady-state subprocess volume is therefore
|
||||
`repos / 30 s`, independent of poll rate, which is exactly the observed
|
||||
~1 sweep / 30.5 s.
|
||||
|
||||
@@ -86,16 +86,16 @@ common directory without a subprocess (read `.git`; if it is a `gitdir:` file,
|
||||
follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a
|
||||
bare gitdir), then records:
|
||||
|
||||
| Input | External change it catches |
|
||||
| --- | --- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| Input | External change it catches |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) |
|
||||
|
||||
"per checkout" covers the main worktree and each linked worktree. Reading the
|
||||
@@ -159,21 +159,21 @@ rescans. Capturing after the scan would let that mutation be masked forever.
|
||||
|
||||
Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint
|
||||
included) and bumps the generation, so every event-driven path still forces a
|
||||
real scan on the next read. The fingerprint only ever *extends* an entry that
|
||||
real scan on the next read. The fingerprint only ever _extends_ an entry that
|
||||
the TTL alone would have refreshed.
|
||||
|
||||
## Freshness budget
|
||||
|
||||
| Change | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
| Change | Before | After |
|
||||
| --------------------------------------------------------------------- | ----------------- | ----------------- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
|
||||
The two regressions are bounded by the reconciliation interval and are both
|
||||
changes Orca does not make itself.
|
||||
@@ -185,10 +185,10 @@ thread" in the naive sense — but they are not equally free there. Measured on
|
||||
macOS with a 1 ms interval sampling event-loop lag while each ran 30 times
|
||||
against a repo with 20 linked worktrees:
|
||||
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| --- | --- | --- | --- |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| ------------------- | ------------- | -------------------------- | ------------------ |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
|
||||
`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency
|
||||
is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout
|
||||
@@ -212,10 +212,10 @@ sparse-checkout probes already do.
|
||||
reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30
|
||||
simulated minutes — and counts `git worktree list` invocations:
|
||||
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| --- | --- | --- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| ------------------------ | ------------------------------ | -------- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
|
||||
A 90 % reduction, with the remainder being the bounded reconciliation. Repos
|
||||
with genuine external activity keep rescanning at the 30 s cadence because the
|
||||
@@ -227,7 +227,7 @@ Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272
|
||||
## Rejected alternatives
|
||||
|
||||
**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess
|
||||
reduction, but it degrades *every* external-change latency to 5 min, including
|
||||
reduction, but it degrades _every_ external-change latency to 5 min, including
|
||||
the common "I ran `git worktree add` in a terminal" case. The fingerprint buys
|
||||
the same reduction without that regression.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ exactly the case a POSIX script uses to mean a literal dollar.
|
||||
Build argv with `buildWslExecArgs()` in `src/shared/wsl-login-shell-command.ts`. A test walks the
|
||||
tree and fails if the `--` form reappears.
|
||||
|
||||
The `--` inside `sh -s -- <path>` is a *shell* argument separator and is unrelated; leave it alone.
|
||||
The `--` inside `sh -s -- <path>` is a _shell_ argument separator and is unrelated; leave it alone.
|
||||
|
||||
## 2. Machine-read output must be fenced
|
||||
|
||||
|
||||
@@ -4,24 +4,24 @@ Three layers of coverage, because each catches what the others structurally cann
|
||||
|
||||
## 1. Unit — runs everywhere, every PR
|
||||
|
||||
| Suite | Pins |
|
||||
|---|---|
|
||||
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
|
||||
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
|
||||
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
|
||||
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
|
||||
| Suite | Pins |
|
||||
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
|
||||
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
|
||||
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
|
||||
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
|
||||
|
||||
## 2. Ratchets — the goalposts, enforced continuously
|
||||
|
||||
| Guard | Measures |
|
||||
|---|---|
|
||||
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
|
||||
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
|
||||
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
|
||||
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
|
||||
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
|
||||
| Guard | Measures |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
|
||||
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
|
||||
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
|
||||
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
|
||||
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
|
||||
|
||||
Each fails on a **new** offender *and* on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
|
||||
Each fails on a **new** offender _and_ on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
|
||||
|
||||
## 3. Real-binary — the assertions nothing else can make
|
||||
|
||||
@@ -48,4 +48,4 @@ Recorded rather than implied, because a guard that looks complete is worse than
|
||||
|
||||
### Verifying a guard change
|
||||
|
||||
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that *reduced* the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
|
||||
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that _reduced_ the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
|
||||
|
||||
@@ -115,7 +115,7 @@ the resulting patch is silently wrong — the failure mode is a `.mjs` that is
|
||||
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
|
||||
to run a build step that names one of those scripts.
|
||||
|
||||
The generator also builds the *unmodified* commit first and asserts that it
|
||||
The generator also builds the _unmodified_ commit first and asserts that it
|
||||
reproduces the published `lib/` byte for byte before it emits anything. A
|
||||
toolchain or build-order problem therefore surfaces as an explicit "did not
|
||||
reproduce the published bundles" error rather than as 7 MB of mystery diff.
|
||||
|
||||
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
|
||||
index: resolve('src/main/index.ts'),
|
||||
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
|
||||
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
|
||||
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
@@ -239,12 +240,6 @@ export const electronViteConfig: UserConfig = {
|
||||
'main-thread-hang-watchdog-entry': resolve(
|
||||
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
|
||||
),
|
||||
// Why: run under ELECTRON_RUN_AS_NODE while the caller blocks on
|
||||
// spawnSync — codex app-server trust grants need a live event loop
|
||||
// but must finish before a Codex pane launch proceeds.
|
||||
'codex/codex-app-server-grant-entry': resolve(
|
||||
'src/main/codex/codex-app-server-grant-entry.ts'
|
||||
),
|
||||
// Why: electron-vite cleans out/main in dev. The dev CLI imports
|
||||
// this path for `orca agent hooks ...`, so it must survive rebuilds.
|
||||
'agent-hooks/managed-agent-hook-controls': resolve(
|
||||
@@ -285,7 +280,7 @@ export const electronViteConfig: UserConfig = {
|
||||
preload: {
|
||||
build: {
|
||||
externalizeDeps: {
|
||||
exclude: ['@electron-toolkit/preload']
|
||||
exclude: ['@electron-toolkit/preload', 'zod']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -75,54 +75,18 @@
|
||||
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/components/mobile-rich-markdown-editor-html.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/components/CustomKeyModal.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/pair-scan.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/mock-server.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/terminal/terminal-accessory-keys.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/troubleshoot.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/repro-worktree-startup-stream.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/repro-terminal-colors.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/h/*/files/*.tsx"],
|
||||
"rules": {
|
||||
|
||||
+209
-23
@@ -1,20 +1,35 @@
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import Constants from 'expo-constants'
|
||||
import { ChevronLeft, Copy, Check } from 'lucide-react-native'
|
||||
import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { connectionLogStore } from '../src/transport/connection-log-buffer'
|
||||
import { useHostClient } from '../src/transport/client-context'
|
||||
import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
|
||||
import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
useConnectionPathStatus,
|
||||
useReconnectAttempt
|
||||
} from '../src/transport/client-context-connection-metrics'
|
||||
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
|
||||
import {
|
||||
diagnoseConnection,
|
||||
getReportableConnectionIncidentId
|
||||
} from '../src/diagnostics/connection-diagnostics-analysis'
|
||||
import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
|
||||
import {
|
||||
readHydratedConnectionLog,
|
||||
readConnectionDiagnosticsSnapshot,
|
||||
resolveDiagnosticsHostId,
|
||||
getDiagnosticsSubmissionState,
|
||||
updateDiagnosticsSubmissionState,
|
||||
type DiagnosticsSubmissionStates
|
||||
} from '../src/diagnostics/connection-diagnostics-screen-data'
|
||||
import { useHostStatusGates } from '../src/transport/host-status-gates'
|
||||
import { loadHostAppVersion } from '../src/transport/host-app-version-store'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
|
||||
|
||||
// Why: getSnapshot must be referentially stable when there's no data —
|
||||
@@ -25,11 +40,19 @@ const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
|
||||
// screen also *acquires* the host client — opening it kicks a dial and the
|
||||
// log fills live instead of showing a stale tail.
|
||||
export default function ConnectionLogScreen() {
|
||||
const clientContext = useRpcClientContext()
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{ hostId?: string }>()
|
||||
const insets = useSafeAreaInsets()
|
||||
const routeKey = useMemo(() => ({}), [params.hostId])
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [manualSelection, setManualSelection] = useState<{
|
||||
hostId: string
|
||||
requestedHostId: string | undefined
|
||||
routeKey: object
|
||||
} | null>(null)
|
||||
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
|
||||
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
@@ -38,17 +61,28 @@ export default function ConnectionLogScreen() {
|
||||
return
|
||||
}
|
||||
setHosts(loaded)
|
||||
setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
|
||||
const selected = hosts.find((h) => h.id === selectedId) ?? null
|
||||
const { state } = useHostClient(selected?.id)
|
||||
const { client, state } = useHostClient(selected?.id)
|
||||
const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
|
||||
hostId: selected?.id,
|
||||
client,
|
||||
connState: state
|
||||
})
|
||||
const reconnectAttempts = useReconnectAttempt(selected?.id)
|
||||
const lastConnectedAt = useLastConnectedAt(selected?.id)
|
||||
const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) {
|
||||
void readHydratedConnectionLog(connectionLogStore, selectedId)
|
||||
}
|
||||
}, [selectedId])
|
||||
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) =>
|
||||
@@ -60,25 +94,93 @@ export default function ConnectionLogScreen() {
|
||||
[selectedId]
|
||||
)
|
||||
const entries = useSyncExternalStore(subscribe, getSnapshot)
|
||||
const diagnosis = selected
|
||||
? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
|
||||
: null
|
||||
const incidentId = selected
|
||||
? getReportableConnectionIncidentId({
|
||||
endpoint: selected.endpoint,
|
||||
state,
|
||||
activePath,
|
||||
pendingPath,
|
||||
entries
|
||||
})
|
||||
: null
|
||||
const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
|
||||
const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
|
||||
const copied = copiedHostId === selectedId
|
||||
|
||||
const copyDiagnostics = useCallback(async () => {
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
|
||||
const snapshot = await readConnectionDiagnosticsSnapshot(
|
||||
clientContext,
|
||||
connectionLogStore,
|
||||
selected.id
|
||||
)
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: selected.name,
|
||||
endpoint: selected.endpoint,
|
||||
state,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt,
|
||||
state: snapshot.state,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
lastConnectedAt: snapshot.lastConnectedAt,
|
||||
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
|
||||
appVersion: Constants.expoConfig?.version ?? 'unknown',
|
||||
entries
|
||||
desktopAppVersion,
|
||||
entries: snapshot.entries,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath
|
||||
})
|
||||
await Clipboard.setStringAsync(report)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [selected, state, reconnectAttempts, lastConnectedAt, entries])
|
||||
setCopiedHostId(selected.id)
|
||||
setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
|
||||
}, [selected, liveDesktopAppVersion, clientContext])
|
||||
|
||||
const sendDiagnostics = useCallback(async () => {
|
||||
if (!selected || !submissionKey || submissionState === 'sending') {
|
||||
return
|
||||
}
|
||||
const startedKey = submissionKey
|
||||
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
|
||||
const appVersion = Constants.expoConfig?.version ?? 'unknown'
|
||||
const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
|
||||
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
|
||||
const snapshot = await readConnectionDiagnosticsSnapshot(
|
||||
clientContext,
|
||||
connectionLogStore,
|
||||
selected.id
|
||||
)
|
||||
const currentIncidentId = getReportableConnectionIncidentId({
|
||||
endpoint: selected.endpoint,
|
||||
state: snapshot.state,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath,
|
||||
entries: snapshot.entries
|
||||
})
|
||||
if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
|
||||
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
|
||||
return
|
||||
}
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: selected.name,
|
||||
endpoint: selected.endpoint,
|
||||
state: snapshot.state,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
lastConnectedAt: snapshot.lastConnectedAt,
|
||||
platform,
|
||||
appVersion,
|
||||
desktopAppVersion,
|
||||
entries: snapshot.entries,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath
|
||||
})
|
||||
const result = await submitConnectionDiagnostics({ report, appVersion, platform })
|
||||
setSubmissionStates((states) =>
|
||||
updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
|
||||
)
|
||||
}, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
@@ -86,7 +188,7 @@ export default function ConnectionLogScreen() {
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Connection log</Text>
|
||||
<Text style={styles.heading}>Network diagnostics</Text>
|
||||
</View>
|
||||
|
||||
{hosts.length > 1 && (
|
||||
@@ -95,7 +197,9 @@ export default function ConnectionLogScreen() {
|
||||
<Pressable
|
||||
key={host.id}
|
||||
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
|
||||
onPress={() => setSelectedId(host.id)}
|
||||
onPress={() =>
|
||||
setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
|
||||
@@ -121,14 +225,49 @@ export default function ConnectionLogScreen() {
|
||||
) : (
|
||||
<Copy size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy report'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{diagnosis && (
|
||||
<View style={styles.diagnosisCard}>
|
||||
<Text style={styles.diagnosisHeading}>What this suggests</Text>
|
||||
<Text style={styles.diagnosisText}>{diagnosis.likelyCause}</Text>
|
||||
<Text style={styles.diagnosisNext}>{diagnosis.nextStep}</Text>
|
||||
{diagnosis.reportability === 'orca-relay' && (
|
||||
<>
|
||||
<Text style={styles.privacyHint}>
|
||||
Sends a size-limited redacted report including host name, endpoint, versions,
|
||||
connection state, and events—never terminal contents or credentials.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.sendButton}
|
||||
onPress={() => void sendDiagnostics()}
|
||||
disabled={submissionState === 'sending'}
|
||||
>
|
||||
{submissionState === 'sent' ? (
|
||||
<Check size={14} color={colors.statusGreen} />
|
||||
) : (
|
||||
<Send size={14} color={colors.textPrimary} />
|
||||
)}
|
||||
<Text style={styles.sendButtonText}>
|
||||
{submissionState === 'sending'
|
||||
? 'Sending…'
|
||||
: submissionState === 'sent'
|
||||
? 'Diagnostics sent'
|
||||
: submissionState === 'failed'
|
||||
? 'Retry sending'
|
||||
: 'Send diagnostics to Orca'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{entries.length > 0 ? (
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} />
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} fillAvailableHeight />
|
||||
) : (
|
||||
<Text style={styles.emptyText}>
|
||||
No connection events yet this session. Events appear as the app dials this host.
|
||||
No connection events yet. Events appear as the app dials this host.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
@@ -199,6 +338,53 @@ const styles = StyleSheet.create({
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
diagnosisCard: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: 10,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
diagnosisHeading: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
diagnosisText: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: 18
|
||||
},
|
||||
diagnosisNext: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 18,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
privacyHint: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: 11,
|
||||
lineHeight: 15,
|
||||
color: colors.textMuted
|
||||
},
|
||||
sendButton: {
|
||||
marginTop: spacing.md,
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
sendButtonText: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user