mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/i18n-catalog-decoupling
# Conflicts: # .github/workflows/pr.yml # package.json # pnpm-lock.yaml
This commit is contained in:
@@ -6,3 +6,9 @@
|
||||
/config/scripts/run-internal-dev-setup.mjs text eol=lf
|
||||
/config/scripts/verify-cli-bin.mjs text eol=lf
|
||||
/config/scripts/verify-release-required-assets.mjs text eol=lf
|
||||
/skill-guides/*.md text eol=lf
|
||||
/skill-stubs/*.md text eol=lf
|
||||
/skills/*/SKILL.md text eol=lf
|
||||
/src/cli/bundled-skill-guides.ts text eol=lf
|
||||
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
|
||||
/resources/plugins/** text eol=lf
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Install Node dependencies
|
||||
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
|
||||
|
||||
inputs:
|
||||
native-runtime:
|
||||
description: Native runtime to prepare after the script-free install (none, node, or electron).
|
||||
required: false
|
||||
default: none
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# setup-node needs pnpm on PATH to locate and restore its store.
|
||||
- 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: Validate native runtime
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
run: |
|
||||
case "$NATIVE_RUNTIME" in
|
||||
none|node|electron) ;;
|
||||
*)
|
||||
echo "::error::native-runtime must be none, node, or electron"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
|
||||
- name: Use external node-gyp
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
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: Prepare dependency install
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -e node_modules ]; then
|
||||
ls -ld node_modules
|
||||
rm -rf node_modules
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install \
|
||||
--no-frozen-lockfile \
|
||||
--prefer-frozen-lockfile=false \
|
||||
--ignore-scripts
|
||||
git diff --exit-code package.json pnpm-lock.yaml
|
||||
|
||||
- name: Prepare native runtime
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
- '.github/workflows/computer-e2e.yml'
|
||||
- 'config/electron-builder.config.cjs'
|
||||
- 'config/scripts/build-computer-macos.mjs'
|
||||
- 'config/scripts/build-windows-cli-launcher.mjs'
|
||||
- 'config/scripts/build-windows-cli-launcher.test.mjs'
|
||||
- 'config/scripts/computer-e2e-workflow.test.mjs'
|
||||
- 'config/scripts/computer-use-skill-guidance.test.mjs'
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
@@ -22,12 +24,15 @@ on:
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
- 'native/computer-use-windows/**'
|
||||
- 'native/windows-cli-launcher/**'
|
||||
- 'skills/computer-use/SKILL.md'
|
||||
- 'src/cli/**'
|
||||
- 'src/main/computer/**'
|
||||
- 'src/main/runtime/rpc/dispatcher.ts'
|
||||
- 'src/main/runtime/rpc/errors.ts'
|
||||
- 'src/main/runtime/rpc/methods/computer*.ts'
|
||||
- 'src/main/ssh/ssh-remote-cli-launcher.ts'
|
||||
- 'src/main/ssh/ssh-remote-cli-launcher.test.ts'
|
||||
- 'src/shared/computer-use-*.ts'
|
||||
- 'tests/e2e/computer-linux.e2e.ts'
|
||||
- 'tests/e2e/computer-mac.e2e.ts'
|
||||
@@ -70,9 +75,14 @@ jobs:
|
||||
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
|
||||
# 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
|
||||
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/computer-use-skill-guidance.test.mjs
|
||||
config/scripts/computer-use-smoke.test.mjs
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
name: E2E
|
||||
|
||||
run-name: E2E ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: checkout + artifact upload only; callers can only further restrict.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
@@ -76,7 +82,7 @@ jobs:
|
||||
name: e2e ${{ matrix.shard_name }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -178,7 +184,9 @@ jobs:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Why 35: the terminal parking + retention specs below add two more
|
||||
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
|
||||
timeout-minutes: 35
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -221,6 +229,13 @@ jobs:
|
||||
- name: Run Docker SSH watcher isolation E2E
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
|
||||
|
||||
# Why always(): these specs gate the C1 terminal parking/retention budget
|
||||
# over a real SSH host and run in no other lane, so a watcher-isolation
|
||||
# failure above must not silently skip them.
|
||||
- name: Run Docker SSH terminal parking + retention E2E
|
||||
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
|
||||
|
||||
- name: Upload watcher isolation traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- 'tests/e2e/fixtures/terminal-emoji-table.md'
|
||||
- 'tests/e2e/helpers/**'
|
||||
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
|
||||
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
|
||||
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
|
||||
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump_patch_version:
|
||||
description: 'Bump the iOS marketing version patch number before release. Tick this after a version has shipped to the App Store (the release fails fast if the current version''s train is already closed).'
|
||||
description: 'Use the first open iOS patch version after the checked-in version, skipping versions already closed on the App Store.'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
@@ -30,6 +30,11 @@ jobs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -51,11 +56,14 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Test iOS release version resolution
|
||||
run: ruby fastlane/ios_release_version_test.rb
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
|
||||
+293
-91
@@ -8,79 +8,67 @@ on:
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
static_analysis:
|
||||
name: static analysis
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: pnpm's bundled node-gyp ships gyp_main.py without execute
|
||||
# permission, which breaks native module builds (e.g. node-pty's
|
||||
# postinstall) with "/bin/sh: gyp_main.py: Permission denied".
|
||||
# Pin the fallback to the lockfile's node-gyp version so CI stays
|
||||
# reproducible while forcing pnpm to bypass its broken bundled copy.
|
||||
# Gate on runner.os == 'Linux' to match release.yml — the
|
||||
# npm-global path layout this step assumes is POSIX-shaped, and the
|
||||
# failure has only been observed on Linux runners. Today this job
|
||||
# pins runs-on: ubuntu-latest so the guard is a no-op, but it
|
||||
# prevents a silent break if a Windows/macOS matrix is added later.
|
||||
- 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: Prepare dependency install
|
||||
run: |
|
||||
if [ -e node_modules ]; then
|
||||
ls -ld node_modules
|
||||
rm -rf node_modules
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
|
||||
# runners while creating the root node_modules. Use the normal resolver
|
||||
# path, then verify package metadata stayed unchanged.
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
|
||||
git diff --exit-code package.json pnpm-lock.yaml
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Lint
|
||||
run: pnpm exec oxlint --format github
|
||||
|
||||
- name: Enforce focused code-quality plugins
|
||||
run: pnpm run audit:code-quality:native
|
||||
|
||||
- name: Enforce type-aware code-quality baseline
|
||||
run: pnpm run audit:code-quality:type-aware
|
||||
|
||||
- name: Enforce changed-code quality
|
||||
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
- name: Enforce React Doctor on changed lines
|
||||
run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
- name: Check Zustand selector fan-out budget
|
||||
run: pnpm run check:zustand-selector-fanout
|
||||
|
||||
- name: Check reliability gate manifest
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
- name: Enforce max-lines ratchet
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
- name: Verify bundled skill guides
|
||||
run: pnpm run verify:bundled-skill-guides
|
||||
|
||||
- name: Verify skill freshness manifest
|
||||
run: pnpm run verify:skill-bundle-manifest
|
||||
|
||||
- name: Verify localization catalog
|
||||
run: pnpm run verify:localization-catalog
|
||||
|
||||
# Why: extraction writes sorted evidence to an isolated temporary path,
|
||||
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
|
||||
- name: Verify localization extraction
|
||||
run: pnpm run verify:localization-extraction
|
||||
|
||||
- name: Check styled scrollbars
|
||||
run: pnpm check:styled-scrollbars
|
||||
|
||||
- name: Check reliability gate manifest
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
|
||||
# ratchet forbids ADDING a new suppression (inline disable or mobile max
|
||||
# bump). Existing oversized files are grandfathered in
|
||||
# config/max-lines-baseline.txt, which may only shrink — new bypasses fail
|
||||
# here with a clear message instead of silently growing the debt.
|
||||
- name: Enforce max-lines ratchet (no new bypasses)
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
- name: Verify localization coverage
|
||||
run: pnpm run verify:localization-coverage
|
||||
|
||||
# Why: project-owned type declarations must live in .ts so tsc
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
@@ -105,55 +93,269 @@ jobs:
|
||||
- name: Verify macOS entitlements
|
||||
run: pnpm verify:macos-entitlements
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
git_compatibility:
|
||||
name: Git compatibility
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: real old Git diagnostics differ from mocked errors. Keep the
|
||||
# fallback predicates executable across the baseline, transition, and
|
||||
# current command shapes so a newly added flag cannot silently regress.
|
||||
- name: Verify Git binary compatibility matrix
|
||||
run: |
|
||||
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
|
||||
source="$RUNNER_TEMP/git-2.25.5"
|
||||
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
|
||||
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
|
||||
| sha256sum --check
|
||||
mkdir -p "$source"
|
||||
tar -xzf "$archive" -C "$source" --strip-components=1
|
||||
make -C "$source" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
|
||||
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
pids=()
|
||||
(
|
||||
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
|
||||
source="$RUNNER_TEMP/git-2.25.5"
|
||||
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
|
||||
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
|
||||
| sha256sum --check
|
||||
mkdir -p "$source"
|
||||
tar -xzf "$archive" -C "$source" --strip-components=1
|
||||
make -C "$source" -j"$(nproc)" \
|
||||
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
|
||||
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
) &
|
||||
pids+=("$!")
|
||||
|
||||
for spec in \
|
||||
"alpine/git:edge-2.38.1|2.38.1" \
|
||||
"alpine/git:v2.49.1|2.49.1"; do
|
||||
image="${spec%%|*}"
|
||||
version="${spec#*|}"
|
||||
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
(
|
||||
image="${spec%%|*}"
|
||||
version="${spec#*|}"
|
||||
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
) &
|
||||
pids+=("$!")
|
||||
done
|
||||
|
||||
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
|
||||
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
|
||||
# it for Node so orchestration tests can load the native module.
|
||||
- name: Rebuild better-sqlite3 for Node
|
||||
run: pnpm rebuild better-sqlite3
|
||||
status=0
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || status=1
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update && sudo apt-get install -y zsh
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Test real shell contracts
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/daemon/shell-ready.test.ts \
|
||||
src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
src/main/providers/local-pty-shell-ready.test.ts \
|
||||
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
name: tests ${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
# Why: install intentionally blocks Electron's package postinstall, but
|
||||
# some unit tests import `electron` under Node and require path.txt.
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.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.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/shared/posix-command-path-lookup.test.ts \
|
||||
--shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
|
||||
- name: Build unpacked app
|
||||
run: pnpm build:unpack
|
||||
package:
|
||||
name: package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-linux-
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
|
||||
- name: Build package inputs
|
||||
run: |
|
||||
status=0
|
||||
pnpm run build:cli || status=1
|
||||
|
||||
scripts=(build:relay build:electron-vite:parallel)
|
||||
pids=()
|
||||
for script in "${scripts[@]}"; do
|
||||
pnpm run "$script" &
|
||||
pids+=("$!")
|
||||
done
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || status=1
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
- name: Project web client from renderer build
|
||||
run: pnpm run build:web-from-renderer
|
||||
|
||||
- name: Build native components
|
||||
run: pnpm run build:native
|
||||
|
||||
- name: Package unpacked app
|
||||
env:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
|
||||
|
||||
# Why: the packaged CLI runs outside Electron's asar integration, so
|
||||
# bare runtime imports must be present in the package itself. Run from a
|
||||
# temp copy so Node cannot mask missing package deps with repo node_modules.
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
|
||||
|
||||
# Why: regression specs under tests/e2e/** used to merge green without ever
|
||||
# running — e2e.yml only fired on schedule/release (#10518). Path-filter so
|
||||
# ordinary PRs stay light; any E2E suite change still gets a full shard run.
|
||||
e2e-paths:
|
||||
name: detect e2e path changes
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft != true
|
||||
# Why: detector only needs to read the checkout; do not inherit repo defaults.
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Filter E2E-relevant paths
|
||||
id: filter
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
# Why: capture first so a failed git diff does not look like "no matches"
|
||||
# (pipeline status in `if` is not aborted by set -e). Merge-base limits the
|
||||
# list to files this PR introduced, not base-branch drift.
|
||||
CHANGED="$(git diff --name-only --merge-base "$BASE" "$HEAD")"
|
||||
# Why: tests/playwright.config.ts sits beside tests/e2e/, not inside it, so
|
||||
# it needs its own pattern — a bare `playwright.` prefix matches no tracked
|
||||
# file and would silently skip E2E when the runner config changes.
|
||||
if printf '%s\n' "$CHANGED" | grep -E '^(tests/e2e/|tests/playwright\.|\.github/workflows/e2e\.yml$)' >/dev/null; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "E2E path changes detected"
|
||||
else
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No E2E path changes"
|
||||
fi
|
||||
|
||||
e2e:
|
||||
name: e2e
|
||||
needs: e2e-paths
|
||||
if: needs.e2e-paths.outputs.should_run == 'true'
|
||||
# Why: reusable e2e.yml only checkouts, builds, and uploads artifacts.
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
|
||||
verify:
|
||||
if: always()
|
||||
needs:
|
||||
- static_analysis
|
||||
- typecheck
|
||||
- git_compatibility
|
||||
- shell_contracts
|
||||
- test
|
||||
- package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Why: e2e is deliberately absent from needs. The suite is currently red on
|
||||
# main (every scheduled run), so gating merges on it would block any PR that
|
||||
# touches tests/e2e/** — including the ones fixing the suite. Until it is
|
||||
# green the job runs and reports for E2E-path PRs without blocking. To flip
|
||||
# it on: add `e2e` to needs, add E2E to the env below, and require
|
||||
# `"$E2E" = success || skipped` after the loop — skipped is the normal
|
||||
# result for a path-filtered job and must keep passing, so it has to be
|
||||
# checked outside the loop or it would excuse the jobs above.
|
||||
- name: Require successful checks
|
||||
env:
|
||||
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
run: |
|
||||
for result in \
|
||||
"$STATIC_ANALYSIS" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$PACKAGE"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -42,7 +42,12 @@ on:
|
||||
default: false
|
||||
type: boolean
|
||||
version_suffix:
|
||||
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only.
|
||||
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). Applies to kind=rc, or to an explicit version that is a bare X.Y.Z-rc.N.
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Exact version to cut (e.g. 1.4.155 or 1.4.155-rc.4), bypassing kind-based computation. Use to leapfrog a deleted/rolled-back stable that regressed the release list. Must be greater than the latest published stable, and an -rc.N must be above the highest RC already cut for its own base.
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
@@ -68,6 +73,28 @@ jobs:
|
||||
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
|
||||
latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }}
|
||||
steps:
|
||||
# Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs):
|
||||
# this job runs with contents:write and secret scope, so avoid executing
|
||||
# any external (mutable @v1) action here. Surfaces every
|
||||
# workflow_dispatch input as a table for audit; the resolved commit /
|
||||
# branch / tag enrichment is written later in "Resolve ref SHA".
|
||||
# Inputs are passed as JSON via env and parsed by jq as data — never
|
||||
# interpolated into the shell — to avoid injection from dispatch values.
|
||||
- name: Summarize workflow inputs
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
INPUTS_JSON: ${{ toJSON(inputs) }}
|
||||
run: |
|
||||
{
|
||||
echo "## Workflow inputs"
|
||||
echo ""
|
||||
echo "| Input | Value |"
|
||||
echo "| --- | --- |"
|
||||
# Values are data from env JSON; wrap in backticks for readability.
|
||||
# Newlines collapsed so a multi-line input cannot break the table.
|
||||
jq -r '(. // {}) | to_entries[] | "| `\(.key)` | `\(.value | tostring | gsub("\n"; " "))` |"' <<<"$INPUTS_JSON"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Checkout ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -86,8 +113,17 @@ jobs:
|
||||
|
||||
- name: Resolve ref SHA
|
||||
id: resolve
|
||||
env:
|
||||
# Why: keep the caller's ref as data (env) so we can label it in the
|
||||
# summary without shell-interpolating a dispatch-controlled string
|
||||
# into the script body.
|
||||
INPUT_REF: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
|
||||
REPO: ${{ github.repository }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
short_sha="$(git rev-parse --short=12 HEAD)"
|
||||
echo "sha=$sha" >>"$GITHUB_OUTPUT"
|
||||
|
||||
# Why: only push the version-bump commit back to main when the
|
||||
@@ -101,6 +137,97 @@ jobs:
|
||||
echo "push_main=false" >>"$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Always surface the resolved commit in the job summary, plus any
|
||||
# branches/tags that currently point at it (clickable). The raw
|
||||
# `ref` input alone is ambiguous (branch vs tag vs SHA); for SHA
|
||||
# inputs it also hides the human-readable names operators need
|
||||
# when auditing RC cuts.
|
||||
input_ref="${INPUT_REF:-main}"
|
||||
repo_url="${SERVER_URL}/${REPO}"
|
||||
|
||||
branches="$(
|
||||
git for-each-ref --format='%(refname:short)' --points-at="$sha" 'refs/remotes/origin/*' \
|
||||
| sed 's|^origin/||' \
|
||||
| grep -vx 'HEAD' \
|
||||
| sort -u \
|
||||
|| true
|
||||
)"
|
||||
tags="$(
|
||||
git for-each-ref --format='%(refname:short)' --points-at="$sha" 'refs/tags/*' \
|
||||
| sort -u \
|
||||
|| true
|
||||
)"
|
||||
|
||||
# Build comma-separated markdown links. Branch/tag names go in the
|
||||
# URL path as-is (slashes must stay literal for GitHub tree URLs).
|
||||
linkify_names() {
|
||||
local url_kind="$1"
|
||||
local names="$2"
|
||||
if [[ -z "${names//[$'\t\r\n']/}" ]]; then
|
||||
printf '_none_'
|
||||
return
|
||||
fi
|
||||
local first=1
|
||||
while IFS= read -r name; do
|
||||
[[ -z "$name" ]] && continue
|
||||
local path_name url
|
||||
path_name="${name// /%20}"
|
||||
case "$url_kind" in
|
||||
branch) url="${repo_url}/tree/${path_name}" ;;
|
||||
tag) url="${repo_url}/releases/tag/${path_name}" ;;
|
||||
*) url="${repo_url}" ;;
|
||||
esac
|
||||
if [[ "$first" -eq 1 ]]; then
|
||||
first=0
|
||||
else
|
||||
printf ', '
|
||||
fi
|
||||
printf '[`%s`](%s)' "$name" "$url"
|
||||
done <<<"$names"
|
||||
}
|
||||
|
||||
branch_md="$(linkify_names branch "$branches")"
|
||||
tag_md="$(linkify_names tag "$tags")"
|
||||
|
||||
# When no branch tip matches (historical SHA cuts), fall back to
|
||||
# name-rev so the summary still shows something like `main~3`.
|
||||
contains_md="_none_"
|
||||
if [[ "$branch_md" == "_none_" ]]; then
|
||||
approx="$(git name-rev --name-only --no-undefined --refs='refs/remotes/origin/*' "$sha" 2>/dev/null || true)"
|
||||
if [[ -n "$approx" ]]; then
|
||||
# name-rev prints remotes/origin/<branch>[~N]; strip to branch[~N].
|
||||
approx="${approx#remotes/origin/}"
|
||||
approx="${approx#origin/}"
|
||||
contains_md="\`${approx}\`"
|
||||
fi
|
||||
fi
|
||||
|
||||
input_kind="ref"
|
||||
if git rev-parse -q --verify "refs/remotes/origin/${input_ref}" >/dev/null 2>&1; then
|
||||
input_kind="branch"
|
||||
elif git rev-parse -q --verify "refs/tags/${input_ref}" >/dev/null 2>&1; then
|
||||
input_kind="tag"
|
||||
elif [[ "$input_ref" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
|
||||
input_kind="sha"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## Resolved source"
|
||||
echo ""
|
||||
echo "Every cut resolves to a commit. Branch/tag rows list refs whose tip is that commit."
|
||||
echo ""
|
||||
echo "| Field | Value |"
|
||||
echo "| --- | --- |"
|
||||
echo "| Input ref | \`${input_ref}\` (${input_kind}) |"
|
||||
echo "| Commit | [\`${short_sha}\`](${repo_url}/commit/${sha}) |"
|
||||
echo "| Branches at commit | ${branch_md} |"
|
||||
echo "| Tags at commit | ${tag_md} |"
|
||||
if [[ "$branch_md" == "_none_" ]]; then
|
||||
echo "| Also on | ${contains_md} |"
|
||||
fi
|
||||
echo ""
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Compute RC slot
|
||||
id: slot
|
||||
run: |
|
||||
@@ -198,6 +325,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
|
||||
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
|
||||
EXPLICIT_VERSION: ${{ github.event_name == 'schedule' && '' || inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -260,6 +388,23 @@ jobs:
|
||||
node config/scripts/release-rc-history.mjs "$1"
|
||||
}
|
||||
|
||||
require_valid_version_suffix() {
|
||||
# Why a dot-appended identifier (rc.N.perf): it sorts just
|
||||
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
|
||||
# branch builds never outrank the main RC series and cannot
|
||||
# hijack the update channel; clients find them by matching the
|
||||
# identifier ("perf") in the prerelease components.
|
||||
# Why the numeric alternation rather than plain [0-9A-Za-z]+:
|
||||
# semver forbids a leading zero on an all-digit identifier, and
|
||||
# `npm version` silently renormalizes rc.4.01 to rc.4.1 while the
|
||||
# tag step keeps the literal input — so the shipped package.json
|
||||
# version and its own release tag would name different releases.
|
||||
if [[ ! "$1" =~ ^(0|[1-9][0-9]*|[0-9A-Za-z]*[A-Za-z][0-9A-Za-z]*)$ ]]; then
|
||||
echo "::error::version_suffix (or the trailing .identifier in version) must be alphanumeric with no leading zero on an all-digit identifier, got: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
current_package_stable() {
|
||||
node -e '
|
||||
const { version } = require("./package.json");
|
||||
@@ -336,7 +481,14 @@ jobs:
|
||||
# floor for the current ref so the next cut cannot reuse an older
|
||||
# stable number just because the public release was nuked.
|
||||
if semver_gt "$package_stable" "$latest_stable"; then
|
||||
if [[ "$KIND" != "rc" ]]; then
|
||||
# Skip floor-tag recovery when an explicit version is requested:
|
||||
# recover_unpublished_tag can exit 0, which would recover the
|
||||
# package-floor tag instead of cutting the requested version —
|
||||
# defeating the very rollback scenario the override exists for.
|
||||
# We still raise latest_stable to the floor below so the explicit
|
||||
# version is gated against it; the collision recovery for the
|
||||
# requested tag runs later.
|
||||
if [[ "$KIND" != "rc" && -z "${EXPLICIT_VERSION:-}" ]]; then
|
||||
package_tag="v$package_stable"
|
||||
if git rev-parse "$package_tag" >/dev/null 2>&1; then
|
||||
recover_unpublished_tag "$package_tag" "current ref stable tag is newer than latest published stable" || true
|
||||
@@ -348,6 +500,98 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
# Explicit version override (manual dispatch only).
|
||||
#
|
||||
# Why: kind-based math derives the next number from the latest
|
||||
# *published* stable. When a shipped stable is deleted (e.g. a
|
||||
# rolled-back 1.4.154), the release list regresses to the prior
|
||||
# stable, so a kind cut recomputes a number at or below the nuked one
|
||||
# and strands every client that already installed the deleted build.
|
||||
# The package.json floor above only recovers this when the deleted
|
||||
# version's bump commit is on the ref being cut, which a hotfix cut
|
||||
# from an older RC ref does not carry. An explicit version lets a
|
||||
# human assert the exact target (e.g. leapfrog to 1.4.155); the
|
||||
# updater-safety gate and tag-collision recovery below still apply.
|
||||
new=""
|
||||
if [[ -n "${EXPLICIT_VERSION:-}" ]]; then
|
||||
explicit="${EXPLICIT_VERSION#v}"
|
||||
# Why the optional trailing identifier: it lets an operator name a
|
||||
# suffixed side-branch RC (X.Y.Z-rc.N.perf) directly, the same shape
|
||||
# the rc path cuts. Note this only ever admits one *above* the
|
||||
# series head — the gate below refuses a suffixed rc at or below it
|
||||
# just like a bare one, so this is a second spelling of
|
||||
# `version=X.Y.Z-rc.N` + `version_suffix`, not a way back into a
|
||||
# series that already shipped.
|
||||
# Why rc.(0|[1-9][0-9]{0,8}): the `-le` below compares with bash's
|
||||
# machine-width integers, so both ends of that range fall *open* on
|
||||
# exactly the RCs this gate must catch. A leading zero (rc.08) is an
|
||||
# invalid octal literal, and the failed test makes the `if` false.
|
||||
# Past INTMAX the literal wraps two's-complement, so whether it
|
||||
# reads as above or below the published rc depends on the value:
|
||||
# rc.99999999999999999999 wraps to 7766279631452241919 and sails
|
||||
# through. The cut then lands a tag that pins highest_rc_for_base
|
||||
# at 1e20 forever, and every later cut wraps to a *lower* rc that
|
||||
# sorts below it, so the fleet never updates again. Nine digits is
|
||||
# far above any real series and exact in bash math either way.
|
||||
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.(0|[1-9][0-9]{0,8})(\.[0-9A-Za-z]+)?)?$ ]]; then
|
||||
echo "::error::version must be X.Y.Z, X.Y.Z-rc.N, or X.Y.Z-rc.N.suffix, got: $EXPLICIT_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Why route the embedded identifier through the same validator the
|
||||
# kind path uses: the regex above only checks shape, and rc.4.01
|
||||
# is a shape-valid identifier that is not valid semver.
|
||||
if [[ "$explicit" == *-rc.*.* ]]; then
|
||||
require_valid_version_suffix "${explicit##*.}"
|
||||
fi
|
||||
# Same updater-safety gate the kind path enforces: stable line must
|
||||
# strictly increase over the latest published stable (prerelease
|
||||
# identifiers ignored for the comparison).
|
||||
if ! semver_gt "$explicit" "$latest_stable"; then
|
||||
echo "::error::Refusing explicit version $explicit: not greater than latest stable $latest_stable." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Why a second gate for prereleases: semver_gt compares through
|
||||
# strip_pre(), so the stable-line check reads 1.4.156-rc.0 as
|
||||
# 1.4.156 and waves it past a 1.4.155 stable even when rc.0..rc.3
|
||||
# already shipped — republishing an RC *below* what clients run,
|
||||
# the same regression class as the rc.4 cut that orphaned live
|
||||
# daemons. Anchor on the same rc history the kind path uses so the
|
||||
# override can only ever advance the series it targets.
|
||||
if [[ "$explicit" == *-rc.* ]]; then
|
||||
explicit_base="${explicit%%-*}"
|
||||
explicit_rc="${explicit#*-rc.}"
|
||||
explicit_rc="${explicit_rc%%.*}"
|
||||
highest_explicit_rc="$(highest_rc_for_base "$explicit_base")"
|
||||
if [[ -n "$highest_explicit_rc" && "$explicit_rc" -le "$highest_explicit_rc" ]]; then
|
||||
# Why the remedy is spelled this narrowly: kind=rc derives its
|
||||
# base from bump(latest_stable, patch), so it can only resume a
|
||||
# series on that base. A minor/major series (1.5.0-rc.N) exists
|
||||
# only because this override created it, and pointing an
|
||||
# operator at kind=rc there would cut an unrelated release.
|
||||
echo "::error::Refusing explicit version $explicit: rc.$explicit_rc is not above rc.$highest_explicit_rc, the highest already cut for $explicit_base. Request rc.$((highest_explicit_rc + 1)) or higher. If you are resuming an unpublished tag and $explicit_base is the next patch after latest stable $latest_stable, dispatch kind=rc instead, which recovers that tag when it was cut from the ref you dispatch; otherwise cut rc.$((highest_explicit_rc + 1)) and leave the unpublished tag alone." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
new="$explicit"
|
||||
# Why here too: the suffix append below lives in the kind path the
|
||||
# override skips, so an operator passing both inputs used to get
|
||||
# their suffix silently dropped. Only a bare rc can take one — a
|
||||
# stable X.Y.Z.perf is not valid semver, and re-suffixing an
|
||||
# already-suffixed rc would produce rc.N.perf.perf.
|
||||
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
|
||||
# Same bounded rc pattern as the shape check above, so the two
|
||||
# cannot drift apart under a later edit.
|
||||
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.(0|[1-9][0-9]{0,8})$ ]]; then
|
||||
echo "::error::version_suffix applies only to a bare X.Y.Z-rc.N version, got: $explicit" >&2
|
||||
exit 1
|
||||
fi
|
||||
require_valid_version_suffix "$VERSION_SUFFIX"
|
||||
new="${new}.${VERSION_SUFFIX}"
|
||||
fi
|
||||
echo "Explicit version override: $new"
|
||||
fi
|
||||
|
||||
if [[ -z "$new" ]]; then
|
||||
case "$KIND" in
|
||||
rc)
|
||||
# Why: RCs always stabilize the *next* patch after whatever
|
||||
@@ -373,15 +617,7 @@ jobs:
|
||||
new="${base}-rc.$((highest_rc + 1))"
|
||||
fi
|
||||
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
|
||||
# Why a dot-appended identifier (rc.N.perf): it sorts just
|
||||
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
|
||||
# branch builds never outrank the main RC series and cannot
|
||||
# hijack the update channel; clients find them by matching the
|
||||
# identifier ("perf") in the prerelease components.
|
||||
if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then
|
||||
echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2
|
||||
exit 1
|
||||
fi
|
||||
require_valid_version_suffix "$VERSION_SUFFIX"
|
||||
new="${new}.${VERSION_SUFFIX}"
|
||||
fi
|
||||
;;
|
||||
@@ -425,6 +661,7 @@ jobs:
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Orphan-tag recovery.
|
||||
#
|
||||
@@ -472,7 +709,19 @@ jobs:
|
||||
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
|
||||
# assumptions and any lifecycle scripts that would run on bump).
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
git add package.json
|
||||
# Why: the cut is the only point where committed skill bytes become a
|
||||
# released revision. Without this row the ledger never advances, so the
|
||||
# next skill change rebuilds the revision this tag ships over different
|
||||
# bytes and every install of it stops matching a known snapshot.
|
||||
# --release is provenance-only: it fails if the content-addressed
|
||||
# artifacts do not already match this ref and writes just the mapping
|
||||
# row, so the version commit stays skill-independent. Node built-ins
|
||||
# only, so this needs no install.
|
||||
if ! node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"; then
|
||||
echo "::error::Refusing to record release provenance for v$VERSION: the committed skill artifacts do not match this ref. Land a regeneration on main, then re-run the cut." >&2
|
||||
exit 1
|
||||
fi
|
||||
git add package.json resources/skills/release-mapping.json
|
||||
commit_message="release: v$VERSION"
|
||||
if [[ "$EVENT_NAME" == "schedule" ]]; then
|
||||
commit_message="$commit_message [rc-slot:$SLOT]"
|
||||
@@ -485,6 +734,22 @@ jobs:
|
||||
else
|
||||
git commit -m "$commit_message"
|
||||
fi
|
||||
# Why: a lint that greps this file cannot see a path built from an env
|
||||
# var, a composite action, or concatenation, and `git commit` has forms
|
||||
# (-a, -i, --only, a pathspec) that commit the working tree rather than
|
||||
# the index. Assert what the commit actually carries, so the tag can
|
||||
# only ever ship the version bump and the provenance row, no matter
|
||||
# which step staged what or how the commit was spelled.
|
||||
# -F because the allowlist is literal: unanchored, `.` would match any
|
||||
# character and quietly admit a path like `packageXjson`.
|
||||
# -m --first-parent: plain diff-tree prints NOTHING for a merge commit,
|
||||
# which would make this guard pass silently rather than fail closed.
|
||||
committed="$(git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD |
|
||||
grep -vxF -e 'package.json' -e 'resources/skills/release-mapping.json' || true)"
|
||||
if [[ -n "$committed" ]]; then
|
||||
echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json and the skill release-mapping row may ship in a version commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
git tag -a "v$VERSION" -m "v$VERSION"
|
||||
echo "tag=v$VERSION" >>"$GITHUB_OUTPUT"
|
||||
echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
|
||||
@@ -512,7 +777,7 @@ jobs:
|
||||
echo "## Release E2E Signal"
|
||||
echo ""
|
||||
echo "- Terminal rendering golden is release-blocking."
|
||||
echo "- Full E2E is diagnostic/non-blocking release evidence."
|
||||
echo "- Full E2E runs separately after publication and cannot change the release result."
|
||||
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
|
||||
echo ""
|
||||
echo "Publishing behavior is controlled by the existing job dependencies; this summary does not change release gating."
|
||||
@@ -542,15 +807,6 @@ jobs:
|
||||
|
||||
node config/scripts/create-draft-release.mjs "$TAG"
|
||||
|
||||
# Why: tag-scoped E2E gives release visibility, but the suite is flaky enough
|
||||
# that publish-release must not depend on it.
|
||||
e2e:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
terminal-rendering-golden:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
@@ -1234,7 +1490,6 @@ jobs:
|
||||
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
|
||||
}
|
||||
# ── End Windows inner-binary signing ───────────────────────────────
|
||||
|
||||
- name: Upload unsigned Windows installer for SignPath
|
||||
if: matrix.platform == 'win'
|
||||
id: upload-unsigned-windows-installer
|
||||
@@ -1326,7 +1581,8 @@ jobs:
|
||||
}
|
||||
|
||||
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
|
||||
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
|
||||
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
|
||||
if ($LASTEXITCODE -ne 0) { throw "blockmap generation failed with exit code $LASTEXITCODE" }
|
||||
|
||||
$installer = Get-Item 'dist/orca-windows-setup.exe'
|
||||
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
|
||||
@@ -1378,8 +1634,41 @@ jobs:
|
||||
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
|
||||
run: |
|
||||
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
|
||||
|
||||
# Why: a fail-open gate that writes nothing is indistinguishable from a
|
||||
# gate that passed. Always leave a verdict in the evidence artifact and
|
||||
# the job summary so a silent degradation is visible (#6487).
|
||||
# Why best-effort: while warn-only, a disk-full or permission error
|
||||
# writing the verdict must not become the thing that fails the release.
|
||||
function Add-GateEvidence([string]$line) {
|
||||
try {
|
||||
Add-Content -Path 'inner-signing-evidence.txt' -Value "`n$line" -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Host "::warning::Could not append to the inner-signing evidence file: $_"
|
||||
}
|
||||
}
|
||||
|
||||
function Add-GateSummary([string]$verdict) {
|
||||
if (-not $env:GITHUB_STEP_SUMMARY) { return }
|
||||
try {
|
||||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows inner-binary signing:** $verdict" -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Host "::warning::Could not write inner-signing verdict to the job summary: $_"
|
||||
}
|
||||
}
|
||||
|
||||
function Write-GateVerdict([string]$verdict) {
|
||||
try {
|
||||
Set-Content -Path 'inner-signing-evidence.txt' -Value $verdict -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Host "::warning::Could not persist inner-signing verdict: $_"
|
||||
}
|
||||
Add-GateSummary $verdict
|
||||
}
|
||||
|
||||
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
|
||||
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
|
||||
Write-GateVerdict "NOT VERIFIED — $message"
|
||||
if ($required) { throw $message }
|
||||
Write-Host "::warning::$message"
|
||||
exit 0
|
||||
@@ -1388,13 +1677,28 @@ jobs:
|
||||
# Why try/catch: while the gate is warn-only, even an unexpected
|
||||
# script error (extraction hiccup, missing file) must not block
|
||||
# the release — only the flip to required makes failures fatal.
|
||||
# Why tracked separately: a required-mode signature failure must not be
|
||||
# rewritten as ERRORED by the catch below, which would replace the
|
||||
# per-file report with an exception string and lose the diagnostics.
|
||||
$policyFailure = $null
|
||||
|
||||
try {
|
||||
$report = New-Object System.Collections.Generic.List[string]
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
# Why: verify the files a user actually gets on disk, not the build
|
||||
# tree — 7z parses the NSIS exe directly as its embedded payload.
|
||||
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
|
||||
# Resolve 7za via app-builder-lib; electron-builder 26.9+ dropped the
|
||||
# bundled 7zip-bin package the old hardcoded path relied on (#6487).
|
||||
$7zaOutput = node config/scripts/resolve-7za-path.mjs
|
||||
$7zaExitCode = $LASTEXITCODE
|
||||
if ($7zaExitCode -ne 0) {
|
||||
throw "The 7za resolver exited with code $7zaExitCode for the inner-binary evidence gate."
|
||||
}
|
||||
$7za = ($7zaOutput | Out-String).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
|
||||
throw "The 7za resolver returned an invalid path for the inner-binary evidence gate: $7za"
|
||||
}
|
||||
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
|
||||
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
|
||||
|
||||
@@ -1428,16 +1732,32 @@ jobs:
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::warning::$_" }
|
||||
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
|
||||
if ($required) { throw $message }
|
||||
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
|
||||
# Why assigned before any I/O: a write that throws here would reach
|
||||
# the catch with $policyFailure still null, so a required-mode
|
||||
# signature failure would be re-reported as ERRORED and the per-file
|
||||
# report overwritten — the exact masking the hoist exists to prevent.
|
||||
if ($required) {
|
||||
$policyFailure = $message
|
||||
} else {
|
||||
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
|
||||
}
|
||||
Add-GateEvidence "VERDICT: FAILED — $message"
|
||||
Add-GateSummary "FAILED — $message"
|
||||
} else {
|
||||
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
|
||||
$ok = "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
|
||||
Add-GateEvidence "VERDICT: PASSED — $ok"
|
||||
Add-GateSummary "PASSED — $ok"
|
||||
Write-Host $ok
|
||||
}
|
||||
} catch {
|
||||
Write-GateVerdict "ERRORED — $_"
|
||||
if ($required) { throw }
|
||||
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
|
||||
}
|
||||
|
||||
# Outside the catch so the FAILED evidence report survives intact.
|
||||
if ($policyFailure) { throw $policyFailure }
|
||||
|
||||
- name: Upload Windows inner signing evidence
|
||||
if: always() && matrix.platform == 'win'
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -1601,6 +1921,32 @@ jobs:
|
||||
--prerelease="$prerelease" \
|
||||
--repo "$GITHUB_REPOSITORY"
|
||||
|
||||
post-release-e2e:
|
||||
needs:
|
||||
- cut
|
||||
- publish-release
|
||||
if: ${{ needs.cut.outputs.tag != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Dispatch tag-scoped E2E
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if gh workflow run e2e.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref "$TAG" \
|
||||
--raw-field "ref=refs/tags/$TAG"; then
|
||||
echo "Dispatched post-release E2E for $TAG."
|
||||
exit 0
|
||||
fi
|
||||
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
|
||||
done
|
||||
echo "::warning::Failed to dispatch post-release E2E for $TAG after 3 attempts."
|
||||
|
||||
homebrew-bump-published-rc-draft:
|
||||
needs:
|
||||
- cut
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Skill update round trip
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'skills/**'
|
||||
- 'resources/skills/**'
|
||||
- 'config/scripts/verify-skill-update-roundtrip.mjs'
|
||||
- 'src/main/skills/skill-freshness-eligibility.ts'
|
||||
- 'src/shared/skill-freshness.ts'
|
||||
- '.github/workflows/skill-update-roundtrip.yml'
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
roundtrip:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
shape: [symlink, copy]
|
||||
autocrlf: ['false', 'true']
|
||||
skills-cli: ['1.5.17']
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
shape: symlink
|
||||
autocrlf: 'false'
|
||||
skills-cli: latest
|
||||
continue-on-error: ${{ matrix.skills-cli == 'latest' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- name: Verify targeted update convergence and copy behavior
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: >-
|
||||
node config/scripts/verify-skill-update-roundtrip.mjs
|
||||
--cli=${{ matrix.skills-cli }}
|
||||
--autocrlf=${{ matrix.autocrlf }}
|
||||
--shape=${{ matrix.shape }}
|
||||
@@ -0,0 +1,199 @@
|
||||
name: Windows Crash-Survival E2E
|
||||
|
||||
# Why: proves the detached daemon-host relocation makes terminal sessions SURVIVE
|
||||
# a CRASH of Orca's main process (GitHub #7742), the companion guarantee to the
|
||||
# update-survival harness. Builds an (unsigned) installer FROM THIS BRANCH,
|
||||
# silent-installs it, opens a terminal, force-kills ONLY the app main (no
|
||||
# tree-kill), and asserts the daemon + shell stay alive, a relaunch adopts the
|
||||
# same daemon and shell, and post-crash input causes no pwsh FailFast. Targeted
|
||||
# pull requests keep it as a durable regression gate. A CI runner is the only
|
||||
# safe place to install — see the relocation post-mortem.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- '.github/workflows/win-crash-survival-e2e.yml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'electron.vite.config.ts'
|
||||
- 'build-plugins/**'
|
||||
- 'config/electron-builder.config.cjs'
|
||||
- 'config/patches/**'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'config/scripts/verify-packaged-plugin-resources.cjs'
|
||||
- 'native/**'
|
||||
# Byte-hashed at package time, and CRLF-sensitive on Windows.
|
||||
- '.gitattributes'
|
||||
- 'resources/plugins/**'
|
||||
- 'resources/win32/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/index.ts'
|
||||
- 'src/main/ipc/pty*.ts'
|
||||
- 'src/main/persistence.ts'
|
||||
- 'src/main/providers/**'
|
||||
- 'src/main/pty/**'
|
||||
- 'src/main/startup/first-window-startup-services.ts'
|
||||
- 'src/main/window/attach-main-window-services.ts'
|
||||
- 'src/preload/**'
|
||||
- 'src/renderer/src/App.tsx'
|
||||
- 'src/renderer/src/components/terminal-pane/**'
|
||||
- 'src/renderer/src/hooks/useIpcEvents.ts'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
- 'src/renderer/src/lib/session-write-subscriber.ts'
|
||||
- 'src/renderer/src/lib/workspace-session-host-persistence.ts'
|
||||
- 'src/renderer/src/store/slices/terminals.ts'
|
||||
- 'src/shared/pty-session-id-format.ts'
|
||||
- 'tools/win-crash-survival-e2e/**'
|
||||
- 'tools/win-update-e2e/**'
|
||||
# Why: source tests and benchmarks do not change the packaged artifact;
|
||||
# their own verify jobs cover them without spending a Windows build slot.
|
||||
- '!src/**/*.test.*'
|
||||
- '!src/**/*.bench.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expect:
|
||||
description: Expected outcome profile
|
||||
required: true
|
||||
type: choice
|
||||
default: survival
|
||||
options:
|
||||
- survival
|
||||
- orphaned
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: win-crash-survival-e2e-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
crash-survival:
|
||||
name: crash-survival (packaged build)
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 50
|
||||
env:
|
||||
EXPECT: ${{ inputs.expect || 'survival' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# This job only builds and runs the harness locally; it never pushes.
|
||||
persist-credentials: false
|
||||
|
||||
# Why: setup-node can restore pnpm's content-addressed store only after
|
||||
# the pnpm binary exists, avoiding repeat dependency downloads per run.
|
||||
- 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: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: cache the built installer by production build inputs, excluding
|
||||
# tests/reliability metadata so harness-only edits skip electron-builder.
|
||||
# The daemon relocation lives under src/, so product changes still rebuild.
|
||||
- name: Cache branch installer
|
||||
id: cache-installer
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: dist/orca-windows-setup.exe
|
||||
key: >-
|
||||
crash-survival-installer-${{ hashFiles(
|
||||
'src/**',
|
||||
'!src/**/*.test.*',
|
||||
'!src/**/*.bench.*',
|
||||
'config/**',
|
||||
'!config/**/*.test.*',
|
||||
'!config/reliability-gates.jsonc',
|
||||
'!config/max-lines-baseline.txt',
|
||||
'!config/vitest.config.ts',
|
||||
'build-plugins/**',
|
||||
'native/**',
|
||||
'resources/**',
|
||||
'electron.vite.config.ts',
|
||||
'tsconfig.json',
|
||||
'vite.web.config.ts',
|
||||
'.npmrc',
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml'
|
||||
) }}
|
||||
|
||||
# Why: production edits miss the installer cache by design, but Electron
|
||||
# and NSIS downloads are lockfile-owned and need not be fetched again.
|
||||
- name: Cache electron-builder downloads
|
||||
if: steps.cache-installer.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: crash-survival-electron-builder-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
crash-survival-electron-builder-
|
||||
|
||||
- name: Build Windows installer (unsigned)
|
||||
if: steps.cache-installer.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
pnpm run build:desktop
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
|
||||
|
||||
# Why: silent-install the branch build so the crash harness has a packaged
|
||||
# Orca.exe to drive. A clean CI runner installs to the default per-user
|
||||
# location (%LOCALAPPDATA%\Programs\Orca), which the harness auto-locates.
|
||||
- name: Silent-install branch build
|
||||
shell: pwsh
|
||||
run: |
|
||||
$exe = "dist/orca-windows-setup.exe"
|
||||
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
|
||||
Start-Process -FilePath $exe -ArgumentList '/S' -Wait
|
||||
$installed = Join-Path $env:LOCALAPPDATA 'Programs/Orca/Orca.exe'
|
||||
if (-not (Test-Path $installed)) {
|
||||
$installed = Join-Path $env:LOCALAPPDATA 'Programs/orca/Orca.exe'
|
||||
}
|
||||
if (-not (Test-Path $installed)) { throw "Installed Orca.exe not found after silent install" }
|
||||
"ORCA_EXE=$installed" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
# Why: crash ONLY the app main and assert the relocated daemon + its shell
|
||||
# survive, then that relaunch adopts them and input causes no pwsh FailFast.
|
||||
- name: Run crash-survival harness
|
||||
id: harness
|
||||
shell: pwsh
|
||||
env:
|
||||
ORCA_E2E_DIAG_DIR: artifacts/diag
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
$log = "artifacts/crash-survival-output.log"
|
||||
node tools/win-crash-survival-e2e/run.mjs `
|
||||
--expect "$env:EXPECT" `
|
||||
--exe-path "$env:ORCA_EXE" `
|
||||
--soak-seconds 8 2>&1 | Tee-Object -FilePath $log
|
||||
exit $LASTEXITCODE
|
||||
|
||||
- name: Upload crash-survival output
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: win-crash-survival-output
|
||||
path: |
|
||||
artifacts/crash-survival-output.log
|
||||
artifacts/diag/**
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
@@ -128,6 +128,7 @@ jobs:
|
||||
node tools/win-update-e2e/run.mjs `
|
||||
--from "$env:FROM_EXE" `
|
||||
--to "$env:TO_EXE" `
|
||||
--require-distinct-artifacts `
|
||||
--expect "$env:EXPECT" `
|
||||
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
|
||||
exit $LASTEXITCODE
|
||||
|
||||
@@ -271,7 +271,7 @@ jobs:
|
||||
throw 'Signed Windows installer was not returned by SignPath.'
|
||||
}
|
||||
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
|
||||
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
|
||||
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
$installer = Get-Item 'dist/orca-windows-setup.exe'
|
||||
@@ -325,7 +325,16 @@ jobs:
|
||||
|
||||
# Extract the signed installer and verify the files a user actually
|
||||
# gets on disk — including the exact file from issue #7785.
|
||||
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
|
||||
# electron-builder 26.9+ removed the bundled 7zip-bin package (#6487).
|
||||
$7zaOutput = node config/scripts/resolve-7za-path.mjs
|
||||
$7zaExitCode = $LASTEXITCODE
|
||||
if ($7zaExitCode -ne 0) {
|
||||
throw "The 7za resolver exited with code $7zaExitCode for the signing rehearsal."
|
||||
}
|
||||
$7za = ($7zaOutput | Out-String).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
|
||||
throw "The 7za resolver returned an invalid path for the signing rehearsal: $7za"
|
||||
}
|
||||
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
|
||||
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Windows terminal restart E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- '.github/workflows/windows-terminal-restart-e2e.yml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'electron.vite.config.ts'
|
||||
- 'config/patches/**'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'tests/playwright.config.ts'
|
||||
- 'tests/e2e/global-setup.ts'
|
||||
- 'tests/e2e/global-teardown.ts'
|
||||
- 'tests/e2e/helpers/**'
|
||||
- 'tests/e2e/restart-restore-terminal-input.spec.ts'
|
||||
- 'tests/e2e/restored-terminal-input-readiness.unit.test.ts'
|
||||
- 'tests/e2e/terminal-probe-input-sequence.ts'
|
||||
- 'tests/e2e/terminal-probe-input-sequence.unit.test.ts'
|
||||
- 'tests/e2e/terminal-restart-persistence.spec.ts'
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/ipc/pty*.ts'
|
||||
- 'src/main/providers/**'
|
||||
- 'src/main/pty/**'
|
||||
- 'src/main/pty-descendant-termination.ts'
|
||||
- 'src/main/windows-process-tree-kill.ts'
|
||||
- 'src/main/windows-pty-root-identity.ts'
|
||||
- 'src/preload/**'
|
||||
- 'src/renderer/src/components/terminal-pane/**'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
- 'src/shared/process-table-snapshot.ts'
|
||||
- 'src/shared/pty-session-id-format.ts'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Ref to check out
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: windows-terminal-restart-e2e-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
windows-terminal-restart:
|
||||
name: Windows terminal restart regressions
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
# Why: pnpm must exist before setup-node resolves its dependency cache.
|
||||
- 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: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for Windows E2E
|
||||
run: pnpm exec electron-vite build --mode e2e
|
||||
|
||||
- name: Run Windows terminal restart regressions
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1'
|
||||
ORCA_REQUIRE_WINDOWS_TERMINAL_RESTART_E2E: '1'
|
||||
# Why: pnpm forwards a literal `--` to Playwright, which makes the
|
||||
# grep and worker flags positional filters instead of CLI options.
|
||||
run: >-
|
||||
pnpm run test:e2e
|
||||
tests/e2e/restart-restore-terminal-input.spec.ts
|
||||
tests/e2e/terminal-restart-persistence.spec.ts
|
||||
--grep "clean restart with a live daemon session|cold-restored pane accepts typing|daemon snapshot relaunch preserves"
|
||||
--workers=1
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: windows-terminal-restart-playwright-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
+12
-10
@@ -77,20 +77,22 @@ design-docs/
|
||||
# Machine-local agent hook endpoint files may contain auth tokens.
|
||||
/agent-hooks/
|
||||
|
||||
# Local-only design/planning docs (not checked in).
|
||||
# Durable docs must live in one of the allow-listed locations below.
|
||||
# Local-only design/planning docs (not checked in), including most of docs/reference/.
|
||||
# Durable docs that should be tracked must live in one of the allow-listed
|
||||
# locations below (assets, readme, STYLEGUIDE, mobile terminal shortcut bar,
|
||||
# and the tracked reference docs linked from AGENTS.md / README.md).
|
||||
docs/**
|
||||
!docs/
|
||||
!docs/assets/
|
||||
!docs/assets/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/reference/
|
||||
!docs/reference/**
|
||||
# Keep generated React perf scan ledgers local; durable docs still use docs/reference.
|
||||
docs/reference/react-performance-audit.md
|
||||
!docs/STYLEGUIDE.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
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
@@ -100,9 +102,9 @@ docs/reference/react-performance-audit.md
|
||||
validation-screenshots/
|
||||
.stably-browser
|
||||
|
||||
# PR verification evidence screenshots are referenced from notes but should not
|
||||
# be committed.
|
||||
notes/artifacts/
|
||||
# Local scratch notes and PR evidence screenshots (not part of the product).
|
||||
/notes/
|
||||
/pr-evidence/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
@@ -123,5 +125,5 @@ src/renderer/src/i18n/locales/.ko-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.ja-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.es-catalog-cache.json
|
||||
|
||||
# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md
|
||||
# Bench result JSONs are working artifacts
|
||||
tools/benchmarks/results/terminal-pipeline-*.json
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "react", "react-hooks", "react-perf", "unicorn"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "mobile-pairing",
|
||||
"specifier": "./config/oxlint-plugins/mobile-pairing-qrcode-import.mjs"
|
||||
},
|
||||
{
|
||||
"name": "app-store-performance",
|
||||
"specifier": "./config/oxlint-plugins/app-store-performance.mjs"
|
||||
},
|
||||
{
|
||||
"name": "quadratic-buffer-concat",
|
||||
"specifier": "./config/oxlint-plugins/quadratic-buffer-concat.mjs"
|
||||
},
|
||||
{
|
||||
"name": "renderer-scrollbar-style",
|
||||
"specifier": "./config/oxlint-plugins/renderer-scrollbar-style.mjs"
|
||||
}
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"rules": {
|
||||
"app-store-performance/require-selector": "error",
|
||||
"app-store-performance/no-identity-selector": "error",
|
||||
"app-store-performance/no-fresh-selector-result": "error",
|
||||
"no-fallthrough": "error",
|
||||
"quadratic-buffer-concat/no-loop-carried-concat": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/no-children-prop": "error",
|
||||
@@ -49,6 +72,19 @@
|
||||
],
|
||||
"curly": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "@linear/sdk",
|
||||
"allowTypeImports": true,
|
||||
"message": "Value-importing @linear/sdk hoists its ~2.6MB CJS bundle into the eager top-level require block and defeats the lazy loader. Use `import type` plus loadLinearSdk() from src/main/linear/linear-sdk.ts."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"mobile-pairing/no-eager-qrcode-import": "error",
|
||||
"no-useless-return": "error",
|
||||
"prefer-template": "error",
|
||||
"unicorn/consistent-empty-array-spread": "error",
|
||||
@@ -74,6 +110,19 @@
|
||||
"unicorn/throw-new-error": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/renderer/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.test.*", "**/*.spec.*", "**/*-benchmark.*"],
|
||||
"rules": {
|
||||
"quadratic-buffer-concat/no-loop-carried-concat": "off",
|
||||
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.ts"],
|
||||
"rules": {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Design System
|
||||
# Design System
|
||||
|
||||
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
|
||||
|
||||
## Code Comments: Document the "Why", Briefly
|
||||
|
||||
When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does.
|
||||
|
||||
Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point.
|
||||
# Style
|
||||
## Concise/Brief Non-obviosu comments ONLY
|
||||
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
* DO: BE CONCISE. 1 LINE if possible
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`.
|
||||
NEVER add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`.
|
||||
|
||||
## File and Module Naming
|
||||
|
||||
Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuff` for files, folders, or modules. They carry zero info and tend to become dumping grounds. Name files after what they _actually_ contain — prefer the concrete domain concept (e.g. `tab-group-state.ts`, `terminal-orphan-cleanup.ts`) over the generic role (`tabs-helpers.ts`, `terminal-utils.ts`). If you find yourself reaching for `helpers`, the file probably has more than one responsibility and should be split, or there's a better name hiding in the code that describes what the functions operate on.
|
||||
|
||||
## Type Declarations: Prefer `.ts` Over `.d.ts`
|
||||
|
||||
# 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.
|
||||
@@ -29,11 +29,16 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
||||
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
|
||||
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
|
||||
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
|
||||
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
|
||||
|
||||
## SSH Use Case
|
||||
|
||||
All changes must consider the SSH use case. Don't assume local-only execution.
|
||||
|
||||
## Folder Workspace Use Case
|
||||
|
||||
All changes must consider folder workspaces as well as git worktrees. Don't assume every workspace is a git worktree.
|
||||
|
||||
## Git Binary Compatibility
|
||||
|
||||
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
|
||||
@@ -53,5 +58,3 @@ Source-control and review changes must consider GitLab and other supported git p
|
||||
## GitHub CLI Usage
|
||||
|
||||
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
|
||||
|
||||
## Type Declarations: Prefer `.ts` Over `.d.ts`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,16 +3,16 @@
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub stars" /></a>
|
||||
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&label=%E2%98%85&color=08C" alt="GitHub stars" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" alt="Total downloads across all releases" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="License: MIT" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Follow Orca on X" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
|
||||
<sub><a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a> · <a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.fr.md">Français</a> · <a href="docs/readme/README.pt.md">Português</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/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.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/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.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -238,10 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
|
||||
- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group.
|
||||
- **WeChat:** If group 5 is full, you can join group 6.
|
||||
|
||||
<img src="docs/assets/wechat-qr.png" alt="WeChat QR code for the Orca community" width="160" />
|
||||
<img src="docs/assets/wechat-qr-backup.jpg" alt="Backup WeChat QR code for the Orca community" width="160" />
|
||||
<img src="docs/assets/wechat-qr.jpg" alt="WeChat group 5 QR code for the Orca community" width="160" style="margin-right: 16px;" />
|
||||
<img src="docs/assets/wechat-qr-group6.jpg" alt="WeChat group 6 QR code for the Orca community" width="160" />
|
||||
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
@@ -261,6 +261,9 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
|
||||
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
|
||||
</p>
|
||||
|
||||
## Signed Builds
|
||||
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
|
||||
|
||||
## License
|
||||
|
||||
Orca is free and open source under the [MIT License](LICENSE).
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
|
||||
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
|
||||
type OutputBundle = Rollup.OutputBundle
|
||||
type OutputChunk = Rollup.OutputChunk
|
||||
|
||||
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
|
||||
// chunk gained `require("electron")` (an import edge added in #7642), and the
|
||||
@@ -17,8 +21,10 @@ import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'computer-sidecar',
|
||||
'agent-hooks/managed-agent-hook-controls'
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'codex/codex-app-server-grant-entry'
|
||||
] as const
|
||||
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
|
||||
@@ -96,6 +102,8 @@ function smokeLoadDaemonEntry(outputDir: string): void {
|
||||
}
|
||||
|
||||
export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
let daemonOutputDir: string | undefined
|
||||
|
||||
return {
|
||||
name: 'orca-plain-node-entry-guard',
|
||||
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
|
||||
@@ -123,7 +131,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
}
|
||||
|
||||
if (entryByName.has('daemon-entry') && options.dir) {
|
||||
smokeLoadDaemonEntry(options.dir)
|
||||
daemonOutputDir = options.dir
|
||||
}
|
||||
},
|
||||
closeBundle() {
|
||||
if (daemonOutputDir) {
|
||||
const outputDir = daemonOutputDir
|
||||
daemonOutputDir = undefined
|
||||
smokeLoadDaemonEntry(outputDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
ARG BASE_IMAGE=ubuntu:24.04
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG LIBASOUND_PACKAGE=libasound2t64
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
dbus-x11 \
|
||||
jq \
|
||||
libatk-bridge2.0-0 \
|
||||
libatspi2.0-0 \
|
||||
"${LIBASOUND_PACKAGE}" \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnss3 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
libxss1 \
|
||||
procps \
|
||||
util-linux \
|
||||
xauth \
|
||||
xvfb \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash orca
|
||||
|
||||
COPY config/docker/headless-pairing/run-appimage-case.sh /usr/local/bin/run-appimage-case
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/run-appimage-case"]
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM node:24-bookworm
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatspi2.0-0 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnss3 \
|
||||
libsecret-1-dev \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
libxss1 \
|
||||
pkg-config \
|
||||
python3 \
|
||||
rpm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable \
|
||||
&& corepack prepare pnpm@10.24.0 --activate
|
||||
|
||||
WORKDIR /workspace
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
case_name=${1:?launch case is required}
|
||||
appimage=${ORCA_TEST_APPIMAGE:-/artifacts/squashfs-root/AppRun}
|
||||
timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-12}
|
||||
pairing_address=${ORCA_PAIRING_ADDRESS:-127.0.0.1}
|
||||
port=${ORCA_SERVE_PORT:-0}
|
||||
state_dir="/tmp/orca-${case_name}"
|
||||
|
||||
if ((EUID == 0)); then
|
||||
mkdir -p "$state_dir/config" "$state_dir/cache"
|
||||
chown -R orca:orca "$state_dir"
|
||||
# Why: packaged serve should exercise the same unprivileged account required by production systemd guidance.
|
||||
exec runuser --user orca --preserve-environment -- "$0" "$@"
|
||||
fi
|
||||
|
||||
mkdir -p "$state_dir/config" "$state_dir/cache"
|
||||
|
||||
if [[ "$appimage" == *.AppImage ]]; then
|
||||
is_appimage=1
|
||||
launcher=("$appimage" --appimage-extract-and-run)
|
||||
else
|
||||
is_appimage=0
|
||||
launcher=("$appimage")
|
||||
fi
|
||||
|
||||
app_args=("${launcher[@]}")
|
||||
if [[ ${ORCA_TEST_NO_SANDBOX:-1} == 1 ]]; then
|
||||
app_args+=(--no-sandbox)
|
||||
fi
|
||||
app_args+=(serve --port "$port" --pairing-address "$pairing_address")
|
||||
if [[ ${ORCA_READY_JSON:-0} == 1 ]]; then
|
||||
app_args+=(--json)
|
||||
fi
|
||||
if [[ ${ORCA_NO_PAIRING:-0} == 1 ]]; then
|
||||
app_args+=(--no-pairing)
|
||||
fi
|
||||
|
||||
case "$case_name" in
|
||||
direct)
|
||||
command=("${app_args[@]}")
|
||||
;;
|
||||
xvfb)
|
||||
command=(xvfb-run -a "${app_args[@]}")
|
||||
;;
|
||||
dbus-xvfb)
|
||||
command=(dbus-run-session -- xvfb-run -a "${app_args[@]}")
|
||||
;;
|
||||
journal)
|
||||
command=(setsid --wait xvfb-run -a "${app_args[@]}")
|
||||
;;
|
||||
*)
|
||||
echo "Unknown launch case: $case_name" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
export HOME="$state_dir"
|
||||
export XDG_CONFIG_HOME="$state_dir/config"
|
||||
export XDG_CACHE_HOME="$state_dir/cache"
|
||||
if [[ $is_appimage == 0 ]]; then
|
||||
export APPDIR=${ORCA_TEST_APPDIR:-"$(dirname "$appimage")"}
|
||||
fi
|
||||
|
||||
if [[ ${ORCA_KEEP_RUNNING:-0} == 1 ]]; then
|
||||
exec "${command[@]}"
|
||||
fi
|
||||
|
||||
set +e
|
||||
timeout --signal=TERM --kill-after=2s "${timeout_seconds}s" "${command[@]}"
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
if ((status == 124)); then
|
||||
echo "STARTUP_TIMEOUT: no ready contract after ${timeout_seconds}s" >&2
|
||||
fi
|
||||
exit "$status"
|
||||
@@ -11,13 +11,24 @@ const {
|
||||
prunePackagedRuntimeNodeModules,
|
||||
verifyPackagedMainRuntimeDeps
|
||||
} = require('./packaged-runtime-node-modules.cjs')
|
||||
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 isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
|
||||
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
|
||||
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const appId = 'com.stablyai.orca'
|
||||
const featureWallResources = {
|
||||
from: 'resources/onboarding/feature-wall',
|
||||
to: 'onboarding/feature-wall'
|
||||
}
|
||||
// Why: freshness detection needs immutable identity metadata from this exact
|
||||
// app build, but never needs the skill package bytes or a runtime network read.
|
||||
const skillFreshnessResources = {
|
||||
from: 'resources/skills',
|
||||
to: 'skills'
|
||||
}
|
||||
// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
|
||||
// apps. Keeping relay assets as extraResources makes them real directories
|
||||
// instead of paths hidden inside app.asar.
|
||||
@@ -25,13 +36,17 @@ const relayExtraResource = {
|
||||
from: 'out/relay',
|
||||
to: 'relay'
|
||||
}
|
||||
// Why: bundled plugins are immutable install inputs and must remain ordinary
|
||||
// directories so the startup bootstrap can verify and publish exact bytes.
|
||||
const bundledPluginResources = {
|
||||
from: 'resources/plugins/launch',
|
||||
to: 'plugins/launch'
|
||||
}
|
||||
// Why: the main bundle, packaged CLI, SSH paths, and speech worker all execute
|
||||
// from package directories where pnpm's symlink farm is absent. Copy the exact
|
||||
// runtime dependency closure to Resources/node_modules so bare require() calls
|
||||
// do not fall through to a developer checkout's node_modules.
|
||||
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
|
||||
|
||||
const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
|
||||
const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources]
|
||||
const macSpeechNativeResource = {
|
||||
from: 'node_modules/sherpa-onnx-darwin-${arch}',
|
||||
to: 'node_modules/sherpa-onnx-darwin-${arch}'
|
||||
@@ -47,8 +62,9 @@ const winSpeechNativeResource = {
|
||||
|
||||
/** @type {import('electron-builder').Configuration} */
|
||||
module.exports = {
|
||||
appId: 'com.stablyai.orca',
|
||||
appId,
|
||||
productName: 'Orca',
|
||||
...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}),
|
||||
directories: {
|
||||
buildResources: 'resources/build'
|
||||
},
|
||||
@@ -62,17 +78,42 @@ module.exports = {
|
||||
'!mobile{,/**/*}',
|
||||
'!native{,/**/*}',
|
||||
'!skills{,/**/*}',
|
||||
// Why: guide/stub authoring sources are compiled into runtime artifacts; shipping
|
||||
// either source tree would duplicate content without a runtime consumer.
|
||||
'!skill-guides{,/**/*}',
|
||||
'!skill-stubs{,/**/*}',
|
||||
'!tests{,/**/*}',
|
||||
// Why: examples/ is plugin authoring documentation with no runtime consumer —
|
||||
// bundled plugins ship via extraResources from resources/plugins/launch/. It also
|
||||
// carries hostile-panel, the adversarial fixture the containment tests point at,
|
||||
// which must never reach a user's install.
|
||||
'!examples{,/**/*}',
|
||||
// Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE);
|
||||
// it is gitignored, but exclude it defensively so a stray local capture at
|
||||
// package time never bloats app.asar.
|
||||
'!pr-evidence{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
|
||||
'!out/**/*.test.js',
|
||||
// Why: Vite's manifest is only used to project the paired web client.
|
||||
'!out/renderer/.vite{,/**/*}',
|
||||
'!electron.vite.config.{js,ts,mjs,cjs}',
|
||||
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
|
||||
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
|
||||
'!tsconfig.json',
|
||||
// Why: feature-wall media is copied via extraResources so runtime can read
|
||||
// it from process.resourcesPath; exclude the source copy from app.asar.
|
||||
'!resources/onboarding/feature-wall/**'
|
||||
'!resources/onboarding/feature-wall/**',
|
||||
'!resources/skills/**',
|
||||
// Why: bundled plugins ship via extraResources to resources/plugins/launch;
|
||||
// packing the source tree into app.asar would duplicate those exact bytes.
|
||||
'!resources/plugins/launch/**',
|
||||
// Why: the Windows CLI shim ships via extraResources to resources/bin/orca.cmd
|
||||
// (beside the native resources/bin/orca.exe). Packing the source tree into
|
||||
// app.asar too lets asarUnpack:['resources/**'] extract a second copy at
|
||||
// app.asar.unpacked/resources/win32/bin/orca.cmd with no adjacent orca.exe,
|
||||
// which fails to launch the CLI (#7351).
|
||||
'!resources/win32{,/**/*}'
|
||||
],
|
||||
// Why: the CLI entry-point lives in out/cli/ but imports shared modules
|
||||
// from out/shared/ and local hook mutators from out/main/. These paths must be
|
||||
@@ -109,8 +150,10 @@ module.exports = {
|
||||
'out/main/hermes/**',
|
||||
'out/main/win32-utils.js',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
'out/main/main-thread-hang-watchdog-entry.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/ws/**',
|
||||
@@ -120,6 +163,12 @@ module.exports = {
|
||||
'node_modules/sherpa-onnx*/**'
|
||||
],
|
||||
afterPack: async (context) => {
|
||||
// Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node
|
||||
// requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902).
|
||||
// Fail packaging if any bundled native binary exceeds the supported floor.
|
||||
if (context.electronPlatformName === 'linux') {
|
||||
verifyLinuxGlibcFloor(context.appOutDir)
|
||||
}
|
||||
const resourcesDir =
|
||||
context.electronPlatformName === 'darwin'
|
||||
? join(
|
||||
@@ -132,6 +181,25 @@ module.exports = {
|
||||
if (!existsSync(resourcesDir)) {
|
||||
return
|
||||
}
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
const architectureByEnum = { 1: 'x64', 3: 'arm64' }
|
||||
const architecture = architectureByEnum[context.arch]
|
||||
if (!architecture) {
|
||||
throw new Error(`Unsupported local-build compatibility architecture: ${context.arch}`)
|
||||
}
|
||||
const version = context.packager.appInfo.version
|
||||
let commit = process.env.ORCA_BUILD_COMMIT || process.env.GITHUB_SHA || 'unknown'
|
||||
if (commit === 'unknown') {
|
||||
try {
|
||||
commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
} catch {
|
||||
// Source archives can still produce a signed build with an explicit version.
|
||||
}
|
||||
}
|
||||
writeMacBuildCompatibility(resourcesDir, { version, commit, architecture })
|
||||
}
|
||||
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
|
||||
verifyPackagedMainRuntimeDeps(resourcesDir)
|
||||
// Why: boot the packaged daemon-entry under plain Node, but only for the
|
||||
@@ -152,6 +220,9 @@ module.exports = {
|
||||
`[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})`
|
||||
)
|
||||
}
|
||||
// Why: inspect electron-builder's real output so a broken extraResources
|
||||
// mapping fails packaging before bundled content reaches users.
|
||||
verifyPackagedPluginResources(resourcesDir)
|
||||
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
|
||||
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
|
||||
for (const filename of readdirSync(resourcesDir)) {
|
||||
@@ -180,6 +251,7 @@ module.exports = {
|
||||
},
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
...createPackagedRuntimeNodeModuleResources('win32'),
|
||||
winSpeechNativeResource,
|
||||
{
|
||||
from: 'resources/win32/bin/orca.cmd',
|
||||
@@ -243,6 +315,7 @@ module.exports = {
|
||||
notarize: isMacRelease,
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
...createPackagedRuntimeNodeModuleResources('darwin'),
|
||||
macSpeechNativeResource,
|
||||
{
|
||||
from: 'resources/darwin/bin/orca',
|
||||
@@ -306,6 +379,7 @@ module.exports = {
|
||||
},
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
...createPackagedRuntimeNodeModuleResources('linux'),
|
||||
linuxSpeechNativeResource,
|
||||
{
|
||||
from: 'resources/linux/bin/orca-ide',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import { electronViteConfig } from '../electron.vite.config'
|
||||
|
||||
const target = process.env.ORCA_ELECTRON_VITE_TARGET
|
||||
const configByTarget = {
|
||||
main: { main: electronViteConfig.main },
|
||||
preload: { preload: electronViteConfig.preload },
|
||||
renderer: { renderer: electronViteConfig.renderer }
|
||||
}
|
||||
|
||||
if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) {
|
||||
throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? '<unset>'}`)
|
||||
}
|
||||
|
||||
export default defineConfig(configByTarget[target as keyof typeof configByTarget])
|
||||
@@ -1 +1,72 @@
|
||||
[]
|
||||
[
|
||||
{
|
||||
"filePath": "src/renderer/src/lib/agent-background-session-test-state.ts",
|
||||
"kind": "object-property:title",
|
||||
"text": "Terminal 1",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "语言",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "語言",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "언어",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "言語",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Idioma",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "ghostty",
|
||||
"dynamic": false,
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "ghostty",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
|
||||
@@ -280,7 +280,6 @@ inline src/renderer/src/lib/pane-manager/pane-manager.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.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/lib/terminal-links.ts
|
||||
inline src/renderer/src/lib/worktree-activation.test.ts
|
||||
inline src/renderer/src/lib/worktree-activation.ts
|
||||
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
|
||||
|
||||
@@ -13,6 +13,26 @@
|
||||
; The image name and the LOCALAPPDATA folder name must stay in sync with
|
||||
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
|
||||
; src/main/daemon/daemon-host-relocation.ts.
|
||||
|
||||
!include "getProcessInfo.nsh"
|
||||
Var pid
|
||||
|
||||
!macro customCheckAppRunning
|
||||
${if} ${isUpdated}
|
||||
; Why: an A-era packaged CLI may still hold Orca.exe while its exact native launcher waits.
|
||||
nsExec::Exec `"$PowerShellPath" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference='Stop'; $$app=[IO.Path]::GetFullPath($$args[0]); $$launcher=[IO.Path]::GetFullPath($$args[1]); $$comparison=[StringComparison]::OrdinalIgnoreCase; $$children=@(Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$_.ExecutablePath), $$app, $$comparison) }); $$launcherProofs=@(); foreach ($$child in $$children) { $$parent=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$child.ParentProcessId) -ErrorAction SilentlyContinue; if ($$parent -and $$parent.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$parent.ExecutablePath), $$launcher, $$comparison)) { $$launcherProofs += [pscustomobject]@{ ProcessId=[int]$$parent.ProcessId; CreationDate=[string]$$parent.CreationDate } }; Stop-Process -Id $$child.ProcessId -Force -ErrorAction SilentlyContinue }; $$launcherDeadline=[DateTime]::UtcNow.AddSeconds(30); do { $$remaining=@($$launcherProofs | Where-Object { $$proof=$$_; $$current=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $$proof.ProcessId) -ErrorAction SilentlyContinue; $$current -and $$current.ExecutablePath -and [String]::Equals([IO.Path]::GetFullPath($$current.ExecutablePath), $$launcher, $$comparison) -and [String]::Equals([string]$$current.CreationDate, $$proof.CreationDate, [StringComparison]::Ordinal) }); if ($$remaining.Count -eq 0) { break }; Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $$launcherDeadline); if ($$remaining.Count -ne 0) { exit 20 }; $$lockDeadline=[DateTime]::UtcNow.AddSeconds(30); $$released=$$false; do { try { $$stream=[IO.File]::Open($$app, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None); $$stream.Dispose(); $$released=$$true } catch { Start-Sleep -Milliseconds 100 } } while (-not $$released -and [DateTime]::UtcNow -lt $$lockDeadline); if (-not $$released) { exit 21 } }" "$INSTDIR\Orca.exe" "$INSTDIR\resources\bin\orca.exe"`
|
||||
Pop $0
|
||||
${if} $0 != 0
|
||||
DetailPrint "Orca update release proof failed with status $0."
|
||||
SetErrorLevel 2
|
||||
Quit
|
||||
${endIf}
|
||||
${else}
|
||||
!insertmacro IS_POWERSHELL_AVAILABLE
|
||||
!insertmacro _CHECK_APP_RUNNING
|
||||
${endIf}
|
||||
!macroend
|
||||
|
||||
!macro customUnInstall
|
||||
${ifNot} ${isUpdated}
|
||||
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "../node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["import", "jsx-a11y", "react-hooks", "vitest"],
|
||||
"categories": {
|
||||
"correctness": "off",
|
||||
"suspicious": "off",
|
||||
"pedantic": "off",
|
||||
"perf": "off",
|
||||
"style": "off",
|
||||
"restriction": "off",
|
||||
"nursery": "off"
|
||||
},
|
||||
"rules": {
|
||||
"import/export": "warn",
|
||||
"import/named": "warn",
|
||||
"import/namespace": "warn",
|
||||
"import/no-cycle": ["warn", { "maxDepth": 3 }],
|
||||
"import/no-duplicates": "warn",
|
||||
"import/no-self-import": "warn"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/renderer/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"jsx-a11y/alt-text": "warn",
|
||||
"jsx-a11y/anchor-has-content": "warn",
|
||||
"jsx-a11y/aria-props": "warn",
|
||||
"jsx-a11y/aria-role": "warn",
|
||||
"jsx-a11y/click-events-have-key-events": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.{test,spec}.{ts,tsx}", "tests/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"vitest/no-conditional-tests": "warn",
|
||||
"vitest/no-focused-tests": "warn",
|
||||
"vitest/no-identical-title": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["mobile/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"react-hooks/exhaustive-deps": "warn"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
|
||||
}
|
||||
+13
-1
@@ -11,9 +11,21 @@
|
||||
"nursery": "off"
|
||||
},
|
||||
"rules": {
|
||||
"typescript/await-thenable": "warn",
|
||||
"typescript/restrict-plus-operands": "warn",
|
||||
"typescript/restrict-template-expressions": "warn",
|
||||
"typescript/switch-exhaustiveness-check": [
|
||||
"error",
|
||||
{ "allowDefaultCaseForExhaustiveSwitch": false }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.test.*", "**/*.spec.*"],
|
||||
"rules": {
|
||||
"typescript/await-thenable": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
const ALLOCATING_METHODS = new Set([
|
||||
'filter',
|
||||
'flat',
|
||||
'flatMap',
|
||||
'map',
|
||||
'toReversed',
|
||||
'toSorted',
|
||||
'toSpliced',
|
||||
'with'
|
||||
])
|
||||
|
||||
function identifierName(node) {
|
||||
return node?.type === 'Identifier' ? node.name : null
|
||||
}
|
||||
|
||||
function propertyName(node) {
|
||||
if (node?.type !== 'MemberExpression') {
|
||||
return null
|
||||
}
|
||||
if (!node.computed) {
|
||||
return identifierName(node.property)
|
||||
}
|
||||
return node.property?.type === 'Literal' && typeof node.property.value === 'string'
|
||||
? node.property.value
|
||||
: null
|
||||
}
|
||||
|
||||
function returnedExpressions(selector) {
|
||||
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
|
||||
return []
|
||||
}
|
||||
if (selector.body.type !== 'BlockStatement') {
|
||||
return [selector.body]
|
||||
}
|
||||
const expressions = []
|
||||
const visit = (node) => {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return
|
||||
}
|
||||
if (
|
||||
node !== selector.body &&
|
||||
['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression'].includes(node.type)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (node.type === 'ReturnStatement') {
|
||||
if (node.argument) {
|
||||
expressions.push(node.argument)
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const [key, child] of Object.entries(node)) {
|
||||
if (key === 'parent') {
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(child)) {
|
||||
child.forEach(visit)
|
||||
} else {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(selector.body)
|
||||
return expressions
|
||||
}
|
||||
|
||||
function unwrapShallowSelector(selector, shallowHooks) {
|
||||
if (
|
||||
selector?.type === 'CallExpression' &&
|
||||
selector.callee.type === 'Identifier' &&
|
||||
shallowHooks.has(selector.callee.name)
|
||||
) {
|
||||
return { selector: selector.arguments[0], shallow: true }
|
||||
}
|
||||
return { selector, shallow: false }
|
||||
}
|
||||
|
||||
function isIdentitySelector(selector) {
|
||||
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
|
||||
return false
|
||||
}
|
||||
const parameter = selector.params[0]
|
||||
if (parameter?.type !== 'Identifier') {
|
||||
return false
|
||||
}
|
||||
return returnedExpressions(selector).some(
|
||||
(expression) => expression.type === 'Identifier' && expression.name === parameter.name
|
||||
)
|
||||
}
|
||||
|
||||
function isAllocatingExpression(expression) {
|
||||
if (expression?.type === 'ConditionalExpression') {
|
||||
return (
|
||||
isAllocatingExpression(expression.consequent) || isAllocatingExpression(expression.alternate)
|
||||
)
|
||||
}
|
||||
if (expression?.type === 'LogicalExpression') {
|
||||
return isAllocatingExpression(expression.left) || isAllocatingExpression(expression.right)
|
||||
}
|
||||
if (
|
||||
expression?.type === 'ArrayExpression' ||
|
||||
expression?.type === 'ObjectExpression' ||
|
||||
expression?.type === 'NewExpression'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (expression?.type !== 'CallExpression') {
|
||||
return false
|
||||
}
|
||||
const method = propertyName(expression.callee)
|
||||
if (method && ALLOCATING_METHODS.has(method)) {
|
||||
return true
|
||||
}
|
||||
const callee = expression.callee
|
||||
return (
|
||||
callee.type === 'MemberExpression' &&
|
||||
identifierName(callee.object) === 'Object' &&
|
||||
['assign', 'create', 'entries', 'fromEntries', 'keys', 'values'].includes(propertyName(callee))
|
||||
)
|
||||
}
|
||||
|
||||
function importedLocalName(specifier, importedName) {
|
||||
if (specifier.type !== 'ImportSpecifier' || identifierName(specifier.imported) !== importedName) {
|
||||
return null
|
||||
}
|
||||
return identifierName(specifier.local)
|
||||
}
|
||||
|
||||
function createRuleState() {
|
||||
return {
|
||||
appStoreHooks: new Set(),
|
||||
shallowHooks: new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function recordImports(node, state) {
|
||||
if (node.source?.value === 'zustand/react/shallow') {
|
||||
for (const specifier of node.specifiers) {
|
||||
const localName = importedLocalName(specifier, 'useShallow')
|
||||
if (localName) {
|
||||
state.shallowHooks.add(localName)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const specifier of node.specifiers) {
|
||||
const localName = importedLocalName(specifier, 'useAppStore')
|
||||
if (localName) {
|
||||
state.appStoreHooks.add(localName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isAppStoreCall(node, state) {
|
||||
return (
|
||||
node.callee.type === 'Identifier' &&
|
||||
state.appStoreHooks.has(node.callee.name) &&
|
||||
node.optional !== true
|
||||
)
|
||||
}
|
||||
|
||||
function requireSelectorRule() {
|
||||
const state = createRuleState()
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
recordImports(node, state)
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (isAppStoreCall(node, state) && node.arguments.length === 0) {
|
||||
this.report({
|
||||
node,
|
||||
message:
|
||||
'Pass a selector to useAppStore so the component does not rerender for every store write.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function noIdentitySelectorRule() {
|
||||
const state = createRuleState()
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
recordImports(node, state)
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (!isAppStoreCall(node, state)) {
|
||||
return
|
||||
}
|
||||
const { selector } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
|
||||
if (isIdentitySelector(selector)) {
|
||||
this.report({
|
||||
node: selector,
|
||||
message:
|
||||
'Select the smallest required fields instead of subscribing to the entire app store.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function noFreshSelectorResultRule() {
|
||||
const state = createRuleState()
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
recordImports(node, state)
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (!isAppStoreCall(node, state)) {
|
||||
return
|
||||
}
|
||||
const { selector, shallow } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
|
||||
if (shallow) {
|
||||
return
|
||||
}
|
||||
const freshResult = returnedExpressions(selector).find(isAllocatingExpression)
|
||||
if (freshResult) {
|
||||
this.report({
|
||||
node: freshResult,
|
||||
message:
|
||||
'This selector returns a fresh reference on every store write; select a stable field, cache the result, or use useShallow.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindContext(createVisitors) {
|
||||
return (context) => {
|
||||
const visitors = createVisitors()
|
||||
for (const [nodeType, visit] of Object.entries(visitors)) {
|
||||
visitors[nodeType] = visit.bind(context)
|
||||
}
|
||||
return visitors
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: 'app-store-performance' },
|
||||
rules: {
|
||||
'require-selector': { create: bindContext(requireSelectorRule) },
|
||||
'no-identity-selector': { create: bindContext(noIdentitySelectorRule) },
|
||||
'no-fresh-selector-result': { create: bindContext(noFreshSelectorResultRule) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
const MESSAGE =
|
||||
"qrcode is only reachable from mobile pairing. Use `import type` plus `await import('qrcode')` so startup does not parse its bundle."
|
||||
|
||||
function hasRuntimeImport(node) {
|
||||
if (node.importKind === 'type') {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
node.specifiers.length === 0 ||
|
||||
node.specifiers.some((specifier) => specifier.importKind !== 'type')
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: 'mobile-pairing' },
|
||||
rules: {
|
||||
'no-eager-qrcode-import': {
|
||||
create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source?.value === 'qrcode' && hasRuntimeImport(node)) {
|
||||
context.report({ node, message: MESSAGE })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
const LOOP_TYPES = new Set([
|
||||
'ForStatement',
|
||||
'ForInStatement',
|
||||
'ForOfStatement',
|
||||
'WhileStatement',
|
||||
'DoWhileStatement'
|
||||
])
|
||||
const ASSIGNMENT_OPERATORS = new Set(['=', '+=', '??=', '||=', '&&='])
|
||||
const EXPRESSION_WRAPPERS = new Set([
|
||||
'ChainExpression',
|
||||
'TSAsExpression',
|
||||
'TSNonNullExpression',
|
||||
'TSSatisfiesExpression',
|
||||
'TypeCastExpression'
|
||||
])
|
||||
|
||||
function normalizeReferenceText(text) {
|
||||
return text.replaceAll(/\s+/g, '')
|
||||
}
|
||||
|
||||
function sourceText(context, node) {
|
||||
return context.sourceCode.getText(node)
|
||||
}
|
||||
|
||||
function memberPropertyName(node) {
|
||||
if (node?.type !== 'MemberExpression') {
|
||||
return null
|
||||
}
|
||||
if (!node.computed && node.property.type === 'Identifier') {
|
||||
return node.property.name
|
||||
}
|
||||
return node.property.type === 'Literal' && typeof node.property.value === 'string'
|
||||
? node.property.value
|
||||
: null
|
||||
}
|
||||
|
||||
function rootReferenceText(context, node) {
|
||||
if (node?.type === 'Identifier') {
|
||||
return node.name
|
||||
}
|
||||
if (node?.type === 'MemberExpression') {
|
||||
return node.object.type === 'ThisExpression'
|
||||
? normalizeReferenceText(sourceText(context, node))
|
||||
: rootReferenceText(context, node.object)
|
||||
}
|
||||
if (node?.type === 'CallExpression') {
|
||||
return rootReferenceText(context, node.callee)
|
||||
}
|
||||
if (EXPRESSION_WRAPPERS.has(node?.type)) {
|
||||
return rootReferenceText(context, node.expression)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isBufferConcatCall(node) {
|
||||
return (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.callee.object.type === 'Identifier' &&
|
||||
node.callee.object.name === 'Buffer' &&
|
||||
memberPropertyName(node.callee) === 'concat' &&
|
||||
node.arguments[0]?.type === 'ArrayExpression'
|
||||
)
|
||||
}
|
||||
|
||||
function enclosingLoop(node) {
|
||||
for (let current = node.parent; current; current = current.parent) {
|
||||
if (LOOP_TYPES.has(current.type)) {
|
||||
return current
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function nodeStart(node) {
|
||||
return node.start ?? node.range?.[0] ?? 0
|
||||
}
|
||||
|
||||
function nodeEnd(node) {
|
||||
return node.end ?? node.range?.[1] ?? 0
|
||||
}
|
||||
|
||||
function isDeclaredInsideLoop(declarationStart, loop) {
|
||||
if (declarationStart < nodeStart(loop) || declarationStart >= nodeEnd(loop)) {
|
||||
return false
|
||||
}
|
||||
if (loop.type !== 'ForStatement' || !loop.init) {
|
||||
return true
|
||||
}
|
||||
return declarationStart < nodeStart(loop.init) || declarationStart >= nodeEnd(loop.init)
|
||||
}
|
||||
|
||||
function collectBindingNames(pattern, names) {
|
||||
if (!pattern) {
|
||||
return
|
||||
}
|
||||
if (pattern.type === 'Identifier') {
|
||||
names.push(pattern.name)
|
||||
} else if (pattern.type === 'RestElement') {
|
||||
collectBindingNames(pattern.argument, names)
|
||||
} else if (pattern.type === 'AssignmentPattern') {
|
||||
collectBindingNames(pattern.left, names)
|
||||
} else if (pattern.type === 'ObjectPattern') {
|
||||
for (const property of pattern.properties) {
|
||||
collectBindingNames(
|
||||
property.type === 'RestElement' ? property.argument : property.value,
|
||||
names
|
||||
)
|
||||
}
|
||||
} else if (pattern.type === 'ArrayPattern') {
|
||||
for (const element of pattern.elements) {
|
||||
collectBindingNames(element, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function visitChildren(node, visit) {
|
||||
for (const [key, child] of Object.entries(node)) {
|
||||
if (['parent', 'loc', 'range'].includes(key)) {
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
if (item?.type) {
|
||||
visit(item)
|
||||
}
|
||||
}
|
||||
} else if (child?.type) {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectAssignedRoots(context, loop) {
|
||||
const assigned = new Set()
|
||||
const visit = (node) => {
|
||||
if (node.type === 'AssignmentExpression' && ASSIGNMENT_OPERATORS.has(node.operator)) {
|
||||
const root = rootReferenceText(context, node.left)
|
||||
if (root) {
|
||||
assigned.add(root)
|
||||
}
|
||||
}
|
||||
visitChildren(node, visit)
|
||||
}
|
||||
visit(loop.body)
|
||||
return assigned
|
||||
}
|
||||
|
||||
function assignmentTargetOf(context, call) {
|
||||
let node = call
|
||||
let parent = node.parent
|
||||
while (
|
||||
parent &&
|
||||
(EXPRESSION_WRAPPERS.has(parent.type) ||
|
||||
(parent.type === 'ConditionalExpression' && parent.test !== node))
|
||||
) {
|
||||
node = parent
|
||||
parent = parent.parent
|
||||
}
|
||||
if (parent?.type !== 'AssignmentExpression' || parent.operator !== '=' || parent.right !== node) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
text: normalizeReferenceText(sourceText(context, parent.left)),
|
||||
root: rootReferenceText(context, parent.left)
|
||||
}
|
||||
}
|
||||
|
||||
function concatOperands(context, call) {
|
||||
return call.arguments[0].elements.filter(Boolean).map((element) => {
|
||||
const spread = element.type === 'SpreadElement'
|
||||
const expression = spread ? element.argument : element
|
||||
return {
|
||||
spread,
|
||||
text: normalizeReferenceText(sourceText(context, expression)),
|
||||
root: rootReferenceText(context, expression)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isLoopCarried(root, loop, declarations) {
|
||||
const starts = declarations.get(root)
|
||||
return !starts || !starts.some((start) => isDeclaredInsideLoop(start, loop))
|
||||
}
|
||||
|
||||
function quadraticAccumulator(context, call, loop, declarations, assignedRoots) {
|
||||
const operands = concatOperands(context, call)
|
||||
const target = assignmentTargetOf(context, call)
|
||||
const selfOperand = target
|
||||
? operands.find((operand) => operand.text === target.text || operand.root === target.root)
|
||||
: null
|
||||
if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) {
|
||||
return target.text
|
||||
}
|
||||
|
||||
for (const operand of operands) {
|
||||
if (
|
||||
!operand.spread &&
|
||||
operand.root &&
|
||||
assignedRoots.has(operand.root) &&
|
||||
isLoopCarried(operand.root, loop, declarations)
|
||||
) {
|
||||
return operand.root
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function createRule(context) {
|
||||
const declarations = new Map()
|
||||
const assignedRootsByLoop = new WeakMap()
|
||||
const recordBindings = (pattern, owner) => {
|
||||
const names = []
|
||||
collectBindingNames(pattern, names)
|
||||
for (const name of names) {
|
||||
const starts = declarations.get(name) ?? []
|
||||
starts.push(nodeStart(owner))
|
||||
declarations.set(name, starts)
|
||||
}
|
||||
}
|
||||
const recordParameters = (node) => {
|
||||
for (const parameter of node.params) {
|
||||
recordBindings(parameter, parameter)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
VariableDeclarator(node) {
|
||||
recordBindings(node.id, node)
|
||||
},
|
||||
FunctionDeclaration: recordParameters,
|
||||
FunctionExpression: recordParameters,
|
||||
ArrowFunctionExpression: recordParameters,
|
||||
CatchClause(node) {
|
||||
recordBindings(node.param, node.param)
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (!isBufferConcatCall(node)) {
|
||||
return
|
||||
}
|
||||
const loop = enclosingLoop(node)
|
||||
if (!loop) {
|
||||
return
|
||||
}
|
||||
let assignedRoots = assignedRootsByLoop.get(loop)
|
||||
if (!assignedRoots) {
|
||||
assignedRoots = collectAssignedRoots(context, loop)
|
||||
assignedRootsByLoop.set(loop, assignedRoots)
|
||||
}
|
||||
const accumulator = quadraticAccumulator(context, node, loop, declarations, assignedRoots)
|
||||
if (accumulator) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Buffer.concat rebuilds loop-carried ${accumulator}; collect chunks and concatenate once after the loop.`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: 'quadratic-buffer-concat' },
|
||||
rules: {
|
||||
'no-loop-carried-concat': { create: createRule }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
const STYLED_SCROLLBAR_CLASSES = new Set([
|
||||
'scrollbar-sleek',
|
||||
'scrollbar-editor',
|
||||
'worktree-sidebar-scrollbar'
|
||||
])
|
||||
const VERTICAL_SCROLL_CLASSES = new Set([
|
||||
'overflow-auto',
|
||||
'overflow-scroll',
|
||||
'overflow-y-auto',
|
||||
'overflow-y-scroll'
|
||||
])
|
||||
const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll'])
|
||||
|
||||
function withoutImportantModifier(className) {
|
||||
const withoutPrefix = className.startsWith('!') ? className.slice(1) : className
|
||||
return withoutPrefix.endsWith('!') ? withoutPrefix.slice(0, -1) : withoutPrefix
|
||||
}
|
||||
|
||||
export function plainClassName(token) {
|
||||
const normalizedToken = token.startsWith('!') ? token.slice(1) : token
|
||||
const parts = []
|
||||
let bracketDepth = 0
|
||||
let currentPart = ''
|
||||
|
||||
for (const char of normalizedToken) {
|
||||
if (char === '[') {
|
||||
bracketDepth += 1
|
||||
} else if (char === ']') {
|
||||
bracketDepth = Math.max(0, bracketDepth - 1)
|
||||
}
|
||||
if (char === ':' && bracketDepth === 0) {
|
||||
parts.push(currentPart)
|
||||
currentPart = ''
|
||||
} else {
|
||||
currentPart += char
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(currentPart)
|
||||
return withoutImportantModifier(parts.at(-1) ?? '')
|
||||
}
|
||||
|
||||
function classTokenParts(token) {
|
||||
const variants = []
|
||||
let bracketDepth = 0
|
||||
let currentPart = ''
|
||||
|
||||
for (const char of token.startsWith('!') ? token.slice(1) : token) {
|
||||
if (char === '[') {
|
||||
bracketDepth += 1
|
||||
} else if (char === ']') {
|
||||
bracketDepth = Math.max(0, bracketDepth - 1)
|
||||
}
|
||||
if (char === ':' && bracketDepth === 0) {
|
||||
variants.push(currentPart)
|
||||
currentPart = ''
|
||||
} else {
|
||||
currentPart += char
|
||||
}
|
||||
}
|
||||
|
||||
return { className: withoutImportantModifier(currentPart), variants: variants.filter(Boolean) }
|
||||
}
|
||||
|
||||
function classTokens(text) {
|
||||
return text.split(/\s+/).filter(Boolean).map(classTokenParts)
|
||||
}
|
||||
|
||||
function sameVariants(left, right) {
|
||||
return left.length === right.length && left.every((variant, index) => variant === right[index])
|
||||
}
|
||||
|
||||
function literalHasScrollbarForVertical(text, verticalToken) {
|
||||
return classTokens(text).some(
|
||||
(candidate) =>
|
||||
STYLED_SCROLLBAR_CLASSES.has(candidate.className) &&
|
||||
(candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants))
|
||||
)
|
||||
}
|
||||
|
||||
function uncoveredVerticalClass(text) {
|
||||
return classTokens(text).find(
|
||||
(token) =>
|
||||
VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token)
|
||||
)
|
||||
}
|
||||
|
||||
function stringLiteralTexts(node) {
|
||||
if (node?.type === 'Literal' && typeof node.value === 'string') {
|
||||
return [node.value]
|
||||
}
|
||||
if (node?.type !== 'TemplateLiteral') {
|
||||
return []
|
||||
}
|
||||
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw)
|
||||
}
|
||||
|
||||
function visitChildren(node, visit) {
|
||||
for (const [key, child] of Object.entries(node)) {
|
||||
if (['parent', 'loc', 'range'].includes(key)) {
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
if (item?.type) {
|
||||
visit(item)
|
||||
}
|
||||
}
|
||||
} else if (child?.type) {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectClassLiteralReports(node) {
|
||||
const reports = []
|
||||
const visit = (current) => {
|
||||
for (const text of stringLiteralTexts(current)) {
|
||||
const uncovered = uncoveredVerticalClass(text)
|
||||
if (uncovered) {
|
||||
reports.push({ node: current, detail: uncovered.className })
|
||||
}
|
||||
}
|
||||
visitChildren(current, visit)
|
||||
}
|
||||
visit(node)
|
||||
return reports
|
||||
}
|
||||
|
||||
function expressionHasStyledScrollbarLiteral(node) {
|
||||
let found = false
|
||||
const visit = (current) => {
|
||||
if (found || current.type === 'ConditionalExpression' || current.type === 'LogicalExpression') {
|
||||
return
|
||||
}
|
||||
found = stringLiteralTexts(current).some((text) =>
|
||||
classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className))
|
||||
)
|
||||
if (!found) {
|
||||
visitChildren(current, visit)
|
||||
}
|
||||
}
|
||||
visit(node)
|
||||
return found
|
||||
}
|
||||
|
||||
function propertyName(node) {
|
||||
if (node?.type !== 'Property') {
|
||||
return null
|
||||
}
|
||||
if (!node.computed && node.key.type === 'Identifier') {
|
||||
return node.key.name
|
||||
}
|
||||
return node.key.type === 'Literal' && typeof node.key.value === 'string' ? node.key.value : null
|
||||
}
|
||||
|
||||
function styleValueIsVerticalScroll(name, value) {
|
||||
const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 0) {
|
||||
return false
|
||||
}
|
||||
if (name === 'overflowY' || name === 'overflow-y') {
|
||||
return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0])
|
||||
}
|
||||
if (name !== 'overflow') {
|
||||
return false
|
||||
}
|
||||
return VERTICAL_SCROLL_STYLE_VALUES.has(parts.length > 1 ? parts[1] : parts[0])
|
||||
}
|
||||
|
||||
function collectStyleReports(node) {
|
||||
const reports = []
|
||||
const visit = (current) => {
|
||||
if (current.type === 'Property') {
|
||||
const name = propertyName(current)
|
||||
for (const value of name ? stringLiteralTexts(current.value) : []) {
|
||||
if (styleValueIsVerticalScroll(name, value)) {
|
||||
reports.push({ node: current, detail: 'inline vertical scroll' })
|
||||
}
|
||||
}
|
||||
visit(current.value)
|
||||
return
|
||||
}
|
||||
visitChildren(current, visit)
|
||||
}
|
||||
visit(node)
|
||||
return reports
|
||||
}
|
||||
|
||||
function unwrapExpression(node) {
|
||||
if (
|
||||
['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ChainExpression'].includes(
|
||||
node?.type
|
||||
)
|
||||
) {
|
||||
return unwrapExpression(node.expression)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function spreadPropExpressions(expression, propName) {
|
||||
const node = unwrapExpression(expression)
|
||||
if (!node) {
|
||||
return []
|
||||
}
|
||||
if (node.type === 'ConditionalExpression') {
|
||||
return [
|
||||
...spreadPropExpressions(node.consequent, propName),
|
||||
...spreadPropExpressions(node.alternate, propName)
|
||||
]
|
||||
}
|
||||
if (node.type === 'LogicalExpression' || node.type === 'BinaryExpression') {
|
||||
return [
|
||||
...spreadPropExpressions(node.left, propName),
|
||||
...spreadPropExpressions(node.right, propName)
|
||||
]
|
||||
}
|
||||
if (node.type !== 'ObjectExpression') {
|
||||
return []
|
||||
}
|
||||
return node.properties.flatMap((property) => {
|
||||
if (property.type === 'SpreadElement') {
|
||||
return spreadPropExpressions(property.argument, propName)
|
||||
}
|
||||
return propertyName(property) === propName ? [property.value] : []
|
||||
})
|
||||
}
|
||||
|
||||
function jsxAttributeExpression(attribute) {
|
||||
if (attribute.value?.type === 'Literal') {
|
||||
return attribute.value
|
||||
}
|
||||
return attribute.value?.type === 'JSXExpressionContainer' ? attribute.value.expression : null
|
||||
}
|
||||
|
||||
function jsxElementReports(node) {
|
||||
let classExpression = null
|
||||
const styleExpressions = []
|
||||
|
||||
for (const attribute of node.attributes) {
|
||||
if (attribute.type === 'JSXSpreadAttribute') {
|
||||
const spreadClassExpression = spreadPropExpressions(attribute.argument, 'className').at(-1)
|
||||
if (spreadClassExpression) {
|
||||
classExpression = spreadClassExpression
|
||||
}
|
||||
styleExpressions.push(...spreadPropExpressions(attribute.argument, 'style'))
|
||||
} else if (attribute.name?.name === 'className') {
|
||||
classExpression = jsxAttributeExpression(attribute)
|
||||
} else if (attribute.name?.name === 'style') {
|
||||
const expression = jsxAttributeExpression(attribute)
|
||||
if (expression) {
|
||||
styleExpressions.push(expression)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reports = classExpression ? collectClassLiteralReports(classExpression) : []
|
||||
if (!classExpression || !expressionHasStyledScrollbarLiteral(classExpression)) {
|
||||
for (const expression of styleExpressions) {
|
||||
reports.push(...collectStyleReports(expression))
|
||||
}
|
||||
}
|
||||
return reports
|
||||
}
|
||||
|
||||
function bindContext(createVisitors) {
|
||||
return (context) => {
|
||||
const visitors = createVisitors()
|
||||
for (const [nodeType, visit] of Object.entries(visitors)) {
|
||||
visitors[nodeType] = visit.bind(context)
|
||||
}
|
||||
return visitors
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: 'renderer-scrollbar-style' },
|
||||
rules: {
|
||||
'require-styled-vertical-scrollbar': {
|
||||
create: bindContext(() => ({
|
||||
JSXOpeningElement(node) {
|
||||
for (const report of jsxElementReports(node)) {
|
||||
this.report({
|
||||
node: report.node,
|
||||
message: `Vertical scroll container (${report.detail}) must use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.`
|
||||
})
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,18 @@
|
||||
},
|
||||
"jsPlugins": [{ "name": "react-doctor", "specifier": "oxlint-plugin-react-doctor" }],
|
||||
"rules": {
|
||||
"react-doctor/effect-needs-cleanup": "warn",
|
||||
"react-doctor/no-array-index-as-key": "warn",
|
||||
"react-doctor/no-adjust-state-on-prop-change": "warn",
|
||||
"react-doctor/no-create-store-in-render": "warn",
|
||||
"react-doctor/no-derived-state-effect": "warn",
|
||||
"react-doctor/no-initialize-state": "warn"
|
||||
"react-doctor/no-initialize-state": "warn",
|
||||
"react-doctor/no-side-effect-in-state-updater-function": "warn",
|
||||
"react-doctor/no-unstable-nested-components": "warn",
|
||||
"react-doctor/zustand-no-fresh-selector-result": "warn",
|
||||
"react-doctor/zustand-no-get-during-initialization": "warn",
|
||||
"react-doctor/zustand-no-mutating-state": "warn",
|
||||
"react-doctor/zustand-no-whole-store-destructure": "warn"
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'yaml',
|
||||
'zod'
|
||||
]
|
||||
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['windows-native-registry']
|
||||
|
||||
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
|
||||
darwin: 'darwin-',
|
||||
@@ -128,7 +129,7 @@ function isKnownOmittedServeSimDependency(packageName, fromDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function collectPackagedRuntimePackages() {
|
||||
function collectPackagedRuntimePackages(electronPlatformName = process.platform) {
|
||||
const packages = new Map()
|
||||
const visit = (packageName, fromDir = projectDir) => {
|
||||
if (packageName === 'electron' || packages.has(packageName)) {
|
||||
@@ -156,7 +157,12 @@ function collectPackagedRuntimePackages() {
|
||||
}
|
||||
}
|
||||
|
||||
for (const packageName of PACKAGED_RUNTIME_PACKAGE_ROOTS) {
|
||||
// Why: cross-builds must select native dependencies from the artifact target, not the build host.
|
||||
const packageRoots = [
|
||||
...PACKAGED_RUNTIME_PACKAGE_ROOTS,
|
||||
...(electronPlatformName === 'win32' ? WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS : [])
|
||||
]
|
||||
for (const packageName of packageRoots) {
|
||||
visit(packageName)
|
||||
}
|
||||
|
||||
@@ -183,8 +189,8 @@ function collectPackagedRuntimePackages() {
|
||||
return [...packages.entries()].sort(([left], [right]) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function createPackagedRuntimeNodeModuleResources() {
|
||||
return collectPackagedRuntimePackages().map(([packageName, packageDir]) => ({
|
||||
function createPackagedRuntimeNodeModuleResources(electronPlatformName = process.platform) {
|
||||
return collectPackagedRuntimePackages(electronPlatformName).map(([packageName, packageDir]) => ({
|
||||
from: packageDir,
|
||||
to: join('node_modules', ...packageName.split('/'))
|
||||
}))
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
diff --git a/binding.gyp b/binding.gyp
|
||||
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc3500acacae 100644
|
||||
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -5,9 +5,6 @@
|
||||
@@ -12,6 +12,23 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc35
|
||||
'msvs_settings': {
|
||||
'VCCLCompilerTool': {
|
||||
'AdditionalOptions': [
|
||||
@@ -88,6 +85,16 @@
|
||||
'libraries!': [
|
||||
'-lutil'
|
||||
]
|
||||
+ }],
|
||||
+ # Orca: pair with the .symver pins in pty.cc. Force the real
|
||||
+ # libutil.so.1/libpthread.so.0 into DT_NEEDED (gcc's default
|
||||
+ # --as-needed drops them because the pinned symbols resolve from
|
||||
+ # libc's compat aliases at build time) so openpty/forkpty/
|
||||
+ # pthread_sigmask still resolve on Ubuntu 20.04 (glibc 2.31).
|
||||
+ ['OS=="linux"', {
|
||||
+ 'ldflags': [
|
||||
+ '-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed'
|
||||
+ ]
|
||||
}]
|
||||
]
|
||||
}
|
||||
diff --git a/deps/winpty/src/winpty.gyp b/deps/winpty/src/winpty.gyp
|
||||
index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8360130ef 100644
|
||||
--- a/deps/winpty/src/winpty.gyp
|
||||
@@ -54,6 +71,27 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
|
||||
'msvs_settings': {
|
||||
# Specify this setting here to override a setting from somewhere
|
||||
# else, such as node's common.gypi.
|
||||
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
|
||||
index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441e02a974c 100644
|
||||
--- a/lib/conpty_console_list_agent.js
|
||||
+++ b/lib/conpty_console_list_agent.js
|
||||
@@ -10,7 +10,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
var utils_1 = require("./utils");
|
||||
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
|
||||
var shellPid = parseInt(process.argv[2], 10);
|
||||
-var consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+var consoleProcessList;
|
||||
+try {
|
||||
+ consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+}
|
||||
+catch (_a) {
|
||||
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
|
||||
+ consoleProcessList = [shellPid];
|
||||
+}
|
||||
process.send({ consoleProcessList: consoleProcessList });
|
||||
process.exit(0);
|
||||
//# sourceMappingURL=conpty_console_list_agent.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
|
||||
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
|
||||
--- a/lib/unixTerminal.js
|
||||
@@ -73,31 +111,12 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
|
||||
var DEFAULT_FILE = 'sh';
|
||||
var DEFAULT_NAME = 'xterm';
|
||||
var DESTROY_SOCKET_TIMEOUT_MS = 200;
|
||||
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
|
||||
index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644
|
||||
--- a/lib/conpty_console_list_agent.js
|
||||
+++ b/lib/conpty_console_list_agent.js
|
||||
@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
var utils_1 = require("./utils");
|
||||
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
|
||||
var shellPid = parseInt(process.argv[2], 10);
|
||||
-var consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+var consoleProcessList;
|
||||
+try {
|
||||
+ consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+}
|
||||
+catch (_a) {
|
||||
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
|
||||
+ consoleProcessList = [shellPid];
|
||||
+}
|
||||
process.send({ consoleProcessList: consoleProcessList });
|
||||
process.exit(0);
|
||||
//# sourceMappingURL=conpty_console_list_agent.js.map
|
||||
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
|
||||
index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644
|
||||
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644
|
||||
--- a/src/conpty_console_list_agent.ts
|
||||
+++ b/src/conpty_console_list_agent.ts
|
||||
@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils';
|
||||
@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils';
|
||||
|
||||
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
|
||||
const shellPid = parseInt(process.argv[2], 10);
|
||||
-const consoleProcessList = getConsoleProcessList(shellPid);
|
||||
@@ -111,7 +130,7 @@ index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc
|
||||
process.send!({ consoleProcessList });
|
||||
process.exit(0);
|
||||
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
|
||||
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
|
||||
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644
|
||||
--- a/src/unix/pty.cc
|
||||
+++ b/src/unix/pty.cc
|
||||
@@ -23,7 +23,9 @@
|
||||
@@ -124,7 +143,33 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
#include <thread>
|
||||
|
||||
#include <sys/types.h>
|
||||
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
|
||||
@@ -47,6 +49,25 @@
|
||||
#include <termios.h>
|
||||
#endif
|
||||
|
||||
+/* Orca: glibc 2.32-2.34 relocated pthread_sigmask/openpty/forkpty into libc
|
||||
+ * under new symbol versions, so building on a newer glibc produces references
|
||||
+ * (GLIBC_2.32/2.34) absent on Ubuntu 20.04 (glibc 2.31) and the app fails to
|
||||
+ * launch. Pin these to the pre-merge version glibc still ships as a compat
|
||||
+ * alias; the binding.gyp ldflags force libutil/libpthread into DT_NEEDED so
|
||||
+ * those aliases are actually loaded on the target. */
|
||||
+#if defined(__linux__)
|
||||
+# if defined(__x86_64__)
|
||||
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.2.5"
|
||||
+# elif defined(__aarch64__)
|
||||
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.17"
|
||||
+# endif
|
||||
+# ifdef ORCA_GLIBC_COMPAT_VERSION
|
||||
+__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);
|
||||
+__asm__(".symver forkpty,forkpty@" ORCA_GLIBC_COMPAT_VERSION);
|
||||
+__asm__(".symver pthread_sigmask,pthread_sigmask@" ORCA_GLIBC_COMPAT_VERSION);
|
||||
+# endif
|
||||
+#endif
|
||||
+
|
||||
/* Some platforms name VWERASE and VDISCARD differently */
|
||||
#if !defined(VWERASE) && defined(VWERSE)
|
||||
#define VWERASE VWERSE
|
||||
@@ -237,13 +258,23 @@ pty_getproc(int, char *);
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__) || defined(__OpenBSD__)
|
||||
@@ -149,7 +194,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
#endif
|
||||
|
||||
struct DelBuf {
|
||||
@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
|
||||
@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
|
||||
argv[i + 3] = strdup(arg.c_str());
|
||||
}
|
||||
|
||||
@@ -165,7 +210,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
}
|
||||
if (pty_nonblock(master) == -1) {
|
||||
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
|
||||
@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) {
|
||||
@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) {
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__)
|
||||
@@ -241,25 +286,25 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
|
||||
for (; count < 3; count++) {
|
||||
low_fds[count] = posix_openpt(O_RDWR);
|
||||
@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env,
|
||||
@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env,
|
||||
POSIX_SPAWN_SETSID;
|
||||
*master = posix_openpt(O_RDWR);
|
||||
if (*master == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "posix_openpt", errno);
|
||||
+ goto done;
|
||||
+ }
|
||||
+
|
||||
+ res = grantpt(*master);
|
||||
+ if (res == -1) {
|
||||
+ pty_set_spawn_error(err, "grantpt", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
- int res = grantpt(*master) || unlockpt(*master);
|
||||
+ res = unlockpt(*master);
|
||||
+ res = grantpt(*master);
|
||||
if (res == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "grantpt", errno);
|
||||
+ goto done;
|
||||
+ }
|
||||
+
|
||||
+ res = unlockpt(*master);
|
||||
+ if (res == -1) {
|
||||
+ pty_set_spawn_error(err, "unlockpt", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const EXPECTED_NODE_PTY_VERSION = '1.1.0'
|
||||
const ORIGINAL_SOURCE_SHA256 = '0d010879bb6680a0253d44363183d53e631f42972594eb6dcb1fb842c8c85e52'
|
||||
const PATCHED_SOURCE_SHA256 = '84df20cfe711a88d2bef35078615c58a6ce14f39348a4aef40e852b854dcd857'
|
||||
const ORIGINAL_BODY = 'var consoleProcessList = getConsoleProcessList(shellPid);'
|
||||
const PATCHED_BODY = `var consoleProcessList;
|
||||
try {
|
||||
consoleProcessList = getConsoleProcessList(shellPid);
|
||||
}
|
||||
catch (_a) {
|
||||
// Why: AttachConsole can fail without a Win32 console; use node-pty's timeout fallback immediately.
|
||||
consoleProcessList = [shellPid];
|
||||
}`
|
||||
|
||||
function inspectNodePtyConsoleListAgent(relayDir = process.cwd()) {
|
||||
const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty')
|
||||
const packageJsonPath = join(nodePtyDir, 'package.json')
|
||||
const agentPath = join(nodePtyDir, 'lib', 'conpty_console_list_agent.js')
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
||||
if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) {
|
||||
throw new Error(
|
||||
`Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}`
|
||||
)
|
||||
}
|
||||
const source = readFileSync(agentPath, 'utf8')
|
||||
return { agentPath, source }
|
||||
}
|
||||
|
||||
function assertPatchedNodePtyConsoleListAgent(relayDir = process.cwd()) {
|
||||
const inspected = inspectNodePtyConsoleListAgent(relayDir)
|
||||
if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) {
|
||||
throw new Error('node-pty ConPTY console-list fallback is not installed')
|
||||
}
|
||||
}
|
||||
|
||||
function patchNodePtyConsoleListAgent(relayDir = process.cwd()) {
|
||||
const inspected = inspectNodePtyConsoleListAgent(relayDir)
|
||||
const sourceHash = sourceSha256(inspected.source)
|
||||
if (sourceHash === PATCHED_SOURCE_SHA256) {
|
||||
return
|
||||
}
|
||||
if (sourceHash !== ORIGINAL_SOURCE_SHA256) {
|
||||
throw new Error('Refusing to patch unexpected node-pty console-list agent source')
|
||||
}
|
||||
const patchedSource = inspected.source.replace(ORIGINAL_BODY, PATCHED_BODY)
|
||||
const temporaryPath = `${inspected.agentPath}.orca-patch-${process.pid}`
|
||||
// Why: a terminated remote install must leave either known source version recoverable on reconnect.
|
||||
try {
|
||||
writeFileSync(temporaryPath, patchedSource)
|
||||
renameSync(temporaryPath, inspected.agentPath)
|
||||
} finally {
|
||||
rmSync(temporaryPath, { force: true })
|
||||
}
|
||||
assertPatchedNodePtyConsoleListAgent(relayDir)
|
||||
}
|
||||
|
||||
function sourceSha256(source) {
|
||||
return createHash('sha256').update(source).digest('hex')
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
patchNodePtyConsoleListAgent()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertPatchedNodePtyConsoleListAgent,
|
||||
patchNodePtyConsoleListAgent
|
||||
}
|
||||
+3531
-133
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: cost of validating an agent-status payload on every hook event.
|
||||
//
|
||||
// 13 of the 15 per-source normalizers in agent-hook-listener.ts built a plain
|
||||
// object, JSON.stringify'd it, and handed the string to parseAgentStatusPayload,
|
||||
// which runs assertJsonTextStructureWithinLimits (a per-character scan of the
|
||||
// WHOLE serialized string) plus JSON.parse — only to reach the same
|
||||
// normalizeAgentStatusObject the object path calls directly. Claude and Codex
|
||||
// were already converted, with a comment calling the round trip "pure overhead
|
||||
// on this hot per-hook path"; the other 13 were not.
|
||||
//
|
||||
// Why the gap widens with payload size: the direct path's field normalizer stops
|
||||
// at the field cap (`normalized.length < maxLength`), so it is O(cap). The round
|
||||
// trip is O(input) — and the input is bounded only by the 1 MB hook request
|
||||
// limit, since tool_response text is passed through uncapped for most sources.
|
||||
//
|
||||
// Amplifier this models in the second table: resolveToolState stores the raw
|
||||
// value in lastToolByPaneKey and inherits it until a turn reset, so one large
|
||||
// tool result is re-serialized and re-scanned on every later event of the turn.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const TYPES_SOURCE = readFileSync(
|
||||
fileURLToPath(new URL('../../src/shared/agent-status-types.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function readMirroredConstant(name) {
|
||||
const match = TYPES_SOURCE.match(new RegExp(`${name}\\s*=\\s*([0-9_]+)`))
|
||||
if (!match) {
|
||||
throw new Error(`agent-status-types.ts no longer defines ${name}; re-sync this benchmark.`)
|
||||
}
|
||||
return Number(match[1].replaceAll('_', ''))
|
||||
}
|
||||
|
||||
// Read the cap the direct path clamps at, so a drifted value fails loudly here
|
||||
// instead of quietly changing what this benchmark claims.
|
||||
const ASSISTANT_MESSAGE_CAP = readMirroredConstant('AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH')
|
||||
|
||||
const ITERATIONS = Number.parseInt(process.env.ORCA_HOOK_NORM_BENCH_ITERATIONS ?? '400', 10)
|
||||
const WARMUP = Number.parseInt(process.env.ORCA_HOOK_NORM_BENCH_WARMUP ?? '200', 10)
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_HOOK_NORM_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_HOOK_NORM_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
const STRUCTURAL_TOKENS = 4096
|
||||
const NESTING_DEPTH = 16
|
||||
|
||||
// Mirror of assertJsonTextStructureWithinLimits — the per-character scan the
|
||||
// round trip pays before JSON.parse even starts.
|
||||
function scanJsonStructure(content) {
|
||||
let structuralTokens = 0
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const character = content[index]
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
} else if (character === '\\') {
|
||||
escaped = true
|
||||
} else if (character === '"') {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (character === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
if (
|
||||
character !== '{' &&
|
||||
character !== '}' &&
|
||||
character !== '[' &&
|
||||
character !== ']' &&
|
||||
character !== ',' &&
|
||||
character !== ':'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
structuralTokens += 1
|
||||
if (structuralTokens > STRUCTURAL_TOKENS) {
|
||||
throw new Error('structuralTokens')
|
||||
}
|
||||
if (character === '{' || character === '[') {
|
||||
depth += 1
|
||||
if (depth > NESTING_DEPTH) {
|
||||
throw new Error('nestingDepth')
|
||||
}
|
||||
} else if (character === '}' || character === ']') {
|
||||
depth = Math.max(0, depth - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror of the field normalizer's bounded walk: it stops consuming at the cap.
|
||||
function normalizeField(value, maxLength) {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
let normalized = ''
|
||||
let newlineRun = 0
|
||||
for (let index = 0; index < value.length && normalized.length < maxLength; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code === 13 || code === 10 || code === 0x2028 || code === 0x2029) {
|
||||
if (code === 13 && value.charCodeAt(index + 1) === 10) {
|
||||
index += 1
|
||||
}
|
||||
if (newlineRun < 2) {
|
||||
normalized += '\n'
|
||||
}
|
||||
newlineRun += 1
|
||||
continue
|
||||
}
|
||||
newlineRun = 0
|
||||
normalized += value[index]
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeObject(payload) {
|
||||
return {
|
||||
state: payload.state,
|
||||
prompt: normalizeField(payload.prompt, ASSISTANT_MESSAGE_CAP),
|
||||
agentType: payload.agentType,
|
||||
toolName: normalizeField(payload.toolName, ASSISTANT_MESSAGE_CAP),
|
||||
toolInput: normalizeField(payload.toolInput, ASSISTANT_MESSAGE_CAP),
|
||||
lastAssistantMessage: normalizeField(payload.lastAssistantMessage, ASSISTANT_MESSAGE_CAP)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fix: serialize, scan every character, parse, then normalize.
|
||||
function validateViaRoundTrip(payload) {
|
||||
const json = JSON.stringify(payload)
|
||||
scanJsonStructure(json)
|
||||
return normalizeObject(JSON.parse(json))
|
||||
}
|
||||
|
||||
// Post-fix: normalize the object that is already in hand.
|
||||
function validateDirect(payload) {
|
||||
return normalizeObject(payload)
|
||||
}
|
||||
|
||||
function makePayload(messageBytes) {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: 'do the thing',
|
||||
agentType: 'grok',
|
||||
toolName: 'shell_command',
|
||||
toolInput: 'ls -la',
|
||||
lastAssistantMessage: 'x'.repeat(messageBytes)
|
||||
}
|
||||
}
|
||||
|
||||
function measure(fn, payload) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
fn(payload)
|
||||
}
|
||||
const samples = []
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
fn(payload)
|
||||
}
|
||||
samples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
samples.sort((a, b) => a - b)
|
||||
return samples[2]
|
||||
}
|
||||
|
||||
const rows = []
|
||||
for (const kb of [4, 16, 64, 256]) {
|
||||
const payload = makePayload(kb * 1024)
|
||||
const before = validateViaRoundTrip(payload)
|
||||
const after = validateDirect(payload)
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
throw new Error(`normalizer mismatch at ${kb} KB`)
|
||||
}
|
||||
rows.push({
|
||||
label: `${kb} KB`,
|
||||
beforeUs: measure(validateViaRoundTrip, payload) * 1000,
|
||||
afterUs: measure(validateDirect, payload) * 1000
|
||||
})
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Agent-status payload validation, per hook event')
|
||||
console.log(
|
||||
`field cap=${ASSISTANT_MESSAGE_CAP} iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`
|
||||
)
|
||||
console.log(
|
||||
`${pad('payload', 9)} ${pad('round trip', 12)} ${pad('direct', 10)} ${pad('speedup', 9)}`
|
||||
)
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
`${pad(row.label, 9)} ${pad(`${row.beforeUs.toFixed(1)} us`, 12)} ${pad(`${row.afterUs.toFixed(1)} us`, 10)} ${pad(`${(row.beforeUs / row.afterUs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
// A single large tool result is inherited across the turn, so every later event
|
||||
// re-pays the round trip on bytes that were already validated once.
|
||||
const TURN_EVENTS = 20
|
||||
const inherited = makePayload(200 * 1024)
|
||||
const beforeTurnMs = (measure(validateViaRoundTrip, inherited) * TURN_EVENTS).toFixed(2)
|
||||
const afterTurnMs = (measure(validateDirect, inherited) * TURN_EVENTS).toFixed(2)
|
||||
console.log(
|
||||
`\nOne 200 KB tool result, inherited across ${TURN_EVENTS} later events in the same turn:` +
|
||||
`\n round trip ${beforeTurnMs} ms total direct ${afterTurnMs} ms total`
|
||||
)
|
||||
console.log(
|
||||
'\nThe direct path is flat because the field normalizer stops at the cap; the\nround trip is linear in the raw payload, which is bounded only by the 1 MB\nhook request limit.'
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs'
|
||||
|
||||
const pluginPath = path.resolve('config/oxlint-plugins/app-store-performance.mjs')
|
||||
|
||||
function lintSource(source) {
|
||||
return runOxlintPluginOnSource({
|
||||
pluginName: 'app-store-performance',
|
||||
pluginPath,
|
||||
source,
|
||||
rules: {
|
||||
'app-store-performance/require-selector': 'warn',
|
||||
'app-store-performance/no-identity-selector': 'warn',
|
||||
'app-store-performance/no-fresh-selector-result': 'warn'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('app store performance Oxlint plugin', () => {
|
||||
it('reports whole-store and fresh-reference subscriptions', () => {
|
||||
const diagnostics = lintSource(`
|
||||
import { useAppStore as useStore } from '@/store'
|
||||
const WholeStore = () => useStore()
|
||||
const Identity = () => useStore((state) => state)
|
||||
const Fresh = () => useStore((state) => ({ active: state.active }))
|
||||
const Conditional = () => useStore((state) => state.active ? state.items : [])
|
||||
const Nested = () => useStore((state) => {
|
||||
if (state.active) return state.items.filter(Boolean)
|
||||
return state.items
|
||||
})
|
||||
`)
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
||||
'app-store-performance(require-selector)',
|
||||
'app-store-performance(no-identity-selector)',
|
||||
'app-store-performance(no-fresh-selector-result)',
|
||||
'app-store-performance(no-fresh-selector-result)',
|
||||
'app-store-performance(no-fresh-selector-result)'
|
||||
])
|
||||
})
|
||||
|
||||
it('allows focused, cached, and useShallow selectors', () => {
|
||||
const diagnostics = lintSource(`
|
||||
import { useAppStore } from '@/store'
|
||||
import { useShallow as shallow } from 'zustand/react/shallow'
|
||||
const selectActive = (state) => state.active
|
||||
const Focused = () => useAppStore(selectActive)
|
||||
const Cached = () => useAppStore((state) => state.cachedProjection)
|
||||
const Shallow = () => useAppStore(shallow((state) => ({ active: state.active })))
|
||||
`)
|
||||
|
||||
expect(diagnostics).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: the head-of-chain reads in getBranchCompare (src/main/git/status.ts).
|
||||
//
|
||||
// Four spawns ran strictly in series before any compare work started: branch
|
||||
// --show-current, the base-ref probe, rev-parse HEAD, and rev-parse <base>. compareRef is
|
||||
// display-only metadata and HEAD's oid does not depend on the base ref, so the first three
|
||||
// can overlap. The probe oid also replaces the fourth spawn when it proves refs/heads/*;
|
||||
// remote-tracking refs require a raw rev-parse because they may store annotated tags.
|
||||
//
|
||||
// This spawns the real git binary against this repo, so it measures actual process-launch
|
||||
// cost rather than a model of it. Over SSH these are host-local spawns inside the relay,
|
||||
// so the saving applies to remote spawn time, not to network round trips.
|
||||
//
|
||||
// Both arms are compared for identical resolved values before timing.
|
||||
//
|
||||
// Run with: node config/scripts/branch-compare-head-benchmark.mjs
|
||||
import { execFile } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { readBranchCompareHead } from '../../src/shared/git-branch-compare-head.ts'
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const ITERATIONS = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_ITERATIONS ?? '8')
|
||||
const WARMUP = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_WARMUP ?? '2')
|
||||
const ROUNDS = 6
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_BRANCH_COMPARE_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_BRANCH_COMPARE_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 64 * 1024 * 1024 }, (error, stdout) =>
|
||||
error ? reject(error) : resolve(stdout.trim())
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function probeOid(qualifiedRef) {
|
||||
try {
|
||||
const out = await git(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`])
|
||||
return out.length > 0 ? out : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fix: serial chain, and the probe's oid discarded then re-resolved.
|
||||
async function readSerial(baseRef) {
|
||||
const compareRef = (await git(['branch', '--show-current']).catch(() => '')) || 'HEAD'
|
||||
let resolvedBaseRef = baseRef
|
||||
if (!baseRef.startsWith('refs/')) {
|
||||
const candidates = baseRef.includes('/')
|
||||
? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`]
|
||||
: [`refs/heads/${baseRef}`]
|
||||
for (const candidate of candidates) {
|
||||
if ((await probeOid(candidate)) !== null) {
|
||||
resolvedBaseRef = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const headOid = await git(['rev-parse', '--verify', '--end-of-options', 'HEAD'])
|
||||
const baseOid = await git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef])
|
||||
return { compareRef, resolvedBaseRef, headOid, baseOid }
|
||||
}
|
||||
|
||||
// Production head reader: overlaps independent reads and reuses only safe probe oids.
|
||||
async function readConcurrent(baseRef) {
|
||||
const reusableProbedOidByRef = new Map()
|
||||
const resolveBaseRef = async () => {
|
||||
if (baseRef.startsWith('refs/')) {
|
||||
return baseRef
|
||||
}
|
||||
const candidates = baseRef.includes('/')
|
||||
? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`]
|
||||
: [`refs/heads/${baseRef}`]
|
||||
for (const candidate of candidates) {
|
||||
const oid = await probeOid(candidate)
|
||||
if (oid !== null) {
|
||||
if (candidate.startsWith('refs/heads/')) {
|
||||
reusableProbedOidByRef.set(candidate, oid)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return baseRef
|
||||
}
|
||||
const result = await readBranchCompareHead({
|
||||
readCompareRef: () =>
|
||||
git(['branch', '--show-current'])
|
||||
.then((out) => out || 'HEAD')
|
||||
.catch(() => 'HEAD'),
|
||||
resolveBaseRef,
|
||||
readHeadOid: () => git(['rev-parse', '--verify', '--end-of-options', 'HEAD']),
|
||||
readBaseOid: (resolvedBaseRef) => {
|
||||
const reusableOid = reusableProbedOidByRef.get(resolvedBaseRef)
|
||||
return reusableOid === undefined
|
||||
? git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef])
|
||||
: Promise.resolve(reusableOid)
|
||||
}
|
||||
})
|
||||
if (!result.headOidResult.ok) {
|
||||
throw result.headOidResult.error
|
||||
}
|
||||
if (!result.baseOidResult.ok) {
|
||||
throw result.baseOidResult.error
|
||||
}
|
||||
return {
|
||||
compareRef: result.compareRef,
|
||||
resolvedBaseRef: result.resolvedBaseRef,
|
||||
headOid: result.headOidResult.oid,
|
||||
baseOid: result.baseOidResult.oid
|
||||
}
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const mid = sorted.length / 2
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
async function timeArm(read, baseRef) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
await read(baseRef)
|
||||
}
|
||||
return (performance.now() - start) / ITERATIONS
|
||||
}
|
||||
|
||||
// Arms alternate which one leads so within-round drift cannot favour either.
|
||||
async function measure(baseRef) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
await readSerial(baseRef)
|
||||
await readConcurrent(baseRef)
|
||||
}
|
||||
const serialSamples = []
|
||||
const concurrentSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
serialSamples.push(await timeArm(readSerial, baseRef))
|
||||
concurrentSamples.push(await timeArm(readConcurrent, baseRef))
|
||||
} else {
|
||||
concurrentSamples.push(await timeArm(readConcurrent, baseRef))
|
||||
serialSamples.push(await timeArm(readSerial, baseRef))
|
||||
}
|
||||
}
|
||||
return { serialMs: median(serialSamples), concurrentMs: median(concurrentSamples) }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('getBranchCompare head-of-chain reads, per call. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`)
|
||||
console.log(
|
||||
`${pad('base ref', 30)} ${pad('serial', 11)} ${pad('concurrent', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
// A short remote label is the common case (Orca's base picker emits `origin/main`); the
|
||||
// already-qualified ref skips the probe entirely, so only the concurrency half applies.
|
||||
const upstream = await git(['rev-parse', '--abbrev-ref', 'HEAD@{upstream}']).catch(() => null)
|
||||
const baseRefs = ['origin/main', 'refs/remotes/origin/main', 'main']
|
||||
if (upstream && !baseRefs.includes(upstream)) {
|
||||
baseRefs.push(upstream)
|
||||
}
|
||||
|
||||
for (const baseRef of baseRefs) {
|
||||
const serial = await readSerial(baseRef)
|
||||
const concurrent = await readConcurrent(baseRef)
|
||||
if (JSON.stringify(serial) !== JSON.stringify(concurrent)) {
|
||||
throw new Error(
|
||||
`resolved values differ for ${baseRef}:\n serial ${JSON.stringify(serial)}\n concurrent ${JSON.stringify(concurrent)}`
|
||||
)
|
||||
}
|
||||
if (!serial.headOid) {
|
||||
throw new Error(`fixture resolved no HEAD oid for ${baseRef}`)
|
||||
}
|
||||
const { serialMs, concurrentMs } = await measure(baseRef)
|
||||
console.log(
|
||||
`${pad(baseRef, 30)} ${pad(`${serialMs.toFixed(1)} ms`, 11)} ${pad(`${concurrentMs.toFixed(1)} ms`, 11)} ${pad(`${(serialMs / concurrentMs).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nThe already-qualified refs/... row skips the probe by design, so it only shows the\nconcurrency half. This times the native/WSL head-of-chain reads, not the whole compare;\nthe relay path has separate production-concurrency coverage.'
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export function createLocalBuildVersion(baseVersion, timestamp, commit) {
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(baseVersion)) {
|
||||
throw new Error(`Package version is not valid semver: ${baseVersion}`)
|
||||
}
|
||||
if (!Number.isSafeInteger(timestamp) || timestamp <= 0) {
|
||||
throw new Error('Local build timestamp is invalid.')
|
||||
}
|
||||
const sanitizedCommit = commit.replace(/[^0-9A-Za-z-]/g, '').slice(0, 12)
|
||||
if (!sanitizedCommit) {
|
||||
throw new Error('Git commit identity is empty.')
|
||||
}
|
||||
const suffix = `local.${timestamp}.${sanitizedCommit}`
|
||||
return baseVersion.includes('-') ? `${baseVersion}.${suffix}` : `${baseVersion}-${suffix}`
|
||||
}
|
||||
|
||||
export function getLocalBuildIdentity() {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
return {
|
||||
commit,
|
||||
version: createLocalBuildVersion(packageJson.version, Date.now(), commit)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const identity = getLocalBuildIdentity()
|
||||
console.log(`[build:mac] local update version ${identity.version}`)
|
||||
execFileSync(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
['exec', 'electron-builder', '--config', 'config/electron-builder.config.cjs', '--mac'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_BUILD_COMMIT: identity.commit,
|
||||
ORCA_LOCAL_BUILD_VERSION: identity.version
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLocalBuildVersion } from './build-mac-local.mjs'
|
||||
|
||||
describe('createLocalBuildVersion', () => {
|
||||
it('creates unique valid prerelease versions without changing the release base', () => {
|
||||
expect(createLocalBuildVersion('1.4.159-rc.0', 123456, 'abc123')).toBe(
|
||||
'1.4.159-rc.0.local.123456.abc123'
|
||||
)
|
||||
expect(createLocalBuildVersion('1.4.159', 123456, 'abc123')).toBe('1.4.159-local.123456.abc123')
|
||||
})
|
||||
|
||||
it('sanitizes commit identifiers', () => {
|
||||
expect(createLocalBuildVersion('1.0.0', 1, 'abc/def')).toBe('1.0.0-local.1.abcdef')
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const __dirname = import.meta.dirname
|
||||
@@ -18,6 +18,21 @@ const __dirname = import.meta.dirname
|
||||
const ROOT = join(__dirname, '..', '..')
|
||||
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
|
||||
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
|
||||
const MANAGED_HOOK_RUNTIME_ENTRY = join(
|
||||
ROOT,
|
||||
'src',
|
||||
'main',
|
||||
'agent-hooks',
|
||||
'managed-hook-runtime.ts'
|
||||
)
|
||||
const JSONC_PARSER_ESM_ENTRY = join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
|
||||
const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
|
||||
const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
|
||||
ROOT,
|
||||
'config',
|
||||
'relay-assets',
|
||||
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
|
||||
)
|
||||
|
||||
const PLATFORMS = [
|
||||
'linux-x64',
|
||||
@@ -51,6 +66,13 @@ for (const platform of PLATFORMS) {
|
||||
}
|
||||
})
|
||||
|
||||
if (platform.startsWith('win32-')) {
|
||||
copyFileSync(
|
||||
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
|
||||
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
|
||||
)
|
||||
}
|
||||
|
||||
await build({
|
||||
entryPoints: [WATCHER_ENTRY],
|
||||
bundle: true,
|
||||
@@ -66,17 +88,39 @@ for (const platform of PLATFORMS) {
|
||||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'managed-hook-runtime.js'),
|
||||
// Why: jsonc-parser's default UMD build keeps relative dynamic requires
|
||||
// that break after bundling; its ESM entry is equivalent and self-contained.
|
||||
alias: { 'jsonc-parser': JSONC_PARSER_ESM_ENTRY },
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
|
||||
// Why: include a content hash so the deploy check detects code changes
|
||||
// even when RELAY_VERSION hasn't been bumped. Hash both process artifacts
|
||||
// so a watcher-only change always deploys beside the matching relay host.
|
||||
// even when RELAY_VERSION hasn't been bumped. Hash every executable module
|
||||
// so a companion-only change always deploys beside the matching relay host.
|
||||
const relayContent = readFileSync(join(outDir, 'relay.js'))
|
||||
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
|
||||
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
|
||||
const hash = createHash('sha256')
|
||||
.update(relayContent)
|
||||
.update(watcherContent)
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
|
||||
.update(managedHookRuntimeContent)
|
||||
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
|
||||
if (platform.startsWith('win32-')) {
|
||||
hash.update(readFileSync(join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)))
|
||||
}
|
||||
const contentHash = hash.digest('hex').slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`)
|
||||
|
||||
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
process.exit(0)
|
||||
// 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, '../..')
|
||||
|
||||
@@ -4,10 +4,33 @@ import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const itWindows = process.platform === 'win32' ? it : it.skip
|
||||
const itCrossHost = process.platform === 'win32' ? it.skip : it
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
|
||||
// keep the larger allowance scoped to the real compiler integration test.
|
||||
function itWindows(name, test) {
|
||||
const runner = process.platform === 'win32' ? it : it.skip
|
||||
runner(name, { timeout: 15_000 }, test)
|
||||
}
|
||||
|
||||
describe('Windows CLI launcher', () => {
|
||||
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
|
||||
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
|
||||
try {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs', '--output', join(outputRoot, 'orca.exe')],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stderr).toContain('Windows CLI launcher')
|
||||
expect(result.stderr).toContain('Windows host')
|
||||
} finally {
|
||||
rmSync(outputRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
|
||||
|
||||
const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/
|
||||
export const OXLINT_SCANS = [
|
||||
{
|
||||
// Why: no --config, so Oxlint keeps discovering nested configs. Pinning the root
|
||||
// config would apply root rules to mobile/, whose .oxlintrc.json turns them off.
|
||||
label: 'code quality',
|
||||
args: ['--report-unused-disable-directives-severity', 'warn']
|
||||
},
|
||||
{
|
||||
label: 'type-aware code quality',
|
||||
args: ['--type-aware', '--config', 'config/oxlint-code-quality-type-aware.json']
|
||||
},
|
||||
{
|
||||
label: 'React Doctor',
|
||||
args: ['--config', 'config/oxlint-react-doctor.json']
|
||||
}
|
||||
]
|
||||
|
||||
export function parseAddedLineRanges(diff) {
|
||||
const ranges = []
|
||||
const hunkPattern = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|
||||
for (const line of diff.split(/\r?\n/)) {
|
||||
const match = hunkPattern.exec(line)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const start = Number.parseInt(match[1], 10)
|
||||
const count = match[2] === undefined ? 1 : Number.parseInt(match[2], 10)
|
||||
if (count > 0) {
|
||||
ranges.push({ start, end: start + count - 1 })
|
||||
}
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
export function overlapsAddedLines(startLine, endLine, ranges) {
|
||||
return ranges.some((range) => startLine <= range.end && endLine >= range.start)
|
||||
}
|
||||
|
||||
function runGit(root, args, options = {}) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: root,
|
||||
encoding: options.encoding ?? 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
}
|
||||
|
||||
function splitNullDelimited(output) {
|
||||
return output.split('\0').filter(Boolean)
|
||||
}
|
||||
|
||||
function resolveBase(root, requestedBase) {
|
||||
for (const candidate of [
|
||||
requestedBase,
|
||||
process.env.ORCA_CODE_QUALITY_BASE,
|
||||
'origin/main',
|
||||
'main'
|
||||
]) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
const result = spawnSync('git', ['rev-parse', '--verify', `${candidate}^{commit}`], {
|
||||
cwd: root,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
if (result.status === 0) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
throw new Error('Pass the pull request base SHA or make origin/main available locally.')
|
||||
}
|
||||
|
||||
export function collectAddedLineRanges(root, requestedBase) {
|
||||
const base = resolveBase(root, requestedBase)
|
||||
const mergeBase = runGit(root, ['merge-base', base, 'HEAD']).trim()
|
||||
const comparisonBase = resolvePullRequestDiffBase(root, mergeBase)
|
||||
const changedFiles = splitNullDelimited(
|
||||
runGit(root, ['diff', '--name-only', '-z', '--diff-filter=ACMRTUB', comparisonBase, '--'])
|
||||
)
|
||||
const untrackedFiles = splitNullDelimited(
|
||||
runGit(root, ['ls-files', '--others', '--exclude-standard', '-z'])
|
||||
)
|
||||
const rangesByFile = new Map()
|
||||
|
||||
for (const file of changedFiles) {
|
||||
if (!SOURCE_FILE_PATTERN.test(file) || !existsSync(path.join(root, file))) {
|
||||
continue
|
||||
}
|
||||
const diff = runGit(root, ['diff', '--unified=0', '--no-color', comparisonBase, '--', file])
|
||||
const ranges = parseAddedLineRanges(diff)
|
||||
if (ranges.length > 0) {
|
||||
rangesByFile.set(file, ranges)
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of untrackedFiles) {
|
||||
const absolutePath = path.join(root, file)
|
||||
if (!SOURCE_FILE_PATTERN.test(file) || !existsSync(absolutePath)) {
|
||||
continue
|
||||
}
|
||||
const lineCount = readFileSync(absolutePath, 'utf8').split(/\r?\n/).length
|
||||
rangesByFile.set(file, [{ start: 1, end: lineCount }])
|
||||
}
|
||||
return { base, comparisonBase, rangesByFile }
|
||||
}
|
||||
|
||||
function parseOxlintOutput(stdout, label) {
|
||||
const start = stdout.indexOf('{')
|
||||
const end = stdout.lastIndexOf('}')
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(`${label} did not return Oxlint JSON output.`)
|
||||
}
|
||||
return JSON.parse(stdout.slice(start, end + 1))
|
||||
}
|
||||
|
||||
function normalizedDiagnosticPath(root, filename) {
|
||||
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
|
||||
return path.relative(root, absolutePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function diagnosticLineRange(root, filename, span) {
|
||||
const startLine = span.line
|
||||
if (!Number.isInteger(startLine)) {
|
||||
return null
|
||||
}
|
||||
if (!Number.isInteger(span.offset) || !Number.isInteger(span.length) || span.length === 0) {
|
||||
return { start: startLine, end: startLine }
|
||||
}
|
||||
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
|
||||
const source = readFileSync(absolutePath)
|
||||
const highlighted = source.subarray(span.offset, span.offset + span.length).toString('utf8')
|
||||
return { start: startLine, end: startLine + (highlighted.match(/\n/g)?.length ?? 0) }
|
||||
}
|
||||
|
||||
export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = process.cwd()) {
|
||||
const file = normalizedDiagnosticPath(root, diagnostic.filename)
|
||||
const ranges = rangesByFile.get(file)
|
||||
if (!ranges) {
|
||||
return false
|
||||
}
|
||||
return (diagnostic.labels ?? []).some((label) => {
|
||||
const lineRange = diagnosticLineRange(root, diagnostic.filename, label.span)
|
||||
return lineRange !== null && overlapsAddedLines(lineRange.start, lineRange.end, ranges)
|
||||
})
|
||||
}
|
||||
|
||||
function annotationValue(value) {
|
||||
return String(value).replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')
|
||||
}
|
||||
|
||||
function printDiagnostic(diagnostic, root) {
|
||||
const file = normalizedDiagnosticPath(root, diagnostic.filename)
|
||||
const line = diagnostic.labels?.[0]?.span?.line ?? 1
|
||||
const code = diagnostic.code ?? 'oxlint'
|
||||
console.error(
|
||||
`::error file=${annotationValue(file)},line=${line},title=${annotationValue(code)}::${annotationValue(diagnostic.message)}`
|
||||
)
|
||||
console.error(`${file}:${line} ${code}: ${diagnostic.message}`)
|
||||
}
|
||||
|
||||
function runOxlintScan(root, scan, files) {
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const result = spawnSync(pnpm, ['exec', 'oxlint', ...scan.args, '--format', 'json', ...files], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 128 * 1024 * 1024
|
||||
})
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (!result.stdout.trim()) {
|
||||
process.stderr.write(result.stderr)
|
||||
throw new Error(`${scan.label} failed before producing diagnostics.`)
|
||||
}
|
||||
return parseOxlintOutput(result.stdout, scan.label).diagnostics ?? []
|
||||
}
|
||||
|
||||
export function main(
|
||||
root = process.cwd(),
|
||||
requestedBase = process.argv.slice(2).find((argument) => argument !== '--')
|
||||
) {
|
||||
const { base, comparisonBase, rangesByFile } = collectAddedLineRanges(root, requestedBase)
|
||||
const files = [...rangesByFile.keys()]
|
||||
if (files.length === 0) {
|
||||
console.log(`Changed-code quality gate: no changed JavaScript or TypeScript since ${base}.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
for (const scan of OXLINT_SCANS) {
|
||||
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root)
|
||||
)
|
||||
for (const diagnostic of diagnostics) {
|
||||
printDiagnostic(diagnostic, root)
|
||||
}
|
||||
failures += diagnostics.length
|
||||
console.log(
|
||||
`${scan.label}: ${diagnostics.length} new finding(s) across ${files.length} changed file(s).`
|
||||
)
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(
|
||||
`Changed-code quality gate failed with ${failures} finding(s) since ${comparisonBase.slice(0, 12)}.`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
console.log(`Changed-code quality gate passed since ${comparisonBase.slice(0, 12)}.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(main())
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
OXLINT_SCANS,
|
||||
diagnosticTouchesAddedLines,
|
||||
overlapsAddedLines,
|
||||
parseAddedLineRanges
|
||||
} from './check-changed-code-quality.mjs'
|
||||
|
||||
describe('changed-code quality line matching', () => {
|
||||
it('parses added and replaced hunk ranges while ignoring deletions', () => {
|
||||
const ranges = parseAddedLineRanges(
|
||||
['@@ -10,2 +10,3 @@', '@@ -20 +21 @@', '@@ -40,4 +42,0 @@', '@@ -50 +48,2 @@'].join('\n')
|
||||
)
|
||||
|
||||
expect(ranges).toEqual([
|
||||
{ start: 10, end: 12 },
|
||||
{ start: 21, end: 21 },
|
||||
{ start: 48, end: 49 }
|
||||
])
|
||||
})
|
||||
|
||||
it('matches diagnostics that overlap any added line', () => {
|
||||
const ranges = [
|
||||
{ start: 5, end: 7 },
|
||||
{ start: 12, end: 12 }
|
||||
]
|
||||
|
||||
expect(overlapsAddedLines(3, 5, ranges)).toBe(true)
|
||||
expect(overlapsAddedLines(8, 11, ranges)).toBe(false)
|
||||
expect(overlapsAddedLines(12, 14, ranges)).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes absolute diagnostic paths before matching', () => {
|
||||
const root = process.cwd()
|
||||
const file = 'config/scripts/check-changed-code-quality.test.mjs'
|
||||
const diagnostic = {
|
||||
filename: `${root}/${file}`,
|
||||
labels: [{ span: { line: 24 } }]
|
||||
}
|
||||
|
||||
expect(
|
||||
diagnosticTouchesAddedLines(diagnostic, new Map([[file, [{ start: 24, end: 24 }]]]), root)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// Why: pinning --config disables nested-config discovery, so root rules that
|
||||
// mobile/.oxlintrc.json turns off would fail the gate on mobile files.
|
||||
it('lets the untyped scan discover nested configs instead of pinning the root config', () => {
|
||||
const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'code quality')
|
||||
|
||||
expect(scan.args).not.toContain('--config')
|
||||
expect(scan.args).not.toContain('--disable-nested-config')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import process from 'node:process'
|
||||
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
|
||||
|
||||
const requestedBase =
|
||||
process.argv.slice(2).find((argument) => argument !== '--') ??
|
||||
process.env.ORCA_CODE_QUALITY_BASE ??
|
||||
'origin/main'
|
||||
const base = resolvePullRequestDiffBase(process.cwd(), requestedBase)
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'dlx',
|
||||
'react-doctor@0.9.1',
|
||||
'.',
|
||||
'--yes',
|
||||
'--scope',
|
||||
'lines',
|
||||
'--base',
|
||||
base,
|
||||
'--include-untracked',
|
||||
'--no-dead-code',
|
||||
'--no-supply-chain',
|
||||
'--no-telemetry',
|
||||
'--blocking',
|
||||
'error'
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -1,87 +0,0 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import { reportUnstyledScrollbars } from './styled-scrollbars/styled-scrollbar-jsx-check.mjs'
|
||||
export {
|
||||
plainClassName,
|
||||
reportUnstyledScrollbars
|
||||
} from './styled-scrollbars/styled-scrollbar-jsx-check.mjs'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__'])
|
||||
|
||||
export function normalizePath(root, filePath) {
|
||||
return path.relative(root, filePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function isSkippedFile(root, filePath) {
|
||||
const relative = normalizePath(root, filePath)
|
||||
if (relative.includes('.test.') || relative.includes('.spec.')) {
|
||||
return true
|
||||
}
|
||||
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
|
||||
}
|
||||
|
||||
async function collectSourceFiles(root, dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_PATH_PARTS.has(entry.name)) {
|
||||
files.push(...(await collectSourceFiles(root, fullPath)))
|
||||
}
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
|
||||
!isSkippedFile(root, fullPath)
|
||||
) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
async function collectUnstyledScrollbarReports(root) {
|
||||
const sourceRoot = path.join(root, 'src', 'renderer', 'src')
|
||||
const files = await collectSourceFiles(root, sourceRoot)
|
||||
const reports = []
|
||||
|
||||
for (const filePath of files) {
|
||||
const sourceText = await fs.readFile(filePath, 'utf8')
|
||||
reports.push(...reportUnstyledScrollbars(filePath, sourceText))
|
||||
}
|
||||
|
||||
return reports
|
||||
}
|
||||
|
||||
function formatReports(root, reports) {
|
||||
return reports
|
||||
.map(
|
||||
(report) =>
|
||||
`${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.text.replace(/\s+/g, ' ')}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd()) {
|
||||
const reports = await collectUnstyledScrollbarReports(root)
|
||||
if (reports.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
console.error('Renderer vertical scroll containers must use an Orca scrollbar style.')
|
||||
console.error('Put the scrollbar class in the same class literal as the vertical overflow class.')
|
||||
console.error('Use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.')
|
||||
console.error('')
|
||||
console.error(formatReports(root, reports))
|
||||
return 1
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { plainClassName, reportUnstyledScrollbars } from './check-styled-scrollbars.mjs'
|
||||
|
||||
describe('check-styled-scrollbars', () => {
|
||||
it('reports renderer vertical scroll containers without an Orca scrollbar style', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="max-h-64 overflow-y-auto" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts obvious styled vertical scroll containers', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not accept nonexistent scrollbar classes as Orca scrollbar styles', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-none" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails closed when a separate class composer argument supplies the scrollbar style', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto', 'scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts static class composer arguments when the same literal is styled', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails closed when a scrollbar class is only conditionally present', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example({ enabled }) { return <div className={cn('overflow-y-auto', enabled && 'scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts conditional branches when overflow and scrollbar live in the same class literal', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example({ enabled }) { return <div className={cn(enabled && 'overflow-y-auto scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports vertical scroll inside arbitrary wrappers when the literal is unstyled', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={identity('overflow-y-auto')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not require a vertical scrollbar style for horizontal-only overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <pre className="max-w-full overflow-x-auto" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not let responsive scrollbar variants satisfy unconditional overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="overflow-y-auto md:scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts matching responsive overflow and scrollbar variants', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="md:overflow-y-auto md:scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts unconditional scrollbar styles for responsive overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="md:overflow-y-auto scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports inline vertical overflow without an Orca scrollbar class', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div style={{ overflowY: 'auto' }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts inline vertical overflow with a stable Orca scrollbar class', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="scrollbar-editor" style={{ overflow: \'auto\' }} /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports inline vertical overflow when the scrollbar class is conditional or short-circuited', () => {
|
||||
for (const classNameExpression of [
|
||||
"enabled && 'scrollbar-sleek'",
|
||||
"enabled ? 'scrollbar-sleek' : undefined",
|
||||
"enabled || 'scrollbar-sleek'",
|
||||
"enabled ?? 'scrollbar-sleek'"
|
||||
]) {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
`export function Example({ enabled }) { return <div className={${classNameExpression}} style={{ overflowY: 'auto' }} /> }`
|
||||
)
|
||||
|
||||
expect(reports, classNameExpression).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports logical inline style spreads without an Orca scrollbar class', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example({ open }) { return <div style={{ ...(open && { overflowY: 'auto' }) }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports JSX spread className props with unstyled vertical overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div {...{ className: 'overflow-y-auto' }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts JSX spread className props when the same literal is styled', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div {...{ className: 'overflow-y-auto scrollbar-sleek' }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses later spread className props over earlier explicit className props', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="scrollbar-sleek" {...{ className: \'overflow-y-auto\' }} /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('supports variant helper className config', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={buttonVariants({ className: 'overflow-y-auto scrollbar-sleek' })} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('normalizes Tailwind variants and important prefixes before matching', () => {
|
||||
expect(plainClassName('md:overflow-y-auto')).toBe('overflow-y-auto')
|
||||
expect(plainClassName('[&:hover]:overflow-y-auto')).toBe('overflow-y-auto')
|
||||
expect(plainClassName('md:!scrollbar-editor')).toBe('scrollbar-editor')
|
||||
expect(plainClassName('!scrollbar-editor')).toBe('scrollbar-editor')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { buildWindowsCommandInvocation } from '../../src/main/claude-accounts/windows-command-invocation.ts'
|
||||
|
||||
const strategy = process.argv[2]
|
||||
if (!['baseline', 'candidate', 'explicit-cmd'].includes(strategy)) {
|
||||
throw new Error(
|
||||
'Usage: node config/scripts/claude-account-windows-spawn-repro.mjs <baseline|candidate|explicit-cmd>'
|
||||
)
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('This reproduction requires a physical Windows host.')
|
||||
}
|
||||
|
||||
const expectedArgs = [
|
||||
'',
|
||||
'two words',
|
||||
'amp&ersand',
|
||||
'pipe|value',
|
||||
'less<value',
|
||||
'greater>value',
|
||||
'caret^value',
|
||||
'trailing\\',
|
||||
'two-trailing\\\\',
|
||||
'(parentheses)',
|
||||
'100%',
|
||||
'%ORCA_ARG_TRAP%',
|
||||
'bang!value',
|
||||
'한글-λ'
|
||||
]
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-claude-spawn-'))
|
||||
const reportedDir = join(tempRoot, 'Profile with spaces 한글')
|
||||
const reportedCapturePath = join(reportedDir, 'capture.json')
|
||||
const reportedPidPath = join(reportedDir, 'pids.json')
|
||||
const reportedShimPath = join(reportedDir, 'claude fixture.cmd')
|
||||
const reportedFixturePath = join(reportedDir, 'capture-child.cjs')
|
||||
const fixtureDir = join(tempRoot, 'Profile space & ^ (paren) %ORCA_PATH_TRAP% !bang! 한글')
|
||||
const capturePath = join(fixtureDir, 'capture.json')
|
||||
const pidPath = join(fixtureDir, 'pids.json')
|
||||
const shimPath = join(fixtureDir, 'claude fixture.cmd')
|
||||
const fixturePath = join(fixtureDir, 'capture-child.cjs')
|
||||
const fixtureEnv = {
|
||||
...process.env,
|
||||
CLAUDE_CONFIG_DIR: join(fixtureDir, 'config space & ^ (paren) %ORCA_ENV_LITERAL% !bang! 한글'),
|
||||
ORCA_ARG_TRAP: 'EXPANDED_ARG',
|
||||
ORCA_PATH_TRAP: 'EXPANDED_PATH',
|
||||
ORCA_FIXTURE_CAPTURE: capturePath,
|
||||
ORCA_FIXTURE_PIDS: pidPath,
|
||||
ORCA_FIXTURE_NODE: process.execPath
|
||||
}
|
||||
const reportedEnv = {
|
||||
...fixtureEnv,
|
||||
CLAUDE_CONFIG_DIR: join(reportedDir, 'config with spaces 한글'),
|
||||
ORCA_FIXTURE_CAPTURE: reportedCapturePath,
|
||||
ORCA_FIXTURE_PIDS: reportedPidPath
|
||||
}
|
||||
|
||||
function quoteForCandidate(value) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function launch(args, command = shimPath, env = fixtureEnv) {
|
||||
if (strategy === 'baseline') {
|
||||
return spawn(command, args, { cwd: tempRoot, env, shell: true, windowsHide: true })
|
||||
}
|
||||
if (strategy === 'candidate') {
|
||||
return spawn(quoteForCandidate(command), args, {
|
||||
cwd: tempRoot,
|
||||
env,
|
||||
shell: true,
|
||||
windowsHide: true
|
||||
})
|
||||
}
|
||||
const invocation = buildWindowsCommandInvocation(command, args)
|
||||
return spawn(invocation.command, invocation.args, {
|
||||
cwd: tempRoot,
|
||||
env,
|
||||
shell: false,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
windowsHide: true
|
||||
})
|
||||
}
|
||||
|
||||
function collect(child) {
|
||||
return new Promise((resolve) => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout?.on('data', (chunk) => (stdout += chunk.toString()))
|
||||
child.stderr?.on('data', (chunk) => (stderr += chunk.toString()))
|
||||
child.on('error', (error) => resolve({ code: null, stdout, stderr, error: error.message }))
|
||||
child.on('close', (code) => resolve({ code, stdout, stderr, error: null }))
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForFile(path, timeoutMs = 5_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf8'))
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for fixture output: ${path}`)
|
||||
}
|
||||
|
||||
async function taskExists(pid) {
|
||||
const result = await collect(
|
||||
spawn('tasklist.exe', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], {
|
||||
windowsHide: true
|
||||
})
|
||||
)
|
||||
if (result.error || result.code !== 0) {
|
||||
throw new Error(`tasklist failed for PID ${pid}: ${result.error ?? result.stderr}`)
|
||||
}
|
||||
return result.stdout.includes(`"${pid}"`)
|
||||
}
|
||||
|
||||
async function killTree(pid) {
|
||||
const result = await collect(
|
||||
spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { windowsHide: true })
|
||||
)
|
||||
if (result.error || result.code !== 0) {
|
||||
throw new Error(`taskkill failed for PID ${pid}: ${result.error ?? result.stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTreeExit(pids, timeoutMs = 5_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let alive = {}
|
||||
do {
|
||||
alive = Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(pids).map(async ([name, pid]) => [name, await taskExists(pid)])
|
||||
)
|
||||
)
|
||||
if (!Object.values(alive).some(Boolean)) {
|
||||
return alive
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
} while (Date.now() < deadline)
|
||||
return alive
|
||||
}
|
||||
|
||||
const results = {
|
||||
strategy,
|
||||
reportedPath: null,
|
||||
pathMatrix: {},
|
||||
argvMatrix: {},
|
||||
hostilePathAndArgv: null,
|
||||
error: null,
|
||||
cancellation: null
|
||||
}
|
||||
const fixtureSource =
|
||||
`const { spawn } = require('node:child_process')\n` +
|
||||
`const { writeFileSync } = require('node:fs')\n` +
|
||||
`if (process.argv[2] === '--exit-error') { process.stderr.write('fixture error: 한글 & ^ % !\\n'); process.exit(23) }\n` +
|
||||
`if (process.argv[2] === '--linger') {\n` +
|
||||
` const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true })\n` +
|
||||
` writeFileSync(process.env.ORCA_FIXTURE_PIDS, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }))\n` +
|
||||
` setInterval(() => {}, 1000)\n` +
|
||||
`} else {\n` +
|
||||
` writeFileSync(process.env.ORCA_FIXTURE_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), configDir: process.env.CLAUDE_CONFIG_DIR }))\n` +
|
||||
`}\n`
|
||||
const shimSource = '@echo off\r\n"%ORCA_FIXTURE_NODE%" "%~dp0capture-child.cjs" %*\r\n'
|
||||
let lingeringShellPid = null
|
||||
try {
|
||||
await mkdir(fixtureDir, { recursive: true })
|
||||
await mkdir(reportedDir, { recursive: true })
|
||||
await writeFile(fixturePath, fixtureSource, 'utf8')
|
||||
await writeFile(shimPath, shimSource, 'utf8')
|
||||
await writeFile(reportedFixturePath, fixtureSource, 'utf8')
|
||||
await writeFile(reportedShimPath, shimSource, 'utf8')
|
||||
|
||||
const reportedArgs = ['auth', 'status', '--json']
|
||||
const reportedRun = await collect(launch(reportedArgs, reportedShimPath, reportedEnv))
|
||||
let reportedCapture = null
|
||||
try {
|
||||
reportedCapture = await waitForFile(reportedCapturePath, 1_000)
|
||||
} catch {}
|
||||
results.reportedPath = {
|
||||
...reportedRun,
|
||||
actual: reportedCapture,
|
||||
expected: { argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR },
|
||||
pass:
|
||||
reportedRun.code === 0 &&
|
||||
JSON.stringify(reportedCapture) ===
|
||||
JSON.stringify({ argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
for (const [name, segment] of Object.entries({
|
||||
spaces: 'profile space',
|
||||
ampersand: 'profile&name',
|
||||
caret: 'profile^name',
|
||||
parentheses: 'profile(name)',
|
||||
percent: 'profile%ORCA_PATH_TRAP%',
|
||||
bang: 'profile!name',
|
||||
unicode: 'profile-한글-λ'
|
||||
})) {
|
||||
const directory = join(tempRoot, segment)
|
||||
const captureFile = join(directory, 'capture.json')
|
||||
const command = join(directory, 'claude fixture.cmd')
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'capture-child.cjs'), fixtureSource, 'utf8')
|
||||
await writeFile(command, shimSource, 'utf8')
|
||||
const env = {
|
||||
...fixtureEnv,
|
||||
CLAUDE_CONFIG_DIR: join(directory, 'config'),
|
||||
ORCA_FIXTURE_CAPTURE: captureFile
|
||||
}
|
||||
const run = await collect(launch(reportedArgs, command, env))
|
||||
let actual = null
|
||||
try {
|
||||
actual = await waitForFile(captureFile, 500)
|
||||
} catch {}
|
||||
results.pathMatrix[name] = {
|
||||
code: run.code,
|
||||
stderr: run.stderr.trim(),
|
||||
actual: actual?.argv ?? null,
|
||||
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(reportedArgs)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries({
|
||||
empty: '',
|
||||
spaces: 'two words',
|
||||
ampersand: 'amp&ersand',
|
||||
pipe: 'pipe|value',
|
||||
lessThan: 'less<value',
|
||||
greaterThan: 'greater>value',
|
||||
caret: 'caret^value',
|
||||
trailingBackslash: 'trailing\\',
|
||||
twoTrailingBackslashes: 'two-trailing\\\\',
|
||||
parentheses: '(parentheses)',
|
||||
percent: '%ORCA_ARG_TRAP%',
|
||||
bang: 'bang!value',
|
||||
unicode: '한글-λ'
|
||||
})) {
|
||||
const args = ['prefix', value, 'suffix']
|
||||
const captureFile = join(reportedDir, `capture-${name}.json`)
|
||||
const env = { ...reportedEnv, ORCA_FIXTURE_CAPTURE: captureFile }
|
||||
const run = await collect(launch(args, reportedShimPath, env))
|
||||
let actual = null
|
||||
try {
|
||||
actual = await waitForFile(captureFile, 500)
|
||||
} catch {}
|
||||
results.argvMatrix[name] = {
|
||||
code: run.code,
|
||||
stderr: run.stderr.trim(),
|
||||
actual: actual?.argv ?? null,
|
||||
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(args)
|
||||
}
|
||||
}
|
||||
|
||||
const argvRun = await collect(launch(expectedArgs))
|
||||
let capture = null
|
||||
try {
|
||||
capture = await waitForFile(capturePath, 1_000)
|
||||
} catch {}
|
||||
results.hostilePathAndArgv = {
|
||||
...argvRun,
|
||||
actual: capture,
|
||||
expected: { argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR },
|
||||
pass:
|
||||
argvRun.code === 0 &&
|
||||
JSON.stringify(capture) ===
|
||||
JSON.stringify({ argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
results.error = await collect(launch(['--exit-error'], reportedShimPath, reportedEnv))
|
||||
results.error.pass =
|
||||
results.error.code === 23 && results.error.stderr.includes('fixture error: 한글 & ^ % !')
|
||||
|
||||
const lingering = launch(['--linger'], reportedShimPath, reportedEnv)
|
||||
lingeringShellPid = lingering.pid
|
||||
const lingeringResult = collect(lingering)
|
||||
try {
|
||||
const pids = await waitForFile(reportedPidPath)
|
||||
await killTree(lingering.pid)
|
||||
const alive = await waitForTreeExit({
|
||||
shell: lingering.pid,
|
||||
child: pids.child,
|
||||
grandchild: pids.grandchild
|
||||
})
|
||||
results.cancellation = {
|
||||
shell: lingering.pid,
|
||||
...pids,
|
||||
alive,
|
||||
pass: !Object.values(alive).some(Boolean)
|
||||
}
|
||||
} catch (error) {
|
||||
if (await taskExists(lingering.pid)) {
|
||||
await killTree(lingering.pid)
|
||||
}
|
||||
const launchResult = await Promise.race([
|
||||
lingeringResult,
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() => resolve({ code: null, error: 'fixture did not exit after cleanup' }),
|
||||
5_000
|
||||
)
|
||||
)
|
||||
])
|
||||
results.cancellation = {
|
||||
shell: lingering.pid,
|
||||
launchResult,
|
||||
pass: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (lingeringShellPid && (await taskExists(lingeringShellPid))) {
|
||||
await killTree(lingeringShellPid)
|
||||
}
|
||||
try {
|
||||
let pids
|
||||
try {
|
||||
pids = JSON.parse(await readFile(reportedPidPath, 'utf8'))
|
||||
} catch {
|
||||
pids = JSON.parse(await readFile(pidPath, 'utf8'))
|
||||
}
|
||||
for (const pid of [pids.child, pids.grandchild]) {
|
||||
if (await taskExists(pid)) {
|
||||
await killTree(pid)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
process.exitCode =
|
||||
results.reportedPath?.pass &&
|
||||
Object.values(results.pathMatrix).every((result) => result.pass) &&
|
||||
Object.values(results.argvMatrix).every((result) => result.pass) &&
|
||||
results.hostilePathAndArgv?.pass &&
|
||||
results.error?.pass &&
|
||||
results.cancellation?.pass
|
||||
? 0
|
||||
: 1
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: the event-loop yield in the Claude usage scanner's batch loops.
|
||||
//
|
||||
// scanner.ts yielded with `setTimeout(resolve, 0)`, which Node clamps to ~1ms. The
|
||||
// loops yield once per FILE_SCAN_BATCH_SIZE files across two passes, so a machine with
|
||||
// thousands of transcripts spent seconds parked on timers doing no work. setImmediate
|
||||
// yields on the same tick's check phase with no clamp. The sibling scanner
|
||||
// (src/main/codex-usage/scanner.ts) already used setImmediate.
|
||||
//
|
||||
// The yield exists to keep the main process responsive during a scan, so this also
|
||||
// measures worst-case latency for a concurrent task -- a "faster" yield that starved
|
||||
// other work would be a regression, not a win.
|
||||
//
|
||||
// Run with: node config/scripts/claude-usage-yield-benchmark.mjs
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const REPO_ROOT = new URL('../..', import.meta.url)
|
||||
const ROUNDS = Number(process.env.ORCA_YIELD_BENCH_ROUNDS ?? '10')
|
||||
|
||||
// Why re-read the source: the claim is that the scanner yields once per batch across
|
||||
// two loops. If the batch size or the yield sites change, these numbers stop meaning
|
||||
// what the header says, so fail loudly instead of reporting a stale ratio.
|
||||
const SCANNER_SOURCE = readFileSync(new URL('src/main/claude-usage/scanner.ts', REPO_ROOT), 'utf8')
|
||||
const batchMatch = SCANNER_SOURCE.match(/const FILE_SCAN_BATCH_SIZE = (\d+)/)
|
||||
if (!batchMatch) {
|
||||
throw new Error('FILE_SCAN_BATCH_SIZE not found; this benchmark is stale')
|
||||
}
|
||||
const FILE_SCAN_BATCH_SIZE = Number(batchMatch[1])
|
||||
const YIELD_SITES = (SCANNER_SOURCE.match(/await yieldToEventLoop\(\)/g) ?? []).length
|
||||
if (YIELD_SITES === 0) {
|
||||
throw new Error('no yieldToEventLoop call sites found; this benchmark is stale')
|
||||
}
|
||||
// Match the call, not the word: a comment mentioning setImmediate would satisfy a
|
||||
// bare substring check even after the yield reverted to setTimeout.
|
||||
if (!/setImmediate\(resolve\)/.test(SCANNER_SOURCE)) {
|
||||
throw new Error('scanner no longer yields with setImmediate; this benchmark is stale')
|
||||
}
|
||||
|
||||
// Real transcript count drives the yield count, so read it rather than assume one.
|
||||
function countClaudeTranscripts() {
|
||||
const root = join(homedir(), '.claude', 'projects')
|
||||
let count = 0
|
||||
const stack = [root]
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(join(dir, entry.name))
|
||||
} else if (entry.name.endsWith('.jsonl')) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
const transcriptCount = (() => {
|
||||
try {
|
||||
statSync(join(homedir(), '.claude', 'projects'))
|
||||
return countClaudeTranscripts()
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})()
|
||||
|
||||
const FALLBACK_TRANSCRIPTS = 7500
|
||||
const effectiveTranscripts = transcriptCount > 0 ? transcriptCount : FALLBACK_TRANSCRIPTS
|
||||
// Each pass walks ceil(files / batch) batches and yields after every batch except the
|
||||
// last, so a pass yields batchesPerPass - 1 times.
|
||||
const BATCHES_PER_PASS = Math.max(1, Math.ceil(effectiveTranscripts / FILE_SCAN_BATCH_SIZE))
|
||||
const YIELDS_PER_SCAN = Math.max(1, (BATCHES_PER_PASS - 1) * YIELD_SITES)
|
||||
|
||||
const yieldWithTimeout = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const yieldWithImmediate = () => new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
// Mirrors the scanner's shape: a little synchronous work per batch, then a yield.
|
||||
async function runBatchLoop(doYield, batches) {
|
||||
let sink = 0
|
||||
for (let batch = 0; batch < batches; batch += 1) {
|
||||
for (let file = 0; file < FILE_SCAN_BATCH_SIZE; file += 1) {
|
||||
sink += (batch * 31 + file) % 7
|
||||
}
|
||||
if (batch + 1 < batches) {
|
||||
await doYield()
|
||||
}
|
||||
}
|
||||
return sink
|
||||
}
|
||||
|
||||
async function timeArm(doYield, batches) {
|
||||
const start = performance.now()
|
||||
const sink = await runBatchLoop(doYield, batches)
|
||||
const elapsed = performance.now() - start
|
||||
if (sink === -1) {
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const mid = sorted.length / 2
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
// Arms alternate which one leads so within-round drift cannot favour either.
|
||||
async function measure(batches) {
|
||||
await runBatchLoop(yieldWithTimeout, Math.min(batches, 50))
|
||||
await runBatchLoop(yieldWithImmediate, Math.min(batches, 50))
|
||||
const timeoutSamples = []
|
||||
const immediateSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
timeoutSamples.push(await timeArm(yieldWithTimeout, batches))
|
||||
immediateSamples.push(await timeArm(yieldWithImmediate, batches))
|
||||
} else {
|
||||
immediateSamples.push(await timeArm(yieldWithImmediate, batches))
|
||||
timeoutSamples.push(await timeArm(yieldWithTimeout, batches))
|
||||
}
|
||||
}
|
||||
return { timeoutMs: median(timeoutSamples), immediateMs: median(immediateSamples) }
|
||||
}
|
||||
|
||||
// The yield exists for responsiveness, so measure what a concurrent task actually sees.
|
||||
async function measureConcurrentLatency(doYield, batches) {
|
||||
let worstLatencyMs = 0
|
||||
let stop = false
|
||||
const probe = (async () => {
|
||||
while (!stop) {
|
||||
const tick = performance.now()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
worstLatencyMs = Math.max(worstLatencyMs, performance.now() - tick)
|
||||
}
|
||||
})()
|
||||
const start = performance.now()
|
||||
await runBatchLoop(doYield, batches)
|
||||
const scanMs = performance.now() - start
|
||||
stop = true
|
||||
await probe
|
||||
return { scanMs, worstLatencyMs }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Claude usage scanner event-loop yield. Lower is better.')
|
||||
console.log(
|
||||
`transcripts=${transcriptCount > 0 ? transcriptCount : `${FALLBACK_TRANSCRIPTS} (none found; synthetic)`} batch=${FILE_SCAN_BATCH_SIZE} sites=${YIELD_SITES} -> ~${YIELDS_PER_SCAN} yields/scan`
|
||||
)
|
||||
console.log(
|
||||
`${pad('yields', 8)} ${pad('setTimeout(0)', 14)} ${pad('setImmediate', 13)} ${pad('speedup', 9)} ${pad('saved', 11)}`
|
||||
)
|
||||
|
||||
for (const yields of [100, 500, YIELDS_PER_SCAN]) {
|
||||
// runBatchLoop yields batches - 1 times, so ask for one more batch than yields.
|
||||
const batches = yields + 1
|
||||
const { timeoutMs, immediateMs } = await measure(batches)
|
||||
// Why report the absolute saving too: the setImmediate arm is small enough that
|
||||
// background load moves the RATIO a lot while the removed wall time barely budges.
|
||||
console.log(
|
||||
`${pad(yields, 8)} ${pad(`${timeoutMs.toFixed(1)} ms`, 14)} ${pad(`${immediateMs.toFixed(1)} ms`, 13)} ${pad(`${(timeoutMs / immediateMs).toFixed(1)}x`, 9)} ${pad(`${(timeoutMs - immediateMs).toFixed(0)} ms`, 11)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\nResponsiveness (the reason the yield exists) at a full scan:')
|
||||
const timeoutLatency = await measureConcurrentLatency(yieldWithTimeout, YIELDS_PER_SCAN + 1)
|
||||
const immediateLatency = await measureConcurrentLatency(yieldWithImmediate, YIELDS_PER_SCAN + 1)
|
||||
console.log(
|
||||
` setTimeout(0): scan ${timeoutLatency.scanMs.toFixed(0)} ms, worst concurrent wait ${timeoutLatency.worstLatencyMs.toFixed(2)} ms`
|
||||
)
|
||||
console.log(
|
||||
` setImmediate : scan ${immediateLatency.scanMs.toFixed(0)} ms, worst concurrent wait ${immediateLatency.worstLatencyMs.toFixed(2)} ms`
|
||||
)
|
||||
if (immediateLatency.worstLatencyMs > timeoutLatency.worstLatencyMs) {
|
||||
console.log(
|
||||
'\n NOTE: setImmediate showed a WORSE concurrent wait here. The yield exists for\n responsiveness, so that would be a regression even though the scan is faster.'
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nRead the SAVED column, not the ratio. The setImmediate arm is small enough that\nbackground load swings the ratio (32x-81x observed across runs on a loaded machine)\nwhile the removed wall time stays at ~4.2-5.1 s. The saving is wall-clock the main\nprocess spent parked on timer clamps, not CPU work removed. It is paid on every\nUsage-pane scan and every forced automation rescan.'
|
||||
)
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: CLI process startup with the RuntimeClient module graph deferred.
|
||||
//
|
||||
// src/cli/index.ts used to value-import RuntimeClient at module scope, and five
|
||||
// modules that load on every invocation (args, flags, dispatch, format,
|
||||
// selectors) pulled RuntimeClientError from the ./runtime-client barrel. Either
|
||||
// edge alone drags in the whole client graph: zod (via shared/pairing ->
|
||||
// shared/mobile-relay-pairing-offer), ws + tweetnacl (via websocket-transport),
|
||||
// plus the environment store and secure-file stack.
|
||||
//
|
||||
// The fix repoints those five at ./runtime/types (zero children) and loads the
|
||||
// client through `await import()` after flag validation, so --help, `help
|
||||
// <cmd>` and every command/flag error return without ever touching it.
|
||||
//
|
||||
// Both arms are REAL tsc emits of real source: the baseline arm restores the
|
||||
// seven touched files from a git rev and compiles that. Each sample is a FRESH
|
||||
// process (module-graph cost is a once-per-process cost; timing it in-process
|
||||
// would measure a warm require cache).
|
||||
//
|
||||
// Arms alternate lead across an even number of rounds and report per-arm
|
||||
// medians. Byte-for-byte output equality is checked BEFORE timing.
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO = fileURLToPath(new URL('../..', import.meta.url))
|
||||
|
||||
const ROUNDS = Number(process.env.ORCA_CLI_DEFER_BENCH_ROUNDS ?? '30')
|
||||
const WARMUP = Number(process.env.ORCA_CLI_DEFER_BENCH_WARMUP ?? '3')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_CLI_DEFER_BENCH_ROUNDS', ROUNDS],
|
||||
['ORCA_CLI_DEFER_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
if (ROUNDS % 2 !== 0) {
|
||||
// Why: arms alternate which one leads; an odd count biases one arm.
|
||||
throw new Error(`ORCA_CLI_DEFER_BENCH_ROUNDS must be even, received ${ROUNDS}`)
|
||||
}
|
||||
|
||||
const TOUCHED = [
|
||||
'src/cli/args.ts',
|
||||
'src/cli/dispatch.ts',
|
||||
'src/cli/flags.ts',
|
||||
'src/cli/format.ts',
|
||||
'src/cli/index.ts',
|
||||
'src/cli/runtime/client.ts',
|
||||
'src/cli/selectors.ts'
|
||||
]
|
||||
|
||||
// Why: if the deferral is reverted or reshaped, both arms would compile to the
|
||||
// same thing and this would quietly report 1.00x forever. Re-read the real call
|
||||
// forms out of the source. Matching the CALL form (not a bare identifier) so a
|
||||
// comment that merely names the function cannot satisfy the check.
|
||||
function assertMarkersFresh() {
|
||||
const checks = [
|
||||
['src/cli/index.ts', "await import('./runtime-client.js')"],
|
||||
['src/cli/index.ts', 'await loadRuntimeClientClass()'],
|
||||
['src/cli/index.ts', "import type { RuntimeClient } from './runtime-client'"],
|
||||
['src/cli/runtime/client.ts', "await import('./websocket-transport.js')"],
|
||||
['src/cli/runtime/client.ts', 'await loadSendWebSocketRequest()'],
|
||||
['src/cli/args.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/flags.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/dispatch.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/selectors.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/format.ts', "} from './runtime/types'"]
|
||||
]
|
||||
for (const [file, marker] of checks) {
|
||||
if (!readFileSync(join(REPO, file), 'utf8').includes(marker)) {
|
||||
throw new Error(
|
||||
`${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-benchmark.mjs is stale`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildArm(label, baselineRev) {
|
||||
// Why: a build under /tmp cannot resolve the repo's node_modules, so the
|
||||
// output has to live inside the repo.
|
||||
const outDir = join(REPO, `.bench-out-${label}`)
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
const restore = []
|
||||
try {
|
||||
if (baselineRev) {
|
||||
for (const file of TOUCHED) {
|
||||
const path = join(REPO, file)
|
||||
restore.push([path, readFileSync(path)])
|
||||
writeFileSync(
|
||||
path,
|
||||
execFileSync('git', ['show', `${baselineRev}:${file}`], {
|
||||
cwd: REPO,
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
execFileSync(
|
||||
'npx',
|
||||
[
|
||||
'tsc',
|
||||
'-p',
|
||||
'config/tsconfig.cli.json',
|
||||
'--outDir',
|
||||
outDir,
|
||||
'--composite',
|
||||
'false',
|
||||
'--incremental',
|
||||
'false'
|
||||
],
|
||||
{ cwd: REPO, stdio: 'inherit' }
|
||||
)
|
||||
} finally {
|
||||
for (const [path, contents] of restore) {
|
||||
writeFileSync(path, contents)
|
||||
}
|
||||
}
|
||||
return join(outDir, 'cli/index.js')
|
||||
}
|
||||
|
||||
function run(entry, argv, env) {
|
||||
const result = spawnSync(process.execPath, [entry, ...argv], {
|
||||
cwd: REPO,
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'buffer'
|
||||
})
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout.toString('utf8'),
|
||||
stderr: result.stderr.toString('utf8')
|
||||
}
|
||||
}
|
||||
|
||||
// Counts the eager CommonJS module graph of a built entry point by hooking
|
||||
// Module._load in a child process. This is the quantity the change moves.
|
||||
function countEagerModules(entry) {
|
||||
const probe = `
|
||||
const Module = require('module')
|
||||
const original = Module._load
|
||||
const seen = new Set()
|
||||
Module._load = function (request, parent, isMain) {
|
||||
try { seen.add(Module._resolveFilename(request, parent, isMain)) } catch { seen.add(request) }
|
||||
return original.apply(this, arguments)
|
||||
}
|
||||
require(${JSON.stringify(entry)})
|
||||
const all = [...seen]
|
||||
process.stdout.write(JSON.stringify({
|
||||
total: all.length,
|
||||
nodeModules: all.filter((p) => p.includes('node_modules')).length
|
||||
}))
|
||||
`
|
||||
const result = spawnSync(process.execPath, ['-e', probe], { cwd: REPO, encoding: 'utf8' })
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`module probe failed: ${result.stderr}`)
|
||||
}
|
||||
return JSON.parse(result.stdout)
|
||||
}
|
||||
|
||||
const median = (values) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
const baselineIndex = process.argv.indexOf('--baseline')
|
||||
const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1]
|
||||
|
||||
assertMarkersFresh()
|
||||
|
||||
const userDataPath = mkdtempSync(join(REPO, '.bench-userdata-'))
|
||||
|
||||
try {
|
||||
console.log(`Building eager baseline (${baselineRev}) …`)
|
||||
const eagerEntry = buildArm('eager', baselineRev)
|
||||
console.log('Building deferred (working tree) …')
|
||||
const deferredEntry = buildArm('deferred', null)
|
||||
|
||||
const eagerGraph = countEagerModules(eagerEntry)
|
||||
const deferredGraph = countEagerModules(deferredEntry)
|
||||
console.log(
|
||||
`\nEager modules at process load: ${eagerGraph.total} -> ${deferredGraph.total} ` +
|
||||
`(node_modules ${eagerGraph.nodeModules} -> ${deferredGraph.nodeModules})`
|
||||
)
|
||||
if (deferredGraph.total >= eagerGraph.total) {
|
||||
throw new Error(
|
||||
'deferred arm loads no fewer modules — the fixture does not exercise the change'
|
||||
)
|
||||
}
|
||||
|
||||
// Each case is (label, argv, env). The runtime-dependent ones point at an
|
||||
// empty user-data dir so both arms get the same deterministic answer.
|
||||
const isolated = { ORCA_USER_DATA_PATH: userDataPath }
|
||||
/** @type {Array<[string, string[], Record<string, string>]>} */
|
||||
const cases = [
|
||||
['orca --help', ['--help'], {}],
|
||||
['orca help worktree', ['help', 'worktree'], {}],
|
||||
['orca (no args)', [], {}],
|
||||
['unknown command', ['no-such-command'], {}],
|
||||
['unknown flag', ['worktree', 'list', '--nope'], {}],
|
||||
['orca agent-context --json', ['agent-context', '--json'], {}],
|
||||
['orca status --json', ['status', '--json'], isolated],
|
||||
['orca worktree list --json', ['worktree', 'list', '--json'], isolated]
|
||||
]
|
||||
|
||||
// Why: a semantically broken arm that prints nothing would look fastest.
|
||||
// Compare bytes and exit codes BEFORE timing anything.
|
||||
for (const [label, argv, env] of cases) {
|
||||
const before = run(eagerEntry, argv, env)
|
||||
const after = run(deferredEntry, argv, env)
|
||||
if (
|
||||
before.status !== after.status ||
|
||||
before.stdout !== after.stdout ||
|
||||
before.stderr !== after.stderr
|
||||
) {
|
||||
throw new Error(`arms disagree for "${label}" — refusing to report a timing`)
|
||||
}
|
||||
if (before.stdout.length + before.stderr.length === 0) {
|
||||
throw new Error(`"${label}" produced no output on either arm; it proves nothing`)
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('\nFresh process per sample, wall clock. Lower is better.')
|
||||
console.log(`rounds=${ROUNDS} warmup=${WARMUP} (per-arm median, arms alternate lead)`)
|
||||
console.log(`${pad('case', 26)} ${pad('eager', 10)} ${pad('deferred', 10)} ${pad('speedup', 9)}`)
|
||||
|
||||
// Accumulated so V8 cannot treat the spawn loop as dead code.
|
||||
let consumed = 0
|
||||
|
||||
for (const [label, argv, env] of cases) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
consumed += run(eagerEntry, argv, env).stdout.length
|
||||
consumed += run(deferredEntry, argv, env).stdout.length
|
||||
}
|
||||
const samples = { eager: [], deferred: [] }
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
// Alternate which arm leads so a drifting machine load cannot be
|
||||
// attributed to one arm.
|
||||
const order =
|
||||
round % 2 === 0
|
||||
? [
|
||||
['eager', eagerEntry],
|
||||
['deferred', deferredEntry]
|
||||
]
|
||||
: [
|
||||
['deferred', deferredEntry],
|
||||
['eager', eagerEntry]
|
||||
]
|
||||
for (const [arm, entry] of order) {
|
||||
const started = performance.now()
|
||||
const result = run(entry, argv, env)
|
||||
samples[arm].push(performance.now() - started)
|
||||
consumed += result.stdout.length
|
||||
}
|
||||
}
|
||||
const eagerMs = median(samples.eager)
|
||||
const deferredMs = median(samples.deferred)
|
||||
console.log(
|
||||
`${pad(label, 26)} ${pad(`${eagerMs.toFixed(1)} ms`, 10)} ${pad(`${deferredMs.toFixed(1)} ms`, 10)} ${pad(`${(eagerMs / deferredMs).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (consumed === 0) {
|
||||
throw new Error('no output consumed — the timing loop was optimised away')
|
||||
}
|
||||
console.log(
|
||||
'\nThe help and error rows are the ones the change targets: they return\n' +
|
||||
'before any client construction, so they drop the whole graph. `status` and\n' +
|
||||
'`worktree list` still construct a client, so they only save the eager parse\n' +
|
||||
'of the parts the local path never uses (ws/tweetnacl via websocket-transport).'
|
||||
)
|
||||
} finally {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
for (const label of ['eager', 'deferred']) {
|
||||
rmSync(join(REPO, `.bench-out-${label}`), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env node
|
||||
// Equivalence check for deferring the RuntimeClient module graph in the CLI.
|
||||
//
|
||||
// Builds the CLI twice with the REAL tsc emit — once from the working tree and
|
||||
// once with the seven touched files restored from git HEAD~ (the pre-deferral
|
||||
// implementation) — then compares stdout, stderr and exit code BYTE FOR BYTE
|
||||
// across a matrix of invocations.
|
||||
//
|
||||
// Why a script and not a vitest case: this compiles two full CLI trees. It is
|
||||
// the artifact that proves the refactor is behaviour-preserving; the fast
|
||||
// invariants (class identity, no eager barrel import) live in
|
||||
// src/cli/runtime-client-deferral.test.ts and run in the normal suite.
|
||||
//
|
||||
// Usage: node config/scripts/cli-runtime-client-deferral-equivalence.mjs [--baseline <rev>]
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO = fileURLToPath(new URL('../..', import.meta.url))
|
||||
|
||||
// The files this change touches. Restoring exactly these from the baseline rev
|
||||
// reconstructs the old implementation without disturbing anything else.
|
||||
const TOUCHED = [
|
||||
'src/cli/args.ts',
|
||||
'src/cli/dispatch.ts',
|
||||
'src/cli/flags.ts',
|
||||
'src/cli/format.ts',
|
||||
'src/cli/index.ts',
|
||||
'src/cli/runtime/client.ts',
|
||||
'src/cli/selectors.ts'
|
||||
]
|
||||
|
||||
// Why: if the deferral is ever reverted or reshaped, the "old" arm would
|
||||
// silently become identical to the new one and every case would pass
|
||||
// vacuously. Re-read the real call form out of the source and fail loudly.
|
||||
function assertMarkersFresh() {
|
||||
const index = readFileSync(join(REPO, 'src/cli/index.ts'), 'utf8')
|
||||
const client = readFileSync(join(REPO, 'src/cli/runtime/client.ts'), 'utf8')
|
||||
const checks = [
|
||||
['src/cli/index.ts', index, 'await loadRuntimeClientClass()'],
|
||||
['src/cli/index.ts', index, "await import('./runtime-client.js')"],
|
||||
['src/cli/index.ts', index, "import type { RuntimeClient } from './runtime-client'"],
|
||||
['src/cli/runtime/client.ts', client, 'await loadSendWebSocketRequest()'],
|
||||
['src/cli/runtime/client.ts', client, "await import('./websocket-transport.js')"]
|
||||
]
|
||||
for (const [file, source, marker] of checks) {
|
||||
// Match the call form, not a bare word: a comment naming the function must
|
||||
// not satisfy the check.
|
||||
if (!source.includes(marker)) {
|
||||
throw new Error(
|
||||
`${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-equivalence.mjs is stale`
|
||||
)
|
||||
}
|
||||
}
|
||||
const repointed = ['src/cli/args.ts', 'src/cli/flags.ts', 'src/cli/dispatch.ts']
|
||||
for (const file of repointed) {
|
||||
const source = readFileSync(join(REPO, file), 'utf8')
|
||||
if (!source.includes("import { RuntimeClientError } from './runtime/types'")) {
|
||||
throw new Error(`${file} no longer repoints RuntimeClientError at ./runtime/types — stale`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildTree(label, baselineRev) {
|
||||
// Why: builds under /tmp cannot resolve the repo's node_modules, so the
|
||||
// output dir has to live inside the repo.
|
||||
const outDir = join(REPO, `.equiv-out-${label}`)
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
const restored = []
|
||||
try {
|
||||
if (baselineRev) {
|
||||
for (const file of TOUCHED) {
|
||||
const path = join(REPO, file)
|
||||
restored.push([path, readFileSync(path)])
|
||||
const old = execFileSync('git', ['show', `${baselineRev}:${file}`], {
|
||||
cwd: REPO,
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
writeFileSync(path, old)
|
||||
}
|
||||
}
|
||||
execFileSync(
|
||||
'npx',
|
||||
[
|
||||
'tsc',
|
||||
'-p',
|
||||
'config/tsconfig.cli.json',
|
||||
'--outDir',
|
||||
outDir,
|
||||
'--composite',
|
||||
'false',
|
||||
'--incremental',
|
||||
'false'
|
||||
],
|
||||
{ cwd: REPO, stdio: 'inherit' }
|
||||
)
|
||||
} finally {
|
||||
for (const [path, contents] of restored) {
|
||||
writeFileSync(path, contents)
|
||||
}
|
||||
}
|
||||
return join(outDir, 'cli/index.js')
|
||||
}
|
||||
|
||||
// Why: every case is a one-shot CLI invocation that must exit on its own. An
|
||||
// unbounded spawnSync turns "this argv reached a blocking command" into an
|
||||
// indefinite stall — a guard that can hang instead of failing is not a guard.
|
||||
const RUN_TIMEOUT_MS = 30_000
|
||||
|
||||
function run(entry, argv, env) {
|
||||
const result = spawnSync(process.execPath, [entry, ...argv], {
|
||||
cwd: REPO,
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'buffer',
|
||||
timeout: RUN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL'
|
||||
})
|
||||
if (result.error) {
|
||||
if (result.error.code === 'ETIMEDOUT') {
|
||||
throw new Error(
|
||||
`orca ${argv.join(' ')} did not exit within ${RUN_TIMEOUT_MS} ms — it reached a blocking command`
|
||||
)
|
||||
}
|
||||
throw result.error
|
||||
}
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout.toString('utf8'),
|
||||
stderr: result.stderr.toString('utf8')
|
||||
}
|
||||
}
|
||||
|
||||
// Every case must produce identical bytes on both arms. The runtime-dependent
|
||||
// ones (status/worktree list) are pointed at an empty user-data dir so the
|
||||
// answer is deterministic "not running" rather than whatever the dev machine
|
||||
// happens to be doing.
|
||||
function buildCases(isolatedUserData) {
|
||||
const isolated = { ORCA_USER_DATA_PATH: isolatedUserData }
|
||||
const cases = [
|
||||
// Paths that must never load the runtime client at all.
|
||||
[[], {}],
|
||||
[['--help'], {}],
|
||||
[['help'], {}],
|
||||
[['help', 'worktree'], {}],
|
||||
[['help', 'browser'], {}],
|
||||
[['worktree', '--help'], {}],
|
||||
[['help', 'no-such-command'], {}],
|
||||
[['no-such-command'], {}],
|
||||
[['no-such-command'], { ORCA_PAIRING_CODE: 'garbage' }],
|
||||
[['wrktree', 'list'], {}],
|
||||
[['agent-context'], {}],
|
||||
[['agent-context', '--json'], {}],
|
||||
// Flag validation must still fire before any runtime lookup.
|
||||
[['worktree', 'list', '--nonexistent-flag'], {}],
|
||||
[['worktree', 'list', '--nonexistent-flag', '--json'], {}],
|
||||
[['browser', 'snapshot', '--nonexistent-flag'], {}],
|
||||
// resolveRemotePairing throws from the RuntimeClient CONSTRUCTOR.
|
||||
[['status', '--pairing-code', 'x', '--environment', 'y'], isolated],
|
||||
[['status', '--pairing-code', 'x', '--environment', 'y', '--json'], isolated],
|
||||
[['status', '--pairing-code', 'not-a-pairing-code'], isolated],
|
||||
[['status', '--pairing-code', 'not-a-pairing-code', '--json'], isolated],
|
||||
[['status', '--pairing-code', 'orca://pair?code=zzzz'], isolated],
|
||||
[['status', '--environment', 'no-such-environment'], isolated],
|
||||
[['status', '--environment', 'no-such-environment', '--json'], isolated],
|
||||
[['worktree', 'list', '--environment', 'no-such-environment', '--json'], isolated],
|
||||
// The env-var fallback must stay live for non-suppressed commands...
|
||||
[['status', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
|
||||
[['status', '--json'], { ...isolated, ORCA_REMOTE_PAIRING: 'not-a-pairing-code' }],
|
||||
[['status', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }],
|
||||
// ...and must stay suppressed for the local-only command groups.
|
||||
//
|
||||
// NOTE: the only commands that both live in a suppressed group AND touch
|
||||
// ctx.client are `agent hooks on|off`, which rewrite the user's real agent
|
||||
// hook configuration in ~/.claude and friends — far outside
|
||||
// ORCA_USER_DATA_PATH. They are deliberately NOT invoked here. The
|
||||
// null-vs-undefined suppression they would exercise is covered
|
||||
// side-effect-free by the constructor-argument assertions in
|
||||
// src/cli/runtime-client-deferral.test.ts instead.
|
||||
[['environment', 'list', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }],
|
||||
[['environment', 'list', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
|
||||
[['agent-context', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
|
||||
// Runtime-unavailable reporting (RuntimeClientError formatting).
|
||||
[['status'], isolated],
|
||||
[['status', '--json'], isolated],
|
||||
[['worktree', 'list', '--json'], isolated],
|
||||
[['terminal', 'list', '--json'], isolated]
|
||||
]
|
||||
// Fuzz: random argv drawn from real command tokens, flags and hostile
|
||||
// strings. Seeded so a failure is reproducible. Every token here must be
|
||||
// safe to actually execute — see UNSAFE_TOKENS, which is cross-checked
|
||||
// against this list before a single case runs.
|
||||
const tokens = [
|
||||
'worktree',
|
||||
'list',
|
||||
'status',
|
||||
'browser',
|
||||
'snapshot',
|
||||
'terminal',
|
||||
'environment',
|
||||
'agent',
|
||||
'agent-context',
|
||||
'vm',
|
||||
'--json',
|
||||
'--help',
|
||||
'--pairing-code',
|
||||
'--environment',
|
||||
'--worktree',
|
||||
'orca://pair?code=!!!',
|
||||
'',
|
||||
'-',
|
||||
'--',
|
||||
'--=',
|
||||
'a'.repeat(300),
|
||||
'näme-ünicode',
|
||||
'💥',
|
||||
'../..',
|
||||
'x\ty'
|
||||
]
|
||||
let seed = 0x9e3779b9
|
||||
const next = () => {
|
||||
seed ^= seed << 13
|
||||
seed ^= seed >>> 17
|
||||
seed ^= seed << 5
|
||||
return (seed >>> 0) / 0x100000000
|
||||
}
|
||||
// Why: check the draw POOL, not just the 400 cases it happens to produce.
|
||||
// `serve` sat in this array for the whole review because the per-case scan
|
||||
// never named it, and the cases that drew it only survived by accident.
|
||||
assertTokensSafe(tokens, 'fuzz token pool')
|
||||
assertFuzzPoolDeclaredReadOnly(tokens)
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
const length = 1 + Math.floor(next() * 4)
|
||||
const argv = []
|
||||
for (let part = 0; part < length; part += 1) {
|
||||
argv.push(tokens[Math.floor(next() * tokens.length)])
|
||||
}
|
||||
cases.push([argv, isolated])
|
||||
}
|
||||
for (const [argv] of cases) {
|
||||
assertTokensSafe(argv, `argv ${JSON.stringify(argv)}`)
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
// Why: this script shells out to the REAL CLI with the developer's own HOME and
|
||||
// PATH, so an argv that reaches the wrong verb does real damage. Two classes:
|
||||
//
|
||||
// 1. FOREGROUND — `orca serve` runs Orca until Ctrl+C and `orca open` /
|
||||
// `claude-teams` spawn processes that outlive the case. A blocking case
|
||||
// does not fail the run, it stalls it, which is worse than a mismatch.
|
||||
// 2. MUTATING — writes outside ORCA_USER_DATA_PATH (`agent hooks off` parks
|
||||
// the real ~/.claude hooks) or drives real browser/desktop input.
|
||||
//
|
||||
// Group tokens whose subcommands split read/write (`capture`, `intercept`,
|
||||
// `label`, `relation`) are denied wholesale: the fuzzer cannot tell them apart.
|
||||
const FOREGROUND_TOKENS = ['serve', 'open', 'claude-teams', 'exec', 'eval', 'launch', 'attach']
|
||||
const MUTATING_TOKENS = [
|
||||
// persistent config and registry state
|
||||
'on',
|
||||
'off',
|
||||
'hooks',
|
||||
'create',
|
||||
'remove',
|
||||
'rm',
|
||||
'delete',
|
||||
'add',
|
||||
'edit',
|
||||
'set',
|
||||
'set-value',
|
||||
'set-base-ref',
|
||||
'setup-clone',
|
||||
'setup-create',
|
||||
'setup-update',
|
||||
'setup-delete',
|
||||
'setup-existing-folder',
|
||||
'install',
|
||||
'uninstall',
|
||||
'reinstall',
|
||||
'update',
|
||||
'clone',
|
||||
'apply',
|
||||
'write',
|
||||
'reset',
|
||||
'clear',
|
||||
'enable',
|
||||
'disable',
|
||||
'use-default',
|
||||
'permissions',
|
||||
// process and pane lifecycle
|
||||
'run',
|
||||
'run-stop',
|
||||
'start',
|
||||
'stop',
|
||||
'kill',
|
||||
'shutdown',
|
||||
'close',
|
||||
'split',
|
||||
'switch',
|
||||
'rename',
|
||||
'focus',
|
||||
// orchestration writes
|
||||
'send',
|
||||
'reply',
|
||||
'dispatch',
|
||||
'task-create',
|
||||
'task-update',
|
||||
'gate-create',
|
||||
'gate-resolve',
|
||||
// issue-tracker writes
|
||||
'save-issue',
|
||||
'comment',
|
||||
'label',
|
||||
'relation',
|
||||
'assignee',
|
||||
'priority',
|
||||
'estimate',
|
||||
'due-date',
|
||||
// browser / computer / emulator input and navigation
|
||||
'goto',
|
||||
'back',
|
||||
'forward',
|
||||
'reload',
|
||||
'click',
|
||||
'dblclick',
|
||||
'hover',
|
||||
'fill',
|
||||
'type',
|
||||
'type-text',
|
||||
'select',
|
||||
'select-all',
|
||||
'uncheck',
|
||||
'keypress',
|
||||
'press-key',
|
||||
'inserttext',
|
||||
'hotkey',
|
||||
'paste-text',
|
||||
'perform-secondary-action',
|
||||
'drag',
|
||||
'scroll',
|
||||
'scrollintoview',
|
||||
'wheel',
|
||||
'move',
|
||||
'up',
|
||||
'down',
|
||||
'tap',
|
||||
'gesture',
|
||||
'button',
|
||||
'rotate',
|
||||
'upload',
|
||||
'download',
|
||||
'dismiss',
|
||||
'accept',
|
||||
'highlight',
|
||||
'viewport',
|
||||
'geolocation',
|
||||
'headers',
|
||||
'credentials',
|
||||
'offline',
|
||||
'media',
|
||||
'device',
|
||||
'capture',
|
||||
'intercept'
|
||||
]
|
||||
|
||||
const UNSAFE_TOKENS = new Map([
|
||||
...FOREGROUND_TOKENS.map((token) => [token, 'runs in the foreground or spawns a process']),
|
||||
...MUTATING_TOKENS.map((token) => [token, 'can write outside ORCA_USER_DATA_PATH'])
|
||||
])
|
||||
|
||||
// Why: the deny list only catches verbs someone already thought of — `serve`
|
||||
// sat in the fuzz pool for the whole review because nobody added it. The pool
|
||||
// is therefore ALSO checked against this allowlist, so a token added to the
|
||||
// pool fails closed until it is consciously declared read-only here.
|
||||
const READ_ONLY_FUZZ_TOKENS = new Set([
|
||||
// command tokens: every path they can form is a list/show or a parse error
|
||||
'agent',
|
||||
'agent-context',
|
||||
'browser',
|
||||
'environment',
|
||||
'list',
|
||||
'snapshot',
|
||||
'status',
|
||||
'terminal',
|
||||
'vm',
|
||||
'worktree',
|
||||
// global flags and hostile strings, which reach no handler at all
|
||||
'--json',
|
||||
'--help',
|
||||
'--pairing-code',
|
||||
'--environment',
|
||||
'--worktree',
|
||||
'orca://pair?code=!!!',
|
||||
'',
|
||||
'-',
|
||||
'--',
|
||||
'--=',
|
||||
'a'.repeat(300),
|
||||
'näme-ünicode',
|
||||
'💥',
|
||||
'../..',
|
||||
'x\ty'
|
||||
])
|
||||
|
||||
function assertTokensSafe(tokens, context) {
|
||||
for (const token of tokens) {
|
||||
const reason = UNSAFE_TOKENS.get(token)
|
||||
if (reason) {
|
||||
throw new Error(`Refusing to run ${context}: "${token}" ${reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a token on neither list (or, worse, on both) means the two lists have
|
||||
// drifted apart. Fail before any case runs rather than sampling and hoping.
|
||||
function assertFuzzPoolDeclaredReadOnly(tokens) {
|
||||
for (const token of tokens) {
|
||||
if (!READ_ONLY_FUZZ_TOKENS.has(token)) {
|
||||
throw new Error(
|
||||
`Fuzz token ${JSON.stringify(token)} is not declared in READ_ONLY_FUZZ_TOKENS — declare it read-only or drop it`
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const token of READ_ONLY_FUZZ_TOKENS) {
|
||||
if (UNSAFE_TOKENS.has(token)) {
|
||||
throw new Error(
|
||||
`Token ${JSON.stringify(token)} is declared both read-only and unsafe — the two lists disagree`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baselineIndex = process.argv.indexOf('--baseline')
|
||||
const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1]
|
||||
|
||||
assertMarkersFresh()
|
||||
|
||||
const isolatedUserData = mkdtempSync(join(REPO, '.equiv-userdata-'))
|
||||
mkdirSync(join(isolatedUserData, 'empty'), { recursive: true })
|
||||
|
||||
let oldEntry
|
||||
let newEntry
|
||||
try {
|
||||
// Why: build the case list (and run its safety guards) BEFORE the two tsc
|
||||
// compiles, so an unsafe token fails in a second instead of two minutes in.
|
||||
const cases = buildCases(isolatedUserData)
|
||||
|
||||
console.log(`Building baseline (${baselineRev}) …`)
|
||||
oldEntry = buildTree('old', baselineRev)
|
||||
console.log('Building working tree …')
|
||||
newEntry = buildTree('new', null)
|
||||
|
||||
console.log(`Comparing ${cases.length} invocations byte for byte …`)
|
||||
let mismatches = 0
|
||||
for (const [argv, env] of cases) {
|
||||
const before = run(oldEntry, argv, env)
|
||||
const after = run(newEntry, argv, env)
|
||||
if (
|
||||
before.status !== after.status ||
|
||||
before.stdout !== after.stdout ||
|
||||
before.stderr !== after.stderr
|
||||
) {
|
||||
mismatches += 1
|
||||
console.error(`\nMISMATCH argv=${JSON.stringify(argv)} env=${JSON.stringify(env)}`)
|
||||
console.error(` exit before=${before.status} after=${after.status}`)
|
||||
if (before.stdout !== after.stdout) {
|
||||
console.error(` stdout before=${JSON.stringify(before.stdout.slice(0, 400))}`)
|
||||
console.error(` after =${JSON.stringify(after.stdout.slice(0, 400))}`)
|
||||
}
|
||||
if (before.stderr !== after.stderr) {
|
||||
console.error(` stderr before=${JSON.stringify(before.stderr.slice(0, 400))}`)
|
||||
console.error(` after =${JSON.stringify(after.stderr.slice(0, 400))}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mismatches > 0) {
|
||||
throw new Error(`${mismatches} of ${cases.length} invocations differ`)
|
||||
}
|
||||
console.log(`\nAll ${cases.length} invocations byte-identical (stdout, stderr, exit code).`)
|
||||
} finally {
|
||||
rmSync(isolatedUserData, { recursive: true, force: true })
|
||||
for (const label of ['old', 'new']) {
|
||||
rmSync(resolve(REPO, `.equiv-out-${label}`), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto'
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 250
|
||||
|
||||
function entryType(stat) {
|
||||
if (stat.isDirectory()) {
|
||||
return 'directory'
|
||||
}
|
||||
if (stat.isFile()) {
|
||||
return 'file'
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
return 'symlink'
|
||||
}
|
||||
if (stat.isSocket()) {
|
||||
return 'socket'
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
async function readEntry(absolutePath, relativePath, entries) {
|
||||
const stat = await lstat(absolutePath)
|
||||
const type = entryType(stat)
|
||||
entries.push({
|
||||
path: relativePath || '.',
|
||||
type,
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
ctimeMs: stat.ctimeMs,
|
||||
ino: stat.ino
|
||||
})
|
||||
// Why: primary-home symlinks may point at user data outside .codex. The
|
||||
// tripwire observes the link itself without traversing its target.
|
||||
if (type !== 'directory') {
|
||||
return
|
||||
}
|
||||
|
||||
const children = await readdir(absolutePath)
|
||||
children.sort((left, right) => left.localeCompare(right))
|
||||
for (const child of children) {
|
||||
await readEntry(
|
||||
path.join(absolutePath, child),
|
||||
relativePath ? path.join(relativePath, child) : child,
|
||||
entries
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function snapshotCodexHome(codexHome) {
|
||||
const resolvedHome = path.resolve(codexHome)
|
||||
const entries = []
|
||||
try {
|
||||
await readEntry(resolvedHome, '', entries)
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
entries.push({ path: '.', type: 'missing' })
|
||||
}
|
||||
const digest = createHash('sha256').update(JSON.stringify(entries)).digest('hex')
|
||||
return { codexHome: resolvedHome, digest, entryCount: entries.length, entries }
|
||||
}
|
||||
|
||||
export function describeCodexHomeChange(before, after) {
|
||||
if (before.digest === after.digest) {
|
||||
return null
|
||||
}
|
||||
const beforeByPath = new Map(before.entries.map((entry) => [entry.path, entry]))
|
||||
const afterByPath = new Map(after.entries.map((entry) => [entry.path, entry]))
|
||||
const changedPaths = [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])]
|
||||
.filter(
|
||||
(entryPath) =>
|
||||
JSON.stringify(beforeByPath.get(entryPath)) !== JSON.stringify(afterByPath.get(entryPath))
|
||||
)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
return {
|
||||
beforeDigest: before.digest,
|
||||
afterDigest: after.digest,
|
||||
changedPaths
|
||||
}
|
||||
}
|
||||
|
||||
// Why: with the real-home flag ON, system-default spawn sites deliberately
|
||||
// delete CODEX_HOME so native codex resolves the user's real ~/.codex — and on
|
||||
// Windows the binary ignores the USERPROFILE sandbox, so its own volatile
|
||||
// runtime churn (root sqlite/WAL/SHM, tmp/, log/) is designed behavior no env
|
||||
// sandbox can contain. Everything else — auth.json, config.toml,
|
||||
// .credentials.json, hooks.json, sessions/, any unknown path — stays a hard
|
||||
// containment violation.
|
||||
const DESIGNED_SYSTEM_DEFAULT_VOLATILE_PATTERN =
|
||||
/^(?:[^\\/]+\.sqlite(?:-wal|-shm|-journal)?|tmp(?:[\\/].*)?|log(?:[\\/].*)?)$/i
|
||||
|
||||
export function classifyCodexHomeTripwireEvent(event) {
|
||||
if (event.scanError || !Array.isArray(event.changedPaths)) {
|
||||
return 'violation'
|
||||
}
|
||||
// Why: the root directory's own mtime churns whenever codex adds/removes a
|
||||
// direct child (e.g. a WAL file); it carries no information beyond the
|
||||
// substantive paths, so it never decides the classification by itself.
|
||||
const substantive = event.changedPaths.filter((entryPath) => entryPath !== '.')
|
||||
if (substantive.length === 0) {
|
||||
return 'designed-system-default'
|
||||
}
|
||||
return substantive.every((entryPath) => DESIGNED_SYSTEM_DEFAULT_VOLATILE_PATTERN.test(entryPath))
|
||||
? 'designed-system-default'
|
||||
: 'violation'
|
||||
}
|
||||
|
||||
export async function startCodexPrimaryHomeTripwire(options = {}) {
|
||||
const primaryHome = path.resolve(options.primaryHome ?? os.homedir())
|
||||
const codexHome = path.join(primaryHome, '.codex')
|
||||
const intervalMs = Math.max(25, Number(options.intervalMs ?? DEFAULT_INTERVAL_MS))
|
||||
const initialSnapshot = await snapshotCodexHome(codexHome)
|
||||
let currentSnapshot = initialSnapshot
|
||||
let timer = null
|
||||
let scanInFlight = false
|
||||
let stopped = false
|
||||
const events = []
|
||||
|
||||
const scan = async () => {
|
||||
if (stopped || scanInFlight) {
|
||||
return
|
||||
}
|
||||
scanInFlight = true
|
||||
try {
|
||||
const nextSnapshot = await snapshotCodexHome(codexHome)
|
||||
const change = describeCodexHomeChange(currentSnapshot, nextSnapshot)
|
||||
if (change) {
|
||||
const event = { detectedAt: new Date().toISOString(), ...change }
|
||||
events.push(event)
|
||||
currentSnapshot = nextSnapshot
|
||||
await options.onChange?.(event)
|
||||
}
|
||||
} catch (error) {
|
||||
const event = {
|
||||
detectedAt: new Date().toISOString(),
|
||||
scanError: error instanceof Error ? error.message : String(error),
|
||||
changedPaths: []
|
||||
}
|
||||
events.push(event)
|
||||
await options.onChange?.(event)
|
||||
} finally {
|
||||
scanInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
timer = setInterval(() => void scan(), intervalMs)
|
||||
// Why: this safety monitor must remain active even when it is the CLI's only open handle.
|
||||
// Callers explicitly stop it after their validation window closes.
|
||||
return {
|
||||
codexHome,
|
||||
initialSnapshot,
|
||||
getStatus: () => ({ clean: events.length === 0, events: [...events] }),
|
||||
scan,
|
||||
async stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
while (scanInFlight) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
await scan()
|
||||
stopped = true
|
||||
return { clean: events.length === 0, events: [...events] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { primaryHome: os.homedir(), intervalMs: DEFAULT_INTERVAL_MS, once: false }
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg === '--primary-home') {
|
||||
const value = argv[index + 1]
|
||||
if (!value) {
|
||||
throw new Error('Missing value for --primary-home')
|
||||
}
|
||||
options.primaryHome = value
|
||||
index += 1
|
||||
} else if (arg === '--interval-ms') {
|
||||
const value = Number(argv[index + 1])
|
||||
if (!Number.isFinite(value) || value < 25) {
|
||||
throw new Error('Invalid --interval-ms')
|
||||
}
|
||||
options.intervalMs = value
|
||||
index += 1
|
||||
} else if (arg === '--once') {
|
||||
options.once = true
|
||||
} else if (arg === '--help') {
|
||||
console.log(
|
||||
'Usage: node config/scripts/codex-primary-home-tripwire.mjs [--primary-home <path>] [--interval-ms <n>] [--once]'
|
||||
)
|
||||
process.exit(0)
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.once) {
|
||||
const snapshot = await snapshotCodexHome(path.join(options.primaryHome, '.codex'))
|
||||
console.log(JSON.stringify({ ...snapshot, entries: undefined }, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
let failed = false
|
||||
const tripwire = await startCodexPrimaryHomeTripwire({
|
||||
...options,
|
||||
onChange: (event) => {
|
||||
failed = true
|
||||
console.error('\u001b[31;1m[CODEX HOME TRIPWIRE] PRIMARY ~/.codex CHANGED\u001b[0m')
|
||||
console.error(JSON.stringify(event, null, 2))
|
||||
}
|
||||
})
|
||||
console.log(
|
||||
`[CODEX HOME TRIPWIRE] Watching ${tripwire.codexHome}; press Ctrl+C to stop. No file contents are read.`
|
||||
)
|
||||
const stop = async () => {
|
||||
const status = await tripwire.stop()
|
||||
console.log(
|
||||
`[CODEX HOME TRIPWIRE] ${status.clean ? 'PASS: no changes' : 'FAIL: changes detected'}`
|
||||
)
|
||||
process.exit(failed || !status.clean ? 2 : 0)
|
||||
}
|
||||
process.once('SIGINT', () => void stop())
|
||||
process.once('SIGTERM', () => void stop())
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null
|
||||
if (invokedPath === import.meta.url) {
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const cleanupPaths: string[] = []
|
||||
const tripwireScriptPath = path.resolve('config/scripts/codex-primary-home-tripwire.mjs')
|
||||
const tripwireModuleUrl = pathToFileURL(tripwireScriptPath).href
|
||||
// Why: native Windows process startup can exceed two seconds under a loaded test worker.
|
||||
const STANDALONE_START_TIMEOUT_MS = 10_000
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
cleanupPaths.splice(0).map((cleanupPath) => rm(cleanupPath, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
async function createPrimaryHome(): Promise<string> {
|
||||
const primaryHome = await mkdtemp(path.join(os.tmpdir(), 'orca-codex-tripwire-'))
|
||||
cleanupPaths.push(primaryHome)
|
||||
await mkdir(path.join(primaryHome, '.codex'))
|
||||
return primaryHome
|
||||
}
|
||||
|
||||
describe('Codex primary-home tripwire', () => {
|
||||
it('keeps the standalone monitor alive until it is stopped', async () => {
|
||||
const primaryHome = await createPrimaryHome()
|
||||
const child = spawn(process.execPath, [tripwireScriptPath, '--primary-home', primaryHome], {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
try {
|
||||
await waitForStdout(child, '[CODEX HOME TRIPWIRE] Watching')
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(child.exitCode).toBeNull()
|
||||
} finally {
|
||||
child.kill()
|
||||
}
|
||||
})
|
||||
|
||||
it('detects a write without reading file contents', async () => {
|
||||
const primaryHome = await createPrimaryHome()
|
||||
const status = runTripwireModule<{
|
||||
clean: boolean
|
||||
events: { changedPaths: string[] }[]
|
||||
}>(
|
||||
`
|
||||
import path from 'node:path'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
const { startCodexPrimaryHomeTripwire } = await import(process.argv[1])
|
||||
const tripwire = await startCodexPrimaryHomeTripwire({
|
||||
primaryHome: process.argv[2],
|
||||
intervalMs: 25
|
||||
})
|
||||
await writeFile(path.join(process.argv[2], '.codex', 'hooks.json'), '{"secret":"not-read"}\\n')
|
||||
await tripwire.scan()
|
||||
console.log(JSON.stringify(await tripwire.stop()))
|
||||
`,
|
||||
[primaryHome]
|
||||
)
|
||||
expect(status.clean).toBe(false)
|
||||
expect(status.events[0].changedPaths).toContain('hooks.json')
|
||||
expect(JSON.stringify(status)).not.toContain('not-read')
|
||||
})
|
||||
|
||||
// Why: ordinary Windows CI tokens cannot create file symlinks without Developer Mode.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'does not follow symlinks outside the watched home',
|
||||
async () => {
|
||||
const primaryHome = await createPrimaryHome()
|
||||
const externalRoot = await mkdtemp(path.join(os.tmpdir(), 'orca-codex-tripwire-external-'))
|
||||
cleanupPaths.push(externalRoot)
|
||||
const externalFile = path.join(externalRoot, 'credential.txt')
|
||||
await writeFile(externalFile, 'before')
|
||||
await symlink(externalFile, path.join(primaryHome, '.codex', 'linked-credential'))
|
||||
const codexHome = path.join(primaryHome, '.codex')
|
||||
const before = snapshotCodexHome(codexHome)
|
||||
|
||||
await writeFile(externalFile, 'after-with-a-different-size')
|
||||
const after = snapshotCodexHome(codexHome)
|
||||
|
||||
expect(after.digest).toBe(before.digest)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function runTripwireModule<T>(source: string, args: string[]): T {
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '--eval', source, tripwireModuleUrl, ...args],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
return JSON.parse(stdout.trim()) as T
|
||||
}
|
||||
|
||||
function snapshotCodexHome(codexHome: string): { digest: string } {
|
||||
return runTripwireModule<{ digest: string }>(
|
||||
`
|
||||
const { snapshotCodexHome } = await import(process.argv[1])
|
||||
console.log(JSON.stringify(await snapshotCodexHome(process.argv[2])))
|
||||
`,
|
||||
[codexHome]
|
||||
)
|
||||
}
|
||||
|
||||
function waitForStdout(child: ReturnType<typeof spawn>, expectedText: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`Timed out waiting for ${expectedText}`)),
|
||||
STANDALONE_START_TIMEOUT_MS
|
||||
)
|
||||
let output = ''
|
||||
const stdout = child.stdout
|
||||
if (!stdout) {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error('Tripwire child stdout is unavailable'))
|
||||
return
|
||||
}
|
||||
stdout.setEncoding('utf8')
|
||||
stdout.on('data', (chunk) => {
|
||||
output += chunk
|
||||
if (output.includes(expectedText)) {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error(`Tripwire exited early with code ${code}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('lane-aware tripwire event classification', () => {
|
||||
it('separates designed system-default churn from containment violations', () => {
|
||||
const verdicts = runTripwireModule<string[]>(
|
||||
`
|
||||
const { classifyCodexHomeTripwireEvent } = await import(process.argv[1])
|
||||
const cases = [
|
||||
{ changedPaths: ['.', 'goals_1.sqlite', 'logs_2.sqlite-wal', 'state_5.sqlite-shm', 'memories_1.sqlite-journal', 'tmp', 'tmp/arg0', 'tmp\\\\arg0', 'log', 'log/codex-tui.log'] },
|
||||
{ changedPaths: ['.'] },
|
||||
{ changedPaths: ['.', 'auth.json'] },
|
||||
{ changedPaths: ['auth.json'] },
|
||||
{ changedPaths: ['config.toml'] },
|
||||
{ changedPaths: ['.credentials.json'] },
|
||||
{ changedPaths: ['hooks.json'] },
|
||||
{ changedPaths: ['sessions/2026/07/rollout-x.jsonl'] },
|
||||
{ changedPaths: ['skills/SKILL.md'] },
|
||||
{ changedPaths: ['unknown-file.bin'] },
|
||||
{ changedPaths: ['goals_1.sqlite', 'auth.json'] },
|
||||
{ changedPaths: ['sessions/index.sqlite-wal'] },
|
||||
{ scanError: 'boom', changedPaths: [] },
|
||||
{}
|
||||
]
|
||||
console.log(JSON.stringify(cases.map((tripwireEvent) => classifyCodexHomeTripwireEvent(tripwireEvent))))
|
||||
`,
|
||||
[]
|
||||
)
|
||||
expect(verdicts).toEqual([
|
||||
'designed-system-default',
|
||||
'designed-system-default',
|
||||
'violation',
|
||||
'violation',
|
||||
'violation',
|
||||
'violation',
|
||||
'violation',
|
||||
'violation',
|
||||
'violation',
|
||||
'violation',
|
||||
'violation',
|
||||
'violation',
|
||||
'violation',
|
||||
'violation'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const GRACEFUL_CLOSE_TIMEOUT_MS = 10_000
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 5_000
|
||||
const FORCE_KILL_WAIT_MS = 2_000
|
||||
|
||||
function delay(ms) {
|
||||
// Why: after Electron dies, this may be the standalone harness's only active
|
||||
// handle; keeping it referenced ensures daemon cleanup reaches completion.
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function hasExited(childProcess) {
|
||||
return childProcess.exitCode !== null || childProcess.signalCode !== null
|
||||
}
|
||||
|
||||
function waitForExit(childProcess, timeoutMs) {
|
||||
if (hasExited(childProcess)) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (exited) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
childProcess.off('exit', onExit)
|
||||
childProcess.off('close', onExit)
|
||||
resolve(exited)
|
||||
}
|
||||
const onExit = () => finish(true)
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs)
|
||||
childProcess.once('exit', onExit)
|
||||
childProcess.once('close', onExit)
|
||||
})
|
||||
}
|
||||
|
||||
async function withTimeout(promise, timeoutMs, message) {
|
||||
let timeout = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readPosixDescendantPids(rootPid) {
|
||||
try {
|
||||
const output = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8' })
|
||||
const childrenByParent = new Map()
|
||||
for (const line of output.split('\n')) {
|
||||
const [pidText, ppidText] = line.trim().split(/\s+/)
|
||||
const pid = Number(pidText)
|
||||
const ppid = Number(ppidText)
|
||||
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
|
||||
continue
|
||||
}
|
||||
const children = childrenByParent.get(ppid) ?? []
|
||||
children.push(pid)
|
||||
childrenByParent.set(ppid, children)
|
||||
}
|
||||
|
||||
const descendants = []
|
||||
const stack = [...(childrenByParent.get(rootPid) ?? [])]
|
||||
while (stack.length > 0) {
|
||||
const pid = stack.pop()
|
||||
if (!pid) {
|
||||
continue
|
||||
}
|
||||
descendants.push(pid)
|
||||
stack.push(...(childrenByParent.get(pid) ?? []))
|
||||
}
|
||||
return descendants
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function killPid(pid, signal) {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
} catch {
|
||||
/* already dead or inaccessible */
|
||||
}
|
||||
}
|
||||
|
||||
async function forceKillPidTree(pid) {
|
||||
if (!pid) {
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
} catch {
|
||||
/* already dead or taskkill unavailable */
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Why: a detached validation daemon can outlive Electron and retain access
|
||||
// to the disposable credential root unless the whole tree is captured first.
|
||||
const pids = [...readPosixDescendantPids(pid), pid]
|
||||
for (const targetPid of [...pids].toReversed()) {
|
||||
killPid(targetPid, 'SIGTERM')
|
||||
}
|
||||
await delay(FORCE_KILL_WAIT_MS)
|
||||
for (const targetPid of [...pids].toReversed()) {
|
||||
killPid(targetPid, 'SIGKILL')
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeValidationElectronApp(app) {
|
||||
if (!app) {
|
||||
return
|
||||
}
|
||||
|
||||
const childProcess = app.process()
|
||||
try {
|
||||
await withTimeout(
|
||||
app.close(),
|
||||
GRACEFUL_CLOSE_TIMEOUT_MS,
|
||||
'Timed out closing validation Electron app'
|
||||
)
|
||||
if (childProcess) {
|
||||
const exited = await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
|
||||
if (!exited) {
|
||||
await forceKillPidTree(childProcess.pid)
|
||||
await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (childProcess && !hasExited(childProcess)) {
|
||||
await forceKillPidTree(childProcess.pid)
|
||||
await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readValidationDaemonPids(userDataDir) {
|
||||
const daemonDir = path.join(userDataDir, 'daemon')
|
||||
if (!existsSync(daemonDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const pids = []
|
||||
for (const entry of readdirSync(daemonDir)) {
|
||||
if (!entry.endsWith('.pid')) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(path.join(daemonDir, entry), 'utf8').trim()
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed.pid === 'number' && Number.isInteger(parsed.pid)) {
|
||||
pids.push(parsed.pid)
|
||||
}
|
||||
} catch {
|
||||
const pid = Number(readFileSync(path.join(daemonDir, entry), 'utf8').trim())
|
||||
if (Number.isInteger(pid)) {
|
||||
pids.push(pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pids
|
||||
}
|
||||
|
||||
export async function cleanupValidationDaemons(userDataDir) {
|
||||
// Why: Orca intentionally keeps its daemon warm after app quit, but this
|
||||
// harness must never leave a process holding disposable account credentials.
|
||||
for (const pid of readValidationDaemonPids(userDataDir)) {
|
||||
await forceKillPidTree(pid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: cost of resolving a Command Code turn prompt from the transcript,
|
||||
// paid on EVERY command-code hook event (PreToolUse/PostToolUse fire once per
|
||||
// tool call, so many per second during an active agent turn).
|
||||
//
|
||||
// Before the fix, readLastCommandCodeUserPromptEntryFromTranscript() read up to
|
||||
// TRANSCRIPT_MAX_SCAN_BYTES (4 MB) synchronously, decoded it all to a JS string,
|
||||
// and JSON-parsed EVERY line to the end of the buffer to find the LAST user
|
||||
// entry — so cost grew with the transcript, which only grows as a session runs.
|
||||
//
|
||||
// The fix scans backward from EOF in TRANSCRIPT_CHUNK_BYTES blocks and returns
|
||||
// on the first user line, the shape the sibling readLastTextFromTranscriptOnce
|
||||
// already used. The answer sits near EOF in a real session (the current turn's
|
||||
// prompt precedes only this turn's output), so the scan reads one or two blocks
|
||||
// instead of the whole file.
|
||||
//
|
||||
// Both implementations are mirrored here: node cannot import the .ts source,
|
||||
// matching the other benchmarks in this directory. Constants are re-read from
|
||||
// the real module so a drifted cap fails loudly instead of measuring dead code.
|
||||
import {
|
||||
closeSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const LISTENER_SOURCE = readFileSync(
|
||||
fileURLToPath(new URL('../../src/shared/agent-hook-listener.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function readMirroredConstant(name) {
|
||||
const match = LISTENER_SOURCE.match(new RegExp(`const ${name} = ([^\\n]+)`))
|
||||
if (!match) {
|
||||
throw new Error(`agent-hook-listener.ts no longer defines ${name}; re-sync this benchmark.`)
|
||||
}
|
||||
const value = Number(new Function(`return (${match[1]})`)())
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} did not resolve to a positive integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const TRANSCRIPT_CHUNK_BYTES = readMirroredConstant('TRANSCRIPT_CHUNK_BYTES')
|
||||
const TRANSCRIPT_MAX_SCAN_BYTES = readMirroredConstant('TRANSCRIPT_MAX_SCAN_BYTES')
|
||||
const EMPTY_REGION = Buffer.alloc(0)
|
||||
const ITERATIONS = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_ITERATIONS ?? '150', 10)
|
||||
const WARMUP = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_WARMUP ?? '20', 10)
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_CC_SCAN_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_CC_SCAN_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror of parseAgentHookJson: the real reader scans a line's structure before
|
||||
// parsing it, on BOTH sides of this comparison. Omitting it made the pre-fix
|
||||
// column ~9x too fast and invented a regression that does not exist.
|
||||
const HOOK_STRUCTURAL_TOKENS = 128 * 1024
|
||||
const HOOK_NESTING_DEPTH = 64
|
||||
|
||||
function assertJsonStructure(content) {
|
||||
let structuralTokens = 0
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const character = content[index]
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
} else if (character === '\\') {
|
||||
escaped = true
|
||||
} else if (character === '"') {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (character === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
if (
|
||||
character !== '{' &&
|
||||
character !== '}' &&
|
||||
character !== '[' &&
|
||||
character !== ']' &&
|
||||
character !== ',' &&
|
||||
character !== ':'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
structuralTokens += 1
|
||||
if (structuralTokens > HOOK_STRUCTURAL_TOKENS) {
|
||||
throw new Error('structuralTokens')
|
||||
}
|
||||
if (character === '{' || character === '[') {
|
||||
depth += 1
|
||||
if (depth > HOOK_NESTING_DEPTH) {
|
||||
throw new Error('nestingDepth')
|
||||
}
|
||||
} else if (character === '}' || character === ']') {
|
||||
depth = Math.max(0, depth - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractUserPrompt(line) {
|
||||
let entry
|
||||
try {
|
||||
assertJsonStructure(line)
|
||||
entry = JSON.parse(line)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (typeof entry !== 'object' || entry === null || entry.role !== 'user') {
|
||||
return undefined
|
||||
}
|
||||
const content = entry.content
|
||||
if (typeof content === 'string' && content.trim().length > 0) {
|
||||
return content
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (typeof part === 'object' && part !== null) {
|
||||
const text = part.text
|
||||
if (typeof text === 'string' && text.trim().length > 0) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Pre-fix: read the capped window, then parse every line to the end.
|
||||
function readForward(path) {
|
||||
const size = statSync(path).size
|
||||
if (size <= 0) {
|
||||
return undefined
|
||||
}
|
||||
const bytesToRead = Math.min(size, TRANSCRIPT_MAX_SCAN_BYTES)
|
||||
const position = size - bytesToRead
|
||||
const fd = openSync(path, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(bytesToRead)
|
||||
let filled = 0
|
||||
while (filled < bytesToRead) {
|
||||
const n = readSync(fd, buffer, filled, bytesToRead - filled, position + filled)
|
||||
if (n === 0) {
|
||||
break
|
||||
}
|
||||
filled += n
|
||||
}
|
||||
let text = buffer.subarray(0, filled).toString('utf8')
|
||||
if (position > 0) {
|
||||
const firstNewline = text.indexOf('\n')
|
||||
text = firstNewline === -1 ? '' : text.slice(firstNewline + 1)
|
||||
}
|
||||
let last
|
||||
for (const line of text.split('\n')) {
|
||||
const prompt = extractUserPrompt(line.trim())
|
||||
if (prompt !== undefined) {
|
||||
last = prompt
|
||||
}
|
||||
}
|
||||
return last
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function findLastPromptInRegion(region) {
|
||||
let lineEnd = region.length
|
||||
for (let index = region.length - 1; index >= -1; index--) {
|
||||
if (index >= 0 && region[index] !== 0x0a) {
|
||||
continue
|
||||
}
|
||||
const lineStart = index + 1
|
||||
if (lineEnd > lineStart) {
|
||||
const prompt = extractUserPrompt(region.subarray(lineStart, lineEnd).toString('utf8').trim())
|
||||
if (prompt !== undefined) {
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
lineEnd = index
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Post-fix: walk backward from EOF, return on the first user line. The carry is
|
||||
// a chunk list, not a re-joined buffer, so one oversized line stays linear.
|
||||
function readBackward(path) {
|
||||
const size = statSync(path).size
|
||||
if (size <= 0) {
|
||||
return undefined
|
||||
}
|
||||
const fd = openSync(path, 'r')
|
||||
try {
|
||||
let carryChunks = []
|
||||
let bytesRead = 0
|
||||
let scanEnd = size
|
||||
while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) {
|
||||
const chunkSize = Math.min(
|
||||
scanEnd,
|
||||
TRANSCRIPT_CHUNK_BYTES,
|
||||
TRANSCRIPT_MAX_SCAN_BYTES - bytesRead
|
||||
)
|
||||
const position = scanEnd - chunkSize
|
||||
const buffer = Buffer.alloc(chunkSize)
|
||||
let filled = 0
|
||||
while (filled < chunkSize) {
|
||||
const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled)
|
||||
if (n === 0) {
|
||||
break
|
||||
}
|
||||
filled += n
|
||||
}
|
||||
if (filled < chunkSize) {
|
||||
break
|
||||
}
|
||||
bytesRead += filled
|
||||
scanEnd = position
|
||||
const firstNewline = buffer.indexOf(0x0a)
|
||||
const atStart = position === 0
|
||||
let completeRegion
|
||||
if (atStart) {
|
||||
completeRegion = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
|
||||
carryChunks = []
|
||||
} else if (firstNewline === -1) {
|
||||
completeRegion = EMPTY_REGION
|
||||
carryChunks.unshift(buffer)
|
||||
} else {
|
||||
const afterNewline = buffer.subarray(firstNewline + 1)
|
||||
completeRegion =
|
||||
carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks])
|
||||
carryChunks = [buffer.subarray(0, firstNewline)]
|
||||
}
|
||||
if (completeRegion.length > 0) {
|
||||
const found = findLastPromptInRegion(completeRegion)
|
||||
if (found !== undefined) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
// A real session: many completed turns, then THIS turn's prompt, then the tool
|
||||
// output produced since. The prompt therefore sits near EOF.
|
||||
function writeTranscript(path, priorTurns) {
|
||||
const lines = []
|
||||
for (let index = 0; index < priorTurns; index += 1) {
|
||||
lines.push(
|
||||
JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] })
|
||||
)
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }]
|
||||
})
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] })
|
||||
)
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }]
|
||||
})
|
||||
)
|
||||
}
|
||||
writeFileSync(path, `${lines.join('\n')}\n`)
|
||||
}
|
||||
|
||||
// A turn already in progress: `trailingBytes` of tool output sits between the
|
||||
// prompt and EOF, which is what the backward scan has to read past.
|
||||
function writeTranscriptWithTrailing(path, priorTurns, trailingBytes) {
|
||||
const lines = []
|
||||
for (let index = 0; index < priorTurns; index += 1) {
|
||||
lines.push(
|
||||
JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] })
|
||||
)
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }]
|
||||
})
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] })
|
||||
)
|
||||
let written = 0
|
||||
let index = 0
|
||||
while (written < trailingBytes) {
|
||||
const line = JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }]
|
||||
})
|
||||
lines.push(line)
|
||||
written += line.length + 1
|
||||
index += 1
|
||||
}
|
||||
writeFileSync(path, `${lines.join('\n')}\n`)
|
||||
}
|
||||
|
||||
// One tool result larger than many read blocks — the shape with no newline for
|
||||
// the backward scan to stop on.
|
||||
function writeTranscriptWithHugeLine(path, lineBytes) {
|
||||
const lines = [
|
||||
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] }),
|
||||
JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'x'.repeat(lineBytes) }] })
|
||||
]
|
||||
writeFileSync(path, `${lines.join('\n')}\n`)
|
||||
}
|
||||
|
||||
function measure(fn, path) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
fn(path)
|
||||
}
|
||||
const samples = []
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
fn(path)
|
||||
}
|
||||
samples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
samples.sort((a, b) => a - b)
|
||||
return samples[1]
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-cc-transcript-bench-'))
|
||||
try {
|
||||
const rows = []
|
||||
for (const priorTurns of [250, 1000, 3000, 6000]) {
|
||||
const path = join(dir, `transcript-${priorTurns}.jsonl`)
|
||||
writeTranscript(path, priorTurns)
|
||||
const forward = readForward(path)
|
||||
const backward = readBackward(path)
|
||||
if (forward !== backward) {
|
||||
throw new Error(`prompt mismatch at ${priorTurns} prior turns: ${forward} vs ${backward}`)
|
||||
}
|
||||
if (backward !== 'the current prompt') {
|
||||
throw new Error(`benchmark fixture resolved the wrong prompt: ${backward}`)
|
||||
}
|
||||
rows.push({
|
||||
sizeMb: statSync(path).size / (1024 * 1024),
|
||||
beforeMs: measure(readForward, path),
|
||||
afterMs: measure(readBackward, path)
|
||||
})
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Command Code transcript prompt read, per hook event')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 3 rounds)`)
|
||||
console.log(
|
||||
`${pad('size', 9)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}`
|
||||
)
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
`${pad(`${row.sizeMb.toFixed(2)} MB`, 9)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nThe old cost grows with the transcript; the new cost is flat because the\ncurrent turn’s prompt sits near EOF and the scan stops at the first hit.'
|
||||
)
|
||||
|
||||
// Worst cases, reported even where the ratio is below 1x. The new cost scales
|
||||
// with bytes-AFTER the prompt, so a long turn (many tool calls since the ask)
|
||||
// and a single oversized tool result are where the win decays or inverts.
|
||||
const worst = []
|
||||
for (const trailingKb of [32, 256, 1024, 3072]) {
|
||||
const path = join(dir, `trailing-${trailingKb}.jsonl`)
|
||||
writeTranscriptWithTrailing(path, 1500, trailingKb * 1024)
|
||||
if (readForward(path) !== readBackward(path)) {
|
||||
throw new Error(`prompt mismatch at trailing ${trailingKb} KB`)
|
||||
}
|
||||
worst.push({
|
||||
label: `${(trailingKb / 1024).toFixed(2)} MB after prompt`,
|
||||
beforeMs: measure(readForward, path),
|
||||
afterMs: measure(readBackward, path)
|
||||
})
|
||||
}
|
||||
const hugePath = join(dir, 'huge-line.jsonl')
|
||||
writeTranscriptWithHugeLine(hugePath, 3 * 1024 * 1024)
|
||||
if (readForward(hugePath) !== readBackward(hugePath)) {
|
||||
throw new Error('prompt mismatch on the oversized-line fixture')
|
||||
}
|
||||
worst.push({
|
||||
label: '3 MB single line',
|
||||
beforeMs: measure(readForward, hugePath),
|
||||
afterMs: measure(readBackward, hugePath)
|
||||
})
|
||||
|
||||
console.log('\nWorst cases (win decays as a turn progresses; <1x means slower):')
|
||||
console.log(
|
||||
`${pad('case', 22)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('ratio', 9)}`
|
||||
)
|
||||
for (const row of worst) {
|
||||
console.log(
|
||||
`${pad(row.label, 22)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nThe win shrinks toward parity as output accumulates after the prompt, since\nthe backward scan has to read past all of it. The single-line row is the floor:\nno newline to stop on, so the scan reads the line in blocks and joins once where\nthe old code issued one flat read. Both sides pay the same per-line structure\nscan, and the carry is a chunk list, so cost stays linear either way.'
|
||||
)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -3,11 +3,15 @@ import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const skillPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
|
||||
// Why: computer-use now ships a hybrid discovery stub, so its version-sensitive command
|
||||
// guidance lives in the authoritative guide source — assert that content there. The
|
||||
// installable stub projection is checked separately below.
|
||||
const guidePath = join(projectDir, 'skill-guides', 'computer-use.md')
|
||||
const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
|
||||
|
||||
describe('computer-use skill guidance', () => {
|
||||
it('keeps web-app targeting on the computer-use surface', () => {
|
||||
const skill = readFileSync(skillPath, 'utf8')
|
||||
const skill = readFileSync(guidePath, 'utf8')
|
||||
|
||||
expect(skill).toContain('Use this skill for desktop UI through `orca computer`')
|
||||
expect(skill).toContain('operate the desktop browser app/window that contains the page')
|
||||
@@ -19,7 +23,7 @@ describe('computer-use skill guidance', () => {
|
||||
})
|
||||
|
||||
it('warns agents to verify browser-hosted form focus before drafting text', () => {
|
||||
const skill = readFileSync(skillPath, 'utf8')
|
||||
const skill = readFileSync(guidePath, 'utf8')
|
||||
|
||||
expect(skill).toContain('For browser-hosted forms such as Gmail compose')
|
||||
expect(skill).toContain('verify the focused UI element after each field action')
|
||||
@@ -27,7 +31,7 @@ describe('computer-use skill guidance', () => {
|
||||
})
|
||||
|
||||
it('warns agents about occluded Linux and Windows screenshots', () => {
|
||||
const skill = readFileSync(skillPath, 'utf8')
|
||||
const skill = readFileSync(guidePath, 'utf8')
|
||||
|
||||
expect(skill).toContain('On Linux and Windows')
|
||||
expect(skill).toContain('use `--restore-window` so another window does not cover')
|
||||
@@ -35,9 +39,50 @@ describe('computer-use skill guidance', () => {
|
||||
})
|
||||
|
||||
it('points JSON users to the public accessibility-tree field', () => {
|
||||
const skill = readFileSync(skillPath, 'utf8')
|
||||
const skill = readFileSync(guidePath, 'utf8')
|
||||
|
||||
expect(skill).toContain('`result.snapshot.treeText`')
|
||||
expect(skill).not.toContain('`result.elements`')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computer-use install stub', () => {
|
||||
it('points at the version-matched guide and preserves the safe resolver', () => {
|
||||
const stub = readFileSync(stubPath, 'utf8')
|
||||
|
||||
expect(stub).toContain('discovery stub')
|
||||
expect(stub).toContain('ORCA skills get computer-use')
|
||||
// The safe CLI-resolution contract must survive in the stub, never a bare `orca`.
|
||||
expect(stub).toContain('ORCA_CLI_COMMAND')
|
||||
expect(stub).toContain('orca-dev')
|
||||
expect(stub).toContain('orca-ide')
|
||||
expect(stub).toContain('GNOME Orca screen reader')
|
||||
expect(stub).not.toMatch(/^orca /mu)
|
||||
})
|
||||
|
||||
it('gives older binaries a bounded fallback instead of a dead end', () => {
|
||||
const stub = readFileSync(stubPath, 'utf8').replace(/\s+/gu, ' ')
|
||||
|
||||
expect(stub).toContain('explicitly reports that `skills get` is an unknown command')
|
||||
expect(stub).toContain('do not invent commands')
|
||||
expect(stub).toContain('ask the user rather than guessing')
|
||||
})
|
||||
|
||||
it('drops the changing command reference from the installable file', () => {
|
||||
const stub = readFileSync(stubPath, 'utf8')
|
||||
const guide = readFileSync(guidePath, 'utf8')
|
||||
|
||||
// Version-sensitive command detail lives in the binary-served guide now, not here.
|
||||
expect(stub).not.toContain('result.snapshot.treeText')
|
||||
expect(stub).not.toContain('--restore-window')
|
||||
expect(stub.length).toBeLessThan(guide.length)
|
||||
})
|
||||
|
||||
it('keeps the routing frontmatter identical to the guide', () => {
|
||||
const frontmatter = (text) => /^---\n[\s\S]*?\n---\n/u.exec(text)[0]
|
||||
|
||||
expect(frontmatter(readFileSync(stubPath, 'utf8'))).toBe(
|
||||
frontmatter(readFileSync(guidePath, 'utf8'))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,10 +36,11 @@ function log(message) {
|
||||
// Why: the daemon rejects a hello whose protocol version differs, so read the
|
||||
// current version from source rather than hardcoding a number that can drift.
|
||||
function readProtocolVersion() {
|
||||
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
|
||||
const protocolSourcePath = 'src/main/daemon/daemon-protocol-version.ts'
|
||||
const source = readFileSync(join(projectDir, protocolSourcePath), 'utf8')
|
||||
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
|
||||
if (!match) {
|
||||
throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts')
|
||||
throw new Error(`could not read PROTOCOL_VERSION from ${protocolSourcePath}`)
|
||||
}
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
function escapeWindowsBatchValue(value) {
|
||||
// Why: cmd.exe expands %NAME% even inside quotes, so literal path percent signs must be doubled.
|
||||
return value.replaceAll('%', '%%')
|
||||
}
|
||||
|
||||
export function prepareDevCliTerminalWrappers({
|
||||
repoRoot,
|
||||
userDataPath,
|
||||
electronExecutable,
|
||||
platform = process.platform
|
||||
}) {
|
||||
const binDir = path.join(repoRoot, 'out', 'bin')
|
||||
const userDataBinDir = path.join(userDataPath, 'cli', 'bin')
|
||||
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
mkdirSync(userDataBinDir, { recursive: true })
|
||||
|
||||
if (platform === 'win32') {
|
||||
const wrapperContent = `@echo off\r\nset "ORCA_USER_DATA_PATH=${escapeWindowsBatchValue(userDataPath)}"\r\nset "ORCA_DEV_CLI_INVOCATION=1"\r\nset "ORCA_APP_EXECUTABLE=${escapeWindowsBatchValue(electronExecutable)}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${escapeWindowsBatchValue(cliPath)}" %*\r\n`
|
||||
for (const targetDir of [binDir, userDataBinDir]) {
|
||||
for (const commandName of ['orca-dev.cmd', 'orca.cmd']) {
|
||||
writeFileSync(path.join(targetDir, commandName), wrapperContent, 'utf8')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_DEV_CLI_INVOCATION=1\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronExecutable)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`
|
||||
for (const targetDir of [binDir, userDataBinDir]) {
|
||||
for (const commandName of ['orca-dev', 'orca']) {
|
||||
const wrapperPath = path.join(targetDir, commandName)
|
||||
writeFileSync(wrapperPath, wrapperContent, 'utf8')
|
||||
chmodSync(wrapperPath, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { binDir, userDataBinDir }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
|
||||
|
||||
describe('dev CLI terminal wrappers', () => {
|
||||
it('writes profile-scoped Windows wrappers for worker terminals', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-'))
|
||||
const userDataPath = path.join(root, 'profile')
|
||||
prepareDevCliTerminalWrappers({
|
||||
repoRoot: root,
|
||||
userDataPath,
|
||||
electronExecutable: path.join(root, 'electron.exe'),
|
||||
platform: 'win32'
|
||||
})
|
||||
|
||||
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev.cmd'), 'utf8')
|
||||
expect(wrapper).toContain(`set "ORCA_USER_DATA_PATH=${userDataPath}"`)
|
||||
expect(wrapper).toContain('set "ORCA_DEV_CLI_INVOCATION=1"')
|
||||
expect(wrapper).toContain(`node "${path.join(root, 'out', 'cli', 'index.js')}" %*`)
|
||||
expect(readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev.cmd'), 'utf8')).toBe(wrapper)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
|
||||
})
|
||||
|
||||
it('escapes literal percent signs in every Windows batch path', () => {
|
||||
const root = path.join(mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-')), '%repo%')
|
||||
const userDataPath = path.join(root, '%profile%')
|
||||
const electronExecutable = path.join(root, '%electron%', 'electron.exe')
|
||||
prepareDevCliTerminalWrappers({
|
||||
repoRoot: root,
|
||||
userDataPath,
|
||||
electronExecutable,
|
||||
platform: 'win32'
|
||||
})
|
||||
|
||||
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev.cmd'), 'utf8')
|
||||
expect(wrapper).toContain(`set "ORCA_USER_DATA_PATH=${userDataPath.replaceAll('%', '%%')}"`)
|
||||
expect(wrapper).toContain(
|
||||
`set "ORCA_APP_EXECUTABLE=${electronExecutable.replaceAll('%', '%%')}"`
|
||||
)
|
||||
expect(wrapper).toContain(
|
||||
`node "${path.join(root, 'out', 'cli', 'index.js').replaceAll('%', '%%')}" %*`
|
||||
)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev.cmd'), 'utf8')).toBe(wrapper)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
|
||||
})
|
||||
|
||||
it('writes executable-style POSIX wrappers with the same profile identity', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-'))
|
||||
const userDataPath = path.join(root, 'profile')
|
||||
prepareDevCliTerminalWrappers({
|
||||
repoRoot: root,
|
||||
userDataPath,
|
||||
electronExecutable: path.join(root, 'electron'),
|
||||
platform: 'linux'
|
||||
})
|
||||
|
||||
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev'), 'utf8')
|
||||
expect(wrapper).toContain(`export ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}`)
|
||||
expect(wrapper).toContain('export ORCA_DEV_CLI_INVOCATION=1')
|
||||
expect(wrapper).toContain(
|
||||
`exec node ${JSON.stringify(path.join(root, 'out', 'cli', 'index.js'))}`
|
||||
)
|
||||
expect(readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca'), 'utf8')).toBe(wrapper)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev'), 'utf8')).toBe(wrapper)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca'), 'utf8')).toBe(wrapper)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const electronBuilderConfig = require('../electron-builder.config.cjs')
|
||||
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
|
||||
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
|
||||
const {
|
||||
createPackagedRuntimeNodeModuleResources,
|
||||
@@ -19,6 +20,12 @@ const {
|
||||
} = require('../packaged-runtime-node-modules.cjs')
|
||||
|
||||
describe('electron-builder config', () => {
|
||||
it('keeps the packaged app identity aligned with local-build validation', () => {
|
||||
expect(electronBuilderConfig.appId).toBe(
|
||||
require('../../src/shared/local-build-compatibility-contract.json').appId
|
||||
)
|
||||
})
|
||||
|
||||
it('excludes repo-only source trees from app.asar', () => {
|
||||
expect(electronBuilderConfig.files).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -28,15 +35,57 @@ describe('electron-builder config', () => {
|
||||
'!mobile{,/**/*}',
|
||||
'!native{,/**/*}',
|
||||
'!skills{,/**/*}',
|
||||
'!skill-guides{,/**/*}',
|
||||
'!skill-stubs{,/**/*}',
|
||||
'!resources/skills/**',
|
||||
'!tests{,/**/*}',
|
||||
'!examples{,/**/*}',
|
||||
'!pr-evidence{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
'!out/**/*.test.js'
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
|
||||
'!out/**/*.test.js',
|
||||
'!resources/plugins/launch/**'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
// Why: `files` is an all-negation list, so electron-builder's default `**/*` packs
|
||||
// anything without an explicit `!` entry — examples/ landed without one and shipped
|
||||
// hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar.
|
||||
// Drive the real matcher: pinning the pattern string cannot prove it excludes the tree.
|
||||
it('keeps plugin authoring examples out of app.asar', () => {
|
||||
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
|
||||
// copyFiles() prepends this itself once the pattern list is all-negation.
|
||||
matcher.prependPattern('**/*')
|
||||
const isPacked = matcher.createFilter()
|
||||
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
|
||||
|
||||
for (const authoringOnly of [
|
||||
'examples/plugins/hostile-panel/panel.html',
|
||||
'examples/plugins/hostile-panel/orca-plugin.json',
|
||||
'examples/plugins/hello-orca/main.mjs',
|
||||
'examples/plugins/hello-orca/orca-plugin.json'
|
||||
]) {
|
||||
expect(packs(authoringOnly)).toBe(false)
|
||||
}
|
||||
// The negation stays anchored at the app root, so nested `examples` segments still ship.
|
||||
expect(packs('out/main/examples/index.js')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps runtime resources available through extraResources', () => {
|
||||
const bundledPluginResources = expect.objectContaining({
|
||||
from: 'resources/plugins/launch',
|
||||
to: 'plugins/launch'
|
||||
})
|
||||
for (const platform of ['mac', 'linux', 'win']) {
|
||||
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
|
||||
from: 'resources/skills',
|
||||
to: 'skills'
|
||||
})
|
||||
expect(electronBuilderConfig[platform].extraResources).toEqual(
|
||||
expect.arrayContaining([bundledPluginResources])
|
||||
)
|
||||
}
|
||||
expect(electronBuilderConfig.mac.extraResources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -67,6 +116,26 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the Windows CLI shim is delivered only via extraResources to
|
||||
// resources/bin/orca.cmd (beside the native resources/bin/orca.exe). If the
|
||||
// source tree is also packed into app.asar it gets extracted by
|
||||
// asarUnpack:['resources/**'] to app.asar.unpacked/resources/win32/bin/orca.cmd,
|
||||
// a duplicate with no adjacent orca.exe that fails to launch (#7351).
|
||||
it('keeps the Windows CLI shim source tree out of app.asar', () => {
|
||||
expect(electronBuilderConfig.files).toEqual(
|
||||
expect.arrayContaining(['!resources/win32{,/**/*}'])
|
||||
)
|
||||
// Regression guard: the working shim must still ship via extraResources.
|
||||
expect(electronBuilderConfig.win.extraResources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
from: 'resources/win32/bin/orca.cmd',
|
||||
to: 'bin/orca.cmd'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
|
||||
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
|
||||
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
|
||||
@@ -97,6 +166,14 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
|
||||
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
|
||||
it('unpacks the forked main-thread hang-watchdog entry', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the multi-size icon source for Linux packages', () => {
|
||||
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
|
||||
})
|
||||
@@ -135,6 +212,58 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('overrides packaged semver only for local macOS builds', () => {
|
||||
const configPath = require.resolve('../electron-builder.config.cjs')
|
||||
const original = process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const originalMacRelease = process.env.ORCA_MAC_RELEASE
|
||||
try {
|
||||
delete require.cache[configPath]
|
||||
delete process.env.ORCA_MAC_RELEASE
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-rc.0.local.123.abc'
|
||||
expect(require('../electron-builder.config.cjs').extraMetadata).toEqual({
|
||||
version: '1.4.159-rc.0.local.123.abc'
|
||||
})
|
||||
} finally {
|
||||
if (originalMacRelease === undefined) {
|
||||
delete process.env.ORCA_MAC_RELEASE
|
||||
} else {
|
||||
process.env.ORCA_MAC_RELEASE = originalMacRelease
|
||||
}
|
||||
if (original === undefined) {
|
||||
delete process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
} else {
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = original
|
||||
}
|
||||
delete require.cache[configPath]
|
||||
require('../electron-builder.config.cjs')
|
||||
}
|
||||
})
|
||||
|
||||
it('never applies local semver to release packaging', () => {
|
||||
const configPath = require.resolve('../electron-builder.config.cjs')
|
||||
const originalLocalVersion = process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const originalMacRelease = process.env.ORCA_MAC_RELEASE
|
||||
try {
|
||||
delete require.cache[configPath]
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-local.123.abc'
|
||||
process.env.ORCA_MAC_RELEASE = '1'
|
||||
expect(require('../electron-builder.config.cjs').extraMetadata).toBeUndefined()
|
||||
} finally {
|
||||
if (originalLocalVersion === undefined) {
|
||||
delete process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
} else {
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = originalLocalVersion
|
||||
}
|
||||
if (originalMacRelease === undefined) {
|
||||
delete process.env.ORCA_MAC_RELEASE
|
||||
} else {
|
||||
process.env.ORCA_MAC_RELEASE = originalMacRelease
|
||||
}
|
||||
delete require.cache[configPath]
|
||||
require('../electron-builder.config.cjs')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
|
||||
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
|
||||
expect(electronBuilderConfig.npmRebuild).toBe(true)
|
||||
@@ -356,6 +485,11 @@ describe('electron-builder config', () => {
|
||||
const resourcesDir = join(root, 'linux-unpacked', 'resources')
|
||||
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
|
||||
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
|
||||
await cp(
|
||||
join(process.cwd(), 'resources', 'plugins', 'launch'),
|
||||
join(resourcesDir, 'plugins', 'launch'),
|
||||
{ recursive: true }
|
||||
)
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
|
||||
// Why: afterPack now fails hard when the unpacked daemon entry is
|
||||
// missing, so the fixture must carry one like a real package layout.
|
||||
|
||||
@@ -7,8 +7,8 @@ function electronBuilderNativeRebuild(context) {
|
||||
return runElectronBuilderNativeRebuild(context)
|
||||
}
|
||||
|
||||
function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
|
||||
const args = buildNativeRebuildArgs(context)
|
||||
function runElectronBuilderNativeRebuild(context, runner = execFileSync, runtime = {}) {
|
||||
const args = buildNativeRebuildArgs(context, runtime)
|
||||
if (readPlatformName(context?.platform) === 'win32') {
|
||||
runner(process.execPath, ['config/scripts/build-windows-cli-launcher.mjs'], {
|
||||
cwd: projectDir,
|
||||
@@ -25,15 +25,22 @@ function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
|
||||
return false
|
||||
}
|
||||
|
||||
function buildNativeRebuildArgs(context) {
|
||||
function buildNativeRebuildArgs(
|
||||
context,
|
||||
{ environment = process.env, hostPlatform = process.platform, hostArch = process.arch } = {}
|
||||
) {
|
||||
const platform = readPlatformName(context?.platform)
|
||||
const arch = readArchName(context?.arch)
|
||||
const canReusePreparedRuntime =
|
||||
environment.ORCA_REUSE_PREPARED_NATIVE_RUNTIME === '1' &&
|
||||
platform === hostPlatform &&
|
||||
arch === hostArch
|
||||
|
||||
return [
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
`--platform=${platform}`,
|
||||
`--arch=${arch}`,
|
||||
'--force'
|
||||
...(canReusePreparedRuntime ? [] : ['--force'])
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,33 @@ describe('electron-builder native rebuild hook', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reuses a prepared native runtime only for the host target', () => {
|
||||
const runtime = {
|
||||
environment: { ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' },
|
||||
hostPlatform: 'linux',
|
||||
hostArch: 'x64'
|
||||
}
|
||||
|
||||
expect(
|
||||
buildNativeRebuildArgs(
|
||||
{
|
||||
platform: { nodeName: 'linux' },
|
||||
arch: 'x64'
|
||||
},
|
||||
runtime
|
||||
)
|
||||
).toEqual(['config/scripts/rebuild-native-deps.mjs', '--platform=linux', '--arch=x64'])
|
||||
expect(
|
||||
buildNativeRebuildArgs(
|
||||
{
|
||||
platform: { nodeName: 'linux' },
|
||||
arch: 'arm64'
|
||||
},
|
||||
runtime
|
||||
)
|
||||
).toContain('--force')
|
||||
})
|
||||
|
||||
it('builds the native CLI launcher before packaging Windows resources', () => {
|
||||
const calls = []
|
||||
const result = runElectronBuilderNativeRebuild(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { electronViteConfig } from '../../electron.vite.config'
|
||||
|
||||
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
|
||||
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
|
||||
|
||||
describe('Electron Vite output contract', () => {
|
||||
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
|
||||
const output = electronViteConfig.main?.build?.rollupOptions?.output
|
||||
if (!output || Array.isArray(output)) {
|
||||
throw new Error('Expected one main-process output')
|
||||
}
|
||||
|
||||
expect(output.format).toBe('cjs')
|
||||
expect(output.entryFileNames).toBe('[name].js')
|
||||
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
|
||||
})
|
||||
|
||||
it('externalizes packaged dependencies but bundles the daemon xterm graph', () => {
|
||||
const external = electronViteConfig.main?.build?.rollupOptions?.external
|
||||
if (typeof external !== 'function') {
|
||||
throw new Error('Expected main-process external predicate')
|
||||
}
|
||||
|
||||
expect(external('node-pty', undefined, false)).toBe(true)
|
||||
expect(external('@parcel/watcher', undefined, false)).toBe(true)
|
||||
expect(external('electron', undefined, false)).toBe(true)
|
||||
expect(external('node:fs', undefined, false)).toBe(true)
|
||||
expect(external('@xterm/headless', undefined, false)).toBe(false)
|
||||
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
|
||||
})
|
||||
|
||||
it('isolates renderer entry side effects behind strict facades', () => {
|
||||
expect(electronViteConfig.renderer?.build?.rollupOptions?.preserveEntrySignatures).toBe(
|
||||
'strict'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects prototype properties as build targets', () => {
|
||||
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
|
||||
})
|
||||
|
||||
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
|
||||
expect(devRunner).toContain('const helperBundleId = `${bundleId}.helper`')
|
||||
expect(devRunner).toContain("'Electron Helper.app',")
|
||||
expect(devRunner).toContain(
|
||||
"setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,11 @@ const scriptPath = import.meta.filename
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const runtime = readRuntimeArg()
|
||||
|
||||
const NATIVE_MODULES = ['node-pty']
|
||||
const NATIVE_MODULES = [
|
||||
'node-pty',
|
||||
...(process.platform === 'win32' ? ['windows-native-registry'] : [])
|
||||
]
|
||||
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
|
||||
const CHILD_CHECK_FLAG = '--check-only'
|
||||
|
||||
if (process.argv.includes(CHILD_CHECK_FLAG)) {
|
||||
@@ -240,6 +244,12 @@ function collectNativeModuleFailures() {
|
||||
}
|
||||
|
||||
function loadNativeModule(moduleName) {
|
||||
if (moduleName === 'windows-native-registry') {
|
||||
const registry = require(moduleName)
|
||||
// Why: the package defers loading its .node addon until the first registry call.
|
||||
registry.getRegistryKey(registry.HK.CU, 'Environment')
|
||||
return
|
||||
}
|
||||
if (moduleName === 'node-pty') {
|
||||
loadNodePtyNativeModule()
|
||||
return
|
||||
@@ -256,13 +266,27 @@ function loadNodePtyNativeModule() {
|
||||
// Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a
|
||||
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
|
||||
const native = loadNativeModule(nativeName)
|
||||
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native.dir)) {
|
||||
assertNodePtyWindowsConptyRuntime(native?.dir)
|
||||
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`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNodePtyWindowsConptyRuntime(nativeDir) {
|
||||
if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) {
|
||||
return
|
||||
}
|
||||
const runtimeDir = resolve(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty')
|
||||
const missingFile = NODE_PTY_CONPTY_RUNTIME_FILES.find(
|
||||
(filename) => !existsSync(resolve(runtimeDir, filename))
|
||||
)
|
||||
if (missingFile) {
|
||||
throw new Error(`node-pty ConPTY runtime file is missing: ${resolve(runtimeDir, missingFile)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function getNodePtyNativeModuleName() {
|
||||
if (process.platform !== 'win32') {
|
||||
return 'pty'
|
||||
|
||||
@@ -193,6 +193,7 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
}
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
}
|
||||
|
||||
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
|
||||
@@ -214,6 +215,19 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
}
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
}
|
||||
|
||||
function writeFakeWindowsRegistry(projectDir) {
|
||||
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'
|
||||
)
|
||||
}
|
||||
|
||||
function writeNodePtyPatchFile(projectDir) {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { constants } from 'node:fs'
|
||||
import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const SCRIPT_DIR = import.meta.dirname
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
|
||||
|
||||
const CANONICAL_GUIDE_NAMES = [
|
||||
'computer-use',
|
||||
'linear-tickets',
|
||||
'orca-cli',
|
||||
'orca-emulator',
|
||||
'orca-emulator-android',
|
||||
'orca-linear',
|
||||
'orca-per-workspace-env',
|
||||
'orchestration'
|
||||
]
|
||||
|
||||
// Why: old discovery stubs can outlive a rename indefinitely, so aliases are
|
||||
// a compatibility ledger: add entries for renames, but never remove them.
|
||||
const GUIDE_ALIASES = {
|
||||
'computer-use': [],
|
||||
'linear-tickets': [],
|
||||
'orca-cli': [],
|
||||
'orca-emulator': [],
|
||||
'orca-emulator-android': [],
|
||||
'orca-linear': [],
|
||||
'orca-per-workspace-env': [],
|
||||
orchestration: []
|
||||
}
|
||||
|
||||
// Why: a stubbed topic ships a hybrid discovery stub as its installable projection while
|
||||
// `orca skills get <topic>` still serves the full version-matched guide from the binary.
|
||||
// Migrating a topic here is effectively one-way — earlier fat installs rely on the stub
|
||||
// landing to converge — so entries are added as skills convert, never removed. The stub
|
||||
// body lives in skill-stubs/<topic>.md; the projection reuses the guide's own frontmatter.
|
||||
const STUB_TOPICS = [
|
||||
'computer-use',
|
||||
'linear-tickets',
|
||||
'orca-cli',
|
||||
'orca-emulator',
|
||||
'orca-emulator-android',
|
||||
'orca-linear',
|
||||
'orca-per-workspace-env',
|
||||
'orchestration'
|
||||
]
|
||||
|
||||
function normalizeMarkdown(markdown) {
|
||||
return markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
}
|
||||
|
||||
function parseFrontmatter(markdown, sourcePath) {
|
||||
const normalized = normalizeMarkdown(markdown)
|
||||
const match = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(normalized)
|
||||
if (!match) {
|
||||
throw new Error(`Guide source has no YAML frontmatter: ${sourcePath}`)
|
||||
}
|
||||
let values
|
||||
try {
|
||||
values = parse(match[1])
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Guide source has invalid YAML frontmatter: ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
!values ||
|
||||
typeof values !== 'object' ||
|
||||
typeof values.name !== 'string' ||
|
||||
typeof values.description !== 'string'
|
||||
) {
|
||||
throw new Error(`Guide source must declare name and description: ${sourcePath}`)
|
||||
}
|
||||
return {
|
||||
name: values.name,
|
||||
description: values.description.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function frontmatterBlock(markdown, sourcePath) {
|
||||
const normalized = normalizeMarkdown(markdown)
|
||||
const match = /^---[ \t]*\n[\s\S]*?\n---[ \t]*\n/.exec(normalized)
|
||||
if (!match) {
|
||||
throw new Error(`Guide source has no YAML frontmatter block: ${sourcePath}`)
|
||||
}
|
||||
return match[0]
|
||||
}
|
||||
|
||||
// Why: the stub's routing frontmatter (name + description) must stay byte-identical to the
|
||||
// guide's — it is the unchanged discovery surface — so we reuse the guide's own block and
|
||||
// replace only the body. Body normalized to LF with exactly one trailing newline.
|
||||
function composeStubProjection(guideMarkdown, stubBody, sourcePath) {
|
||||
const block = frontmatterBlock(guideMarkdown, sourcePath)
|
||||
const body = normalizeMarkdown(stubBody).replace(/^\n+/, '').replace(/\n*$/, '\n')
|
||||
return `${block}\n${body}`
|
||||
}
|
||||
|
||||
function constantName(name) {
|
||||
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
|
||||
}
|
||||
|
||||
function serializeEmbeddedModule(guides) {
|
||||
const markdownConstants = guides
|
||||
.map(
|
||||
(guide) =>
|
||||
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
|
||||
)
|
||||
.join('\n\n')
|
||||
const guideEntries = guides
|
||||
.map((guide) => {
|
||||
const markdownConstant = constantName(guide.name)
|
||||
return [
|
||||
' {',
|
||||
` name: ${JSON.stringify(guide.name)},`,
|
||||
` description: ${JSON.stringify(guide.description)},`,
|
||||
` markdown: ${markdownConstant},`,
|
||||
` fullMarkdown: ${markdownConstant},`,
|
||||
` aliases: ${JSON.stringify(guide.aliases)}`,
|
||||
' }'
|
||||
].join('\n')
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n}\n\n${markdownConstants}\n\n// Why: no current guide has bundled reference documents, so --full is byte-identical for now.\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
|
||||
}
|
||||
|
||||
function assertAliasContract(guides) {
|
||||
const canonicalNames = new Set(guides.map((guide) => guide.name))
|
||||
const seenAliases = new Set()
|
||||
for (const guide of guides) {
|
||||
for (const alias of guide.aliases) {
|
||||
if (canonicalNames.has(alias)) {
|
||||
throw new Error(`Guide alias collides with canonical name: ${alias}`)
|
||||
}
|
||||
if (seenAliases.has(alias)) {
|
||||
throw new Error(`Guide alias is assigned more than once: ${alias}`)
|
||||
}
|
||||
seenAliases.add(alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertStubSourcesMatchTopics(repoRoot) {
|
||||
const stubRoot = path.join(repoRoot, 'skill-stubs')
|
||||
let names = []
|
||||
try {
|
||||
names = (await readdir(stubRoot, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
||||
.map((entry) => entry.name.slice(0, -3))
|
||||
} catch (error) {
|
||||
// Why: a repo state with no stubbed topics yet has no skill-stubs directory at all.
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const found = names.sort((left, right) => left.localeCompare(right, 'en'))
|
||||
const expected = [...STUB_TOPICS].sort((left, right) => left.localeCompare(right, 'en'))
|
||||
if (JSON.stringify(found) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`skill-stubs sources must match STUB_TOPICS.\nExpected: ${expected.join(', ') || '(none)'}\nFound: ${found.join(', ') || '(none)'}`
|
||||
)
|
||||
}
|
||||
for (const name of STUB_TOPICS) {
|
||||
if (!CANONICAL_GUIDE_NAMES.includes(name)) {
|
||||
throw new Error(`Stub topic is not a canonical guide: ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildArtifacts(repoRoot = REPO_ROOT) {
|
||||
const guideRoot = path.join(repoRoot, 'skill-guides')
|
||||
const sourceFiles = (await readdir(guideRoot, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
||||
.map((entry) => entry.name.slice(0, -3))
|
||||
.sort((left, right) => left.localeCompare(right, 'en'))
|
||||
const expectedNames = [...CANONICAL_GUIDE_NAMES].sort((left, right) =>
|
||||
left.localeCompare(right, 'en')
|
||||
)
|
||||
if (JSON.stringify(sourceFiles) !== JSON.stringify(expectedNames)) {
|
||||
throw new Error(
|
||||
`Guide sources must match the canonical topic list.\nExpected: ${expectedNames.join(', ')}\nFound: ${sourceFiles.join(', ')}`
|
||||
)
|
||||
}
|
||||
await assertStubSourcesMatchTopics(repoRoot)
|
||||
|
||||
const stubTopics = new Set(STUB_TOPICS)
|
||||
const guides = []
|
||||
const projections = []
|
||||
for (const name of expectedNames) {
|
||||
const sourcePath = path.join(guideRoot, `${name}.md`)
|
||||
// Why: Git may render text with native EOLs despite repository policy; the
|
||||
// embedded guide and generated projection must have one platform-neutral identity.
|
||||
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
|
||||
const frontmatter = parseFrontmatter(markdown, path.relative(repoRoot, sourcePath))
|
||||
if (frontmatter.name !== name) {
|
||||
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
|
||||
}
|
||||
const aliases = GUIDE_ALIASES[name]
|
||||
// Why: the embedded table always carries the full guide (served by `skills get`);
|
||||
// only the installable projection thins to a stub once a topic is in STUB_TOPICS.
|
||||
guides.push({ name, description: frontmatter.description, markdown, aliases })
|
||||
const stubPath = path.join(repoRoot, 'skill-stubs', `${name}.md`)
|
||||
const content = stubTopics.has(name)
|
||||
? composeStubProjection(markdown, await readFile(stubPath, 'utf8'), `skill-stubs/${name}.md`)
|
||||
: markdown
|
||||
projections.push({
|
||||
path: path.join(repoRoot, 'skills', name, 'SKILL.md'),
|
||||
content
|
||||
})
|
||||
}
|
||||
assertAliasContract(guides)
|
||||
|
||||
return [
|
||||
{
|
||||
path: path.join(repoRoot, 'src', 'cli', 'bundled-skill-guides.ts'),
|
||||
content: serializeEmbeddedModule(guides)
|
||||
},
|
||||
...projections
|
||||
]
|
||||
}
|
||||
|
||||
async function writeArtifacts(artifacts) {
|
||||
for (const artifact of artifacts) {
|
||||
await mkdir(path.dirname(artifact.path), { recursive: true })
|
||||
await writeFile(artifact.path, artifact.content, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyArtifacts(artifacts, repoRoot = REPO_ROOT) {
|
||||
const stale = []
|
||||
for (const artifact of artifacts) {
|
||||
try {
|
||||
await access(artifact.path, constants.R_OK)
|
||||
if ((await readFile(artifact.path, 'utf8')) !== artifact.content) {
|
||||
stale.push(artifact.path)
|
||||
}
|
||||
} catch {
|
||||
stale.push(artifact.path)
|
||||
}
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
throw new Error(
|
||||
`Generated bundled skill guides are stale:\n${stale
|
||||
.map((filePath) => path.relative(repoRoot, filePath))
|
||||
.join('\n')}\nRun node config/scripts/generate-bundled-skill-guides.mjs --write.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const artifacts = await buildArtifacts()
|
||||
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
CANONICAL_GUIDE_NAMES,
|
||||
GUIDE_ALIASES,
|
||||
STUB_TOPICS,
|
||||
assertAliasContract,
|
||||
buildArtifacts,
|
||||
composeStubProjection,
|
||||
frontmatterBlock,
|
||||
normalizeMarkdown,
|
||||
parseFrontmatter,
|
||||
serializeEmbeddedModule,
|
||||
verifyArtifacts,
|
||||
writeArtifacts
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
|
||||
import {
|
||||
CANONICAL_GUIDE_NAMES,
|
||||
GUIDE_ALIASES,
|
||||
STUB_TOPICS,
|
||||
assertAliasContract,
|
||||
buildArtifacts,
|
||||
frontmatterBlock,
|
||||
normalizeMarkdown,
|
||||
parseFrontmatter,
|
||||
verifyArtifacts,
|
||||
writeArtifacts
|
||||
} from './generate-bundled-skill-guides.mjs'
|
||||
|
||||
const projectDir = path.resolve(import.meta.dirname, '..', '..')
|
||||
const temporaryDirectories = []
|
||||
|
||||
async function createFixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
|
||||
temporaryDirectories.push(root)
|
||||
await Promise.all([
|
||||
cp(path.join(projectDir, 'skill-guides'), path.join(root, 'skill-guides'), {
|
||||
recursive: true
|
||||
}),
|
||||
cp(path.join(projectDir, 'skill-stubs'), path.join(root, 'skill-stubs'), {
|
||||
recursive: true
|
||||
}),
|
||||
cp(path.join(projectDir, 'skills'), path.join(root, 'skills'), { recursive: true }),
|
||||
mkdir(path.join(root, 'src', 'cli'), { recursive: true })
|
||||
])
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('bundled skill guide generator', () => {
|
||||
it('keeps every fat (non-stub) projection byte-identical to its authoritative source', async () => {
|
||||
for (const name of CANONICAL_GUIDE_NAMES) {
|
||||
if (STUB_TOPICS.includes(name)) {
|
||||
continue
|
||||
}
|
||||
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`))
|
||||
const projection = await readFile(path.join(projectDir, 'skills', name, 'SKILL.md'))
|
||||
expect(projection, name).toEqual(source)
|
||||
}
|
||||
})
|
||||
|
||||
it('projects stub topics as hybrid discovery stubs that reuse the guide frontmatter', async () => {
|
||||
expect(STUB_TOPICS.length).toBeGreaterThan(0)
|
||||
for (const name of STUB_TOPICS) {
|
||||
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`), 'utf8')
|
||||
const projection = await readFile(path.join(projectDir, 'skills', name, 'SKILL.md'), 'utf8')
|
||||
|
||||
// The routing frontmatter is the unchanged discovery surface.
|
||||
expect(projection.startsWith(frontmatterBlock(source, `${name}.md`))).toBe(true)
|
||||
// The stub is a thin hybrid pointer, not the full guide.
|
||||
expect(projection).not.toEqual(source)
|
||||
expect(projection.length).toBeLessThan(source.length)
|
||||
expect(projection).toContain('discovery stub')
|
||||
expect(projection).toContain(`skills get ${name}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps pre-guide fallback useful and read-only for every converted domain', async () => {
|
||||
const expectedFallbackCommands = {
|
||||
'computer-use': ['ORCA computer capabilities --json', 'ORCA computer list-apps --json'],
|
||||
'linear-tickets': ['ORCA linear --help', 'ORCA linear issue --current --full --json'],
|
||||
'orca-emulator': ['ORCA emulator list --json'],
|
||||
'orca-emulator-android': ['ORCA emulator devices --json'],
|
||||
'orca-linear': ['ORCA linear --help', 'ORCA linear issue --current --full --json'],
|
||||
'orca-per-workspace-env': ['ORCA vm recipe doctor <recipe-id> --repo-path <repo> --json'],
|
||||
orchestration: ['ORCA orchestration task-list --json', 'ORCA terminal list --json']
|
||||
}
|
||||
|
||||
for (const [name, commands] of Object.entries(expectedFallbackCommands)) {
|
||||
const stub = await readFile(path.join(projectDir, 'skill-stubs', `${name}.md`), 'utf8')
|
||||
const fallback = stub.split('## If an older Orca does not recognize `skills get`')[1]
|
||||
|
||||
expect(fallback, name).toBeDefined()
|
||||
for (const command of commands) {
|
||||
expect(fallback, name).toContain(command)
|
||||
}
|
||||
expect(fallback, name).not.toContain('ORCA worktree ps --json')
|
||||
}
|
||||
})
|
||||
|
||||
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
|
||||
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
|
||||
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
|
||||
)
|
||||
|
||||
for (const guide of BUNDLED_SKILL_GUIDES) {
|
||||
const source = await readFile(
|
||||
path.join(projectDir, 'skill-guides', `${guide.name}.md`),
|
||||
'utf8'
|
||||
)
|
||||
const frontmatter = parseFrontmatter(source, `${guide.name}.md`)
|
||||
expect(guide.description).toBe(frontmatter.description)
|
||||
expect(guide.markdown).toBe(source)
|
||||
expect(guide.fullMarkdown).toBe(source)
|
||||
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps CLI guide examples safe across shells and Linux command names', async () => {
|
||||
for (const name of ['orca-cli', 'computer-use', 'orca-emulator', 'orca-emulator-android']) {
|
||||
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`), 'utf8')
|
||||
|
||||
expect(source).toContain('ORCA_CLI_COMMAND')
|
||||
expect(source).toContain('orca-dev')
|
||||
expect(source).toContain('orca-ide')
|
||||
expect(source).toContain('PowerShell')
|
||||
expect(source).toContain('cmd.exe')
|
||||
expect(source).toMatch(/^ORCA .+--json$/mu)
|
||||
// Why: bare command lines can launch GNOME Orca, while shell variables make
|
||||
// the same guide unusable from PowerShell and cmd.exe.
|
||||
expect(source).not.toMatch(/^orca /mu)
|
||||
expect(source).not.toMatch(/\$ORCA(?:_|\b)/u)
|
||||
}
|
||||
})
|
||||
|
||||
it('builds deterministic artifacts and verifies the checked-in outputs', async () => {
|
||||
const first = await buildArtifacts(projectDir)
|
||||
const second = await buildArtifacts(projectDir)
|
||||
|
||||
expect(second).toEqual(first)
|
||||
await expect(verifyArtifacts(first, projectDir)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('generates platform-identical output from CRLF guide sources', async () => {
|
||||
const expected = await buildArtifacts(projectDir)
|
||||
const root = await createFixture()
|
||||
for (const name of CANONICAL_GUIDE_NAMES) {
|
||||
const sourcePath = path.join(root, 'skill-guides', `${name}.md`)
|
||||
const source = await readFile(sourcePath, 'utf8')
|
||||
await writeFile(sourcePath, source.replaceAll('\n', '\r\n'))
|
||||
}
|
||||
for (const name of STUB_TOPICS) {
|
||||
const stubPath = path.join(root, 'skill-stubs', `${name}.md`)
|
||||
const stubSource = await readFile(stubPath, 'utf8')
|
||||
await writeFile(stubPath, stubSource.replaceAll('\n', '\r\n'))
|
||||
}
|
||||
|
||||
const actual = await buildArtifacts(root)
|
||||
expect(actual.map((artifact) => artifact.content)).toEqual(
|
||||
expected.map((artifact) => artifact.content)
|
||||
)
|
||||
})
|
||||
|
||||
it('pins guide sources, projections, and embedded output to LF in Git', async () => {
|
||||
const attributes = await readFile(path.join(projectDir, '.gitattributes'), 'utf8')
|
||||
expect(normalizeMarkdown(attributes)).toContain('/skill-guides/*.md text eol=lf\n')
|
||||
expect(normalizeMarkdown(attributes)).toContain('/skill-stubs/*.md text eol=lf\n')
|
||||
expect(normalizeMarkdown(attributes)).toContain('/skills/*/SKILL.md text eol=lf\n')
|
||||
expect(normalizeMarkdown(attributes)).toContain(
|
||||
'/src/cli/bundled-skill-guides.ts text eol=lf\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports stale outputs and write mode repairs all projections', async () => {
|
||||
const root = await createFixture()
|
||||
const artifacts = await buildArtifacts(root)
|
||||
|
||||
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow(
|
||||
'src/cli/bundled-skill-guides.ts'
|
||||
)
|
||||
await writeArtifacts(artifacts)
|
||||
await expect(verifyArtifacts(artifacts, root)).resolves.toBeUndefined()
|
||||
|
||||
await writeFile(path.join(root, 'skills', 'computer-use', 'SKILL.md'), 'stale\n')
|
||||
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow('skills/computer-use/SKILL.md')
|
||||
})
|
||||
|
||||
it('rejects mismatched source names and ambiguous aliases', async () => {
|
||||
const root = await createFixture()
|
||||
await writeFile(
|
||||
path.join(root, 'skill-guides', 'computer-use.md'),
|
||||
'---\nname: wrong\ndescription: present\n---\n'
|
||||
)
|
||||
await expect(buildArtifacts(root)).rejects.toThrow('declares mismatched name wrong')
|
||||
|
||||
expect(() =>
|
||||
assertAliasContract([
|
||||
{ name: 'first', aliases: ['legacy'] },
|
||||
{ name: 'second', aliases: ['legacy'] }
|
||||
])
|
||||
).toThrow('assigned more than once')
|
||||
expect(() =>
|
||||
assertAliasContract([
|
||||
{ name: 'first', aliases: ['second'] },
|
||||
{ name: 'second', aliases: [] }
|
||||
])
|
||||
).toThrow('collides with canonical name')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,689 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { constants } from 'node:fs'
|
||||
import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
|
||||
// Why: the three artifacts version independently — bumping one shape must not
|
||||
// rewrite the others or bypass the registry's schema-gated append-only guard.
|
||||
const CURRENT_MANIFEST_SCHEMA_VERSION = 2
|
||||
const SNAPSHOT_REGISTRY_SCHEMA_VERSION = 1
|
||||
const RELEASE_MAPPING_SCHEMA_VERSION = 1
|
||||
const SCRIPT_DIR = import.meta.dirname
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
|
||||
const SKILLS_ROOT = path.join(REPO_ROOT, 'skills')
|
||||
const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills')
|
||||
const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json')
|
||||
const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json')
|
||||
const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json')
|
||||
// Why: the manifest and registry are content-addressed — they describe skill
|
||||
// bytes. The mapping is provenance: which already-committed revision a tag
|
||||
// shipped. A release cut may append the second without regenerating the first.
|
||||
const CONTENT_ADDRESSED_PATHS = [CURRENT_MANIFEST_PATH, SNAPSHOT_REGISTRY_PATH]
|
||||
const ALL_ARTIFACT_PATHS = [...CONTENT_ADDRESSED_PATHS, RELEASE_MAPPING_PATH]
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function compareCodeUnits(left, right) {
|
||||
return left === right ? 0 : left < right ? -1 : 1
|
||||
}
|
||||
|
||||
function gitObjectSha(kind, bytes) {
|
||||
return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest()
|
||||
}
|
||||
|
||||
function normalizeText(bytes) {
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
|
||||
}
|
||||
|
||||
function classifyFile(bytes) {
|
||||
if (bytes.includes(0)) {
|
||||
return 'binary'
|
||||
}
|
||||
try {
|
||||
normalizeText(bytes)
|
||||
return 'text'
|
||||
} catch {
|
||||
return 'binary'
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeRelativePath(relativePath) {
|
||||
if (
|
||||
path.isAbsolute(relativePath) ||
|
||||
relativePath === '..' ||
|
||||
relativePath.startsWith(`..${path.sep}`)
|
||||
) {
|
||||
throw new Error(`Unsafe skill package path: ${relativePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
function describeFile(manifestPath, bytes, executable) {
|
||||
const classification = classifyFile(bytes)
|
||||
const exactSha256 = sha256(bytes)
|
||||
const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null
|
||||
return {
|
||||
path: manifestPath,
|
||||
size: bytes.length,
|
||||
executable,
|
||||
classification,
|
||||
exactSha256,
|
||||
textNormalizedSha256,
|
||||
identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256,
|
||||
gitBlobSha: gitObjectSha('blob', bytes).toString('hex')
|
||||
}
|
||||
}
|
||||
|
||||
function gitTreeSha(entries) {
|
||||
const root = { directories: new Map(), files: [] }
|
||||
for (const entry of entries) {
|
||||
const parts = entry.path.split('/')
|
||||
const filename = parts.pop()
|
||||
let directory = root
|
||||
for (const part of parts) {
|
||||
let child = directory.directories.get(part)
|
||||
if (!child) {
|
||||
child = { directories: new Map(), files: [] }
|
||||
directory.directories.set(part, child)
|
||||
}
|
||||
directory = child
|
||||
}
|
||||
directory.files.push({ filename, ...entry })
|
||||
}
|
||||
|
||||
function hashDirectory(directory) {
|
||||
const children = [
|
||||
...[...directory.directories].map(([name, child]) => ({
|
||||
mode: '40000',
|
||||
name,
|
||||
hash: hashDirectory(child)
|
||||
})),
|
||||
...directory.files.map((file) => ({
|
||||
mode: file.executable ? '100755' : '100644',
|
||||
name: file.filename,
|
||||
hash: Buffer.from(file.gitBlobSha, 'hex')
|
||||
}))
|
||||
].sort((left, right) => {
|
||||
const leftName = left.mode === '40000' ? `${left.name}/` : left.name
|
||||
const rightName = right.mode === '40000' ? `${right.name}/` : right.name
|
||||
return Buffer.from(leftName).compare(Buffer.from(rightName))
|
||||
})
|
||||
const body = Buffer.concat(
|
||||
children.map(({ mode, name, hash }) =>
|
||||
Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash])
|
||||
)
|
||||
)
|
||||
return gitObjectSha('tree', body)
|
||||
}
|
||||
|
||||
return hashDirectory(root).toString('hex')
|
||||
}
|
||||
|
||||
async function collectPackageFiles(packageRoot) {
|
||||
const files = []
|
||||
const caseFoldedPaths = new Map()
|
||||
|
||||
async function visit(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
// Why: build-time Node and packaged Electron may ship different ICU data;
|
||||
// package identity order must use the same locale-independent comparison.
|
||||
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(directory, entry.name)
|
||||
const relativePath = path.relative(packageRoot, absolutePath)
|
||||
assertSafeRelativePath(relativePath)
|
||||
const manifestPath = relativePath.split(path.sep).join('/')
|
||||
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
|
||||
const collision = caseFoldedPaths.get(foldedPath)
|
||||
if (collision && collision !== manifestPath) {
|
||||
throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`)
|
||||
}
|
||||
caseFoldedPaths.set(foldedPath, manifestPath)
|
||||
const fileStat = await lstat(absolutePath)
|
||||
if (fileStat.isSymbolicLink()) {
|
||||
throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`)
|
||||
}
|
||||
if (fileStat.isDirectory()) {
|
||||
await visit(absolutePath)
|
||||
continue
|
||||
}
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`)
|
||||
}
|
||||
// Why: Windows observation cannot see execute bits, so an executable file in
|
||||
// a shipped skill would misclassify every pristine Windows install as unrecognized.
|
||||
if ((fileStat.mode & 0o111) !== 0) {
|
||||
throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`)
|
||||
}
|
||||
files.push(describeFile(manifestPath, await readFile(absolutePath), false))
|
||||
}
|
||||
}
|
||||
|
||||
await visit(packageRoot)
|
||||
return sortManifestFiles(files)
|
||||
}
|
||||
|
||||
function collectGitSkillTreeEntries(treeSha) {
|
||||
const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha])
|
||||
.toString('utf8')
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
const packages = new Map()
|
||||
for (const line of output) {
|
||||
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line)
|
||||
if (!match) {
|
||||
throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`)
|
||||
}
|
||||
const [, mode, type, objectSha, sourcePath] = match
|
||||
const separator = sourcePath.indexOf('/')
|
||||
if (separator <= 0 || separator === sourcePath.length - 1) {
|
||||
throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`)
|
||||
}
|
||||
const name = sourcePath.slice(0, separator)
|
||||
const manifestPath = sourcePath.slice(separator + 1)
|
||||
const entries = packages.get(name) ?? []
|
||||
entries.push({ mode, type, objectSha, manifestPath })
|
||||
packages.set(name, entries)
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
function readGitBlobs(objectShas) {
|
||||
const uniqueShas = [...new Set(objectShas)]
|
||||
if (uniqueShas.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
// Why: released history spans hundreds of tags. Batch mode avoids a Git
|
||||
// subprocess per historical file while remaining available on Git 2.25.
|
||||
const output = execFileSync('git', ['cat-file', '--batch'], {
|
||||
input: `${uniqueShas.join('\n')}\n`,
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
const blobs = new Map()
|
||||
let offset = 0
|
||||
for (const requestedSha of uniqueShas) {
|
||||
const headerEnd = output.indexOf(10, offset)
|
||||
if (headerEnd < 0) {
|
||||
throw new Error(`Missing git cat-file header for ${requestedSha}`)
|
||||
}
|
||||
const header = output.subarray(offset, headerEnd).toString('utf8')
|
||||
const match = /^([a-f0-9]+) blob (\d+)$/.exec(header)
|
||||
if (!match || match[1] !== requestedSha) {
|
||||
throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`)
|
||||
}
|
||||
const size = Number(match[2])
|
||||
const contentStart = headerEnd + 1
|
||||
const contentEnd = contentStart + size
|
||||
if (contentEnd >= output.length || output[contentEnd] !== 10) {
|
||||
throw new Error(`Truncated git blob for ${requestedSha}`)
|
||||
}
|
||||
blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd)))
|
||||
offset = contentEnd + 1
|
||||
}
|
||||
return blobs
|
||||
}
|
||||
|
||||
function collectGitPackageFiles(treeSha, name, entries, blobs) {
|
||||
const caseFoldedPaths = new Map()
|
||||
const files = entries.map(({ mode, type, objectSha, manifestPath }) => {
|
||||
if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) {
|
||||
throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`)
|
||||
}
|
||||
assertSafeRelativePath(manifestPath)
|
||||
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
|
||||
const collision = caseFoldedPaths.get(foldedPath)
|
||||
if (collision && collision !== manifestPath) {
|
||||
throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`)
|
||||
}
|
||||
caseFoldedPaths.set(foldedPath, manifestPath)
|
||||
const bytes = blobs.get(objectSha)
|
||||
if (!bytes) {
|
||||
throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`)
|
||||
}
|
||||
return describeFile(manifestPath, bytes, mode === '100755')
|
||||
})
|
||||
// Why: git ls-tree emits git byte-order, not the canonical walk order.
|
||||
return sortManifestFiles(files)
|
||||
}
|
||||
|
||||
// Why: snapshot matching compares files by array index, so every producer —
|
||||
// working-tree walk, git history, and runtime observation — must emit one
|
||||
// canonical order. This mirrors the sorted depth-first filesystem walk.
|
||||
function compareManifestPaths(left, right) {
|
||||
const leftParts = left.split('/')
|
||||
const rightParts = right.split('/')
|
||||
const shared = Math.min(leftParts.length, rightParts.length)
|
||||
for (let index = 0; index < shared; index += 1) {
|
||||
const order = compareCodeUnits(leftParts[index], rightParts[index])
|
||||
if (order !== 0) {
|
||||
return order
|
||||
}
|
||||
}
|
||||
return leftParts.length - rightParts.length
|
||||
}
|
||||
|
||||
function sortManifestFiles(files) {
|
||||
return [...files].sort((left, right) => compareManifestPaths(left.path, right.path))
|
||||
}
|
||||
|
||||
function packageDigest(files) {
|
||||
return sha256(
|
||||
Buffer.from(
|
||||
JSON.stringify(
|
||||
files.map((file) => ({
|
||||
path: file.path,
|
||||
executable: file.executable,
|
||||
classification: file.classification,
|
||||
identitySha256: file.identitySha256
|
||||
}))
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function releaseTags() {
|
||||
return execFileSync(
|
||||
'git',
|
||||
['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
.split('\n')
|
||||
.filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag))
|
||||
}
|
||||
|
||||
function skillsTreeShasAtRefs(refs) {
|
||||
if (refs.length === 0) {
|
||||
return []
|
||||
}
|
||||
const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], {
|
||||
input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
const lines = output.trimEnd().split('\n')
|
||||
if (lines.length !== refs.length) {
|
||||
throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`)
|
||||
}
|
||||
return lines.map((line, index) => {
|
||||
if (line.endsWith(' missing')) {
|
||||
return null
|
||||
}
|
||||
const match = /^([a-f0-9]+) tree$/.exec(line)
|
||||
if (!match) {
|
||||
throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`)
|
||||
}
|
||||
return match[1]
|
||||
})
|
||||
}
|
||||
|
||||
function buildReleasedHistory() {
|
||||
const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} }
|
||||
const mapping = { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] }
|
||||
const tags = releaseTags()
|
||||
const treeShas = skillsTreeShasAtRefs(tags)
|
||||
const distinctTreeShas = [...new Set(treeShas.filter(Boolean))]
|
||||
const packagesByTree = new Map(
|
||||
distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)])
|
||||
)
|
||||
const blobs = readGitBlobs(
|
||||
[...packagesByTree.values()].flatMap((packages) =>
|
||||
[...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha))
|
||||
)
|
||||
)
|
||||
let previousSkillsTreeSha = null
|
||||
for (const [index, tag] of tags.entries()) {
|
||||
const skillsTreeSha = treeShas[index]
|
||||
if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) {
|
||||
continue
|
||||
}
|
||||
previousSkillsTreeSha = skillsTreeSha
|
||||
const revisions = {}
|
||||
const packages = packagesByTree.get(skillsTreeSha)
|
||||
if (!packages) {
|
||||
throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`)
|
||||
}
|
||||
for (const name of [...packages.keys()].sort(compareCodeUnits)) {
|
||||
const entries = packages.get(name)
|
||||
const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs)
|
||||
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
|
||||
continue
|
||||
}
|
||||
const digest = packageDigest(filesWithGitHashes)
|
||||
const snapshots = registry.skills[name] ?? []
|
||||
const latest = snapshots.at(-1)
|
||||
if (!latest || latest.packageDigest !== digest) {
|
||||
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
|
||||
snapshots.push({
|
||||
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
|
||||
packageDigest: digest,
|
||||
gitTreeSha: gitTreeSha(filesWithGitHashes),
|
||||
files
|
||||
})
|
||||
registry.skills[name] = snapshots
|
||||
}
|
||||
revisions[name] = snapshots.at(-1).releaseRevision
|
||||
}
|
||||
if (Object.keys(revisions).length > 0) {
|
||||
mapping.releases.push({ appVersion: tag.slice(1), skills: revisions })
|
||||
}
|
||||
}
|
||||
return { registry, mapping }
|
||||
}
|
||||
|
||||
// Why: released history is authoritative committed data, advanced only at
|
||||
// release cut. Seeding generation from the committed registry + mapping makes
|
||||
// ordinary verify/regeneration a pure function of working-tree bytes, so it
|
||||
// never walks git tags — the root cause of recurring lint drift when a clone
|
||||
// holds stray, deleted, or fork tags the committed artifacts predate.
|
||||
function releasedHistoryFromCommitted(committedRegistry, committedMapping) {
|
||||
const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} }
|
||||
const releasedSnapshotCounts = {}
|
||||
const mapping =
|
||||
committedMapping && committedMapping.schemaVersion === RELEASE_MAPPING_SCHEMA_VERSION
|
||||
? structuredClone(committedMapping)
|
||||
: { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] }
|
||||
if (committedRegistry && committedRegistry.schemaVersion === SNAPSHOT_REGISTRY_SCHEMA_VERSION) {
|
||||
const mappedCounts = releasedSnapshotCountsFromMapping(mapping)
|
||||
for (const [name, snapshots] of Object.entries(committedRegistry.skills ?? {})) {
|
||||
// The committed registry carries at most one unreleased tail beyond the
|
||||
// revisions named by the mapping; drop it and recompute it from bytes.
|
||||
const releasedCount = mappedCounts?.[name] ?? Math.max(0, snapshots.length - 1)
|
||||
registry.skills[name] = snapshots.slice(0, releasedCount)
|
||||
releasedSnapshotCounts[name] = releasedCount
|
||||
}
|
||||
}
|
||||
return { registry, mapping, releasedSnapshotCounts }
|
||||
}
|
||||
|
||||
// Why: disaster recovery only. Reconstruct released history from the immutable
|
||||
// release tags when the committed ledger must be rebuilt from scratch. Kept off
|
||||
// the verify/regenerate path — walking tags there is what coupled lint to the
|
||||
// executing clone's tag state and broke it on version bumps, new tags, and
|
||||
// stray/deleted local tags.
|
||||
function releasedHistoryFromTags() {
|
||||
const { registry, mapping } = buildReleasedHistory()
|
||||
const releasedSnapshotCounts = Object.fromEntries(
|
||||
Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length])
|
||||
)
|
||||
return { registry, mapping, releasedSnapshotCounts }
|
||||
}
|
||||
|
||||
// Why: release cut is the single authoritative point where working-tree bytes
|
||||
// become an immutable released revision. Append one mapping row for the version,
|
||||
// mirroring the historical dedupe where consecutive identical skill trees share
|
||||
// the earliest release's row.
|
||||
function appendReleaseRow(artifacts, version) {
|
||||
const appVersion = version.startsWith('v') ? version.slice(1) : version
|
||||
const currentRevisions = {}
|
||||
for (const skill of artifacts.currentManifest.skills) {
|
||||
currentRevisions[skill.name] = skill.releaseRevision
|
||||
}
|
||||
const releases = artifacts.releaseMapping.releases
|
||||
const last = releases.at(-1)
|
||||
if (last && isDeepStrictEqual(last.skills, currentRevisions)) {
|
||||
return
|
||||
}
|
||||
// Why: a cut that pushed the version bump to main but died before pushing the
|
||||
// tag is re-cut at the same version. If skills changed in between, appending
|
||||
// would leave two rows claiming this version and the stale one would name
|
||||
// revisions that tag never ships — overwrite, since the tag ships these bytes.
|
||||
if (last?.appVersion === appVersion) {
|
||||
releases[releases.length - 1] = { appVersion, skills: currentRevisions }
|
||||
return
|
||||
}
|
||||
// Why: an earlier row means re-cutting an already-shipped version, which the
|
||||
// cut workflow refuses upstream. Fail rather than corrupt shipped provenance.
|
||||
if (releases.some((release) => release.appVersion === appVersion)) {
|
||||
throw new Error(`Release mapping already has a row for ${appVersion}.`)
|
||||
}
|
||||
releases.push({ appVersion, skills: currentRevisions })
|
||||
}
|
||||
|
||||
async function buildArtifacts(releasedHistory) {
|
||||
const { registry, mapping, releasedSnapshotCounts } = releasedHistory
|
||||
const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort(compareCodeUnits)
|
||||
const currentSkills = []
|
||||
for (const name of skillDirectories) {
|
||||
const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name))
|
||||
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
|
||||
throw new Error(`Skill package ${name} has no top-level SKILL.md`)
|
||||
}
|
||||
const digest = packageDigest(filesWithGitHashes)
|
||||
const snapshots = registry.skills[name] ?? []
|
||||
const latest = snapshots.at(-1)
|
||||
let snapshot = latest
|
||||
if (!latest || latest.packageDigest !== digest) {
|
||||
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
|
||||
snapshot = {
|
||||
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
|
||||
packageDigest: digest,
|
||||
gitTreeSha: gitTreeSha(filesWithGitHashes),
|
||||
files
|
||||
}
|
||||
snapshots.push(snapshot)
|
||||
registry.skills[name] = snapshots
|
||||
}
|
||||
currentSkills.push({
|
||||
name,
|
||||
sourcePath: `skills/${name}`,
|
||||
...snapshot
|
||||
})
|
||||
}
|
||||
return {
|
||||
currentManifest: {
|
||||
schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION,
|
||||
skills: currentSkills
|
||||
},
|
||||
snapshotRegistry: registry,
|
||||
releaseMapping: mapping,
|
||||
releasedSnapshotCounts
|
||||
}
|
||||
}
|
||||
|
||||
// Why: released snapshots are the detection ground truth for existing installs,
|
||||
// so a generation-logic change must not rewrite them silently. Only the one
|
||||
// unreleased working-tree append per skill may change between runs.
|
||||
function releasedSnapshotCountsFromMapping(releaseMapping) {
|
||||
if (!releaseMapping || releaseMapping.schemaVersion !== RELEASE_MAPPING_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
const counts = {}
|
||||
for (const release of releaseMapping.releases ?? []) {
|
||||
for (const [name, revision] of Object.entries(release.skills ?? {})) {
|
||||
counts[name] = Math.max(counts[name] ?? 0, revision)
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
function assertReleasedHistoryPreserved(committedRegistry, artifacts, committedReleaseMapping) {
|
||||
if (!committedRegistry || committedRegistry.schemaVersion !== SNAPSHOT_REGISTRY_SCHEMA_VERSION) {
|
||||
return
|
||||
}
|
||||
const committedReleasedCounts = releasedSnapshotCountsFromMapping(committedReleaseMapping)
|
||||
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
|
||||
const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0
|
||||
const regenerated = artifacts.snapshotRegistry.skills[name] ?? []
|
||||
const minimumReleasedCount =
|
||||
committedReleasedCounts?.[name] ?? Math.max(0, committedSnapshots.length - 1)
|
||||
if (releasedCount < minimumReleasedCount) {
|
||||
throw new Error(
|
||||
`Released snapshot history is incomplete for ${name}. ` +
|
||||
'Fetch all release tags before regenerating skill artifacts.'
|
||||
)
|
||||
}
|
||||
const protectedCount = committedReleasedCounts
|
||||
? (committedReleasedCounts[name] ?? 0)
|
||||
: Math.min(committedSnapshots.length, releasedCount)
|
||||
for (let index = 0; index < protectedCount; index += 1) {
|
||||
const committed = committedSnapshots[index]
|
||||
const rebuilt = regenerated[index]
|
||||
if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) {
|
||||
throw new Error(
|
||||
`Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` +
|
||||
'Released snapshots are append-only; a deliberate identity migration must update this check.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readCommittedRegistry() {
|
||||
try {
|
||||
return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function readCommittedReleaseMapping() {
|
||||
try {
|
||||
return JSON.parse(await readFile(RELEASE_MAPPING_PATH, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function serialized(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`
|
||||
}
|
||||
|
||||
async function writeArtifacts(artifacts, paths = ALL_ARTIFACT_PATHS) {
|
||||
const values = new Map([
|
||||
[CURRENT_MANIFEST_PATH, artifacts.currentManifest],
|
||||
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry],
|
||||
[RELEASE_MAPPING_PATH, artifacts.releaseMapping]
|
||||
])
|
||||
await mkdir(OUTPUT_ROOT, { recursive: true })
|
||||
await Promise.all(paths.map((filePath) => writeFile(filePath, serialized(values.get(filePath)))))
|
||||
}
|
||||
|
||||
// Why: cutting a release tag adds a trailing mapping row on every checkout at
|
||||
// once, before any branch can regenerate. A trailing row whose revisions all
|
||||
// equal the current manifest is provably redundant until the next real
|
||||
// regeneration (installs of those bytes classify as current and are labeled by
|
||||
// the running build), so verify must not fail branches over it.
|
||||
function isToleratedReleaseMappingPrefix(committedText, artifacts) {
|
||||
let committed
|
||||
try {
|
||||
committed = JSON.parse(committedText)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const derived = artifacts.releaseMapping
|
||||
const committedCount = Array.isArray(committed?.releases) ? committed.releases.length : -1
|
||||
if (committedCount < 0 || committedCount >= derived.releases.length) {
|
||||
return false
|
||||
}
|
||||
const prefix = {
|
||||
schemaVersion: derived.schemaVersion,
|
||||
releases: derived.releases.slice(0, committedCount)
|
||||
}
|
||||
if (committedText !== serialized(prefix)) {
|
||||
return false
|
||||
}
|
||||
const currentRevisions = Object.fromEntries(
|
||||
artifacts.currentManifest.skills.map((skill) => [skill.name, skill.releaseRevision])
|
||||
)
|
||||
return derived.releases
|
||||
.slice(committedCount)
|
||||
.every((release) => isDeepStrictEqual(release.skills, currentRevisions))
|
||||
}
|
||||
|
||||
async function verifyArtifacts(artifacts, paths = ALL_ARTIFACT_PATHS) {
|
||||
const expected = [
|
||||
[CURRENT_MANIFEST_PATH, artifacts.currentManifest, null],
|
||||
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry, null],
|
||||
[RELEASE_MAPPING_PATH, artifacts.releaseMapping, isToleratedReleaseMappingPrefix]
|
||||
].filter(([filePath]) => paths.includes(filePath))
|
||||
const stale = []
|
||||
for (const [filePath, value, tolerated] of expected) {
|
||||
try {
|
||||
await access(filePath, constants.R_OK)
|
||||
const committedText = await readFile(filePath, 'utf8')
|
||||
if (committedText !== serialized(value) && !tolerated?.(committedText, artifacts)) {
|
||||
stale.push(filePath)
|
||||
}
|
||||
} catch {
|
||||
stale.push(filePath)
|
||||
}
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
throw new Error(
|
||||
`Generated skill artifacts are stale:\n${stale
|
||||
.map((filePath) => path.relative(REPO_ROOT, filePath))
|
||||
.join('\n')}\nRun pnpm generate:skill-bundle-manifest.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2)
|
||||
const rebuildFromTags = argv.includes('--rebuild-from-tags')
|
||||
const releaseIndex = argv.indexOf('--release')
|
||||
const releaseVersion = releaseIndex >= 0 ? argv[releaseIndex + 1] : null
|
||||
if (releaseIndex >= 0 && !releaseVersion) {
|
||||
throw new Error('--release requires a version argument, e.g. --release 1.4.160')
|
||||
}
|
||||
|
||||
const committedRegistry = await readCommittedRegistry()
|
||||
const committedMapping = await readCommittedReleaseMapping()
|
||||
const releasedHistory = rebuildFromTags
|
||||
? releasedHistoryFromTags()
|
||||
: releasedHistoryFromCommitted(committedRegistry, committedMapping)
|
||||
const artifacts = await buildArtifacts(releasedHistory)
|
||||
|
||||
if (releaseVersion) {
|
||||
// Why: the row names revisions the committed registry must already contain,
|
||||
// so proving those artifacts match this ref is what lets the cut record
|
||||
// provenance without regenerating them. If regeneration disagrees with what
|
||||
// is committed, the row would name a revision this tag does not ship.
|
||||
await verifyArtifacts(artifacts, CONTENT_ADDRESSED_PATHS)
|
||||
appendReleaseRow(artifacts, releaseVersion)
|
||||
}
|
||||
|
||||
// Why: released snapshots are append-only. The committed registry/mapping are
|
||||
// read fresh here so the artifacts (which may have appended a release row) can
|
||||
// never alias what we validate against. This mapping must stay the PRE-append
|
||||
// one to match artifacts.releasedSnapshotCounts, which seeding fixed before the
|
||||
// row existed; the post-append mapping names one more revision than the counts
|
||||
// do, which reads as incomplete history and would throw on every cut.
|
||||
assertReleasedHistoryPreserved(committedRegistry, artifacts, committedMapping)
|
||||
|
||||
const shouldWrite = releaseVersion !== null || argv.includes('--write')
|
||||
const writePaths = releaseVersion ? [RELEASE_MAPPING_PATH] : ALL_ARTIFACT_PATHS
|
||||
await (shouldWrite ? writeArtifacts(artifacts, writePaths) : verifyArtifacts(artifacts))
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
appendReleaseRow,
|
||||
assertReleasedHistoryPreserved,
|
||||
buildArtifacts,
|
||||
buildReleasedHistory,
|
||||
classifyFile,
|
||||
collectPackageFiles,
|
||||
describeFile,
|
||||
gitTreeSha,
|
||||
isToleratedReleaseMappingPrefix,
|
||||
normalizeText,
|
||||
packageDigest,
|
||||
releasedHistoryFromCommitted,
|
||||
sortManifestFiles,
|
||||
verifyArtifacts,
|
||||
writeArtifacts
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import {
|
||||
appendReleaseRow,
|
||||
assertReleasedHistoryPreserved,
|
||||
classifyFile,
|
||||
collectPackageFiles,
|
||||
describeFile,
|
||||
gitTreeSha,
|
||||
isToleratedReleaseMappingPrefix,
|
||||
normalizeText,
|
||||
packageDigest,
|
||||
releasedHistoryFromCommitted,
|
||||
sortManifestFiles
|
||||
} from './generate-skill-bundle-manifest.mjs'
|
||||
|
||||
const temporaryDirectories = []
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
|
||||
|
||||
async function createPackage() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
// Why: the generator resolves its repo root from its own location, so a copy of
|
||||
// the script inside a throwaway tree exercises the real CLI — including which
|
||||
// artifacts each mode is allowed to write — without touching resources/skills.
|
||||
async function createReleaseSandbox() {
|
||||
// Node resolves the entry point through symlinks, so the script's own
|
||||
// repo-root check only matches when the sandbox path is already resolved.
|
||||
const root = await realpath(await createPackage())
|
||||
const skillRoot = path.join(root, 'skills', 'demo')
|
||||
const script = path.join(root, 'config', 'scripts', 'generate-skill-bundle-manifest.mjs')
|
||||
await mkdir(path.dirname(script), { recursive: true })
|
||||
await mkdir(skillRoot, { recursive: true })
|
||||
await copyFile(path.join(import.meta.dirname, 'generate-skill-bundle-manifest.mjs'), script)
|
||||
await writeFile(path.join(skillRoot, 'SKILL.md'), 'demo skill\n')
|
||||
return {
|
||||
generate: (...args) => execFileSync(process.execPath, [script, ...args], { stdio: 'pipe' }),
|
||||
read: (name) => readFile(path.join(root, 'resources', 'skills', name), 'utf8'),
|
||||
editSkill: (body) => writeFile(path.join(skillRoot, 'SKILL.md'), body)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('skill bundle manifest generator', () => {
|
||||
it('folds platform line endings for text identity', () => {
|
||||
const lf = Buffer.from('first\nsecond\n')
|
||||
const crlf = Buffer.from('first\r\nsecond\r\n')
|
||||
|
||||
expect(classifyFile(lf)).toBe('text')
|
||||
expect(normalizeText(crlf)).toEqual(lf)
|
||||
})
|
||||
|
||||
it('classifies null-containing and invalid UTF-8 content as binary', () => {
|
||||
expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary')
|
||||
expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary')
|
||||
})
|
||||
|
||||
it('uses normalized text identity but exact executable identity', () => {
|
||||
const skillFile = describeFile('SKILL.md', Buffer.from('line one\r\nline two\r\n'), false)
|
||||
const executable = describeFile('run.sh', Buffer.from('#!/bin/sh\r\necho ok\r\n'), true)
|
||||
|
||||
expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256)
|
||||
expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256)
|
||||
expect(executable.exactSha256).not.toBe(executable.textNormalizedSha256)
|
||||
expect(executable.identitySha256).toBe(executable.exactSha256)
|
||||
expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
it('orders git-history files identically to the filesystem walk', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await mkdir(path.join(packageRoot, 'sub'))
|
||||
for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) {
|
||||
await writeFile(path.join(packageRoot, name), `${name}\n`)
|
||||
}
|
||||
const walked = await collectPackageFiles(packageRoot)
|
||||
|
||||
// Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based
|
||||
// snapshot matching requires history and observation to share one order.
|
||||
const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) =>
|
||||
walked.find((file) => file.path === manifestPath)
|
||||
)
|
||||
|
||||
expect(sortManifestFiles(gitOrdered)).toEqual(walked)
|
||||
expect(packageDigest(sortManifestFiles(gitOrdered))).toBe(packageDigest(walked))
|
||||
expect(walked.map((file) => file.path)).toEqual([
|
||||
'Zebra.md',
|
||||
'apple.md',
|
||||
'sub/inner.txt',
|
||||
'sub.md'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects rewrites of released snapshots and allows floating-tail replacement', () => {
|
||||
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
|
||||
const artifacts = {
|
||||
releasedSnapshotCounts: { 'orca-cli': 2 },
|
||||
snapshotRegistry: {
|
||||
schemaVersion: 1,
|
||||
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] }
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(
|
||||
{ schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } },
|
||||
artifacts
|
||||
)
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] }
|
||||
},
|
||||
artifacts
|
||||
)
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] }
|
||||
},
|
||||
artifacts
|
||||
)
|
||||
).toThrow('Released snapshot history changed for orca-cli at revision 2')
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
skills: {
|
||||
'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }]
|
||||
}
|
||||
},
|
||||
artifacts
|
||||
)
|
||||
).toThrow('Released snapshot history changed for orca-cli at revision 2')
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
skills: {
|
||||
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')]
|
||||
}
|
||||
},
|
||||
{ ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } }
|
||||
)
|
||||
).toThrow('Released snapshot history is incomplete for orca-cli')
|
||||
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
|
||||
})
|
||||
|
||||
it('protects only revisions named by the committed release mapping', () => {
|
||||
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
|
||||
const committedRegistry = {
|
||||
schemaVersion: 1,
|
||||
skills: {
|
||||
'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'unreleased-tail')]
|
||||
}
|
||||
}
|
||||
const artifacts = {
|
||||
releasedSnapshotCounts: { 'linear-tickets': 2 },
|
||||
snapshotRegistry: {
|
||||
schemaVersion: 1,
|
||||
skills: { 'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'new-release')] }
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(committedRegistry, artifacts, {
|
||||
schemaVersion: 1,
|
||||
releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 1 } }]
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(committedRegistry, artifacts, {
|
||||
schemaVersion: 1,
|
||||
releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 2 } }]
|
||||
})
|
||||
).toThrow('Released snapshot history changed for linear-tickets at revision 2')
|
||||
})
|
||||
|
||||
it('tolerates only redundant trailing release-mapping rows', () => {
|
||||
const serialized = (value) => `${JSON.stringify(value, null, 2)}\n`
|
||||
const rows = [
|
||||
{ appVersion: '1.0.0', skills: { 'orca-cli': 1 } },
|
||||
{ appVersion: '1.1.0', skills: { 'orca-cli': 2 } }
|
||||
]
|
||||
const artifacts = {
|
||||
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 2 }] },
|
||||
releaseMapping: { schemaVersion: 1, releases: rows }
|
||||
}
|
||||
const committedPrefix = serialized({ schemaVersion: 1, releases: [rows[0]] })
|
||||
|
||||
// A just-cut tag whose bytes equal the working tree may lag in the mapping.
|
||||
expect(isToleratedReleaseMappingPrefix(committedPrefix, artifacts)).toBe(true)
|
||||
// The committed file matching the derived mapping is byte-equality's job, not tolerance.
|
||||
expect(isToleratedReleaseMappingPrefix(serialized(artifacts.releaseMapping), artifacts)).toBe(
|
||||
false
|
||||
)
|
||||
// A trailing row for bytes the committed artifacts do not describe is a real gap.
|
||||
expect(
|
||||
isToleratedReleaseMappingPrefix(committedPrefix, {
|
||||
...artifacts,
|
||||
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 3 }] }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isToleratedReleaseMappingPrefix(committedPrefix, {
|
||||
...artifacts,
|
||||
currentManifest: {
|
||||
skills: [
|
||||
{ name: 'orca-cli', releaseRevision: 2 },
|
||||
{ name: 'orca-linear', releaseRevision: 1 }
|
||||
]
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
// Rewritten earlier rows never pass, with or without trailing rows.
|
||||
expect(
|
||||
isToleratedReleaseMappingPrefix(
|
||||
serialized({
|
||||
schemaVersion: 1,
|
||||
releases: [{ appVersion: '0.9.0', skills: { 'orca-cli': 1 } }]
|
||||
}),
|
||||
artifacts
|
||||
)
|
||||
).toBe(false)
|
||||
expect(isToleratedReleaseMappingPrefix('not json', artifacts)).toBe(false)
|
||||
expect(isToleratedReleaseMappingPrefix(serialized({ schemaVersion: 1 }), artifacts)).toBe(false)
|
||||
})
|
||||
|
||||
it('seeds released history from the committed ledger and drops the floating tail', () => {
|
||||
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
|
||||
const committedRegistry = {
|
||||
schemaVersion: 1,
|
||||
skills: {
|
||||
// released revs 1..2 named by the mapping, plus an unreleased tail at 3
|
||||
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'unreleased')],
|
||||
// no mapping row -> fall back to all-but-tail
|
||||
'orca-linear': [snapshot(1, 'ccc'), snapshot(2, 'tail')]
|
||||
}
|
||||
}
|
||||
const committedMapping = {
|
||||
schemaVersion: 1,
|
||||
releases: [{ appVersion: '1.0.0', skills: { 'orca-cli': 2 } }]
|
||||
}
|
||||
|
||||
const seeded = releasedHistoryFromCommitted(committedRegistry, committedMapping)
|
||||
|
||||
// The unreleased tail is dropped; only mapping-named revisions survive.
|
||||
expect(seeded.registry.skills['orca-cli']).toEqual([snapshot(1, 'aaa'), snapshot(2, 'bbb')])
|
||||
expect(seeded.registry.skills['orca-linear']).toEqual([snapshot(1, 'ccc')])
|
||||
expect(seeded.releasedSnapshotCounts).toEqual({ 'orca-cli': 2, 'orca-linear': 1 })
|
||||
// The seed clones the mapping so a later release append cannot alias committed state.
|
||||
expect(seeded.mapping).toEqual(committedMapping)
|
||||
expect(seeded.mapping).not.toBe(committedMapping)
|
||||
})
|
||||
|
||||
it('returns an empty ledger when no committed artifacts exist', () => {
|
||||
const seeded = releasedHistoryFromCommitted(null, null)
|
||||
expect(seeded.registry.skills).toEqual({})
|
||||
expect(seeded.releasedSnapshotCounts).toEqual({})
|
||||
expect(seeded.mapping.releases).toEqual([])
|
||||
})
|
||||
|
||||
it('appends one release row, stripping the v-prefix and deduping identical tails', () => {
|
||||
const artifacts = {
|
||||
currentManifest: {
|
||||
skills: [
|
||||
{ name: 'orca-cli', releaseRevision: 36 },
|
||||
{ name: 'orca-linear', releaseRevision: 8 }
|
||||
]
|
||||
},
|
||||
releaseMapping: {
|
||||
schemaVersion: 1,
|
||||
releases: [{ appVersion: '1.4.151', skills: { 'orca-cli': 35, 'orca-linear': 8 } }]
|
||||
}
|
||||
}
|
||||
|
||||
appendReleaseRow(artifacts, 'v1.4.160')
|
||||
expect(artifacts.releaseMapping.releases.at(-1)).toEqual({
|
||||
appVersion: '1.4.160',
|
||||
skills: { 'orca-cli': 36, 'orca-linear': 8 }
|
||||
})
|
||||
|
||||
// A second release over identical revisions adds no row.
|
||||
appendReleaseRow(artifacts, '1.4.161')
|
||||
expect(artifacts.releaseMapping.releases).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('overwrites the trailing row when a failed cut is re-cut at the same version', () => {
|
||||
const artifacts = {
|
||||
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 37 }] },
|
||||
releaseMapping: {
|
||||
schemaVersion: 1,
|
||||
releases: [
|
||||
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
|
||||
// The failed cut already pushed this row to main at revision 36.
|
||||
{ appVersion: '1.4.160', skills: { 'orca-cli': 36 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
appendReleaseRow(artifacts, '1.4.160')
|
||||
|
||||
// One row per version: the tag ships revision 37, so 36 must not linger.
|
||||
expect(artifacts.releaseMapping.releases).toEqual([
|
||||
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
|
||||
{ appVersion: '1.4.160', skills: { 'orca-cli': 37 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses to rewrite an already-shipped version behind the trailing row', () => {
|
||||
const artifacts = {
|
||||
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 37 }] },
|
||||
releaseMapping: {
|
||||
schemaVersion: 1,
|
||||
releases: [
|
||||
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
|
||||
{ appVersion: '1.4.160', skills: { 'orca-cli': 36 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => appendReleaseRow(artifacts, '1.4.151')).toThrow(/already has a row for 1\.4\.151/)
|
||||
})
|
||||
|
||||
it('records a release without regenerating the content-addressed artifacts', async () => {
|
||||
const sandbox = await createReleaseSandbox()
|
||||
|
||||
sandbox.generate('--write')
|
||||
const [manifest, registry] = await Promise.all([
|
||||
sandbox.read('current-manifest.json'),
|
||||
sandbox.read('snapshot-registry.json')
|
||||
])
|
||||
sandbox.generate('--release', 'v1.4.156')
|
||||
|
||||
// The cut records provenance for bytes that are already committed, so a
|
||||
// version-only cut can never rewrite a shipped identity.
|
||||
expect(JSON.parse(await sandbox.read('release-mapping.json')).releases).toEqual([
|
||||
{ appVersion: '1.4.156', skills: { demo: 1 } }
|
||||
])
|
||||
expect(await sandbox.read('current-manifest.json')).toBe(manifest)
|
||||
expect(await sandbox.read('snapshot-registry.json')).toBe(registry)
|
||||
|
||||
// Bytes that changed since the last regeneration would make the row name a
|
||||
// revision this tag does not ship — refuse rather than record it.
|
||||
await sandbox.editSkill('edited after the last regeneration\n')
|
||||
expect(() => sandbox.generate('--release', '1.4.157')).toThrow(
|
||||
/Generated skill artifacts are stale/
|
||||
)
|
||||
expect(JSON.parse(await sandbox.read('release-mapping.json')).releases).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('freezes a revision once a release records it, and only until then', async () => {
|
||||
const sandbox = await createReleaseSandbox()
|
||||
const demoSnapshots = async () =>
|
||||
JSON.parse(await sandbox.read('snapshot-registry.json')).skills.demo
|
||||
|
||||
sandbox.generate('--write')
|
||||
const unreleased = (await demoSnapshots())[0].packageDigest
|
||||
|
||||
// Nothing has shipped revision 1 yet, so re-deriving it over new bytes is
|
||||
// correct: the tail floats until a release names it.
|
||||
await sandbox.editSkill('about to ship\n')
|
||||
sandbox.generate('--write')
|
||||
const shipped = await demoSnapshots()
|
||||
expect(shipped).toHaveLength(1)
|
||||
expect(shipped[0].packageDigest).not.toBe(unreleased)
|
||||
|
||||
sandbox.generate('--release', '1.4.156')
|
||||
|
||||
// The cut named revision 1, so the next change appends revision 2 instead of
|
||||
// rebuilding revision 1. Installs carrying the shipped digest keep matching a
|
||||
// known snapshot — without the ledger row they would match nothing.
|
||||
await sandbox.editSkill('changed again after the cut\n')
|
||||
sandbox.generate('--write')
|
||||
const frozen = await demoSnapshots()
|
||||
expect(frozen).toHaveLength(2)
|
||||
expect(frozen[0]).toEqual(shipped[0])
|
||||
expect(frozen[1].releaseRevision).toBe(2)
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'rejects executable files in shipped skill packages',
|
||||
async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n')
|
||||
await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n')
|
||||
await chmod(path.join(packageRoot, 'run.sh'), 0o755)
|
||||
|
||||
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
|
||||
'Executable file is not allowed in a shipped skill: run.sh'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
|
||||
await writeFile(path.join(packageRoot, 'Readme.md'), 'one')
|
||||
await writeFile(path.join(packageRoot, 'README.md'), 'two')
|
||||
|
||||
await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths')
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
|
||||
await symlink('SKILL.md', path.join(packageRoot, 'linked.md'))
|
||||
|
||||
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
|
||||
'Symlink is not allowed in a shipped skill'
|
||||
)
|
||||
})
|
||||
|
||||
it('computes the same Git tree identity as Git', async () => {
|
||||
const packageRoot = path.resolve('skills', 'orca-cli')
|
||||
const files = await collectPackageFiles(packageRoot)
|
||||
const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
.trim()
|
||||
.split(/\s+/)[2]
|
||||
|
||||
expect(gitTreeSha(files)).toBe(expected)
|
||||
})
|
||||
|
||||
it('matches Git when a directory and file share a name prefix', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await mkdir(path.join(packageRoot, 'sub'))
|
||||
await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n')
|
||||
await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n')
|
||||
const files = await collectPackageFiles(packageRoot)
|
||||
execFileSync('git', ['init', '--quiet'], { cwd: packageRoot })
|
||||
execFileSync('git', ['add', '-A'], { cwd: packageRoot })
|
||||
const expected = execFileSync('git', ['write-tree'], {
|
||||
cwd: packageRoot,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
|
||||
expect(gitTreeSha(files)).toBe(expected)
|
||||
})
|
||||
|
||||
// Why: every step in the cut job shares one workspace and one index, so any of
|
||||
// them can stage the content-addressed artifacts and the bump step's own commit
|
||||
// then carries them into the tag. Grepping the workflow cannot see a path built
|
||||
// from an env var, a composite action, or concatenation, so the cut asserts its
|
||||
// own index before committing; this test pins that guard and adds a tripwire
|
||||
// for the literal spellings.
|
||||
it('keeps the whole release-cut job off skill regeneration', async () => {
|
||||
const workflow = parse(
|
||||
await readFile(path.join(REPO_ROOT, '.github/workflows/release-cut.yml'), 'utf8')
|
||||
)
|
||||
const runSteps = workflow.jobs.cut.steps
|
||||
.filter((step) => typeof step.run === 'string')
|
||||
.map((step) => ({ name: step.name ?? '(unnamed)', run: step.run.replace(/^\s*#.*$/gm, '') }))
|
||||
const bumpStep = runSteps.find((step) => step.name === 'Bump package.json and tag')
|
||||
|
||||
// The load-bearing check: whatever staged it and however the commit was
|
||||
// spelled, only these two paths may ship. Asserted on the commit rather than
|
||||
// the index because `git commit -a/-i/--only/<pathspec>` bypasses the index.
|
||||
// -F is part of the contract; without it `.` admits a path like packageXjson.
|
||||
// Flags pinned, not just the command: a `--diff-filter` slipped in here would
|
||||
// silence modifications, and dropping -m makes a merge commit report nothing.
|
||||
expect(bumpStep.run).toMatch(
|
||||
/git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD\s*\|\s*grep -vxF -e 'package\.json' -e 'resources\/skills\/release-mapping\.json'/
|
||||
)
|
||||
expect(bumpStep.run.indexOf('grep -vxF')).toBeLessThan(bumpStep.run.indexOf('git tag'))
|
||||
// ...and that it aborts. A guard degraded to a warning still reads as covered.
|
||||
// The exit must be inside the guard's own block, not borrowed from a later one.
|
||||
expect(bumpStep.run).toMatch(
|
||||
/if \[\[ -n "\$committed" \]\]; then(?:(?!\bfi\b)[\s\S])*exit 1[\s\S]*?fi/
|
||||
)
|
||||
// Tripwire only. A step that merely READS this directory may be added here;
|
||||
// one that writes or stages it must not, and the guard above will reject it.
|
||||
expect(runSteps.filter((s) => /resources[/\\]skills/.test(s.run)).map((s) => s.name)).toEqual([
|
||||
'Bump package.json and tag'
|
||||
])
|
||||
for (const step of runSteps) {
|
||||
expect(step.run, step.name).not.toMatch(/--write|generate:skill-bundle-manifest/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
// Regenerate the electron-updater `.blockmap` for a (re-signed) Windows installer.
|
||||
// electron-builder 26 dropped the app-builder-bin Go binary; blockmap generation
|
||||
// now lives in app-builder-lib's pure-JS `buildBlockMap`. Mirrors createBlockmap's
|
||||
// "gzip" format for the standalone NSIS installer blockmap.
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const [input, output] = process.argv.slice(2)
|
||||
if (!input || !output) {
|
||||
console.error('usage: generate-windows-blockmap.mjs <input.exe> <output.exe.blockmap>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const { buildBlockMap } = require('app-builder-lib/out/targets/blockmap/blockmap')
|
||||
|
||||
const info = await buildBlockMap(input, 'gzip', output)
|
||||
console.log(`blockmap written: ${output} (installer sha512=${info.sha512}, size=${info.size})`)
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
|
||||
describe('Git binary compatibility PR gate', () => {
|
||||
it('runs the real-binary contract at each compatibility boundary', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const step = workflow.jobs.verify.steps.find(
|
||||
const step = workflow.jobs.git_compatibility.steps.find(
|
||||
(candidate) => candidate.name === 'Verify Git binary compatibility matrix'
|
||||
)
|
||||
|
||||
@@ -16,5 +16,8 @@ describe('Git binary compatibility PR gate', () => {
|
||||
expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1')
|
||||
expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"')
|
||||
expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts')
|
||||
expect(step?.run).toContain('-j"$(nproc)"')
|
||||
expect(step?.run).toContain('pids+=("$!")')
|
||||
expect(step?.run).toContain('wait "$pid" || status=1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: latency of loading one file diff, which reads two git blobs.
|
||||
//
|
||||
// The diff loaders in src/main/git/status.ts awaited their two sides in series,
|
||||
// so the second `git show` could not start until the first had fully returned.
|
||||
// The two reads are independent, so that serialization was pure added latency on
|
||||
// every diff the review panel opens.
|
||||
//
|
||||
// This spawns the real `git` binary against this repo, so it measures actual
|
||||
// process-launch and read cost rather than a model of it. Over SSH each diff is
|
||||
// one relay RPC and the two spawns run host-local inside the relay, so the same
|
||||
// relative saving applies to remote-host spawn time, not to network round trips.
|
||||
import { execFile } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const ITERATIONS = Number(process.env.ORCA_DIFF_BLOB_BENCH_ITERATIONS ?? '10')
|
||||
const WARMUP = Number(process.env.ORCA_DIFF_BLOB_BENCH_WARMUP ?? '3')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_DIFF_BLOB_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_DIFF_BLOB_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 256 * 1024 * 1024 }, (error, stdout) =>
|
||||
error ? reject(error) : resolve(stdout)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Pre-fix: await one side, then the other.
|
||||
async function readSequential(leftRef, rightRef, filePath) {
|
||||
const left = await git(['show', '--end-of-options', `${leftRef}:${filePath}`])
|
||||
const right = await git(['show', '--end-of-options', `${rightRef}:${filePath}`])
|
||||
return left.length + right.length
|
||||
}
|
||||
|
||||
// Post-fix: issue both, await together.
|
||||
async function readConcurrent(leftRef, rightRef, filePath) {
|
||||
const [left, right] = await Promise.all([
|
||||
git(['show', '--end-of-options', `${leftRef}:${filePath}`]),
|
||||
git(['show', '--end-of-options', `${rightRef}:${filePath}`])
|
||||
])
|
||||
return left.length + right.length
|
||||
}
|
||||
|
||||
// Why interleaved: running one strategy's whole batch before the other's lets
|
||||
// cache warming, CPU-frequency drift, and background load correlate with the
|
||||
// strategy being measured. Alternating per iteration and taking medians keeps
|
||||
// that drift common to both arms.
|
||||
async function measureInterleaved(leftRef, rightRef, filePath) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
await readSequential(leftRef, rightRef, filePath)
|
||||
await readConcurrent(leftRef, rightRef, filePath)
|
||||
}
|
||||
const sequentialSamples = []
|
||||
const concurrentSamples = []
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
// Alternate which arm goes first so neither systematically pays a cold cache.
|
||||
const sequentialFirst = index % 2 === 0
|
||||
for (const runSequential of sequentialFirst ? [true, false] : [false, true]) {
|
||||
const start = performance.now()
|
||||
await (runSequential ? readSequential : readConcurrent)(leftRef, rightRef, filePath)
|
||||
;(runSequential ? sequentialSamples : concurrentSamples).push(performance.now() - start)
|
||||
}
|
||||
}
|
||||
const median = (samples) => {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
return { sequential: median(sequentialSamples), concurrent: median(concurrentSamples) }
|
||||
}
|
||||
|
||||
const head = (await git(['rev-parse', 'HEAD'])).trim()
|
||||
const parent = `${head}~1`
|
||||
|
||||
// Files that exist on both sides, spanning small to large so the fixed spawn
|
||||
// cost and the size-dependent read cost are both represented.
|
||||
const CANDIDATES = [
|
||||
'src/main/git/status.ts',
|
||||
'src/shared/agent-hook-listener.ts',
|
||||
'src/renderer/src/components/TaskPage.tsx'
|
||||
]
|
||||
|
||||
const files = []
|
||||
for (const filePath of CANDIDATES) {
|
||||
try {
|
||||
await git(['cat-file', '-e', `${parent}:${filePath}`])
|
||||
await git(['cat-file', '-e', `${head}:${filePath}`])
|
||||
files.push(filePath)
|
||||
} catch {
|
||||
// Skip a path that does not exist on both sides in this checkout.
|
||||
}
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new Error('no benchmark file exists at both HEAD and HEAD~1 in this checkout')
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('One file diff = two git blob reads. Lower is better.')
|
||||
console.log(
|
||||
`iterations=${ITERATIONS} warmup=${WARMUP} (interleaved, medians) head=${head.slice(0, 9)}`
|
||||
)
|
||||
console.log(
|
||||
`${pad('file', 26)} ${pad('sequential', 12)} ${pad('concurrent', 12)} ${pad('speedup', 9)} ${pad('saved', 10)}`
|
||||
)
|
||||
for (const filePath of files) {
|
||||
const sequentialBytes = await readSequential(parent, head, filePath)
|
||||
const concurrentBytes = await readConcurrent(parent, head, filePath)
|
||||
if (sequentialBytes !== concurrentBytes) {
|
||||
throw new Error(`byte mismatch for ${filePath}`)
|
||||
}
|
||||
const { sequential, concurrent } = await measureInterleaved(parent, head, filePath)
|
||||
console.log(
|
||||
`${pad(filePath.split('/').pop(), 26)} ${pad(`${sequential.toFixed(1)} ms`, 12)} ${pad(`${concurrent.toFixed(1)} ms`, 12)} ${pad(`${(sequential / concurrent).toFixed(2)}x`, 9)} ${pad(`${(sequential - concurrent).toFixed(1)} ms`, 10)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nThe saving is per diff opened, and is dominated by process launch rather than\nfile size — which is why it holds roughly constant across these files.'
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import process from 'node:process'
|
||||
|
||||
export function selectPullRequestDiffBase(requestedBase, headParents, eventName) {
|
||||
if (eventName === 'pull_request' && headParents.length >= 2) {
|
||||
return headParents[0]
|
||||
}
|
||||
return requestedBase
|
||||
}
|
||||
|
||||
export function resolvePullRequestDiffBase(
|
||||
root,
|
||||
requestedBase,
|
||||
eventName = process.env.GITHUB_EVENT_NAME
|
||||
) {
|
||||
const [, ...headParents] = execFileSync('git', ['rev-list', '--parents', '-n', '1', 'HEAD'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
return selectPullRequestDiffBase(requestedBase, headParents, eventName)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectPullRequestDiffBase } from './git-pull-request-diff-base.mjs'
|
||||
|
||||
describe('pull request diff base selection', () => {
|
||||
it('uses the merge commit first parent for pull request checkouts', () => {
|
||||
expect(
|
||||
selectPullRequestDiffBase('event-base', ['current-base', 'pull-request-head'], 'pull_request')
|
||||
).toBe('current-base')
|
||||
})
|
||||
|
||||
it('keeps the requested base outside synthetic pull request merges', () => {
|
||||
expect(selectPullRequestDiffBase('requested-base', ['parent'], 'pull_request')).toBe(
|
||||
'requested-base'
|
||||
)
|
||||
expect(selectPullRequestDiffBase('requested-base', ['parent', 'other'], 'push')).toBe(
|
||||
'requested-base'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: the per-id worktree/tab lookups in session hydration and terminal reconnect.
|
||||
//
|
||||
// Four sites in store/slices/terminals.ts re-flattened worktreesByRepo (or tabsByWorktree)
|
||||
// and linearly searched it once per loop iteration -- O(rows x ids) for O(rows + ids)
|
||||
// distinct work. The fix builds one first-wins index per loop.
|
||||
//
|
||||
// This runs on the renderer's synchronous cold-start path and gates workspaceSessionReady,
|
||||
// which blocks terminal pane mounting, so the cost is paid before the first frame.
|
||||
//
|
||||
// Both arms produce the resolved rows and are compared for equality before timing, so an
|
||||
// index that resolved differently could not be reported as a win.
|
||||
//
|
||||
// Run with: node config/scripts/hydrate-worktree-lookup-benchmark.mjs
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const ITERATIONS = Number(process.env.ORCA_HYDRATE_BENCH_ITERATIONS ?? '60')
|
||||
const WARMUP = Number(process.env.ORCA_HYDRATE_BENCH_WARMUP ?? '10')
|
||||
const ROUNDS = 6
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_HYDRATE_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_HYDRATE_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fix: re-flatten and linear-search per id.
|
||||
function resolveByFlatten(worktreesByRepo, ids) {
|
||||
const resolved = []
|
||||
for (const id of ids) {
|
||||
const worktree = Object.values(worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === id)
|
||||
resolved.push(worktree ? worktree.repoId : null)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// Post-fix: mirrors buildWorktreeByIdIndex in store/slices/worktree-by-id-index.ts.
|
||||
function resolveByIndex(worktreesByRepo, ids) {
|
||||
const index = new Map()
|
||||
for (const worktrees of Object.values(worktreesByRepo)) {
|
||||
for (const worktree of worktrees) {
|
||||
if (!index.has(worktree.id)) {
|
||||
index.set(worktree.id, worktree)
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolved = []
|
||||
for (const id of ids) {
|
||||
const worktree = index.get(id)
|
||||
resolved.push(worktree ? worktree.repoId : null)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function makeStore(repoCount, worktreesPerRepo) {
|
||||
const worktreesByRepo = {}
|
||||
for (let repo = 0; repo < repoCount; repo += 1) {
|
||||
const repoId = `repo-${repo}`
|
||||
worktreesByRepo[repoId] = Array.from({ length: worktreesPerRepo }, (_value, index) => ({
|
||||
id: `${repoId}/wt-${index}`,
|
||||
repoId,
|
||||
path: `/Users/dev/worktrees/${repoId}/wt-${index}`,
|
||||
branch: `feature/branch-${index}`
|
||||
}))
|
||||
}
|
||||
// Why a deliberate duplicate: `.find()` is first-wins, so an index that overwrote on
|
||||
// collision would resolve a different repo. Without a collision in the fixture that
|
||||
// difference is unobservable and the equality check below would pass a broken index.
|
||||
if (repoCount > 1) {
|
||||
const [firstRepo, secondRepo] = Object.keys(worktreesByRepo)
|
||||
worktreesByRepo[secondRepo] = [
|
||||
{ ...worktreesByRepo[firstRepo][0], repoId: secondRepo },
|
||||
...worktreesByRepo[secondRepo]
|
||||
]
|
||||
}
|
||||
return worktreesByRepo
|
||||
}
|
||||
|
||||
// Why a miss fraction: SSH worktrees are absent from worktreesByRepo at cold start, so
|
||||
// the real workload includes ids that scan the whole list without matching -- the worst
|
||||
// case for the linear arm, and the one the code comments call out explicitly.
|
||||
function makeIds(worktreesByRepo, count) {
|
||||
const all = Object.values(worktreesByRepo).flat()
|
||||
const ids = Array.from({ length: count }, (_value, index) =>
|
||||
index % 7 === 0 ? `absent/wt-${index}` : all[(index * 31) % all.length].id
|
||||
)
|
||||
// Always look up the duplicated id, so first-wins is exercised, not just present.
|
||||
ids[1] = all[0].id
|
||||
return ids
|
||||
}
|
||||
|
||||
function timeArm(resolve, worktreesByRepo, ids) {
|
||||
let sink = 0
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
// Consume the result so V8 cannot drop the call as dead.
|
||||
sink += resolve(worktreesByRepo, ids).length
|
||||
}
|
||||
const elapsed = (performance.now() - start) / ITERATIONS
|
||||
if (sink === -1) {
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const mid = sorted.length / 2
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
// Arms alternate which one leads so within-round drift cannot favour either.
|
||||
function measure(worktreesByRepo, ids) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
resolveByFlatten(worktreesByRepo, ids)
|
||||
resolveByIndex(worktreesByRepo, ids)
|
||||
}
|
||||
const flattenSamples = []
|
||||
const indexSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids))
|
||||
indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids))
|
||||
} else {
|
||||
indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids))
|
||||
flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids))
|
||||
}
|
||||
}
|
||||
return { flattenMs: median(flattenSamples), indexMs: median(indexSamples) }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Session-hydration worktree lookup, per cold start. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`)
|
||||
console.log(
|
||||
`${pad('repos', 6)} ${pad('worktrees', 10)} ${pad('ids', 5)} ${pad('flatten', 11)} ${pad('indexed', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
// Row shapes are synthetic, sized against a real orca-data.json on a heavy machine
|
||||
// (10 repos / 423 worktrees / 188 pending reconnect). They are not that dataset: the
|
||||
// generator spreads worktrees evenly and injects one duplicate id, so treat the counts
|
||||
// as "about this scale", not a replay.
|
||||
for (const [repoCount, worktreesPerRepo, idCount] of [
|
||||
[1, 5, 5],
|
||||
[3, 20, 20],
|
||||
[10, 42, 188],
|
||||
[10, 100, 400]
|
||||
]) {
|
||||
const worktreesByRepo = makeStore(repoCount, worktreesPerRepo)
|
||||
const ids = makeIds(worktreesByRepo, idCount)
|
||||
const flattenResult = resolveByFlatten(worktreesByRepo, ids)
|
||||
const indexResult = resolveByIndex(worktreesByRepo, ids)
|
||||
if (JSON.stringify(flattenResult) !== JSON.stringify(indexResult)) {
|
||||
throw new Error(`resolution differs at ${repoCount} repos x ${worktreesPerRepo} worktrees`)
|
||||
}
|
||||
if (!flattenResult.some((value) => value !== null)) {
|
||||
throw new Error(`fixture resolved nothing at ${repoCount} repos`)
|
||||
}
|
||||
if (!flattenResult.some((value) => value === null)) {
|
||||
throw new Error(`fixture had no absent ids at ${repoCount} repos`)
|
||||
}
|
||||
// Count the generated rows rather than multiplying: makeStore injects a duplicate
|
||||
// id for multi-repo cases, so the product would misreport the fixture by one.
|
||||
const worktreeCount = Object.values(worktreesByRepo).reduce((sum, rows) => sum + rows.length, 0)
|
||||
const { flattenMs, indexMs } = measure(worktreesByRepo, ids)
|
||||
console.log(
|
||||
`${pad(repoCount, 6)} ${pad(worktreeCount, 10)} ${pad(idCount, 5)} ${pad(`${flattenMs.toFixed(4)} ms`, 11)} ${pad(`${indexMs.toFixed(4)} ms`, 11)} ${pad(`${(flattenMs / indexMs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nFixtures are synthetic at real-world scale, not a replay of a real session.\nThis times one of the four lookup sites. A one-repo session sees almost nothing;\nthe win scales with worktrees x pending ids, and lands on the cold-start path that\ngates terminal pane mounting.'
|
||||
)
|
||||
@@ -24,6 +24,20 @@ const { downloadArtifact } = electronRequire('@electron/get')
|
||||
const targetPlatform = getElectronTargetPlatform()
|
||||
const targetArch = getElectronTargetArch()
|
||||
const platformPath = getElectronPlatformPath(targetPlatform)
|
||||
const transientDownloadErrorCodes = new Set([
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'ENETDOWN',
|
||||
'ENETRESET',
|
||||
'ENETUNREACH',
|
||||
'ENOTFOUND',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
'UND_ERR_CONNECT_TIMEOUT',
|
||||
'UND_ERR_HEADERS_TIMEOUT',
|
||||
'UND_ERR_SOCKET'
|
||||
])
|
||||
|
||||
try {
|
||||
// Why: Electron's own install.js can exit 0 while an async extract promise is
|
||||
@@ -111,7 +125,7 @@ async function installElectronPackageBinary() {
|
||||
const extractDir = join(tempDir, 'extract')
|
||||
|
||||
try {
|
||||
const zipPath = await downloadArtifact({
|
||||
const downloadOptions = {
|
||||
version: electronVersion,
|
||||
artifactName: 'electron',
|
||||
platform: targetPlatform,
|
||||
@@ -120,7 +134,8 @@ async function installElectronPackageBinary() {
|
||||
force: true,
|
||||
tempDirectory: tempDir,
|
||||
...(shouldUseRemoteChecksums() ? {} : { checksums: electronRequire('./checksums.json') })
|
||||
})
|
||||
}
|
||||
const zipPath = await downloadElectronArtifactWithRetry(downloadOptions)
|
||||
|
||||
// Why: CI has observed partial extracts directly under node_modules/electron
|
||||
// that leave only dist/locales. Verify in temp before replacing package dist.
|
||||
@@ -145,6 +160,82 @@ async function installElectronPackageBinary() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadElectronArtifactWithRetry(downloadOptions) {
|
||||
const retryDelays = getDownloadRetryDelays()
|
||||
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
return await downloadArtifact(downloadOptions)
|
||||
} catch (error) {
|
||||
const retryDelay = retryDelays[attempt]
|
||||
if (retryDelay === undefined || !isTransientDownloadError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[electron-package] Transient Electron download failure (${formatDownloadError(error)}); ` +
|
||||
`retrying in ${retryDelay}ms (${attempt + 2}/${retryDelays.length + 1}).`
|
||||
)
|
||||
rmSync(downloadOptions.cacheRoot, { recursive: true, force: true })
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, retryDelay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloadRetryDelays() {
|
||||
const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS
|
||||
if (!configured) {
|
||||
return [1_000, 3_000]
|
||||
}
|
||||
|
||||
const delays = configured.split(',').map(Number)
|
||||
if (delays.some((delay) => !Number.isSafeInteger(delay) || delay < 0)) {
|
||||
throw new Error('ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS must contain non-negative integers')
|
||||
}
|
||||
return delays
|
||||
}
|
||||
|
||||
function isTransientDownloadError(error) {
|
||||
for (const candidate of getErrorChain(error)) {
|
||||
if (transientDownloadErrorCodes.has(candidate?.code)) {
|
||||
return true
|
||||
}
|
||||
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
|
||||
if (
|
||||
statusCode === 408 ||
|
||||
statusCode === 425 ||
|
||||
statusCode === 429 ||
|
||||
(statusCode >= 500 && statusCode < 600)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getErrorChain(error) {
|
||||
const errors = []
|
||||
let candidate = error
|
||||
while (candidate && errors.length < 5) {
|
||||
errors.push(candidate)
|
||||
candidate = candidate.cause
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
function formatDownloadError(error) {
|
||||
for (const candidate of getErrorChain(error)) {
|
||||
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
|
||||
if (statusCode) {
|
||||
return `HTTP ${statusCode}`
|
||||
}
|
||||
if (candidate?.code) {
|
||||
return candidate.code
|
||||
}
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function extractElectronArchive(zipPath, extractDir) {
|
||||
mkdirSync(extractDir, { recursive: true })
|
||||
// Why: extract-zip/Electron install.js can leave Node 24 with an unsettled
|
||||
|
||||
@@ -93,6 +93,102 @@ describe('install-electron-package-binary', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries transient Electron download failures', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 1,
|
||||
downloadErrorCode: 'ECONNRESET'
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(2)
|
||||
expect(result.stderr).toContain('Transient Electron download failure (ECONNRESET)')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails after exhausting transient Electron download retries', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 5,
|
||||
downloadErrorCode: 'ECONNRESET'
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(3)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid Electron download retry delays before downloading', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,nope'
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS must contain non-negative integers'
|
||||
)
|
||||
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not retry permanent Electron download failures', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 3,
|
||||
downloadErrorCode: 'EACCES'
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(1)
|
||||
expect(result.stderr).not.toContain('Transient Electron download failure')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails instead of silently accepting a partial Electron extract', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
@@ -181,7 +277,10 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) {
|
||||
function writeFakeElectronGet(
|
||||
projectDir,
|
||||
{ downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {}
|
||||
) {
|
||||
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
|
||||
mkdirSync(getDir, { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -189,7 +288,9 @@ function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {})
|
||||
`
|
||||
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
let downloadAttempt = 0
|
||||
exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
downloadAttempt += 1
|
||||
appendFileSync(
|
||||
'electron-get.log',
|
||||
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + '\\n'
|
||||
@@ -197,6 +298,12 @@ exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
if (${JSON.stringify(downloadNeverSettles)}) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
|
||||
const cause = Object.assign(new Error('download failed'), {
|
||||
code: ${JSON.stringify(downloadErrorCode)}
|
||||
})
|
||||
throw Object.assign(new TypeError('fetch failed'), { cause })
|
||||
}
|
||||
mkdirSync(details.cacheRoot, { recursive: true })
|
||||
const artifactPath = join(details.cacheRoot, 'electron.zip')
|
||||
writeFileSync(artifactPath, 'fake zip')
|
||||
|
||||
@@ -200,7 +200,7 @@ export async function setupTerminal(page, repoPath, logPhase) {
|
||||
return pane?.container?.dataset?.ptyId ?? null
|
||||
})
|
||||
)
|
||||
logPhase('setup.pty-bound', `ptyId=${ptyId}`)
|
||||
logPhase('setup.pty-bound', `ptyId=${String(ptyId)}`)
|
||||
return ptyId
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
// The theme-picker count row renders "Showing {count}" immediately followed by one of these
|
||||
// fragments, so translations must keep a leading separator or the numbers fuse ("표시 중 3030 중").
|
||||
describe('locale-count-fragment-separator', () => {
|
||||
it('keeps a slash between shown and total theme counts in CJK locales', () => {
|
||||
const brokenByLocale = { ko: '{{value0}} 중', ja: '{{value0}}の', zh: '{{value0}} 的' }
|
||||
for (const [locale, localeValue] of Object.entries(brokenByLocale)) {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.SettingsFormControls.cb330ef7f8',
|
||||
enValue: ' of {{value0}}',
|
||||
localeValue,
|
||||
locale
|
||||
})
|
||||
).toBe('/{{value0}}')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a leading space before the search-match fragment in CJK locales', () => {
|
||||
const cases = [
|
||||
['ko', '"{{value0}}"과(와) 일치', ' "{{value0}}"과(와) 일치'],
|
||||
['ja', '「{{value0}}」に一致', ' 「{{value0}}」に一致'],
|
||||
['zh', '匹配“{{value0}}”', ' 匹配“{{value0}}”']
|
||||
]
|
||||
for (const [locale, localeValue, repaired] of cases) {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.SettingsFormControls.c822571b2e',
|
||||
enValue: ' matching "{{value0}}"',
|
||||
localeValue,
|
||||
locale
|
||||
})
|
||||
).toBe(repaired)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,18 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
|
||||
zh: '集成',
|
||||
ja: '連携'
|
||||
},
|
||||
// Search-match fragment concatenated flush after the visible theme count; MT dropped the
|
||||
// en leading space that separates it from the count.
|
||||
'auto.components.settings.SettingsFormControls.c822571b2e': {
|
||||
zh: ' 匹配“{{value0}}”',
|
||||
ja: ' 「{{value0}}」に一致'
|
||||
},
|
||||
// Total-count fragment on the same row; without the separator "Showing 30 of 30"
|
||||
// rendered as "表示中 3030の" / "显示中 3030 的".
|
||||
'auto.components.settings.SettingsFormControls.cb330ef7f8': {
|
||||
zh: '/{{value0}}',
|
||||
ja: '/{{value0}}'
|
||||
},
|
||||
'auto.components.settings.TasksPane.6b23a34f6d': {
|
||||
zh: 'Jira',
|
||||
ja: 'Jira'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs'
|
||||
import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs'
|
||||
import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs'
|
||||
|
||||
export function mergeLocaleKeyOverrides(base) {
|
||||
const merged = { ...base }
|
||||
@@ -10,5 +11,8 @@ export function mergeLocaleKeyOverrides(base) {
|
||||
// KO split overrides can share keys with zh/ja repairs; merge per locale.
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) {
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -2231,9 +2231,6 @@
|
||||
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
|
||||
"ko": "마이크"
|
||||
},
|
||||
"auto.components.settings.DeveloperPermissionsPane.7ca17b62c8": {
|
||||
"ko": "프로젝트, 워크트리 또는 심볼릭 링크된 파일이 macOS 보호 폴더에 닿을 때 권장됩니다."
|
||||
},
|
||||
"auto.components.settings.DeveloperPermissionsPane.e119f0d66b": {
|
||||
"ko": "자동화"
|
||||
},
|
||||
@@ -2667,7 +2664,10 @@
|
||||
"ko": "SSH를 통해 기존 머신의 파일, terminals, Git, 워크스페이스를 사용합니다."
|
||||
},
|
||||
"auto.components.settings.SettingsFormControls.c822571b2e": {
|
||||
"ko": "\"{{value0}}\"과(와) 일치"
|
||||
"ko": " \"{{value0}}\"과(와) 일치"
|
||||
},
|
||||
"auto.components.settings.SettingsFormControls.cb330ef7f8": {
|
||||
"ko": "/{{value0}}"
|
||||
},
|
||||
"auto.components.settings.SettingsFormControls.fbb428db98": {
|
||||
"ko": "선택됨:"
|
||||
@@ -2811,7 +2811,7 @@
|
||||
"ko": "{{value0}} 메가바이트"
|
||||
},
|
||||
"auto.components.settings.TerminalPane.6e6480a7df": {
|
||||
"ko": "terminal의 프로그램(tmux, Neovim, fzf, SSH)이 시스템 클립보드에 복사할 수 있게 합니다."
|
||||
"ko": "terminal의 프로그램(Zellij, tmux, Neovim, fzf, Grok, SSH)이 시스템 클립보드에 복사할 수 있게 합니다."
|
||||
},
|
||||
"auto.components.settings.TerminalPane.8eefeaa3da": {
|
||||
"ko": "마우스를 따라 포커스 이동"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const MACOS_TCC_KEY_OVERRIDES = {
|
||||
'auto.hooks.useMacosTccPromptNotice.description': {
|
||||
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
|
||||
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
|
||||
ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
|
||||
zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。'
|
||||
},
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
|
||||
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
|
||||
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
|
||||
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
|
||||
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
|
||||
},
|
||||
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
|
||||
ko: '전체 디스크 접근 권한',
|
||||
zh: '完全磁盘访问权限'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const LOCALIZED_PROSE_TERM_KEYS = new Set([
|
||||
'auto.hooks.useMacosTccPromptNotice.description',
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
|
||||
])
|
||||
|
||||
const LOCALIZABLE_PROSE_TERMS = new Set([
|
||||
'Agent',
|
||||
'Agents',
|
||||
'agent',
|
||||
'agents',
|
||||
'Terminal',
|
||||
'Terminals',
|
||||
'terminal',
|
||||
'terminals'
|
||||
])
|
||||
|
||||
export function isLocalizedProseTermContext(term, key) {
|
||||
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
|
||||
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
|
||||
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
|
||||
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
@@ -382,6 +383,9 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
if (isScreenCursorContext(brand, enValue, key)) {
|
||||
continue
|
||||
}
|
||||
if (isLocalizedProseTermContext(brand, key)) {
|
||||
continue
|
||||
}
|
||||
if (includesPreservedLatinTerm(result, brand)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'Open the PR details to view current reviewers.': '打开 PR 详情以查看当前评审人。',
|
||||
'Review comment added.': '已添加评审评论。',
|
||||
'Loading labels': '加载标签',
|
||||
// Why: MT rendered the "Pin Tab" action as "引脚标签" (noun reading of "pin"); pair it with 取消固定标签.
|
||||
'Pin Tab': '固定标签',
|
||||
Approved: '已批准',
|
||||
Strike: '删除线',
|
||||
Bold: '粗体',
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const compatibilityContract = require('../../src/shared/local-build-compatibility-contract.json')
|
||||
|
||||
const MAC_BUILD_COMPATIBILITY_FILENAME = 'orca-local-build.json'
|
||||
|
||||
function createMacBuildCompatibility({ version, commit, architecture }) {
|
||||
if (architecture !== 'arm64' && architecture !== 'x64') {
|
||||
throw new Error(`Unsupported macOS build architecture: ${architecture}`)
|
||||
}
|
||||
return {
|
||||
...compatibilityContract,
|
||||
buildId: `${version}-${commit}-${architecture}`,
|
||||
version,
|
||||
commit,
|
||||
platform: 'darwin',
|
||||
architecture
|
||||
}
|
||||
}
|
||||
|
||||
function writeMacBuildCompatibility(resourcesDir, identity) {
|
||||
const compatibility = createMacBuildCompatibility(identity)
|
||||
writeFileSync(
|
||||
join(resourcesDir, MAC_BUILD_COMPATIBILITY_FILENAME),
|
||||
`${JSON.stringify(compatibility, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAC_BUILD_COMPATIBILITY_FILENAME,
|
||||
createMacBuildCompatibility,
|
||||
writeMacBuildCompatibility
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { createMacBuildCompatibility } = require('./mac-build-compatibility.cjs')
|
||||
|
||||
describe('mac build compatibility metadata', () => {
|
||||
it('binds version, commit, and architecture into the packaged contract', () => {
|
||||
expect(
|
||||
createMacBuildCompatibility({
|
||||
version: '1.2.3-local.1',
|
||||
commit: 'abc123',
|
||||
architecture: 'arm64'
|
||||
})
|
||||
).toMatchObject({
|
||||
formatVersion: 1,
|
||||
appId: 'com.stablyai.orca',
|
||||
buildId: '1.2.3-local.1-abc123-arm64',
|
||||
version: '1.2.3-local.1',
|
||||
commit: 'abc123',
|
||||
stateSchemaVersion: 1,
|
||||
platform: 'darwin',
|
||||
architecture: 'arm64'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unsupported architecture metadata', () => {
|
||||
expect(() =>
|
||||
createMacBuildCompatibility({
|
||||
version: '1.2.3',
|
||||
commit: 'abc123',
|
||||
architecture: 'universal'
|
||||
})
|
||||
).toThrow('Unsupported macOS build architecture')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
const LOCALES = ['es', 'ja', 'ko', 'zh']
|
||||
const KEYS = [
|
||||
'auto.hooks.useMacosTccPromptNotice.title',
|
||||
'auto.hooks.useMacosTccPromptNotice.description',
|
||||
'auto.hooks.useMacosTccPromptNotice.openSettings',
|
||||
'auto.hooks.useMacosTccPromptNotice.dismiss',
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
|
||||
'auto.components.settings.DeveloperPermissionsPane.c566bca278'
|
||||
]
|
||||
|
||||
function readCatalog(locale) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(
|
||||
new URL(`../../src/renderer/src/i18n/locales/${locale}.json`, import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getValue(catalog, key) {
|
||||
return key.split('.').reduce((value, part) => value[part], catalog)
|
||||
}
|
||||
|
||||
describe('macOS TCC prompt localization', () => {
|
||||
it('survives the canonical catalog repair policy', () => {
|
||||
const english = readCatalog('en')
|
||||
for (const locale of LOCALES) {
|
||||
const catalog = readCatalog(locale)
|
||||
for (const key of KEYS) {
|
||||
const enValue = getValue(english, key)
|
||||
const localeValue = getValue(catalog, key)
|
||||
expect(repairTranslatedValue({ key, enValue, localeValue, locale })).toBe(localeValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the macOS Full Disk Access labels in Korean and Chinese', () => {
|
||||
expect(
|
||||
getValue(readCatalog('ko'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
|
||||
).toBe('전체 디스크 접근 권한')
|
||||
expect(
|
||||
getValue(readCatalog('zh'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
|
||||
).toBe('完全磁盘访问权限')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: cost of the mobile agent-status projection per store mutation.
|
||||
//
|
||||
// buildRuntimeMobileAgentStatusProjection runs on the App.tsx global store
|
||||
// subscriber. setAgentStatus replaces one entry and re-spreads
|
||||
// agentStatusByPaneKey, which defeats the reference-equality skip gate, so before
|
||||
// the fix EVERY live agent was re-serialized on EVERY status ping — each carrying
|
||||
// a prompt, a 20-entry stateHistory, toolInput, and an 8 KB-capped
|
||||
// lastAssistantMessage.
|
||||
//
|
||||
// The fix memoizes each row's JSON by entry identity, mirroring the
|
||||
// cachedTabsProjection pattern already in the same file, so a ping re-serializes
|
||||
// only the agent that actually changed.
|
||||
//
|
||||
// The bucket width is re-read from the real module so a drifted constant fails
|
||||
// loudly here instead of quietly changing what this benchmark measures.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const GRAPH_SOURCE = readFileSync(
|
||||
fileURLToPath(new URL('../../src/renderer/src/runtime/sync-runtime-graph.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const bucketMatch = GRAPH_SOURCE.match(/AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = ([0-9_]+)/)
|
||||
if (!bucketMatch) {
|
||||
throw new Error(
|
||||
'sync-runtime-graph.ts no longer defines the updatedAt bucket; re-sync this benchmark.'
|
||||
)
|
||||
}
|
||||
const BUCKET_MS = Number(bucketMatch[1].replaceAll('_', ''))
|
||||
|
||||
const ITERATIONS = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_ITERATIONS ?? '400', 10)
|
||||
const WARMUP = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_WARMUP ?? '60', 10)
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_AGENT_PROJECTION_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_AGENT_PROJECTION_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function toRow(paneKey, entry) {
|
||||
return {
|
||||
paneKey,
|
||||
entryPaneKey: entry.paneKey,
|
||||
state: entry.state,
|
||||
prompt: entry.prompt,
|
||||
updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS),
|
||||
stateStartedAt: entry.stateStartedAt,
|
||||
agentType: entry.agentType ?? null,
|
||||
terminalTitle: entry.terminalTitle ?? null,
|
||||
stateHistory: entry.stateHistory.map((history) => ({
|
||||
state: history.state,
|
||||
prompt: history.prompt,
|
||||
startedAt: history.startedAt,
|
||||
interrupted: history.interrupted ?? null
|
||||
})),
|
||||
toolName: entry.toolName ?? null,
|
||||
toolInput: entry.toolInput ?? null,
|
||||
interactivePrompt: entry.interactivePrompt ?? null,
|
||||
lastAssistantMessage: entry.lastAssistantMessage ?? null,
|
||||
interrupted: entry.interrupted ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function serializeEntry(paneKey, entry) {
|
||||
return JSON.stringify(toRow(paneKey, entry))
|
||||
}
|
||||
|
||||
// Pre-fix: build plain rows and stringify the array once — no per-row roundtrip,
|
||||
// which the original never paid and which would inflate the reported speedup.
|
||||
function buildFull(map) {
|
||||
return JSON.stringify(
|
||||
Object.entries(map)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([paneKey, entry]) => toRow(paneKey, entry))
|
||||
)
|
||||
}
|
||||
|
||||
// Post-fix: reuse each row's JSON while its entry object is unchanged.
|
||||
function makeCachedBuilder() {
|
||||
let cache = null
|
||||
return (map) => {
|
||||
if (cache?.source === map) {
|
||||
return cache.projection
|
||||
}
|
||||
const previous = cache?.entries
|
||||
const entries = new Map()
|
||||
const parts = []
|
||||
for (const [paneKey, entry] of Object.entries(map).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const prior = previous?.get(paneKey)
|
||||
const row =
|
||||
prior?.entry === entry ? prior : { entry, projection: serializeEntry(paneKey, entry) }
|
||||
entries.set(paneKey, row)
|
||||
parts.push(row.projection)
|
||||
}
|
||||
const projection = `[${parts.join(',')}]`
|
||||
cache = { source: map, entries, projection }
|
||||
return projection
|
||||
}
|
||||
}
|
||||
|
||||
// A live agent as the store actually holds it.
|
||||
function makeEntry(index, updatedAt) {
|
||||
return {
|
||||
paneKey: `tab-${index}:leaf-0`,
|
||||
state: 'working',
|
||||
prompt: 'implement the feature and run the tests '.repeat(4),
|
||||
updatedAt,
|
||||
stateStartedAt: 1740000000000,
|
||||
agentType: 'claude',
|
||||
terminalTitle: `agent ${index}`,
|
||||
stateHistory: Array.from({ length: 20 }, (_value, step) => ({
|
||||
state: 'working',
|
||||
prompt: `step ${step} of the current turn`,
|
||||
startedAt: 1740000000000 + step,
|
||||
interrupted: null
|
||||
})),
|
||||
toolName: 'shell_command',
|
||||
toolInput: 'rg --line-number "pattern" src/ '.repeat(8),
|
||||
interactivePrompt: null,
|
||||
// The cap the store applies to assistant text.
|
||||
lastAssistantMessage: 'x'.repeat(8000),
|
||||
interrupted: null
|
||||
}
|
||||
}
|
||||
|
||||
function makeMap(agents) {
|
||||
const map = {}
|
||||
for (let index = 0; index < agents; index += 1) {
|
||||
map[`tab-${index}:leaf-0`] = makeEntry(index, 1740000000000 + index * BUCKET_MS)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// One status ping: one entry replaced, the map re-spread, every other entry
|
||||
// reference-identical — exactly what setAgentStatus produces.
|
||||
function ping(map, round) {
|
||||
return {
|
||||
...map,
|
||||
'tab-0:leaf-0': makeEntry(0, 1740000000000 + BUCKET_MS * (round + 1))
|
||||
}
|
||||
}
|
||||
|
||||
function measure(build, map) {
|
||||
let current = map
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
current = ping(current, index)
|
||||
build(current)
|
||||
}
|
||||
const samples = []
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
current = ping(current, index)
|
||||
build(current)
|
||||
}
|
||||
samples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
samples.sort((a, b) => a - b)
|
||||
return samples[2]
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Mobile agent-status projection, per status ping (one agent changed)')
|
||||
console.log(`bucket=${BUCKET_MS}ms iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
|
||||
console.log(`${pad('agents', 8)} ${pad('full', 11)} ${pad('cached', 11)} ${pad('speedup', 9)}`)
|
||||
for (const agents of [3, 8, 20, 40]) {
|
||||
const map = makeMap(agents)
|
||||
const cachedBuilder = makeCachedBuilder()
|
||||
if (buildFull(map) !== cachedBuilder(map)) {
|
||||
throw new Error(`projection mismatch at ${agents} agents`)
|
||||
}
|
||||
// Why also after a ping: the cold call reuses nothing, so a stale-row bug would
|
||||
// only surface once the cache is actually exercised.
|
||||
const pinged = ping(map, 0)
|
||||
if (buildFull(pinged) !== cachedBuilder(pinged)) {
|
||||
throw new Error(`projection mismatch after a ping at ${agents} agents`)
|
||||
}
|
||||
const full = measure(buildFull, map)
|
||||
const cached = measure(makeCachedBuilder(), map)
|
||||
console.log(
|
||||
`${pad(agents, 8)} ${pad(`${full.toFixed(4)} ms`, 11)} ${pad(`${cached.toFixed(4)} ms`, 11)} ${pad(`${(full / cached).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nThis runs on the global store subscriber, so the cost is paid per status ping\nand scales with the number of agents running in parallel — the workload this\napp exists for.'
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const pluginPath = path.resolve('config/oxlint-plugins/mobile-pairing-qrcode-import.mjs')
|
||||
const oxlintPath = path.resolve(
|
||||
process.platform === 'win32' ? 'node_modules/.bin/oxlint.cmd' : 'node_modules/.bin/oxlint'
|
||||
)
|
||||
|
||||
function lintSource(source) {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'orca-qrcode-import-lint-'))
|
||||
const sourcePath = path.join(directory, 'sample.ts')
|
||||
const configPath = path.join(directory, 'oxlint.json')
|
||||
writeFileSync(sourcePath, source)
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
categories: { correctness: 'off' },
|
||||
jsPlugins: [{ name: 'mobile-pairing', specifier: pluginPath }],
|
||||
rules: { 'mobile-pairing/no-eager-qrcode-import': 'error' }
|
||||
})
|
||||
)
|
||||
const result = spawnSync(oxlintPath, ['--config', configPath, '--format', 'json', sourcePath], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
return JSON.parse(result.stdout).diagnostics
|
||||
}
|
||||
|
||||
describe('mobile pairing qrcode import rule', () => {
|
||||
it('rejects eager runtime imports', () => {
|
||||
const diagnostics = lintSource("import QRCode from 'qrcode'\nvoid QRCode")
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
||||
'mobile-pairing(no-eager-qrcode-import)'
|
||||
])
|
||||
})
|
||||
|
||||
it('allows type-only and lazy imports', () => {
|
||||
expect(lintSource("import type QRCode from 'qrcode'\nlet qr: typeof QRCode")).toEqual([])
|
||||
expect(lintSource("const QRCode = await import('qrcode')\nvoid QRCode")).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
assertPatchedNodePtyConsoleListAgent,
|
||||
patchNodePtyConsoleListAgent
|
||||
} = require('../relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs')
|
||||
const projectDir = resolve(import.meta.dirname, '..', '..')
|
||||
const cleanupDirs = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of cleanupDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Windows SSH relay node-pty console-list patch', () => {
|
||||
it('installs and verifies the fallback idempotently', () => {
|
||||
const fixture = writeNodePtyFixture('1.1.0', publishedAgentSource())
|
||||
|
||||
patchNodePtyConsoleListAgent(fixture.root)
|
||||
const once = readFileSync(fixture.agentPath, 'utf8')
|
||||
expect(once).toContain('consoleProcessList = [shellPid];')
|
||||
expect(existsSync(`${fixture.agentPath}.orca-patch-${process.pid}`)).toBe(false)
|
||||
expect(() => assertPatchedNodePtyConsoleListAgent(fixture.root)).not.toThrow()
|
||||
|
||||
patchNodePtyConsoleListAgent(fixture.root)
|
||||
expect(readFileSync(fixture.agentPath, 'utf8')).toBe(once)
|
||||
})
|
||||
|
||||
it('refuses a different package version or unexpected agent source', () => {
|
||||
const wrongVersion = writeNodePtyFixture('1.2.0-beta.11', publishedAgentSource())
|
||||
expect(() => patchNodePtyConsoleListAgent(wrongVersion.root)).toThrow('expected 1.1.0')
|
||||
|
||||
const drifted = writeNodePtyFixture('1.1.0', `${publishedAgentSource()}\n// drift`)
|
||||
expect(() => patchNodePtyConsoleListAgent(drifted.root)).toThrow('unexpected node-pty')
|
||||
|
||||
const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource())
|
||||
patchNodePtyConsoleListAgent(tamperedPatch.root)
|
||||
writeFileSync(
|
||||
tamperedPatch.agentPath,
|
||||
`${readFileSync(tamperedPatch.agentPath, 'utf8')}\n// drift`
|
||||
)
|
||||
expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed')
|
||||
})
|
||||
})
|
||||
|
||||
function writeNodePtyFixture(version, agentSource) {
|
||||
const root = mkdtempSync(join(projectDir, '.node-pty-console-list-patch-test-'))
|
||||
cleanupDirs.push(root)
|
||||
const nodePtyDir = join(root, 'node_modules', 'node-pty')
|
||||
const libDir = join(nodePtyDir, 'lib')
|
||||
const agentPath = join(libDir, 'conpty_console_list_agent.js')
|
||||
mkdirSync(libDir, { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version }))
|
||||
writeFileSync(agentPath, agentSource)
|
||||
return { root, agentPath }
|
||||
}
|
||||
|
||||
function publishedAgentSource() {
|
||||
return [
|
||||
'"use strict";',
|
||||
'/**',
|
||||
' * Copyright (c) 2019, Microsoft Corporation (MIT License).',
|
||||
' *',
|
||||
' * This module fetches the console process list for a particular PID. It must be',
|
||||
' * called from a different process (child_process.fork) as there can only be a',
|
||||
' * single console attached to a process.',
|
||||
' */',
|
||||
'Object.defineProperty(exports, "__esModule", { value: true });',
|
||||
'var utils_1 = require("./utils");',
|
||||
"var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;",
|
||||
'var shellPid = parseInt(process.argv[2], 10);',
|
||||
'var consoleProcessList = getConsoleProcessList(shellPid);',
|
||||
'process.send({ consoleProcessList: consoleProcessList });',
|
||||
'process.exit(0);',
|
||||
'//# sourceMappingURL=conpty_console_list_agent.js.map'
|
||||
].join('\n')
|
||||
}
|
||||
@@ -3,11 +3,17 @@ import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const skillPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md')
|
||||
const orchestrationSkillPath = join(projectDir, 'skills', 'orchestration', 'SKILL.md')
|
||||
const emulatorSkillPath = join(projectDir, 'skills', 'orca-emulator', 'SKILL.md')
|
||||
// Why: orca-cli now ships a hybrid discovery stub, so its version-sensitive command
|
||||
// guidance lives in the authoritative guide source — assert that content there. The
|
||||
// installable stub projection is checked separately below.
|
||||
const guidePath = join(projectDir, 'skill-guides', 'orca-cli.md')
|
||||
const stubPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md')
|
||||
// Why: orchestration and orca-emulator also ship hybrid stubs now, so their version-sensitive
|
||||
// command guidance lives in the guide sources — read the cross-guide worktree-id contract there.
|
||||
const orchestrationSkillPath = join(projectDir, 'skill-guides', 'orchestration.md')
|
||||
const emulatorSkillPath = join(projectDir, 'skill-guides', 'orca-emulator.md')
|
||||
|
||||
function readSkill(path = skillPath) {
|
||||
function readSkill(path = guidePath) {
|
||||
return readFileSync(path, 'utf8')
|
||||
}
|
||||
|
||||
@@ -40,7 +46,7 @@ describe('orca CLI skill guidance', () => {
|
||||
'`task-create` is also forbidden because it records coordinator-owned tracking state'
|
||||
)
|
||||
expect(skill).toContain(
|
||||
'orca worktree create --name <task-name> --no-parent --agent codex --prompt'
|
||||
'ORCA worktree create --name <task-name> --no-parent --agent codex --prompt'
|
||||
)
|
||||
expect(skill).toContain('codex --model gpt-5.5 -c model_reasoning_effort="xhigh"')
|
||||
expect(skill).toContain('wait only for TUI readiness if needed to avoid losing input')
|
||||
@@ -99,3 +105,51 @@ describe('orca CLI skill guidance', () => {
|
||||
expect(skill).not.toContain('live_sk_')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca CLI install stub', () => {
|
||||
it('points at the version-matched guide and preserves the safe resolver', () => {
|
||||
const stub = readSkill(stubPath)
|
||||
|
||||
expect(stub).toContain('discovery stub')
|
||||
expect(stub).toContain('ORCA skills get orca-cli')
|
||||
// The safe CLI-resolution contract must survive in the stub, never a bare `orca`.
|
||||
expect(stub).toContain('ORCA_CLI_COMMAND')
|
||||
expect(stub).toContain('orca-dev')
|
||||
expect(stub).toContain('orca-ide')
|
||||
expect(stub).toContain('GNOME Orca screen reader')
|
||||
expect(stub).not.toMatch(/^orca /mu)
|
||||
})
|
||||
|
||||
it('gives older binaries a bounded fallback instead of a dead end', () => {
|
||||
const stub = readSkill(stubPath).replace(/\s+/gu, ' ')
|
||||
|
||||
expect(stub).toContain('explicitly reports that `skills get` is an unknown command')
|
||||
expect(stub).toContain('do not invent commands')
|
||||
expect(stub).toContain('ask the user rather than guessing')
|
||||
})
|
||||
|
||||
it('does not mistake resolution or execution failures for an older binary', () => {
|
||||
const stub = readSkill(stubPath).replace(/\s+/gu, ' ')
|
||||
|
||||
// Falling through can silently pair a version-matched guide with the wrong Orca build.
|
||||
expect(stub).toContain('report its exact error and stop')
|
||||
expect(stub).toContain('Do not fall through to another executable')
|
||||
expect(stub).toContain('Another failure is not proof of an older binary')
|
||||
})
|
||||
|
||||
it('drops the changing command reference from the installable file', () => {
|
||||
const stub = readSkill(stubPath)
|
||||
|
||||
// Version-sensitive command detail lives in the binary-served guide now, not here.
|
||||
expect(stub).not.toContain('Prefer agent-first create for agent workers')
|
||||
expect(stub).not.toContain('--parent-worktree')
|
||||
expect(stub).not.toContain('ORCA automations create')
|
||||
expect(stub.length).toBeLessThan(readSkill(guidePath).length)
|
||||
})
|
||||
|
||||
it('keeps the routing frontmatter identical to the guide', () => {
|
||||
const frontmatter = (text) => /^---\n[\s\S]*?\n---\n/u.exec(text)[0]
|
||||
|
||||
expect(frontmatter(readSkill(stubPath))).toBe(frontmatter(readSkill(guidePath)))
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user