mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/pty-provider-types-split
# Conflicts: # src/main/providers/types.ts
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Product source remains owned by its normal reviewers; localization inputs need focused review.
|
||||
/src/renderer/src/i18n/locales/ @brennanb2025
|
||||
/config/scripts/*localization*.mjs @brennanb2025
|
||||
/config/scripts/*locale*.mjs @brennanb2025
|
||||
/i18next.config.ts @brennanb2025
|
||||
@@ -6,6 +6,10 @@ inputs:
|
||||
description: Native runtime to prepare after the script-free install (none, node, or electron).
|
||||
required: false
|
||||
default: none
|
||||
node-version:
|
||||
description: Node.js version override; defaults to the version declared in package.json.
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -17,11 +21,19 @@ runs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: inputs.node-version == ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup requested Node.js
|
||||
if: inputs.node-version != ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate native runtime
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- 'config/scripts/build-windows-cli-launcher.mjs'
|
||||
- 'config/scripts/build-windows-cli-launcher.test.mjs'
|
||||
- 'config/scripts/computer-e2e-workflow.test.mjs'
|
||||
- 'config/scripts/computer-use-modifier-safety.test.mjs'
|
||||
- 'config/scripts/computer-use-skill-guidance.test.mjs'
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
- 'config/scripts/computer-use-smoke.test.mjs'
|
||||
@@ -84,6 +85,7 @@ jobs:
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/computer-use-modifier-safety.test.mjs
|
||||
config/scripts/computer-use-skill-guidance.test.mjs
|
||||
config/scripts/computer-use-smoke.test.mjs
|
||||
src/main/computer/computer-provider-lifecycle.test.ts
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
name: E2E
|
||||
|
||||
run-name: E2E ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: checkout + artifact upload only; callers can only further restrict.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
@@ -76,7 +82,7 @@ jobs:
|
||||
name: e2e ${{ matrix.shard_name }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -178,7 +184,9 @@ jobs:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Why 35: the terminal parking + retention specs below add two more
|
||||
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
|
||||
timeout-minutes: 35
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -221,6 +229,13 @@ jobs:
|
||||
- name: Run Docker SSH watcher isolation E2E
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
|
||||
|
||||
# Why always(): these specs gate the C1 terminal parking/retention budget
|
||||
# over a real SSH host and run in no other lane, so a watcher-isolation
|
||||
# failure above must not silently skip them.
|
||||
- name: Run Docker SSH terminal parking + retention E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
|
||||
|
||||
- name: Upload watcher isolation traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
@@ -31,8 +32,20 @@ jobs:
|
||||
- name: Lint
|
||||
run: pnpm exec oxlint --format github
|
||||
|
||||
- name: Check styled scrollbars
|
||||
run: pnpm check:styled-scrollbars
|
||||
- name: Enforce focused code-quality plugins
|
||||
run: pnpm run audit:code-quality:native
|
||||
|
||||
- name: Enforce type-aware code-quality baseline
|
||||
run: pnpm run audit:code-quality:type-aware
|
||||
|
||||
- name: Enforce changed-code quality
|
||||
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
- name: Enforce React Doctor on changed lines
|
||||
run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
- name: Check Zustand selector fan-out budget
|
||||
run: pnpm run check:zustand-selector-fanout
|
||||
|
||||
- name: Check reliability gate manifest
|
||||
run: pnpm run check:reliability-gates
|
||||
@@ -46,6 +59,22 @@ jobs:
|
||||
- name: Verify skill freshness manifest
|
||||
run: pnpm run verify:skill-bundle-manifest
|
||||
|
||||
- name: Verify localization catalog
|
||||
run: pnpm run verify:localization-catalog
|
||||
|
||||
# Why: extraction writes sorted evidence to an isolated temporary path,
|
||||
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
|
||||
- name: Verify localization extraction
|
||||
run: pnpm run verify:localization-extraction
|
||||
|
||||
- name: Verify localization coverage
|
||||
run: pnpm run verify:localization-coverage
|
||||
|
||||
# Why: project-owned type declarations must live in .ts so tsc
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# 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)
|
||||
@@ -155,12 +184,14 @@ jobs:
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
name: tests ${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['24', '26']
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
shard_total: [16]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -171,6 +202,7 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
@@ -184,7 +216,7 @@ jobs:
|
||||
--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 }}
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
package:
|
||||
name: package
|
||||
@@ -241,6 +273,55 @@ jobs:
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
|
||||
|
||||
# Why: regression specs under tests/e2e/** used to merge green without ever
|
||||
# running — e2e.yml only fired on schedule/release (#10518). Path-filter so
|
||||
# ordinary PRs stay light; any E2E suite change still gets a full shard run.
|
||||
e2e-paths:
|
||||
name: detect e2e path changes
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft != true
|
||||
# Why: detector only needs to read the checkout; do not inherit repo defaults.
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Filter E2E-relevant paths
|
||||
id: filter
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
# Why: capture first so a failed git diff does not look like "no matches"
|
||||
# (pipeline status in `if` is not aborted by set -e). Merge-base limits the
|
||||
# list to files this PR introduced, not base-branch drift.
|
||||
CHANGED="$(git diff --name-only --merge-base "$BASE" "$HEAD")"
|
||||
# Why: tests/playwright.config.ts sits beside tests/e2e/, not inside it, so
|
||||
# it needs its own pattern — a bare `playwright.` prefix matches no tracked
|
||||
# file and would silently skip E2E when the runner config changes.
|
||||
if printf '%s\n' "$CHANGED" | grep -E '^(tests/e2e/|tests/playwright\.|\.github/workflows/e2e\.yml$)' >/dev/null; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "E2E path changes detected"
|
||||
else
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No E2E path changes"
|
||||
fi
|
||||
|
||||
e2e:
|
||||
name: e2e
|
||||
needs: e2e-paths
|
||||
if: needs.e2e-paths.outputs.should_run == 'true'
|
||||
# Why: reusable e2e.yml only checkouts, builds, and uploads artifacts.
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
|
||||
verify:
|
||||
if: always()
|
||||
needs:
|
||||
@@ -253,6 +334,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Why: e2e is deliberately absent from needs. The suite is currently red on
|
||||
# main (every scheduled run), so gating merges on it would block any PR that
|
||||
# touches tests/e2e/** — including the ones fixing the suite. Until it is
|
||||
# green the job runs and reports for E2E-path PRs without blocking. To flip
|
||||
# it on: add `e2e` to needs, add E2E to the env below, and require
|
||||
# `"$E2E" = success || skipped` after the loop — skipped is the normal
|
||||
# result for a path-filtered job and must keep passing, so it has to be
|
||||
# checked outside the loop or it would excuse the jobs above.
|
||||
- name: Require successful checks
|
||||
env:
|
||||
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
|
||||
|
||||
@@ -777,7 +777,7 @@ jobs:
|
||||
echo "## Release E2E Signal"
|
||||
echo ""
|
||||
echo "- Terminal rendering golden is release-blocking."
|
||||
echo "- Full E2E is diagnostic/non-blocking release evidence."
|
||||
echo "- Full E2E runs separately after publication and cannot change the release result."
|
||||
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
|
||||
echo ""
|
||||
echo "Publishing behavior is controlled by the existing job dependencies; this summary does not change release gating."
|
||||
@@ -807,15 +807,6 @@ jobs:
|
||||
|
||||
node config/scripts/create-draft-release.mjs "$TAG"
|
||||
|
||||
# Why: tag-scoped E2E gives release visibility, but the suite is flaky enough
|
||||
# that publish-release must not depend on it.
|
||||
e2e:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
terminal-rendering-golden:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
@@ -1930,6 +1921,32 @@ jobs:
|
||||
--prerelease="$prerelease" \
|
||||
--repo "$GITHUB_REPOSITORY"
|
||||
|
||||
post-release-e2e:
|
||||
needs:
|
||||
- cut
|
||||
- publish-release
|
||||
if: ${{ needs.cut.outputs.tag != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Dispatch tag-scoped E2E
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if gh workflow run e2e.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref "$TAG" \
|
||||
--raw-field "ref=refs/tags/$TAG"; then
|
||||
echo "Dispatched post-release E2E for $TAG."
|
||||
exit 0
|
||||
fi
|
||||
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
|
||||
done
|
||||
echo "::warning::Failed to dispatch post-release E2E for $TAG after 3 attempts."
|
||||
|
||||
homebrew-bump-published-rc-draft:
|
||||
needs:
|
||||
- cut
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Terminal IME E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/terminal-ime-e2e.yml'
|
||||
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
|
||||
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
|
||||
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
|
||||
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
|
||||
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
|
||||
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
|
||||
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
|
||||
- 'tests/e2e/terminal-ime-*.ts'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 9 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux-x11:
|
||||
name: Linux X11 terminal IME
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install native build and IME tools
|
||||
run: >-
|
||||
sudo apt-get update &&
|
||||
sudo apt-get install -y
|
||||
build-essential
|
||||
dbus-x11
|
||||
dconf-gsettings-backend
|
||||
ibus
|
||||
ibus-hangul
|
||||
libglib2.0-bin
|
||||
python3
|
||||
xdotool
|
||||
xfwm4
|
||||
xvfb
|
||||
|
||||
- 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
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for E2E
|
||||
run: pnpm exec electron-vite build --mode e2e
|
||||
|
||||
- name: Run deterministic terminal IME boundary tests
|
||||
run: >-
|
||||
xvfb-run --auto-servernum
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
|
||||
pnpm run test:e2e --
|
||||
tests/e2e/terminal-ime-exact-byte.spec.ts
|
||||
--workers=1
|
||||
|
||||
- name: Run native IBus Hangul exact-byte tests
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
run: pnpm run test:e2e:terminal-ime-native
|
||||
|
||||
- name: Upload terminal IME evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-ime-evidence
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
+37
-5
@@ -1,10 +1,33 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "react", "react-hooks", "react-perf", "unicorn"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "mobile-pairing",
|
||||
"specifier": "./config/oxlint-plugins/mobile-pairing-qrcode-import.mjs"
|
||||
},
|
||||
{
|
||||
"name": "app-store-performance",
|
||||
"specifier": "./config/oxlint-plugins/app-store-performance.mjs"
|
||||
},
|
||||
{
|
||||
"name": "quadratic-buffer-concat",
|
||||
"specifier": "./config/oxlint-plugins/quadratic-buffer-concat.mjs"
|
||||
},
|
||||
{
|
||||
"name": "renderer-scrollbar-style",
|
||||
"specifier": "./config/oxlint-plugins/renderer-scrollbar-style.mjs"
|
||||
}
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"rules": {
|
||||
"app-store-performance/require-selector": "error",
|
||||
"app-store-performance/no-identity-selector": "error",
|
||||
"app-store-performance/no-fresh-selector-result": "error",
|
||||
"no-fallthrough": "error",
|
||||
"quadratic-buffer-concat/no-loop-carried-concat": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/no-children-prop": "error",
|
||||
@@ -57,15 +80,11 @@
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"mobile-pairing/no-eager-qrcode-import": "error",
|
||||
"no-useless-return": "error",
|
||||
"prefer-template": "error",
|
||||
"unicorn/consistent-empty-array-spread": "error",
|
||||
@@ -91,6 +110,19 @@
|
||||
"unicorn/throw-new-error": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/renderer/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.test.*", "**/*.spec.*", "**/*-benchmark.*"],
|
||||
"rules": {
|
||||
"quadratic-buffer-concat/no-loop-carried-concat": "off",
|
||||
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.ts"],
|
||||
"rules": {
|
||||
|
||||
@@ -1,604 +0,0 @@
|
||||
# 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.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/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.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/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.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -238,9 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
|
||||
- **WeChat:** All other groups are full, now we're on group 5.
|
||||
- **WeChat:** If group 5 is full, you can join group 6.
|
||||
|
||||
<img src="docs/assets/wechat-qr.jpg" alt="WeChat QR code for the Orca community" width="160" />
|
||||
<img src="docs/assets/wechat-qr.jpg" alt="WeChat group 5 QR code for the Orca community" width="160" style="margin-right: 16px;" />
|
||||
<img src="docs/assets/wechat-qr-group6.jpg" alt="WeChat group 6 QR code for the Orca community" width="160" />
|
||||
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
|
||||
@@ -21,6 +21,7 @@ type OutputChunk = Rollup.OutputChunk
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'computer-sidecar',
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'codex/codex-app-server-grant-entry'
|
||||
|
||||
@@ -12,10 +12,13 @@ const {
|
||||
verifyPackagedMainRuntimeDeps
|
||||
} = require('./packaged-runtime-node-modules.cjs')
|
||||
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
|
||||
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
|
||||
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
|
||||
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
|
||||
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
|
||||
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const appId = 'com.stablyai.orca'
|
||||
const featureWallResources = {
|
||||
from: 'resources/onboarding/feature-wall',
|
||||
to: 'onboarding/feature-wall'
|
||||
@@ -59,8 +62,9 @@ const winSpeechNativeResource = {
|
||||
|
||||
/** @type {import('electron-builder').Configuration} */
|
||||
module.exports = {
|
||||
appId: 'com.stablyai.orca',
|
||||
appId,
|
||||
productName: 'Orca',
|
||||
...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}),
|
||||
directories: {
|
||||
buildResources: 'resources/build'
|
||||
},
|
||||
@@ -79,12 +83,17 @@ module.exports = {
|
||||
'!skill-guides{,/**/*}',
|
||||
'!skill-stubs{,/**/*}',
|
||||
'!tests{,/**/*}',
|
||||
// Why: examples/ is plugin authoring documentation with no runtime consumer —
|
||||
// bundled plugins ship via extraResources from resources/plugins/launch/. It also
|
||||
// carries hostile-panel, the adversarial fixture the containment tests point at,
|
||||
// which must never reach a user's install.
|
||||
'!examples{,/**/*}',
|
||||
// Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE);
|
||||
// it is gitignored, but exclude it defensively so a stray local capture at
|
||||
// package time never bloats app.asar.
|
||||
'!pr-evidence{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
|
||||
'!out/**/*.test.js',
|
||||
// Why: Vite's manifest is only used to project the paired web client.
|
||||
'!out/renderer/.vite{,/**/*}',
|
||||
@@ -144,6 +153,7 @@ module.exports = {
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
'out/main/main-thread-hang-watchdog-entry.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/ws/**',
|
||||
@@ -171,6 +181,25 @@ module.exports = {
|
||||
if (!existsSync(resourcesDir)) {
|
||||
return
|
||||
}
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
const architectureByEnum = { 1: 'x64', 3: 'arm64' }
|
||||
const architecture = architectureByEnum[context.arch]
|
||||
if (!architecture) {
|
||||
throw new Error(`Unsupported local-build compatibility architecture: ${context.arch}`)
|
||||
}
|
||||
const version = context.packager.appInfo.version
|
||||
let commit = process.env.ORCA_BUILD_COMMIT || process.env.GITHUB_SHA || 'unknown'
|
||||
if (commit === 'unknown') {
|
||||
try {
|
||||
commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
} catch {
|
||||
// Source archives can still produce a signed build with an explicit version.
|
||||
}
|
||||
}
|
||||
writeMacBuildCompatibility(resourcesDir, { version, commit, architecture })
|
||||
}
|
||||
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
|
||||
verifyPackagedMainRuntimeDeps(resourcesDir)
|
||||
// Why: boot the packaged daemon-entry under plain Node, but only for the
|
||||
|
||||
@@ -52,11 +52,27 @@ Sync catalog keys after adding or removing `translate(...)` calls:
|
||||
pnpm run sync:localization-catalog
|
||||
```
|
||||
|
||||
The sync command adds missing `en.json` entries from each call's string fallback,
|
||||
copies untranslated English placeholders into other locale catalogs to keep
|
||||
parity, removes locale entries whose English key was deleted, and repairs
|
||||
placeholder mismatches. Run the machine-translation bootstrap commands only when
|
||||
refreshing real translations, not for ordinary UI copy changes.
|
||||
The sync command adds missing `en.json` entries from each call's string fallback.
|
||||
It never edits target catalogs: missing values remain absent and use the existing
|
||||
runtime English fallback. Existing placeholder mismatches fail validation until
|
||||
a localization PR fixes or retires the target entry.
|
||||
|
||||
Run maintained source extraction without committing a second English catalog:
|
||||
|
||||
```sh
|
||||
pnpm run verify:localization-extraction
|
||||
```
|
||||
|
||||
Extraction fails when a statically extracted key is absent from `en.json` or an
|
||||
inline default has incompatible placeholders. Existing unreferenced English keys
|
||||
and wording-only fallback drift are reported as migration debt; the permanent
|
||||
bilingual translation source will reconcile them without a large disposition
|
||||
database. Reviewed and stale translation state is likewise deferred to that
|
||||
source rather than inferred permanently from Git history.
|
||||
|
||||
The legacy free-endpoint bootstrap and whole-catalog repair scripts intentionally
|
||||
have no package-script entry points. Ordinary product and localization work must
|
||||
not invoke tools that can overwrite an entire target catalog.
|
||||
|
||||
The coverage gate compares current candidates against
|
||||
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
|
||||
@@ -97,8 +113,8 @@ Recommended migration order:
|
||||
The final gate should combine three checks:
|
||||
|
||||
1. Scanner coverage: no unclassified localizable candidates remain.
|
||||
2. Catalog coverage: every supported locale has the same keys as English, with
|
||||
matching interpolation variables.
|
||||
2. Catalog correctness: existing translations have matching interpolation
|
||||
variables; missing target entries are reported rather than rejected.
|
||||
3. Runtime coverage: pseudo-localization and real locale smoke tests show no
|
||||
obvious English leftovers or layout clipping in core screens.
|
||||
|
||||
|
||||
@@ -40,5 +40,33 @@
|
||||
"text": "Idioma",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "ghostty",
|
||||
"dynamic": false,
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "ghostty",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "../node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["import", "jsx-a11y", "react-hooks", "vitest"],
|
||||
"categories": {
|
||||
"correctness": "off",
|
||||
"suspicious": "off",
|
||||
"pedantic": "off",
|
||||
"perf": "off",
|
||||
"style": "off",
|
||||
"restriction": "off",
|
||||
"nursery": "off"
|
||||
},
|
||||
"rules": {
|
||||
"import/export": "warn",
|
||||
"import/named": "warn",
|
||||
"import/namespace": "warn",
|
||||
"import/no-cycle": ["warn", { "maxDepth": 3 }],
|
||||
"import/no-duplicates": "warn",
|
||||
"import/no-self-import": "warn"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/renderer/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"jsx-a11y/alt-text": "warn",
|
||||
"jsx-a11y/anchor-has-content": "warn",
|
||||
"jsx-a11y/aria-props": "warn",
|
||||
"jsx-a11y/aria-role": "warn",
|
||||
"jsx-a11y/click-events-have-key-events": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.{test,spec}.{ts,tsx}", "tests/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"vitest/no-conditional-tests": "warn",
|
||||
"vitest/no-focused-tests": "warn",
|
||||
"vitest/no-identical-title": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["mobile/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"react-hooks/exhaustive-deps": "warn"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
|
||||
}
|
||||
+13
-1
@@ -11,9 +11,21 @@
|
||||
"nursery": "off"
|
||||
},
|
||||
"rules": {
|
||||
"typescript/await-thenable": "warn",
|
||||
"typescript/restrict-plus-operands": "warn",
|
||||
"typescript/restrict-template-expressions": "warn",
|
||||
"typescript/switch-exhaustiveness-check": [
|
||||
"error",
|
||||
{ "allowDefaultCaseForExhaustiveSwitch": false }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.test.*", "**/*.spec.*"],
|
||||
"rules": {
|
||||
"typescript/await-thenable": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
const ALLOCATING_METHODS = new Set([
|
||||
'filter',
|
||||
'flat',
|
||||
'flatMap',
|
||||
'map',
|
||||
'toReversed',
|
||||
'toSorted',
|
||||
'toSpliced',
|
||||
'with'
|
||||
])
|
||||
|
||||
function identifierName(node) {
|
||||
return node?.type === 'Identifier' ? node.name : null
|
||||
}
|
||||
|
||||
function propertyName(node) {
|
||||
if (node?.type !== 'MemberExpression') {
|
||||
return null
|
||||
}
|
||||
if (!node.computed) {
|
||||
return identifierName(node.property)
|
||||
}
|
||||
return node.property?.type === 'Literal' && typeof node.property.value === 'string'
|
||||
? node.property.value
|
||||
: null
|
||||
}
|
||||
|
||||
function returnedExpressions(selector) {
|
||||
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
|
||||
return []
|
||||
}
|
||||
if (selector.body.type !== 'BlockStatement') {
|
||||
return [selector.body]
|
||||
}
|
||||
const expressions = []
|
||||
const visit = (node) => {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return
|
||||
}
|
||||
if (
|
||||
node !== selector.body &&
|
||||
['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression'].includes(node.type)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (node.type === 'ReturnStatement') {
|
||||
if (node.argument) {
|
||||
expressions.push(node.argument)
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const [key, child] of Object.entries(node)) {
|
||||
if (key === 'parent') {
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(child)) {
|
||||
child.forEach(visit)
|
||||
} else {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(selector.body)
|
||||
return expressions
|
||||
}
|
||||
|
||||
function unwrapShallowSelector(selector, shallowHooks) {
|
||||
if (
|
||||
selector?.type === 'CallExpression' &&
|
||||
selector.callee.type === 'Identifier' &&
|
||||
shallowHooks.has(selector.callee.name)
|
||||
) {
|
||||
return { selector: selector.arguments[0], shallow: true }
|
||||
}
|
||||
return { selector, shallow: false }
|
||||
}
|
||||
|
||||
function isIdentitySelector(selector) {
|
||||
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
|
||||
return false
|
||||
}
|
||||
const parameter = selector.params[0]
|
||||
if (parameter?.type !== 'Identifier') {
|
||||
return false
|
||||
}
|
||||
return returnedExpressions(selector).some(
|
||||
(expression) => expression.type === 'Identifier' && expression.name === parameter.name
|
||||
)
|
||||
}
|
||||
|
||||
function isAllocatingExpression(expression) {
|
||||
if (expression?.type === 'ConditionalExpression') {
|
||||
return (
|
||||
isAllocatingExpression(expression.consequent) || isAllocatingExpression(expression.alternate)
|
||||
)
|
||||
}
|
||||
if (expression?.type === 'LogicalExpression') {
|
||||
return isAllocatingExpression(expression.left) || isAllocatingExpression(expression.right)
|
||||
}
|
||||
if (
|
||||
expression?.type === 'ArrayExpression' ||
|
||||
expression?.type === 'ObjectExpression' ||
|
||||
expression?.type === 'NewExpression'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (expression?.type !== 'CallExpression') {
|
||||
return false
|
||||
}
|
||||
const method = propertyName(expression.callee)
|
||||
if (method && ALLOCATING_METHODS.has(method)) {
|
||||
return true
|
||||
}
|
||||
const callee = expression.callee
|
||||
return (
|
||||
callee.type === 'MemberExpression' &&
|
||||
identifierName(callee.object) === 'Object' &&
|
||||
['assign', 'create', 'entries', 'fromEntries', 'keys', 'values'].includes(propertyName(callee))
|
||||
)
|
||||
}
|
||||
|
||||
function importedLocalName(specifier, importedName) {
|
||||
if (specifier.type !== 'ImportSpecifier' || identifierName(specifier.imported) !== importedName) {
|
||||
return null
|
||||
}
|
||||
return identifierName(specifier.local)
|
||||
}
|
||||
|
||||
function createRuleState() {
|
||||
return {
|
||||
appStoreHooks: new Set(),
|
||||
shallowHooks: new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function recordImports(node, state) {
|
||||
if (node.source?.value === 'zustand/react/shallow') {
|
||||
for (const specifier of node.specifiers) {
|
||||
const localName = importedLocalName(specifier, 'useShallow')
|
||||
if (localName) {
|
||||
state.shallowHooks.add(localName)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const specifier of node.specifiers) {
|
||||
const localName = importedLocalName(specifier, 'useAppStore')
|
||||
if (localName) {
|
||||
state.appStoreHooks.add(localName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isAppStoreCall(node, state) {
|
||||
return (
|
||||
node.callee.type === 'Identifier' &&
|
||||
state.appStoreHooks.has(node.callee.name) &&
|
||||
node.optional !== true
|
||||
)
|
||||
}
|
||||
|
||||
function requireSelectorRule() {
|
||||
const state = createRuleState()
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
recordImports(node, state)
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (isAppStoreCall(node, state) && node.arguments.length === 0) {
|
||||
this.report({
|
||||
node,
|
||||
message:
|
||||
'Pass a selector to useAppStore so the component does not rerender for every store write.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function noIdentitySelectorRule() {
|
||||
const state = createRuleState()
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
recordImports(node, state)
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (!isAppStoreCall(node, state)) {
|
||||
return
|
||||
}
|
||||
const { selector } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
|
||||
if (isIdentitySelector(selector)) {
|
||||
this.report({
|
||||
node: selector,
|
||||
message:
|
||||
'Select the smallest required fields instead of subscribing to the entire app store.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function noFreshSelectorResultRule() {
|
||||
const state = createRuleState()
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
recordImports(node, state)
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (!isAppStoreCall(node, state)) {
|
||||
return
|
||||
}
|
||||
const { selector, shallow } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
|
||||
if (shallow) {
|
||||
return
|
||||
}
|
||||
const freshResult = returnedExpressions(selector).find(isAllocatingExpression)
|
||||
if (freshResult) {
|
||||
this.report({
|
||||
node: freshResult,
|
||||
message:
|
||||
'This selector returns a fresh reference on every store write; select a stable field, cache the result, or use useShallow.'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindContext(createVisitors) {
|
||||
return (context) => {
|
||||
const visitors = createVisitors()
|
||||
for (const [nodeType, visit] of Object.entries(visitors)) {
|
||||
visitors[nodeType] = visit.bind(context)
|
||||
}
|
||||
return visitors
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: 'app-store-performance' },
|
||||
rules: {
|
||||
'require-selector': { create: bindContext(requireSelectorRule) },
|
||||
'no-identity-selector': { create: bindContext(noIdentitySelectorRule) },
|
||||
'no-fresh-selector-result': { create: bindContext(noFreshSelectorResultRule) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
const MESSAGE =
|
||||
"qrcode is only reachable from mobile pairing. Use `import type` plus `await import('qrcode')` so startup does not parse its bundle."
|
||||
|
||||
function hasRuntimeImport(node) {
|
||||
if (node.importKind === 'type') {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
node.specifiers.length === 0 ||
|
||||
node.specifiers.some((specifier) => specifier.importKind !== 'type')
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: 'mobile-pairing' },
|
||||
rules: {
|
||||
'no-eager-qrcode-import': {
|
||||
create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source?.value === 'qrcode' && hasRuntimeImport(node)) {
|
||||
context.report({ node, message: MESSAGE })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
const LOOP_TYPES = new Set([
|
||||
'ForStatement',
|
||||
'ForInStatement',
|
||||
'ForOfStatement',
|
||||
'WhileStatement',
|
||||
'DoWhileStatement'
|
||||
])
|
||||
const ASSIGNMENT_OPERATORS = new Set(['=', '+=', '??=', '||=', '&&='])
|
||||
const EXPRESSION_WRAPPERS = new Set([
|
||||
'ChainExpression',
|
||||
'TSAsExpression',
|
||||
'TSNonNullExpression',
|
||||
'TSSatisfiesExpression',
|
||||
'TypeCastExpression'
|
||||
])
|
||||
|
||||
function normalizeReferenceText(text) {
|
||||
return text.replaceAll(/\s+/g, '')
|
||||
}
|
||||
|
||||
function sourceText(context, node) {
|
||||
return context.sourceCode.getText(node)
|
||||
}
|
||||
|
||||
function memberPropertyName(node) {
|
||||
if (node?.type !== 'MemberExpression') {
|
||||
return null
|
||||
}
|
||||
if (!node.computed && node.property.type === 'Identifier') {
|
||||
return node.property.name
|
||||
}
|
||||
return node.property.type === 'Literal' && typeof node.property.value === 'string'
|
||||
? node.property.value
|
||||
: null
|
||||
}
|
||||
|
||||
function rootReferenceText(context, node) {
|
||||
if (node?.type === 'Identifier') {
|
||||
return node.name
|
||||
}
|
||||
if (node?.type === 'MemberExpression') {
|
||||
return node.object.type === 'ThisExpression'
|
||||
? normalizeReferenceText(sourceText(context, node))
|
||||
: rootReferenceText(context, node.object)
|
||||
}
|
||||
if (node?.type === 'CallExpression') {
|
||||
return rootReferenceText(context, node.callee)
|
||||
}
|
||||
if (EXPRESSION_WRAPPERS.has(node?.type)) {
|
||||
return rootReferenceText(context, node.expression)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isBufferConcatCall(node) {
|
||||
return (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.callee.object.type === 'Identifier' &&
|
||||
node.callee.object.name === 'Buffer' &&
|
||||
memberPropertyName(node.callee) === 'concat' &&
|
||||
node.arguments[0]?.type === 'ArrayExpression'
|
||||
)
|
||||
}
|
||||
|
||||
function enclosingLoop(node) {
|
||||
for (let current = node.parent; current; current = current.parent) {
|
||||
if (LOOP_TYPES.has(current.type)) {
|
||||
return current
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function nodeStart(node) {
|
||||
return node.start ?? node.range?.[0] ?? 0
|
||||
}
|
||||
|
||||
function nodeEnd(node) {
|
||||
return node.end ?? node.range?.[1] ?? 0
|
||||
}
|
||||
|
||||
function isDeclaredInsideLoop(declarationStart, loop) {
|
||||
if (declarationStart < nodeStart(loop) || declarationStart >= nodeEnd(loop)) {
|
||||
return false
|
||||
}
|
||||
if (loop.type !== 'ForStatement' || !loop.init) {
|
||||
return true
|
||||
}
|
||||
return declarationStart < nodeStart(loop.init) || declarationStart >= nodeEnd(loop.init)
|
||||
}
|
||||
|
||||
function collectBindingNames(pattern, names) {
|
||||
if (!pattern) {
|
||||
return
|
||||
}
|
||||
if (pattern.type === 'Identifier') {
|
||||
names.push(pattern.name)
|
||||
} else if (pattern.type === 'RestElement') {
|
||||
collectBindingNames(pattern.argument, names)
|
||||
} else if (pattern.type === 'AssignmentPattern') {
|
||||
collectBindingNames(pattern.left, names)
|
||||
} else if (pattern.type === 'ObjectPattern') {
|
||||
for (const property of pattern.properties) {
|
||||
collectBindingNames(
|
||||
property.type === 'RestElement' ? property.argument : property.value,
|
||||
names
|
||||
)
|
||||
}
|
||||
} else if (pattern.type === 'ArrayPattern') {
|
||||
for (const element of pattern.elements) {
|
||||
collectBindingNames(element, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function visitChildren(node, visit) {
|
||||
for (const [key, child] of Object.entries(node)) {
|
||||
if (['parent', 'loc', 'range'].includes(key)) {
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
if (item?.type) {
|
||||
visit(item)
|
||||
}
|
||||
}
|
||||
} else if (child?.type) {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectAssignedRoots(context, loop) {
|
||||
const assigned = new Set()
|
||||
const visit = (node) => {
|
||||
if (node.type === 'AssignmentExpression' && ASSIGNMENT_OPERATORS.has(node.operator)) {
|
||||
const root = rootReferenceText(context, node.left)
|
||||
if (root) {
|
||||
assigned.add(root)
|
||||
}
|
||||
}
|
||||
visitChildren(node, visit)
|
||||
}
|
||||
visit(loop.body)
|
||||
return assigned
|
||||
}
|
||||
|
||||
function assignmentTargetOf(context, call) {
|
||||
let node = call
|
||||
let parent = node.parent
|
||||
while (
|
||||
parent &&
|
||||
(EXPRESSION_WRAPPERS.has(parent.type) ||
|
||||
(parent.type === 'ConditionalExpression' && parent.test !== node))
|
||||
) {
|
||||
node = parent
|
||||
parent = parent.parent
|
||||
}
|
||||
if (parent?.type !== 'AssignmentExpression' || parent.operator !== '=' || parent.right !== node) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
text: normalizeReferenceText(sourceText(context, parent.left)),
|
||||
root: rootReferenceText(context, parent.left)
|
||||
}
|
||||
}
|
||||
|
||||
function concatOperands(context, call) {
|
||||
return call.arguments[0].elements.filter(Boolean).map((element) => {
|
||||
const spread = element.type === 'SpreadElement'
|
||||
const expression = spread ? element.argument : element
|
||||
return {
|
||||
spread,
|
||||
text: normalizeReferenceText(sourceText(context, expression)),
|
||||
root: rootReferenceText(context, expression)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isLoopCarried(root, loop, declarations) {
|
||||
const starts = declarations.get(root)
|
||||
return !starts || !starts.some((start) => isDeclaredInsideLoop(start, loop))
|
||||
}
|
||||
|
||||
function quadraticAccumulator(context, call, loop, declarations, assignedRoots) {
|
||||
const operands = concatOperands(context, call)
|
||||
const target = assignmentTargetOf(context, call)
|
||||
const selfOperand = target
|
||||
? operands.find((operand) => operand.text === target.text || operand.root === target.root)
|
||||
: null
|
||||
if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) {
|
||||
return target.text
|
||||
}
|
||||
|
||||
for (const operand of operands) {
|
||||
if (
|
||||
!operand.spread &&
|
||||
operand.root &&
|
||||
assignedRoots.has(operand.root) &&
|
||||
isLoopCarried(operand.root, loop, declarations)
|
||||
) {
|
||||
return operand.root
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function createRule(context) {
|
||||
const declarations = new Map()
|
||||
const assignedRootsByLoop = new WeakMap()
|
||||
const recordBindings = (pattern, owner) => {
|
||||
const names = []
|
||||
collectBindingNames(pattern, names)
|
||||
for (const name of names) {
|
||||
const starts = declarations.get(name) ?? []
|
||||
starts.push(nodeStart(owner))
|
||||
declarations.set(name, starts)
|
||||
}
|
||||
}
|
||||
const recordParameters = (node) => {
|
||||
for (const parameter of node.params) {
|
||||
recordBindings(parameter, parameter)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
VariableDeclarator(node) {
|
||||
recordBindings(node.id, node)
|
||||
},
|
||||
FunctionDeclaration: recordParameters,
|
||||
FunctionExpression: recordParameters,
|
||||
ArrowFunctionExpression: recordParameters,
|
||||
CatchClause(node) {
|
||||
recordBindings(node.param, node.param)
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (!isBufferConcatCall(node)) {
|
||||
return
|
||||
}
|
||||
const loop = enclosingLoop(node)
|
||||
if (!loop) {
|
||||
return
|
||||
}
|
||||
let assignedRoots = assignedRootsByLoop.get(loop)
|
||||
if (!assignedRoots) {
|
||||
assignedRoots = collectAssignedRoots(context, loop)
|
||||
assignedRootsByLoop.set(loop, assignedRoots)
|
||||
}
|
||||
const accumulator = quadraticAccumulator(context, node, loop, declarations, assignedRoots)
|
||||
if (accumulator) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Buffer.concat rebuilds loop-carried ${accumulator}; collect chunks and concatenate once after the loop.`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: 'quadratic-buffer-concat' },
|
||||
rules: {
|
||||
'no-loop-carried-concat': { create: createRule }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
const STYLED_SCROLLBAR_CLASSES = new Set([
|
||||
'scrollbar-sleek',
|
||||
'scrollbar-editor',
|
||||
'worktree-sidebar-scrollbar'
|
||||
])
|
||||
const VERTICAL_SCROLL_CLASSES = new Set([
|
||||
'overflow-auto',
|
||||
'overflow-scroll',
|
||||
'overflow-y-auto',
|
||||
'overflow-y-scroll'
|
||||
])
|
||||
const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll'])
|
||||
|
||||
function withoutImportantModifier(className) {
|
||||
const withoutPrefix = className.startsWith('!') ? className.slice(1) : className
|
||||
return withoutPrefix.endsWith('!') ? withoutPrefix.slice(0, -1) : withoutPrefix
|
||||
}
|
||||
|
||||
export function plainClassName(token) {
|
||||
const normalizedToken = token.startsWith('!') ? token.slice(1) : token
|
||||
const parts = []
|
||||
let bracketDepth = 0
|
||||
let currentPart = ''
|
||||
|
||||
for (const char of normalizedToken) {
|
||||
if (char === '[') {
|
||||
bracketDepth += 1
|
||||
} else if (char === ']') {
|
||||
bracketDepth = Math.max(0, bracketDepth - 1)
|
||||
}
|
||||
if (char === ':' && bracketDepth === 0) {
|
||||
parts.push(currentPart)
|
||||
currentPart = ''
|
||||
} else {
|
||||
currentPart += char
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(currentPart)
|
||||
return withoutImportantModifier(parts.at(-1) ?? '')
|
||||
}
|
||||
|
||||
function classTokenParts(token) {
|
||||
const variants = []
|
||||
let bracketDepth = 0
|
||||
let currentPart = ''
|
||||
|
||||
for (const char of token.startsWith('!') ? token.slice(1) : token) {
|
||||
if (char === '[') {
|
||||
bracketDepth += 1
|
||||
} else if (char === ']') {
|
||||
bracketDepth = Math.max(0, bracketDepth - 1)
|
||||
}
|
||||
if (char === ':' && bracketDepth === 0) {
|
||||
variants.push(currentPart)
|
||||
currentPart = ''
|
||||
} else {
|
||||
currentPart += char
|
||||
}
|
||||
}
|
||||
|
||||
return { className: withoutImportantModifier(currentPart), variants: variants.filter(Boolean) }
|
||||
}
|
||||
|
||||
function classTokens(text) {
|
||||
return text.split(/\s+/).filter(Boolean).map(classTokenParts)
|
||||
}
|
||||
|
||||
function sameVariants(left, right) {
|
||||
return left.length === right.length && left.every((variant, index) => variant === right[index])
|
||||
}
|
||||
|
||||
function literalHasScrollbarForVertical(text, verticalToken) {
|
||||
return classTokens(text).some(
|
||||
(candidate) =>
|
||||
STYLED_SCROLLBAR_CLASSES.has(candidate.className) &&
|
||||
(candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants))
|
||||
)
|
||||
}
|
||||
|
||||
function uncoveredVerticalClass(text) {
|
||||
return classTokens(text).find(
|
||||
(token) =>
|
||||
VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token)
|
||||
)
|
||||
}
|
||||
|
||||
function stringLiteralTexts(node) {
|
||||
if (node?.type === 'Literal' && typeof node.value === 'string') {
|
||||
return [node.value]
|
||||
}
|
||||
if (node?.type !== 'TemplateLiteral') {
|
||||
return []
|
||||
}
|
||||
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw)
|
||||
}
|
||||
|
||||
function visitChildren(node, visit) {
|
||||
for (const [key, child] of Object.entries(node)) {
|
||||
if (['parent', 'loc', 'range'].includes(key)) {
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(child)) {
|
||||
for (const item of child) {
|
||||
if (item?.type) {
|
||||
visit(item)
|
||||
}
|
||||
}
|
||||
} else if (child?.type) {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectClassLiteralReports(node) {
|
||||
const reports = []
|
||||
const visit = (current) => {
|
||||
for (const text of stringLiteralTexts(current)) {
|
||||
const uncovered = uncoveredVerticalClass(text)
|
||||
if (uncovered) {
|
||||
reports.push({ node: current, detail: uncovered.className })
|
||||
}
|
||||
}
|
||||
visitChildren(current, visit)
|
||||
}
|
||||
visit(node)
|
||||
return reports
|
||||
}
|
||||
|
||||
function expressionHasStyledScrollbarLiteral(node) {
|
||||
let found = false
|
||||
const visit = (current) => {
|
||||
if (found || current.type === 'ConditionalExpression' || current.type === 'LogicalExpression') {
|
||||
return
|
||||
}
|
||||
found = stringLiteralTexts(current).some((text) =>
|
||||
classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className))
|
||||
)
|
||||
if (!found) {
|
||||
visitChildren(current, visit)
|
||||
}
|
||||
}
|
||||
visit(node)
|
||||
return found
|
||||
}
|
||||
|
||||
function propertyName(node) {
|
||||
if (node?.type !== 'Property') {
|
||||
return null
|
||||
}
|
||||
if (!node.computed && node.key.type === 'Identifier') {
|
||||
return node.key.name
|
||||
}
|
||||
return node.key.type === 'Literal' && typeof node.key.value === 'string' ? node.key.value : null
|
||||
}
|
||||
|
||||
function styleValueIsVerticalScroll(name, value) {
|
||||
const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 0) {
|
||||
return false
|
||||
}
|
||||
if (name === 'overflowY' || name === 'overflow-y') {
|
||||
return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0])
|
||||
}
|
||||
if (name !== 'overflow') {
|
||||
return false
|
||||
}
|
||||
return VERTICAL_SCROLL_STYLE_VALUES.has(parts.length > 1 ? parts[1] : parts[0])
|
||||
}
|
||||
|
||||
function collectStyleReports(node) {
|
||||
const reports = []
|
||||
const visit = (current) => {
|
||||
if (current.type === 'Property') {
|
||||
const name = propertyName(current)
|
||||
for (const value of name ? stringLiteralTexts(current.value) : []) {
|
||||
if (styleValueIsVerticalScroll(name, value)) {
|
||||
reports.push({ node: current, detail: 'inline vertical scroll' })
|
||||
}
|
||||
}
|
||||
visit(current.value)
|
||||
return
|
||||
}
|
||||
visitChildren(current, visit)
|
||||
}
|
||||
visit(node)
|
||||
return reports
|
||||
}
|
||||
|
||||
function unwrapExpression(node) {
|
||||
if (
|
||||
['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ChainExpression'].includes(
|
||||
node?.type
|
||||
)
|
||||
) {
|
||||
return unwrapExpression(node.expression)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function spreadPropExpressions(expression, propName) {
|
||||
const node = unwrapExpression(expression)
|
||||
if (!node) {
|
||||
return []
|
||||
}
|
||||
if (node.type === 'ConditionalExpression') {
|
||||
return [
|
||||
...spreadPropExpressions(node.consequent, propName),
|
||||
...spreadPropExpressions(node.alternate, propName)
|
||||
]
|
||||
}
|
||||
if (node.type === 'LogicalExpression' || node.type === 'BinaryExpression') {
|
||||
return [
|
||||
...spreadPropExpressions(node.left, propName),
|
||||
...spreadPropExpressions(node.right, propName)
|
||||
]
|
||||
}
|
||||
if (node.type !== 'ObjectExpression') {
|
||||
return []
|
||||
}
|
||||
return node.properties.flatMap((property) => {
|
||||
if (property.type === 'SpreadElement') {
|
||||
return spreadPropExpressions(property.argument, propName)
|
||||
}
|
||||
return propertyName(property) === propName ? [property.value] : []
|
||||
})
|
||||
}
|
||||
|
||||
function jsxAttributeExpression(attribute) {
|
||||
if (attribute.value?.type === 'Literal') {
|
||||
return attribute.value
|
||||
}
|
||||
return attribute.value?.type === 'JSXExpressionContainer' ? attribute.value.expression : null
|
||||
}
|
||||
|
||||
function jsxElementReports(node) {
|
||||
let classExpression = null
|
||||
const styleExpressions = []
|
||||
|
||||
for (const attribute of node.attributes) {
|
||||
if (attribute.type === 'JSXSpreadAttribute') {
|
||||
const spreadClassExpression = spreadPropExpressions(attribute.argument, 'className').at(-1)
|
||||
if (spreadClassExpression) {
|
||||
classExpression = spreadClassExpression
|
||||
}
|
||||
styleExpressions.push(...spreadPropExpressions(attribute.argument, 'style'))
|
||||
} else if (attribute.name?.name === 'className') {
|
||||
classExpression = jsxAttributeExpression(attribute)
|
||||
} else if (attribute.name?.name === 'style') {
|
||||
const expression = jsxAttributeExpression(attribute)
|
||||
if (expression) {
|
||||
styleExpressions.push(expression)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reports = classExpression ? collectClassLiteralReports(classExpression) : []
|
||||
if (!classExpression || !expressionHasStyledScrollbarLiteral(classExpression)) {
|
||||
for (const expression of styleExpressions) {
|
||||
reports.push(...collectStyleReports(expression))
|
||||
}
|
||||
}
|
||||
return reports
|
||||
}
|
||||
|
||||
function bindContext(createVisitors) {
|
||||
return (context) => {
|
||||
const visitors = createVisitors()
|
||||
for (const [nodeType, visit] of Object.entries(visitors)) {
|
||||
visitors[nodeType] = visit.bind(context)
|
||||
}
|
||||
return visitors
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: 'renderer-scrollbar-style' },
|
||||
rules: {
|
||||
'require-styled-vertical-scrollbar': {
|
||||
create: bindContext(() => ({
|
||||
JSXOpeningElement(node) {
|
||||
for (const report of jsxElementReports(node)) {
|
||||
this.report({
|
||||
node: report.node,
|
||||
message: `Vertical scroll container (${report.detail}) must use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.`
|
||||
})
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,18 @@
|
||||
},
|
||||
"jsPlugins": [{ "name": "react-doctor", "specifier": "oxlint-plugin-react-doctor" }],
|
||||
"rules": {
|
||||
"react-doctor/effect-needs-cleanup": "warn",
|
||||
"react-doctor/no-array-index-as-key": "warn",
|
||||
"react-doctor/no-adjust-state-on-prop-change": "warn",
|
||||
"react-doctor/no-create-store-in-render": "warn",
|
||||
"react-doctor/no-derived-state-effect": "warn",
|
||||
"react-doctor/no-initialize-state": "warn"
|
||||
"react-doctor/no-initialize-state": "warn",
|
||||
"react-doctor/no-side-effect-in-state-updater-function": "warn",
|
||||
"react-doctor/no-unstable-nested-components": "warn",
|
||||
"react-doctor/zustand-no-fresh-selector-result": "warn",
|
||||
"react-doctor/zustand-no-get-during-initialization": "warn",
|
||||
"react-doctor/zustand-no-mutating-state": "warn",
|
||||
"react-doctor/zustand-no-whole-store-destructure": "warn"
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1768
-845
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs'
|
||||
|
||||
const pluginPath = path.resolve('config/oxlint-plugins/app-store-performance.mjs')
|
||||
|
||||
function lintSource(source) {
|
||||
return runOxlintPluginOnSource({
|
||||
pluginName: 'app-store-performance',
|
||||
pluginPath,
|
||||
source,
|
||||
rules: {
|
||||
'app-store-performance/require-selector': 'warn',
|
||||
'app-store-performance/no-identity-selector': 'warn',
|
||||
'app-store-performance/no-fresh-selector-result': 'warn'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('app store performance Oxlint plugin', () => {
|
||||
it('reports whole-store and fresh-reference subscriptions', () => {
|
||||
const diagnostics = lintSource(`
|
||||
import { useAppStore as useStore } from '@/store'
|
||||
const WholeStore = () => useStore()
|
||||
const Identity = () => useStore((state) => state)
|
||||
const Fresh = () => useStore((state) => ({ active: state.active }))
|
||||
const Conditional = () => useStore((state) => state.active ? state.items : [])
|
||||
const Nested = () => useStore((state) => {
|
||||
if (state.active) return state.items.filter(Boolean)
|
||||
return state.items
|
||||
})
|
||||
`)
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
||||
'app-store-performance(require-selector)',
|
||||
'app-store-performance(no-identity-selector)',
|
||||
'app-store-performance(no-fresh-selector-result)',
|
||||
'app-store-performance(no-fresh-selector-result)',
|
||||
'app-store-performance(no-fresh-selector-result)'
|
||||
])
|
||||
})
|
||||
|
||||
it('allows focused, cached, and useShallow selectors', () => {
|
||||
const diagnostics = lintSource(`
|
||||
import { useAppStore } from '@/store'
|
||||
import { useShallow as shallow } from 'zustand/react/shallow'
|
||||
const selectActive = (state) => state.active
|
||||
const Focused = () => useAppStore(selectActive)
|
||||
const Cached = () => useAppStore((state) => state.cachedProjection)
|
||||
const Shallow = () => useAppStore(shallow((state) => ({ active: state.active })))
|
||||
`)
|
||||
|
||||
expect(diagnostics).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: the head-of-chain reads in getBranchCompare (src/main/git/status.ts).
|
||||
//
|
||||
// Four spawns ran strictly in series before any compare work started: branch
|
||||
// --show-current, the base-ref probe, rev-parse HEAD, and rev-parse <base>. compareRef is
|
||||
// display-only metadata and HEAD's oid does not depend on the base ref, so the first three
|
||||
// can overlap. The probe oid also replaces the fourth spawn when it proves refs/heads/*;
|
||||
// remote-tracking refs require a raw rev-parse because they may store annotated tags.
|
||||
//
|
||||
// This spawns the real git binary against this repo, so it measures actual process-launch
|
||||
// cost rather than a model of it. Over SSH these are host-local spawns inside the relay,
|
||||
// so the saving applies to remote spawn time, not to network round trips.
|
||||
//
|
||||
// Both arms are compared for identical resolved values before timing.
|
||||
//
|
||||
// Run with: node config/scripts/branch-compare-head-benchmark.mjs
|
||||
import { execFile } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { readBranchCompareHead } from '../../src/shared/git-branch-compare-head.ts'
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const ITERATIONS = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_ITERATIONS ?? '8')
|
||||
const WARMUP = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_WARMUP ?? '2')
|
||||
const ROUNDS = 6
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_BRANCH_COMPARE_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_BRANCH_COMPARE_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 64 * 1024 * 1024 }, (error, stdout) =>
|
||||
error ? reject(error) : resolve(stdout.trim())
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function probeOid(qualifiedRef) {
|
||||
try {
|
||||
const out = await git(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`])
|
||||
return out.length > 0 ? out : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fix: serial chain, and the probe's oid discarded then re-resolved.
|
||||
async function readSerial(baseRef) {
|
||||
const compareRef = (await git(['branch', '--show-current']).catch(() => '')) || 'HEAD'
|
||||
let resolvedBaseRef = baseRef
|
||||
if (!baseRef.startsWith('refs/')) {
|
||||
const candidates = baseRef.includes('/')
|
||||
? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`]
|
||||
: [`refs/heads/${baseRef}`]
|
||||
for (const candidate of candidates) {
|
||||
if ((await probeOid(candidate)) !== null) {
|
||||
resolvedBaseRef = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const headOid = await git(['rev-parse', '--verify', '--end-of-options', 'HEAD'])
|
||||
const baseOid = await git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef])
|
||||
return { compareRef, resolvedBaseRef, headOid, baseOid }
|
||||
}
|
||||
|
||||
// Production head reader: overlaps independent reads and reuses only safe probe oids.
|
||||
async function readConcurrent(baseRef) {
|
||||
const reusableProbedOidByRef = new Map()
|
||||
const resolveBaseRef = async () => {
|
||||
if (baseRef.startsWith('refs/')) {
|
||||
return baseRef
|
||||
}
|
||||
const candidates = baseRef.includes('/')
|
||||
? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`]
|
||||
: [`refs/heads/${baseRef}`]
|
||||
for (const candidate of candidates) {
|
||||
const oid = await probeOid(candidate)
|
||||
if (oid !== null) {
|
||||
if (candidate.startsWith('refs/heads/')) {
|
||||
reusableProbedOidByRef.set(candidate, oid)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return baseRef
|
||||
}
|
||||
const result = await readBranchCompareHead({
|
||||
readCompareRef: () =>
|
||||
git(['branch', '--show-current'])
|
||||
.then((out) => out || 'HEAD')
|
||||
.catch(() => 'HEAD'),
|
||||
resolveBaseRef,
|
||||
readHeadOid: () => git(['rev-parse', '--verify', '--end-of-options', 'HEAD']),
|
||||
readBaseOid: (resolvedBaseRef) => {
|
||||
const reusableOid = reusableProbedOidByRef.get(resolvedBaseRef)
|
||||
return reusableOid === undefined
|
||||
? git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef])
|
||||
: Promise.resolve(reusableOid)
|
||||
}
|
||||
})
|
||||
if (!result.headOidResult.ok) {
|
||||
throw result.headOidResult.error
|
||||
}
|
||||
if (!result.baseOidResult.ok) {
|
||||
throw result.baseOidResult.error
|
||||
}
|
||||
return {
|
||||
compareRef: result.compareRef,
|
||||
resolvedBaseRef: result.resolvedBaseRef,
|
||||
headOid: result.headOidResult.oid,
|
||||
baseOid: result.baseOidResult.oid
|
||||
}
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const mid = sorted.length / 2
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
async function timeArm(read, baseRef) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
await read(baseRef)
|
||||
}
|
||||
return (performance.now() - start) / ITERATIONS
|
||||
}
|
||||
|
||||
// Arms alternate which one leads so within-round drift cannot favour either.
|
||||
async function measure(baseRef) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
await readSerial(baseRef)
|
||||
await readConcurrent(baseRef)
|
||||
}
|
||||
const serialSamples = []
|
||||
const concurrentSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
serialSamples.push(await timeArm(readSerial, baseRef))
|
||||
concurrentSamples.push(await timeArm(readConcurrent, baseRef))
|
||||
} else {
|
||||
concurrentSamples.push(await timeArm(readConcurrent, baseRef))
|
||||
serialSamples.push(await timeArm(readSerial, baseRef))
|
||||
}
|
||||
}
|
||||
return { serialMs: median(serialSamples), concurrentMs: median(concurrentSamples) }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('getBranchCompare head-of-chain reads, per call. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`)
|
||||
console.log(
|
||||
`${pad('base ref', 30)} ${pad('serial', 11)} ${pad('concurrent', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
// A short remote label is the common case (Orca's base picker emits `origin/main`); the
|
||||
// already-qualified ref skips the probe entirely, so only the concurrency half applies.
|
||||
const upstream = await git(['rev-parse', '--abbrev-ref', 'HEAD@{upstream}']).catch(() => null)
|
||||
const baseRefs = ['origin/main', 'refs/remotes/origin/main', 'main']
|
||||
if (upstream && !baseRefs.includes(upstream)) {
|
||||
baseRefs.push(upstream)
|
||||
}
|
||||
|
||||
for (const baseRef of baseRefs) {
|
||||
const serial = await readSerial(baseRef)
|
||||
const concurrent = await readConcurrent(baseRef)
|
||||
if (JSON.stringify(serial) !== JSON.stringify(concurrent)) {
|
||||
throw new Error(
|
||||
`resolved values differ for ${baseRef}:\n serial ${JSON.stringify(serial)}\n concurrent ${JSON.stringify(concurrent)}`
|
||||
)
|
||||
}
|
||||
if (!serial.headOid) {
|
||||
throw new Error(`fixture resolved no HEAD oid for ${baseRef}`)
|
||||
}
|
||||
const { serialMs, concurrentMs } = await measure(baseRef)
|
||||
console.log(
|
||||
`${pad(baseRef, 30)} ${pad(`${serialMs.toFixed(1)} ms`, 11)} ${pad(`${concurrentMs.toFixed(1)} ms`, 11)} ${pad(`${(serialMs / concurrentMs).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nThe already-qualified refs/... row skips the probe by design, so it only shows the\nconcurrency half. This times the native/WSL head-of-chain reads, not the whole compare;\nthe relay path has separate production-concurrency coverage.'
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export function createLocalBuildVersion(baseVersion, timestamp, commit) {
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(baseVersion)) {
|
||||
throw new Error(`Package version is not valid semver: ${baseVersion}`)
|
||||
}
|
||||
if (!Number.isSafeInteger(timestamp) || timestamp <= 0) {
|
||||
throw new Error('Local build timestamp is invalid.')
|
||||
}
|
||||
const sanitizedCommit = commit.replace(/[^0-9A-Za-z-]/g, '').slice(0, 12)
|
||||
if (!sanitizedCommit) {
|
||||
throw new Error('Git commit identity is empty.')
|
||||
}
|
||||
const suffix = `local.${timestamp}.${sanitizedCommit}`
|
||||
return baseVersion.includes('-') ? `${baseVersion}.${suffix}` : `${baseVersion}-${suffix}`
|
||||
}
|
||||
|
||||
export function getLocalBuildIdentity() {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
return {
|
||||
commit,
|
||||
version: createLocalBuildVersion(packageJson.version, Date.now(), commit)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const identity = getLocalBuildIdentity()
|
||||
console.log(`[build:mac] local update version ${identity.version}`)
|
||||
execFileSync(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
['exec', 'electron-builder', '--config', 'config/electron-builder.config.cjs', '--mac'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_BUILD_COMMIT: identity.commit,
|
||||
ORCA_LOCAL_BUILD_VERSION: identity.version
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLocalBuildVersion } from './build-mac-local.mjs'
|
||||
|
||||
describe('createLocalBuildVersion', () => {
|
||||
it('creates unique valid prerelease versions without changing the release base', () => {
|
||||
expect(createLocalBuildVersion('1.4.159-rc.0', 123456, 'abc123')).toBe(
|
||||
'1.4.159-rc.0.local.123456.abc123'
|
||||
)
|
||||
expect(createLocalBuildVersion('1.4.159', 123456, 'abc123')).toBe('1.4.159-local.123456.abc123')
|
||||
})
|
||||
|
||||
it('sanitizes commit identifiers', () => {
|
||||
expect(createLocalBuildVersion('1.0.0', 1, 'abc/def')).toBe('1.0.0-local.1.abcdef')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
|
||||
|
||||
const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/
|
||||
export const OXLINT_SCANS = [
|
||||
{
|
||||
// Why: no --config, so Oxlint keeps discovering nested configs. Pinning the root
|
||||
// config would apply root rules to mobile/, whose .oxlintrc.json turns them off.
|
||||
label: 'code quality',
|
||||
args: ['--report-unused-disable-directives-severity', 'warn']
|
||||
},
|
||||
{
|
||||
label: 'type-aware code quality',
|
||||
args: ['--type-aware', '--config', 'config/oxlint-code-quality-type-aware.json']
|
||||
},
|
||||
{
|
||||
label: 'React Doctor',
|
||||
args: ['--config', 'config/oxlint-react-doctor.json']
|
||||
}
|
||||
]
|
||||
|
||||
export function parseAddedLineRanges(diff) {
|
||||
const ranges = []
|
||||
const hunkPattern = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|
||||
for (const line of diff.split(/\r?\n/)) {
|
||||
const match = hunkPattern.exec(line)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const start = Number.parseInt(match[1], 10)
|
||||
const count = match[2] === undefined ? 1 : Number.parseInt(match[2], 10)
|
||||
if (count > 0) {
|
||||
ranges.push({ start, end: start + count - 1 })
|
||||
}
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
export function overlapsAddedLines(startLine, endLine, ranges) {
|
||||
return ranges.some((range) => startLine <= range.end && endLine >= range.start)
|
||||
}
|
||||
|
||||
function runGit(root, args, options = {}) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: root,
|
||||
encoding: options.encoding ?? 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
}
|
||||
|
||||
function splitNullDelimited(output) {
|
||||
return output.split('\0').filter(Boolean)
|
||||
}
|
||||
|
||||
function resolveBase(root, requestedBase) {
|
||||
for (const candidate of [
|
||||
requestedBase,
|
||||
process.env.ORCA_CODE_QUALITY_BASE,
|
||||
'origin/main',
|
||||
'main'
|
||||
]) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
const result = spawnSync('git', ['rev-parse', '--verify', `${candidate}^{commit}`], {
|
||||
cwd: root,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
if (result.status === 0) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
throw new Error('Pass the pull request base SHA or make origin/main available locally.')
|
||||
}
|
||||
|
||||
export function collectAddedLineRanges(root, requestedBase) {
|
||||
const base = resolveBase(root, requestedBase)
|
||||
const mergeBase = runGit(root, ['merge-base', base, 'HEAD']).trim()
|
||||
const comparisonBase = resolvePullRequestDiffBase(root, mergeBase)
|
||||
const changedFiles = splitNullDelimited(
|
||||
runGit(root, ['diff', '--name-only', '-z', '--diff-filter=ACMRTUB', comparisonBase, '--'])
|
||||
)
|
||||
const untrackedFiles = splitNullDelimited(
|
||||
runGit(root, ['ls-files', '--others', '--exclude-standard', '-z'])
|
||||
)
|
||||
const rangesByFile = new Map()
|
||||
|
||||
for (const file of changedFiles) {
|
||||
if (!SOURCE_FILE_PATTERN.test(file) || !existsSync(path.join(root, file))) {
|
||||
continue
|
||||
}
|
||||
const diff = runGit(root, ['diff', '--unified=0', '--no-color', comparisonBase, '--', file])
|
||||
const ranges = parseAddedLineRanges(diff)
|
||||
if (ranges.length > 0) {
|
||||
rangesByFile.set(file, ranges)
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of untrackedFiles) {
|
||||
const absolutePath = path.join(root, file)
|
||||
if (!SOURCE_FILE_PATTERN.test(file) || !existsSync(absolutePath)) {
|
||||
continue
|
||||
}
|
||||
const lineCount = readFileSync(absolutePath, 'utf8').split(/\r?\n/).length
|
||||
rangesByFile.set(file, [{ start: 1, end: lineCount }])
|
||||
}
|
||||
return { base, comparisonBase, rangesByFile }
|
||||
}
|
||||
|
||||
function parseOxlintOutput(stdout, label) {
|
||||
const start = stdout.indexOf('{')
|
||||
const end = stdout.lastIndexOf('}')
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(`${label} did not return Oxlint JSON output.`)
|
||||
}
|
||||
return JSON.parse(stdout.slice(start, end + 1))
|
||||
}
|
||||
|
||||
function normalizedDiagnosticPath(root, filename) {
|
||||
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
|
||||
return path.relative(root, absolutePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function diagnosticLineRange(root, filename, span) {
|
||||
const startLine = span.line
|
||||
if (!Number.isInteger(startLine)) {
|
||||
return null
|
||||
}
|
||||
if (!Number.isInteger(span.offset) || !Number.isInteger(span.length) || span.length === 0) {
|
||||
return { start: startLine, end: startLine }
|
||||
}
|
||||
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
|
||||
const source = readFileSync(absolutePath)
|
||||
const highlighted = source.subarray(span.offset, span.offset + span.length).toString('utf8')
|
||||
return { start: startLine, end: startLine + (highlighted.match(/\n/g)?.length ?? 0) }
|
||||
}
|
||||
|
||||
export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = process.cwd()) {
|
||||
const file = normalizedDiagnosticPath(root, diagnostic.filename)
|
||||
const ranges = rangesByFile.get(file)
|
||||
if (!ranges) {
|
||||
return false
|
||||
}
|
||||
return (diagnostic.labels ?? []).some((label) => {
|
||||
const lineRange = diagnosticLineRange(root, diagnostic.filename, label.span)
|
||||
return lineRange !== null && overlapsAddedLines(lineRange.start, lineRange.end, ranges)
|
||||
})
|
||||
}
|
||||
|
||||
function annotationValue(value) {
|
||||
return String(value).replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')
|
||||
}
|
||||
|
||||
function printDiagnostic(diagnostic, root) {
|
||||
const file = normalizedDiagnosticPath(root, diagnostic.filename)
|
||||
const line = diagnostic.labels?.[0]?.span?.line ?? 1
|
||||
const code = diagnostic.code ?? 'oxlint'
|
||||
console.error(
|
||||
`::error file=${annotationValue(file)},line=${line},title=${annotationValue(code)}::${annotationValue(diagnostic.message)}`
|
||||
)
|
||||
console.error(`${file}:${line} ${code}: ${diagnostic.message}`)
|
||||
}
|
||||
|
||||
function runOxlintScan(root, scan, files) {
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const result = spawnSync(pnpm, ['exec', 'oxlint', ...scan.args, '--format', 'json', ...files], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 128 * 1024 * 1024
|
||||
})
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (!result.stdout.trim()) {
|
||||
process.stderr.write(result.stderr)
|
||||
throw new Error(`${scan.label} failed before producing diagnostics.`)
|
||||
}
|
||||
return parseOxlintOutput(result.stdout, scan.label).diagnostics ?? []
|
||||
}
|
||||
|
||||
export function main(
|
||||
root = process.cwd(),
|
||||
requestedBase = process.argv.slice(2).find((argument) => argument !== '--')
|
||||
) {
|
||||
const { base, comparisonBase, rangesByFile } = collectAddedLineRanges(root, requestedBase)
|
||||
const files = [...rangesByFile.keys()]
|
||||
if (files.length === 0) {
|
||||
console.log(`Changed-code quality gate: no changed JavaScript or TypeScript since ${base}.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
for (const scan of OXLINT_SCANS) {
|
||||
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root)
|
||||
)
|
||||
for (const diagnostic of diagnostics) {
|
||||
printDiagnostic(diagnostic, root)
|
||||
}
|
||||
failures += diagnostics.length
|
||||
console.log(
|
||||
`${scan.label}: ${diagnostics.length} new finding(s) across ${files.length} changed file(s).`
|
||||
)
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(
|
||||
`Changed-code quality gate failed with ${failures} finding(s) since ${comparisonBase.slice(0, 12)}.`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
console.log(`Changed-code quality gate passed since ${comparisonBase.slice(0, 12)}.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(main())
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
OXLINT_SCANS,
|
||||
diagnosticTouchesAddedLines,
|
||||
overlapsAddedLines,
|
||||
parseAddedLineRanges
|
||||
} from './check-changed-code-quality.mjs'
|
||||
|
||||
describe('changed-code quality line matching', () => {
|
||||
it('parses added and replaced hunk ranges while ignoring deletions', () => {
|
||||
const ranges = parseAddedLineRanges(
|
||||
['@@ -10,2 +10,3 @@', '@@ -20 +21 @@', '@@ -40,4 +42,0 @@', '@@ -50 +48,2 @@'].join('\n')
|
||||
)
|
||||
|
||||
expect(ranges).toEqual([
|
||||
{ start: 10, end: 12 },
|
||||
{ start: 21, end: 21 },
|
||||
{ start: 48, end: 49 }
|
||||
])
|
||||
})
|
||||
|
||||
it('matches diagnostics that overlap any added line', () => {
|
||||
const ranges = [
|
||||
{ start: 5, end: 7 },
|
||||
{ start: 12, end: 12 }
|
||||
]
|
||||
|
||||
expect(overlapsAddedLines(3, 5, ranges)).toBe(true)
|
||||
expect(overlapsAddedLines(8, 11, ranges)).toBe(false)
|
||||
expect(overlapsAddedLines(12, 14, ranges)).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes absolute diagnostic paths before matching', () => {
|
||||
const root = process.cwd()
|
||||
const file = 'config/scripts/check-changed-code-quality.test.mjs'
|
||||
const diagnostic = {
|
||||
filename: `${root}/${file}`,
|
||||
labels: [{ span: { line: 24 } }]
|
||||
}
|
||||
|
||||
expect(
|
||||
diagnosticTouchesAddedLines(diagnostic, new Map([[file, [{ start: 24, end: 24 }]]]), root)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// Why: pinning --config disables nested-config discovery, so root rules that
|
||||
// mobile/.oxlintrc.json turns off would fail the gate on mobile files.
|
||||
it('lets the untyped scan discover nested configs instead of pinning the root config', () => {
|
||||
const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'code quality')
|
||||
|
||||
expect(scan.args).not.toContain('--config')
|
||||
expect(scan.args).not.toContain('--disable-nested-config')
|
||||
})
|
||||
})
|
||||
@@ -1,343 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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,35 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import process from 'node:process'
|
||||
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
|
||||
|
||||
const requestedBase =
|
||||
process.argv.slice(2).find((argument) => argument !== '--') ??
|
||||
process.env.ORCA_CODE_QUALITY_BASE ??
|
||||
'origin/main'
|
||||
const base = resolvePullRequestDiffBase(process.cwd(), requestedBase)
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'dlx',
|
||||
'react-doctor@0.9.1',
|
||||
'.',
|
||||
'--yes',
|
||||
'--scope',
|
||||
'lines',
|
||||
'--base',
|
||||
base,
|
||||
'--include-untracked',
|
||||
'--no-dead-code',
|
||||
'--no-supply-chain',
|
||||
'--no-telemetry',
|
||||
'--blocking',
|
||||
'error'
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -1,87 +0,0 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import { reportUnstyledScrollbars } from './styled-scrollbars/styled-scrollbar-jsx-check.mjs'
|
||||
export {
|
||||
plainClassName,
|
||||
reportUnstyledScrollbars
|
||||
} from './styled-scrollbars/styled-scrollbar-jsx-check.mjs'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__'])
|
||||
|
||||
export function normalizePath(root, filePath) {
|
||||
return path.relative(root, filePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function isSkippedFile(root, filePath) {
|
||||
const relative = normalizePath(root, filePath)
|
||||
if (relative.includes('.test.') || relative.includes('.spec.')) {
|
||||
return true
|
||||
}
|
||||
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
|
||||
}
|
||||
|
||||
async function collectSourceFiles(root, dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_PATH_PARTS.has(entry.name)) {
|
||||
files.push(...(await collectSourceFiles(root, fullPath)))
|
||||
}
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
|
||||
!isSkippedFile(root, fullPath)
|
||||
) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
async function collectUnstyledScrollbarReports(root) {
|
||||
const sourceRoot = path.join(root, 'src', 'renderer', 'src')
|
||||
const files = await collectSourceFiles(root, sourceRoot)
|
||||
const reports = []
|
||||
|
||||
for (const filePath of files) {
|
||||
const sourceText = await fs.readFile(filePath, 'utf8')
|
||||
reports.push(...reportUnstyledScrollbars(filePath, sourceText))
|
||||
}
|
||||
|
||||
return reports
|
||||
}
|
||||
|
||||
function formatReports(root, reports) {
|
||||
return reports
|
||||
.map(
|
||||
(report) =>
|
||||
`${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.text.replace(/\s+/g, ' ')}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd()) {
|
||||
const reports = await collectUnstyledScrollbarReports(root)
|
||||
if (reports.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
console.error('Renderer vertical scroll containers must use an Orca scrollbar style.')
|
||||
console.error('Put the scrollbar class in the same class literal as the vertical overflow class.')
|
||||
console.error('Use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.')
|
||||
console.error('')
|
||||
console.error(formatReports(root, reports))
|
||||
return 1
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { plainClassName, reportUnstyledScrollbars } from './check-styled-scrollbars.mjs'
|
||||
|
||||
describe('check-styled-scrollbars', () => {
|
||||
it('reports renderer vertical scroll containers without an Orca scrollbar style', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="max-h-64 overflow-y-auto" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts obvious styled vertical scroll containers', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not accept nonexistent scrollbar classes as Orca scrollbar styles', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-none" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails closed when a separate class composer argument supplies the scrollbar style', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto', 'scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts static class composer arguments when the same literal is styled', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails closed when a scrollbar class is only conditionally present', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example({ enabled }) { return <div className={cn('overflow-y-auto', enabled && 'scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts conditional branches when overflow and scrollbar live in the same class literal', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example({ enabled }) { return <div className={cn(enabled && 'overflow-y-auto scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports vertical scroll inside arbitrary wrappers when the literal is unstyled', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={identity('overflow-y-auto')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not require a vertical scrollbar style for horizontal-only overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <pre className="max-w-full overflow-x-auto" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not let responsive scrollbar variants satisfy unconditional overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="overflow-y-auto md:scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts matching responsive overflow and scrollbar variants', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="md:overflow-y-auto md:scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts unconditional scrollbar styles for responsive overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="md:overflow-y-auto scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports inline vertical overflow without an Orca scrollbar class', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div style={{ overflowY: 'auto' }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts inline vertical overflow with a stable Orca scrollbar class', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="scrollbar-editor" style={{ overflow: \'auto\' }} /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports inline vertical overflow when the scrollbar class is conditional or short-circuited', () => {
|
||||
for (const classNameExpression of [
|
||||
"enabled && 'scrollbar-sleek'",
|
||||
"enabled ? 'scrollbar-sleek' : undefined",
|
||||
"enabled || 'scrollbar-sleek'",
|
||||
"enabled ?? 'scrollbar-sleek'"
|
||||
]) {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
`export function Example({ enabled }) { return <div className={${classNameExpression}} style={{ overflowY: 'auto' }} /> }`
|
||||
)
|
||||
|
||||
expect(reports, classNameExpression).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports logical inline style spreads without an Orca scrollbar class', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example({ open }) { return <div style={{ ...(open && { overflowY: 'auto' }) }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports JSX spread className props with unstyled vertical overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div {...{ className: 'overflow-y-auto' }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts JSX spread className props when the same literal is styled', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div {...{ className: 'overflow-y-auto scrollbar-sleek' }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses later spread className props over earlier explicit className props', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="scrollbar-sleek" {...{ className: \'overflow-y-auto\' }} /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('supports variant helper className config', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={buttonVariants({ className: 'overflow-y-auto scrollbar-sleek' })} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('normalizes Tailwind variants and important prefixes before matching', () => {
|
||||
expect(plainClassName('md:overflow-y-auto')).toBe('overflow-y-auto')
|
||||
expect(plainClassName('[&:hover]:overflow-y-auto')).toBe('overflow-y-auto')
|
||||
expect(plainClassName('md:!scrollbar-editor')).toBe('scrollbar-editor')
|
||||
expect(plainClassName('!scrollbar-editor')).toBe('scrollbar-editor')
|
||||
})
|
||||
})
|
||||
@@ -12,11 +12,10 @@
|
||||
// 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 { readFileSync, readdirSync, statSync } 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')
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: CLI process startup with the RuntimeClient module graph deferred.
|
||||
//
|
||||
// src/cli/index.ts used to value-import RuntimeClient at module scope, and five
|
||||
// modules that load on every invocation (args, flags, dispatch, format,
|
||||
// selectors) pulled RuntimeClientError from the ./runtime-client barrel. Either
|
||||
// edge alone drags in the whole client graph: zod (via shared/pairing ->
|
||||
// shared/mobile-relay-pairing-offer), ws + tweetnacl (via websocket-transport),
|
||||
// plus the environment store and secure-file stack.
|
||||
//
|
||||
// The fix repoints those five at ./runtime/types (zero children) and loads the
|
||||
// client through `await import()` after flag validation, so --help, `help
|
||||
// <cmd>` and every command/flag error return without ever touching it.
|
||||
//
|
||||
// Both arms are REAL tsc emits of real source: the baseline arm restores the
|
||||
// seven touched files from a git rev and compiles that. Each sample is a FRESH
|
||||
// process (module-graph cost is a once-per-process cost; timing it in-process
|
||||
// would measure a warm require cache).
|
||||
//
|
||||
// Arms alternate lead across an even number of rounds and report per-arm
|
||||
// medians. Byte-for-byte output equality is checked BEFORE timing.
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO = fileURLToPath(new URL('../..', import.meta.url))
|
||||
|
||||
const ROUNDS = Number(process.env.ORCA_CLI_DEFER_BENCH_ROUNDS ?? '30')
|
||||
const WARMUP = Number(process.env.ORCA_CLI_DEFER_BENCH_WARMUP ?? '3')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_CLI_DEFER_BENCH_ROUNDS', ROUNDS],
|
||||
['ORCA_CLI_DEFER_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
if (ROUNDS % 2 !== 0) {
|
||||
// Why: arms alternate which one leads; an odd count biases one arm.
|
||||
throw new Error(`ORCA_CLI_DEFER_BENCH_ROUNDS must be even, received ${ROUNDS}`)
|
||||
}
|
||||
|
||||
const TOUCHED = [
|
||||
'src/cli/args.ts',
|
||||
'src/cli/dispatch.ts',
|
||||
'src/cli/flags.ts',
|
||||
'src/cli/format.ts',
|
||||
'src/cli/index.ts',
|
||||
'src/cli/runtime/client.ts',
|
||||
'src/cli/selectors.ts'
|
||||
]
|
||||
|
||||
// Why: if the deferral is reverted or reshaped, both arms would compile to the
|
||||
// same thing and this would quietly report 1.00x forever. Re-read the real call
|
||||
// forms out of the source. Matching the CALL form (not a bare identifier) so a
|
||||
// comment that merely names the function cannot satisfy the check.
|
||||
function assertMarkersFresh() {
|
||||
const checks = [
|
||||
['src/cli/index.ts', "await import('./runtime-client.js')"],
|
||||
['src/cli/index.ts', 'await loadRuntimeClientClass()'],
|
||||
['src/cli/index.ts', "import type { RuntimeClient } from './runtime-client'"],
|
||||
['src/cli/runtime/client.ts', "await import('./websocket-transport.js')"],
|
||||
['src/cli/runtime/client.ts', 'await loadSendWebSocketRequest()'],
|
||||
['src/cli/args.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/flags.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/dispatch.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/selectors.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/format.ts', "} from './runtime/types'"]
|
||||
]
|
||||
for (const [file, marker] of checks) {
|
||||
if (!readFileSync(join(REPO, file), 'utf8').includes(marker)) {
|
||||
throw new Error(
|
||||
`${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-benchmark.mjs is stale`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildArm(label, baselineRev) {
|
||||
// Why: a build under /tmp cannot resolve the repo's node_modules, so the
|
||||
// output has to live inside the repo.
|
||||
const outDir = join(REPO, `.bench-out-${label}`)
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
const restore = []
|
||||
try {
|
||||
if (baselineRev) {
|
||||
for (const file of TOUCHED) {
|
||||
const path = join(REPO, file)
|
||||
restore.push([path, readFileSync(path)])
|
||||
writeFileSync(
|
||||
path,
|
||||
execFileSync('git', ['show', `${baselineRev}:${file}`], {
|
||||
cwd: REPO,
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
execFileSync(
|
||||
'npx',
|
||||
[
|
||||
'tsc',
|
||||
'-p',
|
||||
'config/tsconfig.cli.json',
|
||||
'--outDir',
|
||||
outDir,
|
||||
'--composite',
|
||||
'false',
|
||||
'--incremental',
|
||||
'false'
|
||||
],
|
||||
{ cwd: REPO, stdio: 'inherit' }
|
||||
)
|
||||
} finally {
|
||||
for (const [path, contents] of restore) {
|
||||
writeFileSync(path, contents)
|
||||
}
|
||||
}
|
||||
return join(outDir, 'cli/index.js')
|
||||
}
|
||||
|
||||
function run(entry, argv, env) {
|
||||
const result = spawnSync(process.execPath, [entry, ...argv], {
|
||||
cwd: REPO,
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'buffer'
|
||||
})
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout.toString('utf8'),
|
||||
stderr: result.stderr.toString('utf8')
|
||||
}
|
||||
}
|
||||
|
||||
// Counts the eager CommonJS module graph of a built entry point by hooking
|
||||
// Module._load in a child process. This is the quantity the change moves.
|
||||
function countEagerModules(entry) {
|
||||
const probe = `
|
||||
const Module = require('module')
|
||||
const original = Module._load
|
||||
const seen = new Set()
|
||||
Module._load = function (request, parent, isMain) {
|
||||
try { seen.add(Module._resolveFilename(request, parent, isMain)) } catch { seen.add(request) }
|
||||
return original.apply(this, arguments)
|
||||
}
|
||||
require(${JSON.stringify(entry)})
|
||||
const all = [...seen]
|
||||
process.stdout.write(JSON.stringify({
|
||||
total: all.length,
|
||||
nodeModules: all.filter((p) => p.includes('node_modules')).length
|
||||
}))
|
||||
`
|
||||
const result = spawnSync(process.execPath, ['-e', probe], { cwd: REPO, encoding: 'utf8' })
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`module probe failed: ${result.stderr}`)
|
||||
}
|
||||
return JSON.parse(result.stdout)
|
||||
}
|
||||
|
||||
const median = (values) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
const baselineIndex = process.argv.indexOf('--baseline')
|
||||
const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1]
|
||||
|
||||
assertMarkersFresh()
|
||||
|
||||
const userDataPath = mkdtempSync(join(REPO, '.bench-userdata-'))
|
||||
|
||||
try {
|
||||
console.log(`Building eager baseline (${baselineRev}) …`)
|
||||
const eagerEntry = buildArm('eager', baselineRev)
|
||||
console.log('Building deferred (working tree) …')
|
||||
const deferredEntry = buildArm('deferred', null)
|
||||
|
||||
const eagerGraph = countEagerModules(eagerEntry)
|
||||
const deferredGraph = countEagerModules(deferredEntry)
|
||||
console.log(
|
||||
`\nEager modules at process load: ${eagerGraph.total} -> ${deferredGraph.total} ` +
|
||||
`(node_modules ${eagerGraph.nodeModules} -> ${deferredGraph.nodeModules})`
|
||||
)
|
||||
if (deferredGraph.total >= eagerGraph.total) {
|
||||
throw new Error(
|
||||
'deferred arm loads no fewer modules — the fixture does not exercise the change'
|
||||
)
|
||||
}
|
||||
|
||||
// Each case is (label, argv, env). The runtime-dependent ones point at an
|
||||
// empty user-data dir so both arms get the same deterministic answer.
|
||||
const isolated = { ORCA_USER_DATA_PATH: userDataPath }
|
||||
/** @type {Array<[string, string[], Record<string, string>]>} */
|
||||
const cases = [
|
||||
['orca --help', ['--help'], {}],
|
||||
['orca help worktree', ['help', 'worktree'], {}],
|
||||
['orca (no args)', [], {}],
|
||||
['unknown command', ['no-such-command'], {}],
|
||||
['unknown flag', ['worktree', 'list', '--nope'], {}],
|
||||
['orca agent-context --json', ['agent-context', '--json'], {}],
|
||||
['orca status --json', ['status', '--json'], isolated],
|
||||
['orca worktree list --json', ['worktree', 'list', '--json'], isolated]
|
||||
]
|
||||
|
||||
// Why: a semantically broken arm that prints nothing would look fastest.
|
||||
// Compare bytes and exit codes BEFORE timing anything.
|
||||
for (const [label, argv, env] of cases) {
|
||||
const before = run(eagerEntry, argv, env)
|
||||
const after = run(deferredEntry, argv, env)
|
||||
if (
|
||||
before.status !== after.status ||
|
||||
before.stdout !== after.stdout ||
|
||||
before.stderr !== after.stderr
|
||||
) {
|
||||
throw new Error(`arms disagree for "${label}" — refusing to report a timing`)
|
||||
}
|
||||
if (before.stdout.length + before.stderr.length === 0) {
|
||||
throw new Error(`"${label}" produced no output on either arm; it proves nothing`)
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('\nFresh process per sample, wall clock. Lower is better.')
|
||||
console.log(`rounds=${ROUNDS} warmup=${WARMUP} (per-arm median, arms alternate lead)`)
|
||||
console.log(`${pad('case', 26)} ${pad('eager', 10)} ${pad('deferred', 10)} ${pad('speedup', 9)}`)
|
||||
|
||||
// Accumulated so V8 cannot treat the spawn loop as dead code.
|
||||
let consumed = 0
|
||||
|
||||
for (const [label, argv, env] of cases) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
consumed += run(eagerEntry, argv, env).stdout.length
|
||||
consumed += run(deferredEntry, argv, env).stdout.length
|
||||
}
|
||||
const samples = { eager: [], deferred: [] }
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
// Alternate which arm leads so a drifting machine load cannot be
|
||||
// attributed to one arm.
|
||||
const order =
|
||||
round % 2 === 0
|
||||
? [
|
||||
['eager', eagerEntry],
|
||||
['deferred', deferredEntry]
|
||||
]
|
||||
: [
|
||||
['deferred', deferredEntry],
|
||||
['eager', eagerEntry]
|
||||
]
|
||||
for (const [arm, entry] of order) {
|
||||
const started = performance.now()
|
||||
const result = run(entry, argv, env)
|
||||
samples[arm].push(performance.now() - started)
|
||||
consumed += result.stdout.length
|
||||
}
|
||||
}
|
||||
const eagerMs = median(samples.eager)
|
||||
const deferredMs = median(samples.deferred)
|
||||
console.log(
|
||||
`${pad(label, 26)} ${pad(`${eagerMs.toFixed(1)} ms`, 10)} ${pad(`${deferredMs.toFixed(1)} ms`, 10)} ${pad(`${(eagerMs / deferredMs).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (consumed === 0) {
|
||||
throw new Error('no output consumed — the timing loop was optimised away')
|
||||
}
|
||||
console.log(
|
||||
'\nThe help and error rows are the ones the change targets: they return\n' +
|
||||
'before any client construction, so they drop the whole graph. `status` and\n' +
|
||||
'`worktree list` still construct a client, so they only save the eager parse\n' +
|
||||
'of the parts the local path never uses (ws/tweetnacl via websocket-transport).'
|
||||
)
|
||||
} finally {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
for (const label of ['eager', 'deferred']) {
|
||||
rmSync(join(REPO, `.bench-out-${label}`), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env node
|
||||
// Equivalence check for deferring the RuntimeClient module graph in the CLI.
|
||||
//
|
||||
// Builds the CLI twice with the REAL tsc emit — once from the working tree and
|
||||
// once with the seven touched files restored from git HEAD~ (the pre-deferral
|
||||
// implementation) — then compares stdout, stderr and exit code BYTE FOR BYTE
|
||||
// across a matrix of invocations.
|
||||
//
|
||||
// Why a script and not a vitest case: this compiles two full CLI trees. It is
|
||||
// the artifact that proves the refactor is behaviour-preserving; the fast
|
||||
// invariants (class identity, no eager barrel import) live in
|
||||
// src/cli/runtime-client-deferral.test.ts and run in the normal suite.
|
||||
//
|
||||
// Usage: node config/scripts/cli-runtime-client-deferral-equivalence.mjs [--baseline <rev>]
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO = fileURLToPath(new URL('../..', import.meta.url))
|
||||
|
||||
// The files this change touches. Restoring exactly these from the baseline rev
|
||||
// reconstructs the old implementation without disturbing anything else.
|
||||
const TOUCHED = [
|
||||
'src/cli/args.ts',
|
||||
'src/cli/dispatch.ts',
|
||||
'src/cli/flags.ts',
|
||||
'src/cli/format.ts',
|
||||
'src/cli/index.ts',
|
||||
'src/cli/runtime/client.ts',
|
||||
'src/cli/selectors.ts'
|
||||
]
|
||||
|
||||
// Why: if the deferral is ever reverted or reshaped, the "old" arm would
|
||||
// silently become identical to the new one and every case would pass
|
||||
// vacuously. Re-read the real call form out of the source and fail loudly.
|
||||
function assertMarkersFresh() {
|
||||
const index = readFileSync(join(REPO, 'src/cli/index.ts'), 'utf8')
|
||||
const client = readFileSync(join(REPO, 'src/cli/runtime/client.ts'), 'utf8')
|
||||
const checks = [
|
||||
['src/cli/index.ts', index, 'await loadRuntimeClientClass()'],
|
||||
['src/cli/index.ts', index, "await import('./runtime-client.js')"],
|
||||
['src/cli/index.ts', index, "import type { RuntimeClient } from './runtime-client'"],
|
||||
['src/cli/runtime/client.ts', client, 'await loadSendWebSocketRequest()'],
|
||||
['src/cli/runtime/client.ts', client, "await import('./websocket-transport.js')"]
|
||||
]
|
||||
for (const [file, source, marker] of checks) {
|
||||
// Match the call form, not a bare word: a comment naming the function must
|
||||
// not satisfy the check.
|
||||
if (!source.includes(marker)) {
|
||||
throw new Error(
|
||||
`${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-equivalence.mjs is stale`
|
||||
)
|
||||
}
|
||||
}
|
||||
const repointed = ['src/cli/args.ts', 'src/cli/flags.ts', 'src/cli/dispatch.ts']
|
||||
for (const file of repointed) {
|
||||
const source = readFileSync(join(REPO, file), 'utf8')
|
||||
if (!source.includes("import { RuntimeClientError } from './runtime/types'")) {
|
||||
throw new Error(`${file} no longer repoints RuntimeClientError at ./runtime/types — stale`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildTree(label, baselineRev) {
|
||||
// Why: builds under /tmp cannot resolve the repo's node_modules, so the
|
||||
// output dir has to live inside the repo.
|
||||
const outDir = join(REPO, `.equiv-out-${label}`)
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
const restored = []
|
||||
try {
|
||||
if (baselineRev) {
|
||||
for (const file of TOUCHED) {
|
||||
const path = join(REPO, file)
|
||||
restored.push([path, readFileSync(path)])
|
||||
const old = execFileSync('git', ['show', `${baselineRev}:${file}`], {
|
||||
cwd: REPO,
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
writeFileSync(path, old)
|
||||
}
|
||||
}
|
||||
execFileSync(
|
||||
'npx',
|
||||
[
|
||||
'tsc',
|
||||
'-p',
|
||||
'config/tsconfig.cli.json',
|
||||
'--outDir',
|
||||
outDir,
|
||||
'--composite',
|
||||
'false',
|
||||
'--incremental',
|
||||
'false'
|
||||
],
|
||||
{ cwd: REPO, stdio: 'inherit' }
|
||||
)
|
||||
} finally {
|
||||
for (const [path, contents] of restored) {
|
||||
writeFileSync(path, contents)
|
||||
}
|
||||
}
|
||||
return join(outDir, 'cli/index.js')
|
||||
}
|
||||
|
||||
// Why: every case is a one-shot CLI invocation that must exit on its own. An
|
||||
// unbounded spawnSync turns "this argv reached a blocking command" into an
|
||||
// indefinite stall — a guard that can hang instead of failing is not a guard.
|
||||
const RUN_TIMEOUT_MS = 30_000
|
||||
|
||||
function run(entry, argv, env) {
|
||||
const result = spawnSync(process.execPath, [entry, ...argv], {
|
||||
cwd: REPO,
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'buffer',
|
||||
timeout: RUN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL'
|
||||
})
|
||||
if (result.error) {
|
||||
if (result.error.code === 'ETIMEDOUT') {
|
||||
throw new Error(
|
||||
`orca ${argv.join(' ')} did not exit within ${RUN_TIMEOUT_MS} ms — it reached a blocking command`
|
||||
)
|
||||
}
|
||||
throw result.error
|
||||
}
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout.toString('utf8'),
|
||||
stderr: result.stderr.toString('utf8')
|
||||
}
|
||||
}
|
||||
|
||||
// Every case must produce identical bytes on both arms. The runtime-dependent
|
||||
// ones (status/worktree list) are pointed at an empty user-data dir so the
|
||||
// answer is deterministic "not running" rather than whatever the dev machine
|
||||
// happens to be doing.
|
||||
function buildCases(isolatedUserData) {
|
||||
const isolated = { ORCA_USER_DATA_PATH: isolatedUserData }
|
||||
const cases = [
|
||||
// Paths that must never load the runtime client at all.
|
||||
[[], {}],
|
||||
[['--help'], {}],
|
||||
[['help'], {}],
|
||||
[['help', 'worktree'], {}],
|
||||
[['help', 'browser'], {}],
|
||||
[['worktree', '--help'], {}],
|
||||
[['help', 'no-such-command'], {}],
|
||||
[['no-such-command'], {}],
|
||||
[['no-such-command'], { ORCA_PAIRING_CODE: 'garbage' }],
|
||||
[['wrktree', 'list'], {}],
|
||||
[['agent-context'], {}],
|
||||
[['agent-context', '--json'], {}],
|
||||
// Flag validation must still fire before any runtime lookup.
|
||||
[['worktree', 'list', '--nonexistent-flag'], {}],
|
||||
[['worktree', 'list', '--nonexistent-flag', '--json'], {}],
|
||||
[['browser', 'snapshot', '--nonexistent-flag'], {}],
|
||||
// resolveRemotePairing throws from the RuntimeClient CONSTRUCTOR.
|
||||
[['status', '--pairing-code', 'x', '--environment', 'y'], isolated],
|
||||
[['status', '--pairing-code', 'x', '--environment', 'y', '--json'], isolated],
|
||||
[['status', '--pairing-code', 'not-a-pairing-code'], isolated],
|
||||
[['status', '--pairing-code', 'not-a-pairing-code', '--json'], isolated],
|
||||
[['status', '--pairing-code', 'orca://pair?code=zzzz'], isolated],
|
||||
[['status', '--environment', 'no-such-environment'], isolated],
|
||||
[['status', '--environment', 'no-such-environment', '--json'], isolated],
|
||||
[['worktree', 'list', '--environment', 'no-such-environment', '--json'], isolated],
|
||||
// The env-var fallback must stay live for non-suppressed commands...
|
||||
[['status', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
|
||||
[['status', '--json'], { ...isolated, ORCA_REMOTE_PAIRING: 'not-a-pairing-code' }],
|
||||
[['status', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }],
|
||||
// ...and must stay suppressed for the local-only command groups.
|
||||
//
|
||||
// NOTE: the only commands that both live in a suppressed group AND touch
|
||||
// ctx.client are `agent hooks on|off`, which rewrite the user's real agent
|
||||
// hook configuration in ~/.claude and friends — far outside
|
||||
// ORCA_USER_DATA_PATH. They are deliberately NOT invoked here. The
|
||||
// null-vs-undefined suppression they would exercise is covered
|
||||
// side-effect-free by the constructor-argument assertions in
|
||||
// src/cli/runtime-client-deferral.test.ts instead.
|
||||
[['environment', 'list', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }],
|
||||
[['environment', 'list', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
|
||||
[['agent-context', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
|
||||
// Runtime-unavailable reporting (RuntimeClientError formatting).
|
||||
[['status'], isolated],
|
||||
[['status', '--json'], isolated],
|
||||
[['worktree', 'list', '--json'], isolated],
|
||||
[['terminal', 'list', '--json'], isolated]
|
||||
]
|
||||
// Fuzz: random argv drawn from real command tokens, flags and hostile
|
||||
// strings. Seeded so a failure is reproducible. Every token here must be
|
||||
// safe to actually execute — see UNSAFE_TOKENS, which is cross-checked
|
||||
// against this list before a single case runs.
|
||||
const tokens = [
|
||||
'worktree',
|
||||
'list',
|
||||
'status',
|
||||
'browser',
|
||||
'snapshot',
|
||||
'terminal',
|
||||
'environment',
|
||||
'agent',
|
||||
'agent-context',
|
||||
'vm',
|
||||
'--json',
|
||||
'--help',
|
||||
'--pairing-code',
|
||||
'--environment',
|
||||
'--worktree',
|
||||
'orca://pair?code=!!!',
|
||||
'',
|
||||
'-',
|
||||
'--',
|
||||
'--=',
|
||||
'a'.repeat(300),
|
||||
'näme-ünicode',
|
||||
'💥',
|
||||
'../..',
|
||||
'x\ty'
|
||||
]
|
||||
let seed = 0x9e3779b9
|
||||
const next = () => {
|
||||
seed ^= seed << 13
|
||||
seed ^= seed >>> 17
|
||||
seed ^= seed << 5
|
||||
return (seed >>> 0) / 0x100000000
|
||||
}
|
||||
// Why: check the draw POOL, not just the 400 cases it happens to produce.
|
||||
// `serve` sat in this array for the whole review because the per-case scan
|
||||
// never named it, and the cases that drew it only survived by accident.
|
||||
assertTokensSafe(tokens, 'fuzz token pool')
|
||||
assertFuzzPoolDeclaredReadOnly(tokens)
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
const length = 1 + Math.floor(next() * 4)
|
||||
const argv = []
|
||||
for (let part = 0; part < length; part += 1) {
|
||||
argv.push(tokens[Math.floor(next() * tokens.length)])
|
||||
}
|
||||
cases.push([argv, isolated])
|
||||
}
|
||||
for (const [argv] of cases) {
|
||||
assertTokensSafe(argv, `argv ${JSON.stringify(argv)}`)
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
// Why: this script shells out to the REAL CLI with the developer's own HOME and
|
||||
// PATH, so an argv that reaches the wrong verb does real damage. Two classes:
|
||||
//
|
||||
// 1. FOREGROUND — `orca serve` runs Orca until Ctrl+C and `orca open` /
|
||||
// `claude-teams` spawn processes that outlive the case. A blocking case
|
||||
// does not fail the run, it stalls it, which is worse than a mismatch.
|
||||
// 2. MUTATING — writes outside ORCA_USER_DATA_PATH (`agent hooks off` parks
|
||||
// the real ~/.claude hooks) or drives real browser/desktop input.
|
||||
//
|
||||
// Group tokens whose subcommands split read/write (`capture`, `intercept`,
|
||||
// `label`, `relation`) are denied wholesale: the fuzzer cannot tell them apart.
|
||||
const FOREGROUND_TOKENS = ['serve', 'open', 'claude-teams', 'exec', 'eval', 'launch', 'attach']
|
||||
const MUTATING_TOKENS = [
|
||||
// persistent config and registry state
|
||||
'on',
|
||||
'off',
|
||||
'hooks',
|
||||
'create',
|
||||
'remove',
|
||||
'rm',
|
||||
'delete',
|
||||
'add',
|
||||
'edit',
|
||||
'set',
|
||||
'set-value',
|
||||
'set-base-ref',
|
||||
'setup-clone',
|
||||
'setup-create',
|
||||
'setup-update',
|
||||
'setup-delete',
|
||||
'setup-existing-folder',
|
||||
'install',
|
||||
'uninstall',
|
||||
'reinstall',
|
||||
'update',
|
||||
'clone',
|
||||
'apply',
|
||||
'write',
|
||||
'reset',
|
||||
'clear',
|
||||
'enable',
|
||||
'disable',
|
||||
'use-default',
|
||||
'permissions',
|
||||
// process and pane lifecycle
|
||||
'run',
|
||||
'run-stop',
|
||||
'start',
|
||||
'stop',
|
||||
'kill',
|
||||
'shutdown',
|
||||
'close',
|
||||
'split',
|
||||
'switch',
|
||||
'rename',
|
||||
'focus',
|
||||
// orchestration writes
|
||||
'send',
|
||||
'reply',
|
||||
'dispatch',
|
||||
'task-create',
|
||||
'task-update',
|
||||
'gate-create',
|
||||
'gate-resolve',
|
||||
// issue-tracker writes
|
||||
'save-issue',
|
||||
'comment',
|
||||
'label',
|
||||
'relation',
|
||||
'assignee',
|
||||
'priority',
|
||||
'estimate',
|
||||
'due-date',
|
||||
// browser / computer / emulator input and navigation
|
||||
'goto',
|
||||
'back',
|
||||
'forward',
|
||||
'reload',
|
||||
'click',
|
||||
'dblclick',
|
||||
'hover',
|
||||
'fill',
|
||||
'type',
|
||||
'type-text',
|
||||
'select',
|
||||
'select-all',
|
||||
'uncheck',
|
||||
'keypress',
|
||||
'press-key',
|
||||
'inserttext',
|
||||
'hotkey',
|
||||
'paste-text',
|
||||
'perform-secondary-action',
|
||||
'drag',
|
||||
'scroll',
|
||||
'scrollintoview',
|
||||
'wheel',
|
||||
'move',
|
||||
'up',
|
||||
'down',
|
||||
'tap',
|
||||
'gesture',
|
||||
'button',
|
||||
'rotate',
|
||||
'upload',
|
||||
'download',
|
||||
'dismiss',
|
||||
'accept',
|
||||
'highlight',
|
||||
'viewport',
|
||||
'geolocation',
|
||||
'headers',
|
||||
'credentials',
|
||||
'offline',
|
||||
'media',
|
||||
'device',
|
||||
'capture',
|
||||
'intercept'
|
||||
]
|
||||
|
||||
const UNSAFE_TOKENS = new Map([
|
||||
...FOREGROUND_TOKENS.map((token) => [token, 'runs in the foreground or spawns a process']),
|
||||
...MUTATING_TOKENS.map((token) => [token, 'can write outside ORCA_USER_DATA_PATH'])
|
||||
])
|
||||
|
||||
// Why: the deny list only catches verbs someone already thought of — `serve`
|
||||
// sat in the fuzz pool for the whole review because nobody added it. The pool
|
||||
// is therefore ALSO checked against this allowlist, so a token added to the
|
||||
// pool fails closed until it is consciously declared read-only here.
|
||||
const READ_ONLY_FUZZ_TOKENS = new Set([
|
||||
// command tokens: every path they can form is a list/show or a parse error
|
||||
'agent',
|
||||
'agent-context',
|
||||
'browser',
|
||||
'environment',
|
||||
'list',
|
||||
'snapshot',
|
||||
'status',
|
||||
'terminal',
|
||||
'vm',
|
||||
'worktree',
|
||||
// global flags and hostile strings, which reach no handler at all
|
||||
'--json',
|
||||
'--help',
|
||||
'--pairing-code',
|
||||
'--environment',
|
||||
'--worktree',
|
||||
'orca://pair?code=!!!',
|
||||
'',
|
||||
'-',
|
||||
'--',
|
||||
'--=',
|
||||
'a'.repeat(300),
|
||||
'näme-ünicode',
|
||||
'💥',
|
||||
'../..',
|
||||
'x\ty'
|
||||
])
|
||||
|
||||
function assertTokensSafe(tokens, context) {
|
||||
for (const token of tokens) {
|
||||
const reason = UNSAFE_TOKENS.get(token)
|
||||
if (reason) {
|
||||
throw new Error(`Refusing to run ${context}: "${token}" ${reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a token on neither list (or, worse, on both) means the two lists have
|
||||
// drifted apart. Fail before any case runs rather than sampling and hoping.
|
||||
function assertFuzzPoolDeclaredReadOnly(tokens) {
|
||||
for (const token of tokens) {
|
||||
if (!READ_ONLY_FUZZ_TOKENS.has(token)) {
|
||||
throw new Error(
|
||||
`Fuzz token ${JSON.stringify(token)} is not declared in READ_ONLY_FUZZ_TOKENS — declare it read-only or drop it`
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const token of READ_ONLY_FUZZ_TOKENS) {
|
||||
if (UNSAFE_TOKENS.has(token)) {
|
||||
throw new Error(
|
||||
`Token ${JSON.stringify(token)} is declared both read-only and unsafe — the two lists disagree`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baselineIndex = process.argv.indexOf('--baseline')
|
||||
const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1]
|
||||
|
||||
assertMarkersFresh()
|
||||
|
||||
const isolatedUserData = mkdtempSync(join(REPO, '.equiv-userdata-'))
|
||||
mkdirSync(join(isolatedUserData, 'empty'), { recursive: true })
|
||||
|
||||
let oldEntry
|
||||
let newEntry
|
||||
try {
|
||||
// Why: build the case list (and run its safety guards) BEFORE the two tsc
|
||||
// compiles, so an unsafe token fails in a second instead of two minutes in.
|
||||
const cases = buildCases(isolatedUserData)
|
||||
|
||||
console.log(`Building baseline (${baselineRev}) …`)
|
||||
oldEntry = buildTree('old', baselineRev)
|
||||
console.log('Building working tree …')
|
||||
newEntry = buildTree('new', null)
|
||||
|
||||
console.log(`Comparing ${cases.length} invocations byte for byte …`)
|
||||
let mismatches = 0
|
||||
for (const [argv, env] of cases) {
|
||||
const before = run(oldEntry, argv, env)
|
||||
const after = run(newEntry, argv, env)
|
||||
if (
|
||||
before.status !== after.status ||
|
||||
before.stdout !== after.stdout ||
|
||||
before.stderr !== after.stderr
|
||||
) {
|
||||
mismatches += 1
|
||||
console.error(`\nMISMATCH argv=${JSON.stringify(argv)} env=${JSON.stringify(env)}`)
|
||||
console.error(` exit before=${before.status} after=${after.status}`)
|
||||
if (before.stdout !== after.stdout) {
|
||||
console.error(` stdout before=${JSON.stringify(before.stdout.slice(0, 400))}`)
|
||||
console.error(` after =${JSON.stringify(after.stdout.slice(0, 400))}`)
|
||||
}
|
||||
if (before.stderr !== after.stderr) {
|
||||
console.error(` stderr before=${JSON.stringify(before.stderr.slice(0, 400))}`)
|
||||
console.error(` after =${JSON.stringify(after.stderr.slice(0, 400))}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mismatches > 0) {
|
||||
throw new Error(`${mismatches} of ${cases.length} invocations differ`)
|
||||
}
|
||||
console.log(`\nAll ${cases.length} invocations byte-identical (stdout, stderr, exit code).`)
|
||||
} finally {
|
||||
rmSync(isolatedUserData, { recursive: true, force: true })
|
||||
for (const label of ['old', 'new']) {
|
||||
rmSync(resolve(REPO, `.equiv-out-${label}`), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
@@ -72,6 +73,7 @@ describe('computer-use e2e workflow', () => {
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
'src/main/computer/computer-provider-lifecycle.test.ts',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(join(projectDir, path), 'utf8')
|
||||
}
|
||||
|
||||
function sourceBetween(contents, startMarker, endMarker) {
|
||||
const start = contents.indexOf(startMarker)
|
||||
const end = contents.indexOf(endMarker, start + startMarker.length)
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
|
||||
}
|
||||
return contents.slice(start, end)
|
||||
}
|
||||
|
||||
describe('computer-use modifier safety', () => {
|
||||
it('uses mouse-event flags instead of held modifier keys on macOS', () => {
|
||||
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
|
||||
const clickInput = sourceBetween(macOS, 'static func click(', 'static func scroll(')
|
||||
const mouseInput = sourceBetween(
|
||||
macOS,
|
||||
'private static func mouse(',
|
||||
'private static func keyEvent('
|
||||
)
|
||||
|
||||
expect(mouseInput).toContain('event.flags = flags')
|
||||
expect(clickInput.match(/flags: flags/gu)).toHaveLength(3)
|
||||
expect(clickInput).not.toContain('down: true')
|
||||
})
|
||||
|
||||
it('submits each modified Windows click in a closed, timed SendInput batch', () => {
|
||||
const windows = source('native/computer-use-windows/runtime.ps1')
|
||||
const modifiedClick = sourceBetween(
|
||||
windows,
|
||||
'public static void SendModifiedClick',
|
||||
'private static INPUT KeyboardInput'
|
||||
)
|
||||
const mouseClick = sourceBetween(
|
||||
windows,
|
||||
'function Send-OrcaMouseClick',
|
||||
'function Send-OrcaDrag'
|
||||
)
|
||||
|
||||
expect(modifiedClick).toContain('SendInput((uint)values.Length, values')
|
||||
expect(modifiedClick).toContain('SendInput((uint)releaseValues.Length, releaseValues')
|
||||
expect(modifiedClick).toContain('if (sent != (uint)values.Length)')
|
||||
expect(modifiedClick).toContain('releases.Add(MouseInput(mouseInput, mouseUp))')
|
||||
expect(modifiedClick).not.toContain('int count')
|
||||
expect(mouseClick).toMatch(
|
||||
/for \(\$i = 0; \$i -lt \$clickCount; \$i\+\+\) \{\s+\[OrcaDesktopWin32\]::SendModifiedClick\(/
|
||||
)
|
||||
expect(mouseClick).toContain('if ($i + 1 -lt $clickCount) { Start-Sleep -Milliseconds 35 }')
|
||||
expect(windows).not.toContain('keybd_event')
|
||||
})
|
||||
|
||||
it('keeps Linux modifier release in the xdotool sequence and a fallback', () => {
|
||||
const linux = source('native/computer-use-linux/runtime.py')
|
||||
const modifiedClick = sourceBetween(linux, 'def modified_click_at(', 'def scroll_at(')
|
||||
|
||||
expect(modifiedClick).toContain('command.extend(["keyup", modifier])')
|
||||
expect(modifiedClick).toContain('is_wayland')
|
||||
expect(modifiedClick).toContain('modified clicks require xdotool on an X11 session')
|
||||
expect(modifiedClick).toContain('finally:')
|
||||
expect(modifiedClick).toContain('check=False')
|
||||
expect(modifiedClick).toContain('timeout=5')
|
||||
expect(modifiedClick).toContain('timeout=2')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
// Why: computer-use now ships a hybrid discovery stub, so its version-sensitive command
|
||||
@@ -8,6 +9,7 @@ const projectDir = resolve(import.meta.dirname, '../..')
|
||||
// installable stub projection is checked separately below.
|
||||
const guidePath = join(projectDir, 'skill-guides', 'computer-use.md')
|
||||
const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
|
||||
const bundledGuide = BUNDLED_SKILL_GUIDES.find((guide) => guide.name === 'computer-use')?.markdown
|
||||
|
||||
describe('computer-use skill guidance', () => {
|
||||
it('keeps web-app targeting on the computer-use surface', () => {
|
||||
@@ -44,6 +46,15 @@ describe('computer-use skill guidance', () => {
|
||||
expect(skill).toContain('`result.snapshot.treeText`')
|
||||
expect(skill).not.toContain('`result.elements`')
|
||||
})
|
||||
|
||||
it('requires atomic modifier-click actions in the source and bundled guide', () => {
|
||||
expect(bundledGuide).toBeDefined()
|
||||
|
||||
for (const skill of [readFileSync(guidePath, 'utf8'), bundledGuide]) {
|
||||
expect(skill).toContain('click --modifiers <chord>')
|
||||
expect(skill).toContain('Never synthesize separate modifier-down and modifier-up commands')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('computer-use install stub', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const electronBuilderConfig = require('../electron-builder.config.cjs')
|
||||
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
|
||||
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
|
||||
const {
|
||||
createPackagedRuntimeNodeModuleResources,
|
||||
@@ -19,6 +20,12 @@ const {
|
||||
} = require('../packaged-runtime-node-modules.cjs')
|
||||
|
||||
describe('electron-builder config', () => {
|
||||
it('keeps the packaged app identity aligned with local-build validation', () => {
|
||||
expect(electronBuilderConfig.appId).toBe(
|
||||
require('../../src/shared/local-build-compatibility-contract.json').appId
|
||||
)
|
||||
})
|
||||
|
||||
it('excludes repo-only source trees from app.asar', () => {
|
||||
expect(electronBuilderConfig.files).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -32,15 +39,39 @@ describe('electron-builder config', () => {
|
||||
'!skill-stubs{,/**/*}',
|
||||
'!resources/skills/**',
|
||||
'!tests{,/**/*}',
|
||||
'!examples{,/**/*}',
|
||||
'!pr-evidence{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
|
||||
'!out/**/*.test.js',
|
||||
'!resources/plugins/launch/**'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
// Why: `files` is an all-negation list, so electron-builder's default `**/*` packs
|
||||
// anything without an explicit `!` entry — examples/ landed without one and shipped
|
||||
// hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar.
|
||||
// Drive the real matcher: pinning the pattern string cannot prove it excludes the tree.
|
||||
it('keeps plugin authoring examples out of app.asar', () => {
|
||||
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
|
||||
// copyFiles() prepends this itself once the pattern list is all-negation.
|
||||
matcher.prependPattern('**/*')
|
||||
const isPacked = matcher.createFilter()
|
||||
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
|
||||
|
||||
for (const authoringOnly of [
|
||||
'examples/plugins/hostile-panel/panel.html',
|
||||
'examples/plugins/hostile-panel/orca-plugin.json',
|
||||
'examples/plugins/hello-orca/main.mjs',
|
||||
'examples/plugins/hello-orca/orca-plugin.json'
|
||||
]) {
|
||||
expect(packs(authoringOnly)).toBe(false)
|
||||
}
|
||||
// The negation stays anchored at the app root, so nested `examples` segments still ship.
|
||||
expect(packs('out/main/examples/index.js')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps runtime resources available through extraResources', () => {
|
||||
const bundledPluginResources = expect.objectContaining({
|
||||
from: 'resources/plugins/launch',
|
||||
@@ -135,6 +166,14 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
|
||||
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
|
||||
it('unpacks the forked main-thread hang-watchdog entry', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the multi-size icon source for Linux packages', () => {
|
||||
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
|
||||
})
|
||||
@@ -173,6 +212,58 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('overrides packaged semver only for local macOS builds', () => {
|
||||
const configPath = require.resolve('../electron-builder.config.cjs')
|
||||
const original = process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const originalMacRelease = process.env.ORCA_MAC_RELEASE
|
||||
try {
|
||||
delete require.cache[configPath]
|
||||
delete process.env.ORCA_MAC_RELEASE
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-rc.0.local.123.abc'
|
||||
expect(require('../electron-builder.config.cjs').extraMetadata).toEqual({
|
||||
version: '1.4.159-rc.0.local.123.abc'
|
||||
})
|
||||
} finally {
|
||||
if (originalMacRelease === undefined) {
|
||||
delete process.env.ORCA_MAC_RELEASE
|
||||
} else {
|
||||
process.env.ORCA_MAC_RELEASE = originalMacRelease
|
||||
}
|
||||
if (original === undefined) {
|
||||
delete process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
} else {
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = original
|
||||
}
|
||||
delete require.cache[configPath]
|
||||
require('../electron-builder.config.cjs')
|
||||
}
|
||||
})
|
||||
|
||||
it('never applies local semver to release packaging', () => {
|
||||
const configPath = require.resolve('../electron-builder.config.cjs')
|
||||
const originalLocalVersion = process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const originalMacRelease = process.env.ORCA_MAC_RELEASE
|
||||
try {
|
||||
delete require.cache[configPath]
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-local.123.abc'
|
||||
process.env.ORCA_MAC_RELEASE = '1'
|
||||
expect(require('../electron-builder.config.cjs').extraMetadata).toBeUndefined()
|
||||
} finally {
|
||||
if (originalLocalVersion === undefined) {
|
||||
delete process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
} else {
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = originalLocalVersion
|
||||
}
|
||||
if (originalMacRelease === undefined) {
|
||||
delete process.env.ORCA_MAC_RELEASE
|
||||
} else {
|
||||
process.env.ORCA_MAC_RELEASE = originalMacRelease
|
||||
}
|
||||
delete require.cache[configPath]
|
||||
require('../electron-builder.config.cjs')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
|
||||
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
|
||||
expect(electronBuilderConfig.npmRebuild).toBe(true)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { electronViteConfig } from '../../electron.vite.config'
|
||||
|
||||
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
|
||||
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
|
||||
|
||||
describe('Electron Vite output contract', () => {
|
||||
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
|
||||
@@ -39,4 +40,12 @@ describe('Electron Vite output contract', () => {
|
||||
it('rejects prototype properties as build targets', () => {
|
||||
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
|
||||
})
|
||||
|
||||
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
|
||||
expect(devRunner).toContain('const helperBundleId = `${bundleId}.helper`')
|
||||
expect(devRunner).toContain("'Electron Helper.app',")
|
||||
expect(devRunner).toContain(
|
||||
"setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import process from 'node:process'
|
||||
|
||||
export function selectPullRequestDiffBase(requestedBase, headParents, eventName) {
|
||||
if (eventName === 'pull_request' && headParents.length >= 2) {
|
||||
return headParents[0]
|
||||
}
|
||||
return requestedBase
|
||||
}
|
||||
|
||||
export function resolvePullRequestDiffBase(
|
||||
root,
|
||||
requestedBase,
|
||||
eventName = process.env.GITHUB_EVENT_NAME
|
||||
) {
|
||||
const [, ...headParents] = execFileSync('git', ['rev-list', '--parents', '-n', '1', 'HEAD'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
return selectPullRequestDiffBase(requestedBase, headParents, eventName)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectPullRequestDiffBase } from './git-pull-request-diff-base.mjs'
|
||||
|
||||
describe('pull request diff base selection', () => {
|
||||
it('uses the merge commit first parent for pull request checkouts', () => {
|
||||
expect(
|
||||
selectPullRequestDiffBase('event-base', ['current-base', 'pull-request-head'], 'pull_request')
|
||||
).toBe('current-base')
|
||||
})
|
||||
|
||||
it('keeps the requested base outside synthetic pull request merges', () => {
|
||||
expect(selectPullRequestDiffBase('requested-base', ['parent'], 'pull_request')).toBe(
|
||||
'requested-base'
|
||||
)
|
||||
expect(selectPullRequestDiffBase('requested-base', ['parent', 'other'], 'push')).toBe(
|
||||
'requested-base'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: the per-id worktree/tab lookups in session hydration and terminal reconnect.
|
||||
//
|
||||
// Four sites in store/slices/terminals.ts re-flattened worktreesByRepo (or tabsByWorktree)
|
||||
// and linearly searched it once per loop iteration -- O(rows x ids) for O(rows + ids)
|
||||
// distinct work. The fix builds one first-wins index per loop.
|
||||
//
|
||||
// This runs on the renderer's synchronous cold-start path and gates workspaceSessionReady,
|
||||
// which blocks terminal pane mounting, so the cost is paid before the first frame.
|
||||
//
|
||||
// Both arms produce the resolved rows and are compared for equality before timing, so an
|
||||
// index that resolved differently could not be reported as a win.
|
||||
//
|
||||
// Run with: node config/scripts/hydrate-worktree-lookup-benchmark.mjs
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const ITERATIONS = Number(process.env.ORCA_HYDRATE_BENCH_ITERATIONS ?? '60')
|
||||
const WARMUP = Number(process.env.ORCA_HYDRATE_BENCH_WARMUP ?? '10')
|
||||
const ROUNDS = 6
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_HYDRATE_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_HYDRATE_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fix: re-flatten and linear-search per id.
|
||||
function resolveByFlatten(worktreesByRepo, ids) {
|
||||
const resolved = []
|
||||
for (const id of ids) {
|
||||
const worktree = Object.values(worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === id)
|
||||
resolved.push(worktree ? worktree.repoId : null)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// Post-fix: mirrors buildWorktreeByIdIndex in store/slices/worktree-by-id-index.ts.
|
||||
function resolveByIndex(worktreesByRepo, ids) {
|
||||
const index = new Map()
|
||||
for (const worktrees of Object.values(worktreesByRepo)) {
|
||||
for (const worktree of worktrees) {
|
||||
if (!index.has(worktree.id)) {
|
||||
index.set(worktree.id, worktree)
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolved = []
|
||||
for (const id of ids) {
|
||||
const worktree = index.get(id)
|
||||
resolved.push(worktree ? worktree.repoId : null)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function makeStore(repoCount, worktreesPerRepo) {
|
||||
const worktreesByRepo = {}
|
||||
for (let repo = 0; repo < repoCount; repo += 1) {
|
||||
const repoId = `repo-${repo}`
|
||||
worktreesByRepo[repoId] = Array.from({ length: worktreesPerRepo }, (_value, index) => ({
|
||||
id: `${repoId}/wt-${index}`,
|
||||
repoId,
|
||||
path: `/Users/dev/worktrees/${repoId}/wt-${index}`,
|
||||
branch: `feature/branch-${index}`
|
||||
}))
|
||||
}
|
||||
// Why a deliberate duplicate: `.find()` is first-wins, so an index that overwrote on
|
||||
// collision would resolve a different repo. Without a collision in the fixture that
|
||||
// difference is unobservable and the equality check below would pass a broken index.
|
||||
if (repoCount > 1) {
|
||||
const [firstRepo, secondRepo] = Object.keys(worktreesByRepo)
|
||||
worktreesByRepo[secondRepo] = [
|
||||
{ ...worktreesByRepo[firstRepo][0], repoId: secondRepo },
|
||||
...worktreesByRepo[secondRepo]
|
||||
]
|
||||
}
|
||||
return worktreesByRepo
|
||||
}
|
||||
|
||||
// Why a miss fraction: SSH worktrees are absent from worktreesByRepo at cold start, so
|
||||
// the real workload includes ids that scan the whole list without matching -- the worst
|
||||
// case for the linear arm, and the one the code comments call out explicitly.
|
||||
function makeIds(worktreesByRepo, count) {
|
||||
const all = Object.values(worktreesByRepo).flat()
|
||||
const ids = Array.from({ length: count }, (_value, index) =>
|
||||
index % 7 === 0 ? `absent/wt-${index}` : all[(index * 31) % all.length].id
|
||||
)
|
||||
// Always look up the duplicated id, so first-wins is exercised, not just present.
|
||||
ids[1] = all[0].id
|
||||
return ids
|
||||
}
|
||||
|
||||
function timeArm(resolve, worktreesByRepo, ids) {
|
||||
let sink = 0
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
// Consume the result so V8 cannot drop the call as dead.
|
||||
sink += resolve(worktreesByRepo, ids).length
|
||||
}
|
||||
const elapsed = (performance.now() - start) / ITERATIONS
|
||||
if (sink === -1) {
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const mid = sorted.length / 2
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
// Arms alternate which one leads so within-round drift cannot favour either.
|
||||
function measure(worktreesByRepo, ids) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
resolveByFlatten(worktreesByRepo, ids)
|
||||
resolveByIndex(worktreesByRepo, ids)
|
||||
}
|
||||
const flattenSamples = []
|
||||
const indexSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids))
|
||||
indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids))
|
||||
} else {
|
||||
indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids))
|
||||
flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids))
|
||||
}
|
||||
}
|
||||
return { flattenMs: median(flattenSamples), indexMs: median(indexSamples) }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Session-hydration worktree lookup, per cold start. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`)
|
||||
console.log(
|
||||
`${pad('repos', 6)} ${pad('worktrees', 10)} ${pad('ids', 5)} ${pad('flatten', 11)} ${pad('indexed', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
// Row shapes are synthetic, sized against a real orca-data.json on a heavy machine
|
||||
// (10 repos / 423 worktrees / 188 pending reconnect). They are not that dataset: the
|
||||
// generator spreads worktrees evenly and injects one duplicate id, so treat the counts
|
||||
// as "about this scale", not a replay.
|
||||
for (const [repoCount, worktreesPerRepo, idCount] of [
|
||||
[1, 5, 5],
|
||||
[3, 20, 20],
|
||||
[10, 42, 188],
|
||||
[10, 100, 400]
|
||||
]) {
|
||||
const worktreesByRepo = makeStore(repoCount, worktreesPerRepo)
|
||||
const ids = makeIds(worktreesByRepo, idCount)
|
||||
const flattenResult = resolveByFlatten(worktreesByRepo, ids)
|
||||
const indexResult = resolveByIndex(worktreesByRepo, ids)
|
||||
if (JSON.stringify(flattenResult) !== JSON.stringify(indexResult)) {
|
||||
throw new Error(`resolution differs at ${repoCount} repos x ${worktreesPerRepo} worktrees`)
|
||||
}
|
||||
if (!flattenResult.some((value) => value !== null)) {
|
||||
throw new Error(`fixture resolved nothing at ${repoCount} repos`)
|
||||
}
|
||||
if (!flattenResult.some((value) => value === null)) {
|
||||
throw new Error(`fixture had no absent ids at ${repoCount} repos`)
|
||||
}
|
||||
// Count the generated rows rather than multiplying: makeStore injects a duplicate
|
||||
// id for multi-repo cases, so the product would misreport the fixture by one.
|
||||
const worktreeCount = Object.values(worktreesByRepo).reduce((sum, rows) => sum + rows.length, 0)
|
||||
const { flattenMs, indexMs } = measure(worktreesByRepo, ids)
|
||||
console.log(
|
||||
`${pad(repoCount, 6)} ${pad(worktreeCount, 10)} ${pad(idCount, 5)} ${pad(`${flattenMs.toFixed(4)} ms`, 11)} ${pad(`${indexMs.toFixed(4)} ms`, 11)} ${pad(`${(flattenMs / indexMs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nFixtures are synthetic at real-world scale, not a replay of a real session.\nThis times one of the four lookup sites. A one-repo session sees almost nothing;\nthe win scales with worktrees x pending ids, and lands on the cold-start path that\ngates terminal pane mounting.'
|
||||
)
|
||||
@@ -200,7 +200,7 @@ export async function setupTerminal(page, repoPath, logPhase) {
|
||||
return pane?.container?.dataset?.ptyId ?? null
|
||||
})
|
||||
)
|
||||
logPhase('setup.pty-bound', `ptyId=${ptyId}`)
|
||||
logPhase('setup.pty-bound', `ptyId=${String(ptyId)}`)
|
||||
return ptyId
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs'
|
||||
import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs'
|
||||
import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs'
|
||||
|
||||
export function mergeLocaleKeyOverrides(base) {
|
||||
const merged = { ...base }
|
||||
@@ -10,5 +11,8 @@ export function mergeLocaleKeyOverrides(base) {
|
||||
// KO split overrides can share keys with zh/ja repairs; merge per locale.
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) {
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -2231,9 +2231,6 @@
|
||||
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
|
||||
"ko": "마이크"
|
||||
},
|
||||
"auto.components.settings.DeveloperPermissionsPane.7ca17b62c8": {
|
||||
"ko": "프로젝트, 워크트리 또는 심볼릭 링크된 파일이 macOS 보호 폴더에 닿을 때 권장됩니다."
|
||||
},
|
||||
"auto.components.settings.DeveloperPermissionsPane.e119f0d66b": {
|
||||
"ko": "자동화"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const MACOS_TCC_KEY_OVERRIDES = {
|
||||
'auto.hooks.useMacosTccPromptNotice.description': {
|
||||
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
|
||||
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
|
||||
ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
|
||||
zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。'
|
||||
},
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
|
||||
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
|
||||
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
|
||||
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
|
||||
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
|
||||
},
|
||||
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
|
||||
ko: '전체 디스크 접근 권한',
|
||||
zh: '完全磁盘访问权限'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const LOCALIZED_PROSE_TERM_KEYS = new Set([
|
||||
'auto.hooks.useMacosTccPromptNotice.description',
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
|
||||
])
|
||||
|
||||
const LOCALIZABLE_PROSE_TERMS = new Set([
|
||||
'Agent',
|
||||
'Agents',
|
||||
'agent',
|
||||
'agents',
|
||||
'Terminal',
|
||||
'Terminals',
|
||||
'terminal',
|
||||
'terminals'
|
||||
])
|
||||
|
||||
export function isLocalizedProseTermContext(term, key) {
|
||||
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
|
||||
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
|
||||
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
|
||||
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
@@ -382,6 +383,9 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
if (isScreenCursorContext(brand, enValue, key)) {
|
||||
continue
|
||||
}
|
||||
if (isLocalizedProseTermContext(brand, key)) {
|
||||
continue
|
||||
}
|
||||
if (includesPreservedLatinTerm(result, brand)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('localization package scripts', () => {
|
||||
const scripts = JSON.parse(readFileSync('package.json', 'utf8')).scripts
|
||||
|
||||
it('keeps safe catalog and extraction verification available', () => {
|
||||
expect(scripts['verify:localization-catalog']).toBeDefined()
|
||||
expect(scripts['sync:localization-catalog']).toBeDefined()
|
||||
expect(scripts['verify:localization-extraction']).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not expose whole-catalog translation and repair commands', () => {
|
||||
expect(scripts['bootstrap:locale-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:zh-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:ko-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:ja-catalog']).toBeUndefined()
|
||||
expect(scripts['bootstrap:es-catalog']).toBeUndefined()
|
||||
expect(scripts['repair:locale-catalog']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const compatibilityContract = require('../../src/shared/local-build-compatibility-contract.json')
|
||||
|
||||
const MAC_BUILD_COMPATIBILITY_FILENAME = 'orca-local-build.json'
|
||||
|
||||
function createMacBuildCompatibility({ version, commit, architecture }) {
|
||||
if (architecture !== 'arm64' && architecture !== 'x64') {
|
||||
throw new Error(`Unsupported macOS build architecture: ${architecture}`)
|
||||
}
|
||||
return {
|
||||
...compatibilityContract,
|
||||
buildId: `${version}-${commit}-${architecture}`,
|
||||
version,
|
||||
commit,
|
||||
platform: 'darwin',
|
||||
architecture
|
||||
}
|
||||
}
|
||||
|
||||
function writeMacBuildCompatibility(resourcesDir, identity) {
|
||||
const compatibility = createMacBuildCompatibility(identity)
|
||||
writeFileSync(
|
||||
join(resourcesDir, MAC_BUILD_COMPATIBILITY_FILENAME),
|
||||
`${JSON.stringify(compatibility, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAC_BUILD_COMPATIBILITY_FILENAME,
|
||||
createMacBuildCompatibility,
|
||||
writeMacBuildCompatibility
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { createMacBuildCompatibility } = require('./mac-build-compatibility.cjs')
|
||||
|
||||
describe('mac build compatibility metadata', () => {
|
||||
it('binds version, commit, and architecture into the packaged contract', () => {
|
||||
expect(
|
||||
createMacBuildCompatibility({
|
||||
version: '1.2.3-local.1',
|
||||
commit: 'abc123',
|
||||
architecture: 'arm64'
|
||||
})
|
||||
).toMatchObject({
|
||||
formatVersion: 1,
|
||||
appId: 'com.stablyai.orca',
|
||||
buildId: '1.2.3-local.1-abc123-arm64',
|
||||
version: '1.2.3-local.1',
|
||||
commit: 'abc123',
|
||||
stateSchemaVersion: 1,
|
||||
platform: 'darwin',
|
||||
architecture: 'arm64'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unsupported architecture metadata', () => {
|
||||
expect(() =>
|
||||
createMacBuildCompatibility({
|
||||
version: '1.2.3',
|
||||
commit: 'abc123',
|
||||
architecture: 'universal'
|
||||
})
|
||||
).toThrow('Unsupported macOS build architecture')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
const LOCALES = ['es', 'ja', 'ko', 'zh']
|
||||
const KEYS = [
|
||||
'auto.hooks.useMacosTccPromptNotice.title',
|
||||
'auto.hooks.useMacosTccPromptNotice.description',
|
||||
'auto.hooks.useMacosTccPromptNotice.openSettings',
|
||||
'auto.hooks.useMacosTccPromptNotice.dismiss',
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
|
||||
'auto.components.settings.DeveloperPermissionsPane.c566bca278'
|
||||
]
|
||||
|
||||
function readCatalog(locale) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(
|
||||
new URL(`../../src/renderer/src/i18n/locales/${locale}.json`, import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getValue(catalog, key) {
|
||||
return key.split('.').reduce((value, part) => value[part], catalog)
|
||||
}
|
||||
|
||||
describe('macOS TCC prompt localization', () => {
|
||||
it('survives the canonical catalog repair policy', () => {
|
||||
const english = readCatalog('en')
|
||||
for (const locale of LOCALES) {
|
||||
const catalog = readCatalog(locale)
|
||||
for (const key of KEYS) {
|
||||
const enValue = getValue(english, key)
|
||||
const localeValue = getValue(catalog, key)
|
||||
expect(repairTranslatedValue({ key, enValue, localeValue, locale })).toBe(localeValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the macOS Full Disk Access labels in Korean and Chinese', () => {
|
||||
expect(
|
||||
getValue(readCatalog('ko'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
|
||||
).toBe('전체 디스크 접근 권한')
|
||||
expect(
|
||||
getValue(readCatalog('zh'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
|
||||
).toBe('完全磁盘访问权限')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const pluginPath = path.resolve('config/oxlint-plugins/mobile-pairing-qrcode-import.mjs')
|
||||
const oxlintPath = path.resolve(
|
||||
process.platform === 'win32' ? 'node_modules/.bin/oxlint.cmd' : 'node_modules/.bin/oxlint'
|
||||
)
|
||||
|
||||
function lintSource(source) {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'orca-qrcode-import-lint-'))
|
||||
const sourcePath = path.join(directory, 'sample.ts')
|
||||
const configPath = path.join(directory, 'oxlint.json')
|
||||
writeFileSync(sourcePath, source)
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
categories: { correctness: 'off' },
|
||||
jsPlugins: [{ name: 'mobile-pairing', specifier: pluginPath }],
|
||||
rules: { 'mobile-pairing/no-eager-qrcode-import': 'error' }
|
||||
})
|
||||
)
|
||||
const result = spawnSync(oxlintPath, ['--config', configPath, '--format', 'json', sourcePath], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
return JSON.parse(result.stdout).diagnostics
|
||||
}
|
||||
|
||||
describe('mobile pairing qrcode import rule', () => {
|
||||
it('rejects eager runtime imports', () => {
|
||||
const diagnostics = lintSource("import QRCode from 'qrcode'\nvoid QRCode")
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
||||
'mobile-pairing(no-eager-qrcode-import)'
|
||||
])
|
||||
})
|
||||
|
||||
it('allows type-only and lazy imports', () => {
|
||||
expect(lintSource("import type QRCode from 'qrcode'\nlet qr: typeof QRCode")).toEqual([])
|
||||
expect(lintSource("const QRCode = await import('qrcode')\nvoid QRCode")).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,10 @@ describe('Windows SSH relay node-pty console-list patch', () => {
|
||||
|
||||
const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource())
|
||||
patchNodePtyConsoleListAgent(tamperedPatch.root)
|
||||
writeFileSync(tamperedPatch.agentPath, `${readFileSync(tamperedPatch.agentPath)}\n// drift`)
|
||||
writeFileSync(
|
||||
tamperedPatch.agentPath,
|
||||
`${readFileSync(tamperedPatch.agentPath, 'utf8')}\n// drift`
|
||||
)
|
||||
expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,18 +51,39 @@ describe('orchestration skill guidance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('teaches the hard cutover without reviving a legacy executor', () => {
|
||||
it('teaches attested adoption without reviving the retired scheduler', () => {
|
||||
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')
|
||||
expect(migration).toContain(
|
||||
'adopts a live pre-update orchestration assignment into an ordinary Run'
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch'
|
||||
)
|
||||
expect(migration).toContain('never restarts or replaces the worker')
|
||||
expect(migration).toContain('The retired scheduler is not revived')
|
||||
expect(migration).toContain('[LEGACY COMPATIBILITY]')
|
||||
expect(migration).toContain('[LEGACY READ-ONLY]')
|
||||
expect(migration).toContain(
|
||||
'Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.'
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal.'
|
||||
)
|
||||
expect(migration).not.toContain('task-list --run run_legacy_local')
|
||||
expect(migration).toContain('run_legacy_local is an empty audit tombstone')
|
||||
expect(migration).toContain('Recovered orchestration work from a contract update')
|
||||
expect(migration).toContain('run-show --id <adopted_run_id>')
|
||||
expect(migration).toContain('task-list --run <adopted_run_id>')
|
||||
expect(migration).toContain('Legacy inspection remains available without consuming mail')
|
||||
expect(migration).toContain('run-use --id <adopted_run_id> --takeover-legacy')
|
||||
expect(migration).toContain('Takeover fences only the old coordinator')
|
||||
expect(migration).toContain('Live legacy workers keep their original Tasks, Dispatches')
|
||||
expect(migration).toContain(
|
||||
'keep the original worker as the only editor until it reaches a stable handoff point'
|
||||
)
|
||||
expect(migration).toContain('a conflict-free placement for any remaining work')
|
||||
})
|
||||
|
||||
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const oxlintPackageDirectory = path.dirname(
|
||||
createRequire(import.meta.url).resolve('oxlint/package.json')
|
||||
)
|
||||
const oxlintPath = path.join(oxlintPackageDirectory, 'bin', 'oxlint')
|
||||
|
||||
export function runOxlintPluginOnSource({
|
||||
pluginName,
|
||||
pluginPath,
|
||||
rules,
|
||||
source,
|
||||
extension = 'tsx'
|
||||
}) {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), `orca-${pluginName}-lint-`))
|
||||
const sourcePath = path.join(directory, `sample.${extension}`)
|
||||
const configPath = path.join(directory, 'oxlint.json')
|
||||
|
||||
try {
|
||||
writeFileSync(sourcePath, source)
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
plugins: [],
|
||||
categories: {
|
||||
correctness: 'off',
|
||||
suspicious: 'off',
|
||||
pedantic: 'off',
|
||||
perf: 'off',
|
||||
style: 'off',
|
||||
restriction: 'off',
|
||||
nursery: 'off'
|
||||
},
|
||||
jsPlugins: [{ name: pluginName, specifier: pluginPath }],
|
||||
rules
|
||||
})
|
||||
)
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[oxlintPath, '--config', configPath, '--format', 'json', sourcePath],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (!result.stdout.trim()) {
|
||||
throw new Error(result.stderr || `${pluginName} did not produce Oxlint output`)
|
||||
}
|
||||
return JSON.parse(result.stdout).diagnostics
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
|
||||
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
|
||||
(step) => step.name === 'Filter E2E-relevant paths'
|
||||
)
|
||||
const verifyStep = prWorkflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
|
||||
describe('PR E2E gate contract', () => {
|
||||
it('keeps E2E advisory while the suite is red on main', () => {
|
||||
// Why: pin the deliberate choice so it reads as intentional rather than as
|
||||
// the "forgot to wire the gate" bug this file originally caught. Gating on a
|
||||
// suite that fails every scheduled run would block the PRs that fix it.
|
||||
// Flipping to blocking means updating this expectation too — see the comment
|
||||
// on verify's Require-successful-checks step for the exact wiring.
|
||||
expect(prWorkflow.jobs.verify.needs).not.toContain('e2e')
|
||||
expect(verifyStep.env.E2E).toBeUndefined()
|
||||
expect(verifyStep.run).not.toContain('$E2E')
|
||||
})
|
||||
|
||||
it('runs E2E only when the detector says the PR touches E2E paths', () => {
|
||||
// Why: without this the job could lose its filter and run on every PR — the
|
||||
// cost the path filter exists to avoid — while the gate assertions above
|
||||
// stay green.
|
||||
expect(prWorkflow.jobs.e2e.needs).toBe('e2e-paths')
|
||||
expect(prWorkflow.jobs.e2e.if).toBe("needs.e2e-paths.outputs.should_run == 'true'")
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.should_run).toBe(
|
||||
'${{ steps.filter.outputs.should_run }}'
|
||||
)
|
||||
})
|
||||
|
||||
it('enforces every job verify depends on', () => {
|
||||
// Why: derive from verify.needs rather than hardcoding, so adding a required
|
||||
// job without adding it to the strict loop fails here instead of silently
|
||||
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
|
||||
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
|
||||
const strictLoop = verifyStep.run.slice(0, verifyStep.run.indexOf('done'))
|
||||
for (const job of prWorkflow.jobs.verify.needs) {
|
||||
const envVar = job.toUpperCase()
|
||||
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
|
||||
expect(strictLoop).toContain(`"$${envVar}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the Playwright config where it actually lives', () => {
|
||||
// Why: the config is tests/playwright.config.ts, beside tests/e2e/ rather
|
||||
// than inside it. A bare `playwright.` prefix matches no tracked file, so
|
||||
// editing the runner config would silently skip E2E.
|
||||
expect(filterStep.run).toContain('tests/playwright\\.')
|
||||
expect(filterStep.run).not.toMatch(/\(\^?\|\|]tests\/e2e\/\|playwright\\\./)
|
||||
})
|
||||
|
||||
it('scopes detection to the PR range so base drift cannot false-trigger', () => {
|
||||
expect(filterStep.run).toContain('git diff --name-only --merge-base "$BASE" "$HEAD"')
|
||||
expect(filterStep.run).toContain('set -euo pipefail')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Why: pr.yml re-lists the `lint` chain as individual steps so a single failure
|
||||
// does not mask the rest and oxlint keeps its `--format github` annotations.
|
||||
// Hand-maintained mirrors drift (#10601: three verifiers never ran on PRs), so
|
||||
// this gate fails the moment a `lint` step has no counterpart in pr.yml.
|
||||
|
||||
// Flags that only change reporting, so they must not split two otherwise identical commands.
|
||||
const REPORTING_FLAGS_WITH_VALUE = new Set(['--format', '--reporter'])
|
||||
const REPORTING_FLAGS = new Set(['--quiet'])
|
||||
const PACKAGE_RUNNER_TOKENS = new Set(['pnpm', 'npm', 'yarn', 'npx', 'run', 'exec', 'node'])
|
||||
|
||||
function splitCommandChain(command) {
|
||||
return command
|
||||
.split(/\n|&&|;/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function canonicalize(command) {
|
||||
const tokens = command.split(/\s+/)
|
||||
const canonical = []
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index]
|
||||
if (canonical.length === 0 && PACKAGE_RUNNER_TOKENS.has(token)) {
|
||||
continue
|
||||
}
|
||||
if (REPORTING_FLAGS.has(token)) {
|
||||
continue
|
||||
}
|
||||
if (REPORTING_FLAGS_WITH_VALUE.has(token)) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
canonical.push(token)
|
||||
}
|
||||
|
||||
return canonical.join(' ')
|
||||
}
|
||||
|
||||
/** Expands `pnpm run x` indirection until every entry is a real binary invocation. */
|
||||
function resolveLeafCommands(command, scripts, seen = new Set()) {
|
||||
const leaves = []
|
||||
|
||||
for (const part of splitCommandChain(command)) {
|
||||
const scriptName = part.match(/^(?:pnpm|npm|yarn)(?:\s+run)?\s+([\w:-]+)$/)?.[1]
|
||||
if (scriptName && scripts[scriptName] && !seen.has(scriptName)) {
|
||||
leaves.push(
|
||||
...resolveLeafCommands(scripts[scriptName], scripts, new Set([...seen, scriptName]))
|
||||
)
|
||||
continue
|
||||
}
|
||||
leaves.push(canonicalize(part))
|
||||
}
|
||||
|
||||
return leaves
|
||||
}
|
||||
|
||||
describe('PR workflow lint parity', () => {
|
||||
it('runs every `pnpm lint` step on pull requests', () => {
|
||||
const { scripts } = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
|
||||
// Scan every job: which one hosts the lint steps is an organizational
|
||||
// detail that has already been renamed once (verify -> static_analysis).
|
||||
const workflowCommands = new Set(
|
||||
Object.values(workflow.jobs)
|
||||
.flatMap((job) => job.steps ?? [])
|
||||
.filter((step) => typeof step.run === 'string')
|
||||
.flatMap((step) => resolveLeafCommands(step.run, scripts))
|
||||
)
|
||||
|
||||
const missing = resolveLeafCommands(scripts.lint, scripts).filter(
|
||||
(leaf) => !workflowCommands.has(leaf)
|
||||
)
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`.github/workflows/pr.yml is missing lint steps: ${missing.join(', ')}. ` +
|
||||
'Add a step for each one so PR CI matches `pnpm lint`.'
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -37,13 +37,19 @@ describe('PR workflow parallelism', () => {
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
})
|
||||
|
||||
it('shards the general test suite across sixteen runners', () => {
|
||||
it('shards the general test suite across Node 24 and Node 26', () => {
|
||||
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
|
||||
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 16 }, (_, index) => index + 1)
|
||||
)
|
||||
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
|
||||
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = workflow.jobs.test.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}')
|
||||
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(testStep.run).toContain(`--exclude=${testFile}`)
|
||||
}
|
||||
@@ -95,9 +101,15 @@ describe('PR workflow parallelism', () => {
|
||||
const steps = dependencyAction.runs.steps
|
||||
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
|
||||
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
|
||||
const requestedNodeIndex = steps.findIndex((step) => step.name === 'Setup requested Node.js')
|
||||
|
||||
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
||||
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
|
||||
expect(steps[nodeIndex].with.cache).toBe('pnpm')
|
||||
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
|
||||
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
|
||||
expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}')
|
||||
expect(steps[requestedNodeIndex].with.cache).toBe('pnpm')
|
||||
})
|
||||
|
||||
it('restores Electron downloads before preparing the package runtime', () => {
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function publishCompleteDraftReleases({
|
||||
|
||||
for (const release of candidates) {
|
||||
const tag = release.tag_name
|
||||
if (!(await isDraftBuiltFromCurrentRef({ tag, release }))) {
|
||||
if (!(await Promise.resolve(isDraftBuiltFromCurrentRef({ tag, release })))) {
|
||||
const reason = 'tag is not built from the current release ref'
|
||||
skipped.push({ tag, reason })
|
||||
log(`Skipping stale RC draft release ${tag}: ${reason}`)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs'
|
||||
|
||||
const pluginPath = path.resolve('config/oxlint-plugins/quadratic-buffer-concat.mjs')
|
||||
|
||||
function lintSource(source) {
|
||||
return runOxlintPluginOnSource({
|
||||
pluginName: 'quadratic-buffer-concat',
|
||||
pluginPath,
|
||||
source,
|
||||
extension: 'ts',
|
||||
rules: {
|
||||
'quadratic-buffer-concat/no-loop-carried-concat': 'warn'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const violations = [
|
||||
[
|
||||
'self accumulator',
|
||||
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }',
|
||||
'acc'
|
||||
],
|
||||
[
|
||||
'trailing accumulator',
|
||||
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }',
|
||||
'acc'
|
||||
],
|
||||
[
|
||||
'spread self accumulator',
|
||||
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }',
|
||||
'acc'
|
||||
],
|
||||
[
|
||||
'indirect stream carry',
|
||||
`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))
|
||||
}
|
||||
}`,
|
||||
'remainder'
|
||||
],
|
||||
[
|
||||
'indirect transcript carry',
|
||||
`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)
|
||||
}
|
||||
}`,
|
||||
'carryBytes'
|
||||
],
|
||||
[
|
||||
'classic for initializer',
|
||||
'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }',
|
||||
'acc'
|
||||
],
|
||||
[
|
||||
'class field accumulator',
|
||||
'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }',
|
||||
'this.pending'
|
||||
],
|
||||
[
|
||||
'guarded accumulator',
|
||||
'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }',
|
||||
'acc'
|
||||
]
|
||||
]
|
||||
|
||||
const accepted = [
|
||||
[
|
||||
'single concat after loop',
|
||||
'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)'
|
||||
],
|
||||
[
|
||||
'spread chunk list',
|
||||
`let carryChunks = []
|
||||
while (scanEnd > 0) {
|
||||
const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
|
||||
carryChunks = [buffer.subarray(0, firstNewline)]
|
||||
}`
|
||||
],
|
||||
[
|
||||
'iteration-local result',
|
||||
'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }'
|
||||
],
|
||||
[
|
||||
'iteration-local accumulator',
|
||||
'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }'
|
||||
],
|
||||
[
|
||||
'no enclosing loop',
|
||||
'class S { handle(chunk) { const buffer = Buffer.concat([this.pending, chunk]); this.pending = parse(buffer).pending } }'
|
||||
],
|
||||
['no Buffer concat', 'export const x = 1']
|
||||
]
|
||||
|
||||
describe('quadratic Buffer.concat Oxlint plugin', () => {
|
||||
it.each(violations)('reports %s', (_name, source, accumulator) => {
|
||||
const diagnostics = lintSource(source)
|
||||
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0].message).toContain(accumulator)
|
||||
})
|
||||
|
||||
it.each(accepted)('accepts %s', (_name, source) => {
|
||||
expect(lintSource(source)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: the relay's per-PTY-chunk replay buffer append (src/relay/pty-handler.ts).
|
||||
//
|
||||
// appendReplayBuffer did `buffered += data` then, over the cap, `buffered.slice(-CAP)`.
|
||||
// Once a PTY has produced CAP bytes -- which a long-lived shell does almost immediately
|
||||
// -- every subsequent chunk flattened and copied the whole 100 KB window. The append is
|
||||
// called per raw node-pty emission, before batching, so it is per chunk, not per flush.
|
||||
//
|
||||
// The fix reuses RecentPtyOutputBuffer: keep chunks, drop from the head, and defer the
|
||||
// join to read(), which only attach/adopt/revive call.
|
||||
//
|
||||
// Both arms are compared for an identical retained tail before timing.
|
||||
//
|
||||
// Run with: node config/scripts/relay-replay-buffer-benchmark.mjs
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const ROUNDS = 6
|
||||
const SECONDS = Number(process.env.ORCA_REPLAY_BENCH_SECONDS ?? '1')
|
||||
if (!Number.isFinite(SECONDS) || SECONDS <= 0) {
|
||||
throw new Error(`ORCA_REPLAY_BENCH_SECONDS must be positive, received ${SECONDS}`)
|
||||
}
|
||||
|
||||
// Why re-read the sources: the claim is that the relay now appends into a chunk deque
|
||||
// with the relay's own cap. If either reverts, these numbers stop meaning what they say.
|
||||
const HANDLER_SOURCE = readFileSync(
|
||||
new URL('../../src/relay/pty-handler.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
if (!/managed\.buffered\.append\(/.test(HANDLER_SOURCE)) {
|
||||
throw new Error('relay no longer appends into a chunk deque; this benchmark is stale')
|
||||
}
|
||||
const capMatch = HANDLER_SOURCE.match(/REPLAY_BUFFER_MAX = ([\d *]+)/)
|
||||
if (!capMatch) {
|
||||
throw new Error('REPLAY_BUFFER_MAX not found; this benchmark is stale')
|
||||
}
|
||||
// The regex admits only digits, spaces, and `*`, so the literal is a plain product.
|
||||
const REPLAY_BUFFER_MAX = capMatch[1]
|
||||
.split('*')
|
||||
.map((factor) => Number(factor.trim()))
|
||||
.reduce((product, factor) => product * factor, 1)
|
||||
if (!Number.isSafeInteger(REPLAY_BUFFER_MAX) || REPLAY_BUFFER_MAX <= 0) {
|
||||
throw new Error(`could not read REPLAY_BUFFER_MAX from source, got ${capMatch[1]}`)
|
||||
}
|
||||
|
||||
// Pre-fix: rolling string, re-sliced once over the cap.
|
||||
function appendString(state, data) {
|
||||
if (data.length === 0) {
|
||||
return state
|
||||
}
|
||||
const next = state + data
|
||||
return next.length > REPLAY_BUFFER_MAX ? next.slice(-REPLAY_BUFFER_MAX) : next
|
||||
}
|
||||
|
||||
// Post-fix: mirrors RecentPtyOutputBuffer's append/read for the relay's options.
|
||||
class ChunkDeque {
|
||||
constructor(limit) {
|
||||
this.chunks = []
|
||||
this.headIndex = 0
|
||||
this.headOffset = 0
|
||||
this.totalLen = 0
|
||||
this.limit = limit
|
||||
}
|
||||
|
||||
append(data) {
|
||||
if (data.length === 0) {
|
||||
return
|
||||
}
|
||||
if (data.length >= this.limit) {
|
||||
this.chunks = [data.slice(-this.limit)]
|
||||
this.headIndex = 0
|
||||
this.headOffset = 0
|
||||
this.totalLen = this.limit
|
||||
return
|
||||
}
|
||||
this.chunks.push(data)
|
||||
this.totalLen += data.length
|
||||
while (this.totalLen > this.limit) {
|
||||
const headRemaining = this.chunks[this.headIndex].length - this.headOffset
|
||||
const excess = this.totalLen - this.limit
|
||||
if (headRemaining <= excess) {
|
||||
this.chunks[this.headIndex] = ''
|
||||
this.headIndex += 1
|
||||
this.headOffset = 0
|
||||
this.totalLen -= headRemaining
|
||||
} else {
|
||||
this.headOffset += excess
|
||||
this.totalLen -= excess
|
||||
}
|
||||
}
|
||||
if (this.headIndex >= 1024) {
|
||||
this.chunks = this.chunks.slice(this.headIndex)
|
||||
this.headIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
read() {
|
||||
if (this.chunks.length - this.headIndex > 1) {
|
||||
const retained = this.chunks.slice(this.headIndex)
|
||||
if (this.headOffset > 0) {
|
||||
retained[0] = retained[0].slice(this.headOffset)
|
||||
this.headOffset = 0
|
||||
}
|
||||
this.chunks = [retained.join('')]
|
||||
this.headIndex = 0
|
||||
} else if (this.headOffset > 0) {
|
||||
this.chunks[this.headIndex] = this.chunks[this.headIndex].slice(this.headOffset)
|
||||
this.headOffset = 0
|
||||
}
|
||||
return this.chunks[this.headIndex] ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function makeChunks(chunkBytes, chunkCount) {
|
||||
// Vary content so V8 cannot dedupe or treat the appends as loop-invariant.
|
||||
return Array.from({ length: chunkCount }, (_value, index) =>
|
||||
`${index}:`.padEnd(chunkBytes, 'abcdefghijklmnopqrstuvwxyz')
|
||||
)
|
||||
}
|
||||
|
||||
// Why pre-saturate: the interesting regime is a PTY that has already filled the window,
|
||||
// which is where the old form copied 100 KB on literally every chunk. Timing from empty
|
||||
// would average in a cheap warm-up the real process leaves behind in milliseconds.
|
||||
function saturate(chunks) {
|
||||
let stringState = ''
|
||||
const deque = new ChunkDeque(REPLAY_BUFFER_MAX)
|
||||
const preload = 'p'.repeat(REPLAY_BUFFER_MAX)
|
||||
stringState = appendString(stringState, preload)
|
||||
deque.append(preload)
|
||||
return { stringState, deque, chunks }
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const mid = sorted.length / 2
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
function timeString(chunks) {
|
||||
let state = 'p'.repeat(REPLAY_BUFFER_MAX)
|
||||
const start = performance.now()
|
||||
for (const chunk of chunks) {
|
||||
state = appendString(state, chunk)
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
if (state.length !== REPLAY_BUFFER_MAX) {
|
||||
throw new Error('string arm lost its window')
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
function timeDeque(chunks) {
|
||||
const deque = new ChunkDeque(REPLAY_BUFFER_MAX)
|
||||
deque.append('p'.repeat(REPLAY_BUFFER_MAX))
|
||||
const start = performance.now()
|
||||
for (const chunk of chunks) {
|
||||
deque.append(chunk)
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
return elapsed
|
||||
}
|
||||
|
||||
// Arms alternate which one leads so within-round drift cannot favour either.
|
||||
function measure(chunks) {
|
||||
timeString(chunks)
|
||||
timeDeque(chunks)
|
||||
const stringSamples = []
|
||||
const dequeSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
stringSamples.push(timeString(chunks))
|
||||
dequeSamples.push(timeDeque(chunks))
|
||||
} else {
|
||||
dequeSamples.push(timeDeque(chunks))
|
||||
stringSamples.push(timeString(chunks))
|
||||
}
|
||||
}
|
||||
return { stringMs: median(stringSamples), dequeMs: median(dequeSamples) }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Relay PTY replay-buffer append, per second of output. Lower is better.')
|
||||
console.log(
|
||||
`cap=${(REPLAY_BUFFER_MAX / 1024).toFixed(0)} KiB rounds=${ROUNDS} (per-arm medians, pre-saturated)`
|
||||
)
|
||||
console.log(
|
||||
`${pad('workload', 30)} ${pad('rolling str', 12)} ${pad('chunk deque', 12)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
for (const [label, chunkBytes, chunksPerSecond] of [
|
||||
['interactive shell 64B x200', 64, 200],
|
||||
['agent TUI 512B x400', 512, 400],
|
||||
['build log 4KiB x256 (1 MiB/s)', 4 * 1024, 256],
|
||||
['dump 8KiB x512 (4 MiB/s)', 8 * 1024, 512],
|
||||
['firehose 16KiB x1024 (16 MiB/s)', 16 * 1024, 1024]
|
||||
]) {
|
||||
const chunks = makeChunks(chunkBytes, Math.round(chunksPerSecond * SECONDS))
|
||||
const { stringState, deque } = saturate(chunks)
|
||||
let stringTail = stringState
|
||||
for (const chunk of chunks) {
|
||||
stringTail = appendString(stringTail, chunk)
|
||||
deque.append(chunk)
|
||||
}
|
||||
if (deque.read() !== stringTail) {
|
||||
throw new Error(`retained tail differs for ${label}`)
|
||||
}
|
||||
if (stringTail.length !== REPLAY_BUFFER_MAX) {
|
||||
throw new Error(`fixture never saturated the window for ${label}`)
|
||||
}
|
||||
const { stringMs, dequeMs } = measure(chunks)
|
||||
console.log(
|
||||
`${pad(label, 30)} ${pad(`${stringMs.toFixed(3)} ms`, 12)} ${pad(`${dequeMs.toFixed(3)} ms`, 12)} ${pad(`${(stringMs / dequeMs).toFixed(0)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
"\nThis is per PTY, and the relay runs on the user's SSH host. Reads (attach, adopt,\nrevive) now pay the join instead, but those are rare and were already O(window)."
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const releaseWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
|
||||
)
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
|
||||
describe('release E2E dispatch contract', () => {
|
||||
it('dispatches tag-scoped E2E only after publication', () => {
|
||||
const dispatchJob = releaseWorkflow.jobs['post-release-e2e']
|
||||
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch tag-scoped E2E')
|
||||
|
||||
expect(releaseWorkflow.jobs.e2e).toBeUndefined()
|
||||
expect(dispatchJob.needs).toEqual(['cut', 'publish-release'])
|
||||
expect(dispatchJob.if).toBe("${{ needs.cut.outputs.tag != '' }}")
|
||||
expect(dispatchJob.permissions.actions).toBe('write')
|
||||
expect(dispatchStep.env.TAG).toBe('${{ needs.cut.outputs.tag }}')
|
||||
expect(dispatchStep.run).toContain('gh workflow run e2e.yml')
|
||||
expect(dispatchStep.run).toContain('--ref "$TAG"')
|
||||
expect(dispatchStep.run).toContain('--raw-field "ref=refs/tags/$TAG"')
|
||||
expect(dispatchStep.run).toContain('for attempt in 1 2 3')
|
||||
expect(dispatchStep.run).toContain('[[ "$attempt" -eq 3 ]] || sleep')
|
||||
expect(dispatchStep.run).toContain('::warning::Failed to dispatch post-release E2E')
|
||||
})
|
||||
|
||||
it('keeps detached E2E identifiable and manually dispatchable by ref', () => {
|
||||
const refInput = e2eWorkflow.on.workflow_dispatch.inputs.ref
|
||||
|
||||
expect(e2eWorkflow['run-name']).toBe('E2E ${{ inputs.ref || github.ref }}')
|
||||
expect(refInput.type).toBe('string')
|
||||
expect(refInput.required).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { plainClassName } from '../oxlint-plugins/renderer-scrollbar-style.mjs'
|
||||
import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs'
|
||||
|
||||
const pluginPath = path.resolve('config/oxlint-plugins/renderer-scrollbar-style.mjs')
|
||||
|
||||
function lintSource(source) {
|
||||
return runOxlintPluginOnSource({
|
||||
pluginName: 'renderer-scrollbar-style',
|
||||
pluginPath,
|
||||
source,
|
||||
rules: {
|
||||
'renderer-scrollbar-style/require-styled-vertical-scrollbar': 'warn'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const violations = [
|
||||
['unstyled class', 'export const X = () => <div className="max-h-64 overflow-y-auto" />'],
|
||||
[
|
||||
'unstyled suffix-important class',
|
||||
'export const X = () => <div className="max-h-64 overflow-y-auto!" />'
|
||||
],
|
||||
[
|
||||
'unknown scrollbar class',
|
||||
'export const X = () => <div className="overflow-auto scrollbar-none" />'
|
||||
],
|
||||
[
|
||||
'separate class composer arguments',
|
||||
"export const X = () => <div className={cn('overflow-y-auto', 'scrollbar-sleek')} />"
|
||||
],
|
||||
[
|
||||
'conditional scrollbar',
|
||||
"export const X = ({ enabled }) => <div className={cn('overflow-y-auto', enabled && 'scrollbar-sleek')} />"
|
||||
],
|
||||
[
|
||||
'arbitrary class wrapper',
|
||||
"export const X = () => <div className={identity('overflow-y-auto')} />"
|
||||
],
|
||||
[
|
||||
'mismatched responsive variants',
|
||||
'export const X = () => <div className="overflow-y-auto md:scrollbar-sleek" />'
|
||||
],
|
||||
['inline overflow', "export const X = () => <div style={{ overflowY: 'auto' }} />"],
|
||||
[
|
||||
'logical inline style spread',
|
||||
"export const X = ({ open }) => <div style={{ ...(open && { overflowY: 'auto' }) }} />"
|
||||
],
|
||||
['JSX spread class', "export const X = () => <div {...{ className: 'overflow-y-auto' }} />"],
|
||||
[
|
||||
'later spread override',
|
||||
'export const X = () => <div className="scrollbar-sleek" {...{ className: \'overflow-y-auto\' }} />'
|
||||
]
|
||||
]
|
||||
|
||||
const accepted = [
|
||||
[
|
||||
'styled vertical class',
|
||||
'export const X = () => <div className="overflow-auto scrollbar-sleek" />'
|
||||
],
|
||||
[
|
||||
'styled suffix-important classes',
|
||||
'export const X = () => <div className="overflow-y-auto! scrollbar-sleek!" />'
|
||||
],
|
||||
[
|
||||
'same composer literal',
|
||||
"export const X = () => <div className={cn('overflow-y-auto scrollbar-sleek')} />"
|
||||
],
|
||||
[
|
||||
'same conditional literal',
|
||||
"export const X = ({ enabled }) => <div className={cn(enabled && 'overflow-y-auto scrollbar-sleek')} />"
|
||||
],
|
||||
['horizontal-only overflow', 'export const X = () => <pre className="overflow-x-auto" />'],
|
||||
[
|
||||
'matching responsive variants',
|
||||
'export const X = () => <div className="md:overflow-y-auto md:scrollbar-sleek" />'
|
||||
],
|
||||
[
|
||||
'unconditional scrollbar',
|
||||
'export const X = () => <div className="md:overflow-y-auto scrollbar-sleek" />'
|
||||
],
|
||||
[
|
||||
'styled inline overflow',
|
||||
'export const X = () => <div className="scrollbar-editor" style={{ overflow: \'auto\' }} />'
|
||||
],
|
||||
[
|
||||
'styled JSX spread class',
|
||||
"export const X = () => <div {...{ className: 'overflow-y-auto scrollbar-sleek' }} />"
|
||||
],
|
||||
[
|
||||
'variant configuration',
|
||||
"export const X = () => <div className={buttonVariants({ className: 'overflow-y-auto scrollbar-sleek' })} />"
|
||||
]
|
||||
]
|
||||
|
||||
describe('renderer scrollbar style Oxlint plugin', () => {
|
||||
it.each(violations)('reports %s', (_name, source) => {
|
||||
expect(lintSource(source)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each(accepted)('accepts %s', (_name, source) => {
|
||||
expect(lintSource(source)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['md:overflow-y-auto', 'overflow-y-auto'],
|
||||
['[&:hover]:overflow-y-auto', 'overflow-y-auto'],
|
||||
['md:!scrollbar-editor', 'scrollbar-editor'],
|
||||
['!scrollbar-editor', 'scrollbar-editor'],
|
||||
['overflow-y-auto!', 'overflow-y-auto'],
|
||||
['md:scrollbar-editor!', 'scrollbar-editor']
|
||||
])('normalizes %s', (token, expected) => {
|
||||
expect(plainClassName(token)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
// Measures the complete ProseMirror doc traversal used by the two doc-link plugins.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Schema } from '@tiptap/pm/model'
|
||||
import {
|
||||
canHoldDocLink,
|
||||
DOC_LINK_PATTERN,
|
||||
isDocLinkLiteralCodeTextNode
|
||||
} from '../../src/renderer/src/components/editor/rich-markdown-doc-link-scan.ts'
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const ITERATIONS = Number(process.env.ORCA_DOC_LINK_BENCH_ITERATIONS ?? '41')
|
||||
const WARMUP_ITERATIONS = Math.min(9, ITERATIONS)
|
||||
|
||||
if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) {
|
||||
throw new Error(`ORCA_DOC_LINK_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`)
|
||||
}
|
||||
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'paragraph+' },
|
||||
paragraph: { content: 'text*' },
|
||||
text: { group: 'inline' }
|
||||
}
|
||||
})
|
||||
|
||||
function walkUngated(doc) {
|
||||
let matches = 0
|
||||
let visited = 0
|
||||
doc.descendants((node, _pos, parent) => {
|
||||
visited += 1
|
||||
if (node.type.name !== 'text' || !node.text || isDocLinkLiteralCodeTextNode(node, parent)) {
|
||||
return
|
||||
}
|
||||
for (const _match of node.text.matchAll(DOC_LINK_PATTERN)) {
|
||||
matches += 1
|
||||
}
|
||||
})
|
||||
return { matches, visited }
|
||||
}
|
||||
|
||||
function walkGated(doc) {
|
||||
let matches = 0
|
||||
let visited = 0
|
||||
doc.descendants((node, _pos, parent) => {
|
||||
visited += 1
|
||||
if (!canHoldDocLink(node, parent)) {
|
||||
return
|
||||
}
|
||||
for (const _match of node.text.matchAll(DOC_LINK_PATTERN)) {
|
||||
matches += 1
|
||||
}
|
||||
})
|
||||
return { matches, visited }
|
||||
}
|
||||
|
||||
function countMatches(source) {
|
||||
let matches = 0
|
||||
for (const _match of source.matchAll(DOC_LINK_PATTERN)) {
|
||||
matches += 1
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
function loadDocs() {
|
||||
const files = execFileSync('git', ['ls-files', '*.md', 'docs/*.md'], {
|
||||
cwd: REPO_ROOT,
|
||||
maxBuffer: 256 * 1024 * 1024
|
||||
})
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
const docs = []
|
||||
for (const file of files) {
|
||||
try {
|
||||
const source = readFileSync(join(REPO_ROOT, file), 'utf8')
|
||||
const lines = source.split('\n').filter(Boolean)
|
||||
if (lines.length > 0) {
|
||||
docs.push({ file, lines, size: source.length, matches: countMatches(source) })
|
||||
}
|
||||
} catch {
|
||||
// Indexed paths can disappear while the benchmark is running.
|
||||
}
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
function createFixture(doc, nonce) {
|
||||
const paragraphs = doc.lines.map((line, index) => {
|
||||
const text = index === 0 ? `${line} bench-${nonce}` : line
|
||||
return schema.node('paragraph', null, text ? schema.text(text) : undefined)
|
||||
})
|
||||
return schema.node('doc', null, paragraphs)
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
function measureCorpus(docs) {
|
||||
const samples = { ungated: [], gated: [] }
|
||||
const totals = {
|
||||
ungated: { matches: 0, visited: 0 },
|
||||
gated: { matches: 0, visited: 0 }
|
||||
}
|
||||
const seenFixtures = new WeakSet()
|
||||
let expectedMatches = 0
|
||||
let expectedVisited = 0
|
||||
const measuredOrder = []
|
||||
|
||||
for (let round = -WARMUP_ITERATIONS; round < ITERATIONS; round += 1) {
|
||||
const doc = docs[(round + WARMUP_ITERATIONS) % docs.length]
|
||||
const measured = round >= 0
|
||||
const order =
|
||||
(round + WARMUP_ITERATIONS) % 2 === 0 ? ['ungated', 'gated'] : ['gated', 'ungated']
|
||||
const fixtures = {
|
||||
ungated: createFixture(doc, String(round)),
|
||||
gated: createFixture(doc, String(round))
|
||||
}
|
||||
|
||||
for (const arm of order) {
|
||||
const fixture = fixtures[arm]
|
||||
if (seenFixtures.has(fixture)) {
|
||||
throw new Error('timed fixture was reused')
|
||||
}
|
||||
seenFixtures.add(fixture)
|
||||
const start = performance.now()
|
||||
const result = arm === 'ungated' ? walkUngated(fixture) : walkGated(fixture)
|
||||
const elapsed = performance.now() - start
|
||||
if (measured) {
|
||||
samples[arm].push(elapsed)
|
||||
totals[arm].matches += result.matches
|
||||
totals[arm].visited += result.visited
|
||||
measuredOrder.push(arm)
|
||||
}
|
||||
}
|
||||
if (measured) {
|
||||
expectedMatches += doc.matches
|
||||
expectedVisited += doc.lines.length * 2
|
||||
}
|
||||
}
|
||||
|
||||
if (totals.ungated.matches !== expectedMatches || totals.gated.matches !== expectedMatches) {
|
||||
throw new Error(
|
||||
`gate changed matches: expected ${expectedMatches}, ungated ${totals.ungated.matches}, gated ${totals.gated.matches}`
|
||||
)
|
||||
}
|
||||
if (totals.ungated.visited !== expectedVisited || totals.gated.visited !== expectedVisited) {
|
||||
throw new Error(
|
||||
`full traversal result was not consumed: expected ${expectedVisited}, ungated ${totals.ungated.visited}, gated ${totals.gated.visited}`
|
||||
)
|
||||
}
|
||||
for (let index = 0; index < measuredOrder.length; index += 2) {
|
||||
const pair = measuredOrder.slice(index, index + 2).join(',')
|
||||
const previousPair = index === 0 ? null : measuredOrder.slice(index - 2, index).join(',')
|
||||
if (!['ungated,gated', 'gated,ungated'].includes(pair) || pair === previousPair) {
|
||||
throw new Error('benchmark arms were not interleaved in alternating order')
|
||||
}
|
||||
}
|
||||
|
||||
return { ungated: median(samples.ungated), gated: median(samples.gated) }
|
||||
}
|
||||
|
||||
const docs = loadDocs()
|
||||
if (docs.length === 0) {
|
||||
throw new Error('no markdown files found in the index')
|
||||
}
|
||||
const large = docs.filter((doc) => doc.size > 3000)
|
||||
const biggest = docs.reduce((a, b) => (b.size > a.size ? b : a))
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
|
||||
console.log('Doc-link ProseMirror traversal, per editor transaction. Lower is better.')
|
||||
console.log(
|
||||
`docs=${docs.length} (>3KB: ${large.length}) iterations=${ITERATIONS} (interleaved median)`
|
||||
)
|
||||
console.log(
|
||||
`${pad('corpus', 26)} ${pad('ungated', 11)} ${pad('gated', 11)} ${pad('delta', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
for (const [label, set] of [
|
||||
['all repo markdown', docs],
|
||||
['docs over 3 KB', large],
|
||||
[`biggest (${biggest.file.split('/').pop()})`, [biggest]]
|
||||
]) {
|
||||
if (set.length === 0) {
|
||||
console.log(`${pad(label, 26)} ${pad('no docs in this corpus — skipped', 44)}`)
|
||||
continue
|
||||
}
|
||||
const { ungated, gated } = measureCorpus(set)
|
||||
const delta = ungated - gated
|
||||
console.log(
|
||||
`${pad(label, 26)} ${pad(`${(ungated * 1000).toFixed(1)} us`, 11)} ${pad(`${(gated * 1000).toFixed(1)} us`, 11)} ${pad(`${(delta * 1000).toFixed(1)} us`, 11)} ${pad(`${(ungated / gated).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nAuto-conversion pays this once per keystroke; preview decorations pay it once\nper keystroke and once per caret move.'
|
||||
)
|
||||
@@ -128,10 +128,8 @@ function prepareMacDevElectronApp() {
|
||||
|
||||
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
|
||||
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
|
||||
// v6: bundle the notification-status helper (real permission readout) and
|
||||
// ad-hoc re-sign after plist edits so Notification Center accepts the
|
||||
// bundle; bumping forces stale cached copies to be recreated.
|
||||
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
|
||||
// v7: give the terminal daemon helper an Orca-specific TCC identity.
|
||||
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v7'
|
||||
const hash = createHash('sha1')
|
||||
.update(
|
||||
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
|
||||
@@ -155,6 +153,7 @@ function prepareMacDevElectronApp() {
|
||||
// Electron drops clicks for notification ids it didn't create, so the
|
||||
// click is lost, not misdirected.
|
||||
const bundleId = 'com.stablyai.orca.dev'
|
||||
const helperBundleId = `${bundleId}.helper`
|
||||
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
|
||||
const expectedMarker = JSON.stringify(
|
||||
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
|
||||
@@ -205,9 +204,18 @@ function prepareMacDevElectronApp() {
|
||||
restoreElectronFrameworkSymlinks(appPath)
|
||||
|
||||
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
|
||||
const helperPlistPath = path.join(
|
||||
appPath,
|
||||
'Contents',
|
||||
'Frameworks',
|
||||
'Electron Helper.app',
|
||||
'Contents',
|
||||
'Info.plist'
|
||||
)
|
||||
setPlistValue(plistPath, 'CFBundleName', title)
|
||||
setPlistValue(plistPath, 'CFBundleDisplayName', title)
|
||||
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
|
||||
setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)
|
||||
|
||||
// Why: the notification-status helper reads the app's real macOS
|
||||
// notification authorization (UNUserNotificationCenter has no Electron
|
||||
|
||||
@@ -572,7 +572,7 @@ async function main() {
|
||||
if (options.output) {
|
||||
mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
|
||||
writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(`[idle-cpu] wrote ${options.output}`)
|
||||
console.log(`[idle-cpu] wrote ${String(options.output)}`)
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
// Why both specs in one runner: they share the docker SSH rig and the same
|
||||
// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and
|
||||
// restore, the retention spec proves the C1 budget bounds what they retain.
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/ssh-terminal-parking.spec.ts',
|
||||
'tests/e2e/terminal-retention-budget.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -0,0 +1,306 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const projectDir = path.resolve(import.meta.dirname, '../..')
|
||||
const scriptPath = import.meta.filename
|
||||
const insideSessionFlag = '--inside-session'
|
||||
const processStopTimeoutMs = 5_000
|
||||
const processKillTimeoutMs = 1_000
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function waitForExit(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
|
||||
})
|
||||
}
|
||||
|
||||
function processGroupMembers(processGroupId) {
|
||||
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
return []
|
||||
}
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function stopOwnedProcessGroup(processGroupId) {
|
||||
let members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
console.error(
|
||||
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
|
||||
)
|
||||
try {
|
||||
process.kill(-processGroupId, 'SIGTERM')
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + processStopTimeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-processGroupId, 'SIGKILL')
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const killDeadline = Date.now() + processKillTimeoutMs
|
||||
do {
|
||||
members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
await delay(100)
|
||||
} while (Date.now() < killDeadline)
|
||||
return members
|
||||
}
|
||||
|
||||
function commandOutput(command, args) {
|
||||
const result = spawnSync(command, args, { encoding: 'utf8' })
|
||||
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
|
||||
}
|
||||
|
||||
function configureHangulEngine() {
|
||||
for (const [key, value] of [
|
||||
['initial-input-mode', 'hangul'],
|
||||
['hangul-keyboard', '2']
|
||||
]) {
|
||||
const result = spawnSync(
|
||||
'gsettings',
|
||||
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHangulEngine(ibusProcess) {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
if (ibusProcess.exitCode !== null) {
|
||||
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
|
||||
}
|
||||
const result = spawnSync('ibus', ['engine', 'hangul'], { stdio: 'pipe' })
|
||||
if (result.status === 0) {
|
||||
return
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
throw new Error('Timed out while selecting the IBus Hangul engine')
|
||||
}
|
||||
|
||||
async function runInsideSession(evidenceDir) {
|
||||
const ibusLogPath = path.join(evidenceDir, 'ibus-daemon.log')
|
||||
const ibusLogFd = openSync(ibusLogPath, 'w')
|
||||
const windowManagerLogPath = path.join(evidenceDir, 'xfwm4.log')
|
||||
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
|
||||
const evidence = {
|
||||
display: process.env.DISPLAY ?? null,
|
||||
ibusDaemonPid: null,
|
||||
ibusGroupBeforeCleanup: [],
|
||||
ibusGroupAfterCleanup: [],
|
||||
playwrightPid: null,
|
||||
windowManagerPid: null,
|
||||
windowManagerGroupAfterCleanup: []
|
||||
}
|
||||
let ibusProcess
|
||||
let windowManagerProcess
|
||||
let testExitCode = 1
|
||||
|
||||
try {
|
||||
configureHangulEngine()
|
||||
windowManagerProcess = spawn('xfwm4', ['--compositor=off'], {
|
||||
detached: true,
|
||||
env: process.env,
|
||||
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
|
||||
})
|
||||
if (!windowManagerProcess.pid) {
|
||||
throw new Error('xfwm4 did not return a PID')
|
||||
}
|
||||
evidence.windowManagerPid = windowManagerProcess.pid
|
||||
console.error(`[terminal-ime] started xfwm4 PID ${windowManagerProcess.pid}`)
|
||||
|
||||
ibusProcess = spawn(
|
||||
'ibus-daemon',
|
||||
['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable'],
|
||||
{
|
||||
detached: true,
|
||||
env: process.env,
|
||||
stdio: ['ignore', ibusLogFd, ibusLogFd]
|
||||
}
|
||||
)
|
||||
if (!ibusProcess.pid) {
|
||||
throw new Error('ibus-daemon did not return a PID')
|
||||
}
|
||||
evidence.ibusDaemonPid = ibusProcess.pid
|
||||
console.error(`[terminal-ime] started ibus-daemon PID ${ibusProcess.pid}`)
|
||||
await waitForHangulEngine(ibusProcess)
|
||||
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
|
||||
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
|
||||
console.error(
|
||||
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
|
||||
'get',
|
||||
'org.freedesktop.ibus.engine.hangul',
|
||||
'initial-input-mode'
|
||||
])}`
|
||||
)
|
||||
console.error(
|
||||
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
|
||||
'get',
|
||||
'org.freedesktop.ibus.engine.hangul',
|
||||
'hangul-keyboard'
|
||||
])}`
|
||||
)
|
||||
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
|
||||
console.error(`[terminal-ime] owned IBus group: ${evidence.ibusGroupBeforeCleanup.join('; ')}`)
|
||||
|
||||
const testProcess = spawn(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
[
|
||||
'run',
|
||||
'test:e2e:headful',
|
||||
'--workers=1',
|
||||
'--',
|
||||
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
|
||||
],
|
||||
{
|
||||
cwd: projectDir,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1',
|
||||
ORCA_E2E_NATIVE_IBUS_HANGUL: '1'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
if (!testProcess.pid) {
|
||||
throw new Error('Playwright did not return a PID')
|
||||
}
|
||||
evidence.playwrightPid = testProcess.pid
|
||||
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
|
||||
testExitCode = await waitForExit(testProcess)
|
||||
} finally {
|
||||
if (ibusProcess?.pid) {
|
||||
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
|
||||
evidence.ibusGroupAfterCleanup = await stopOwnedProcessGroup(ibusProcess.pid)
|
||||
}
|
||||
if (windowManagerProcess?.pid) {
|
||||
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
|
||||
windowManagerProcess.pid
|
||||
)
|
||||
}
|
||||
closeSync(ibusLogFd)
|
||||
closeSync(windowManagerLogFd)
|
||||
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
|
||||
copyFileSync(
|
||||
ibusLogPath,
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-ibus.log')
|
||||
)
|
||||
copyFileSync(
|
||||
windowManagerLogPath,
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-xfwm4.log')
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-processes.json'),
|
||||
`${JSON.stringify(evidence, null, 2)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
if (evidence.ibusGroupAfterCleanup.length > 0) {
|
||||
throw new Error(
|
||||
`Owned IBus processes survived cleanup: ${evidence.ibusGroupAfterCleanup.join('; ')}`
|
||||
)
|
||||
}
|
||||
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
|
||||
throw new Error(
|
||||
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
|
||||
)
|
||||
}
|
||||
return testExitCode
|
||||
}
|
||||
|
||||
async function runOuter() {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error('The native IBus Hangul E2E runner requires Linux/X11')
|
||||
}
|
||||
|
||||
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
|
||||
const runtimeDir = path.join(evidenceDir, 'runtime')
|
||||
mkdirSync(runtimeDir, { mode: 0o700 })
|
||||
mkdirSync(path.join(evidenceDir, 'config'))
|
||||
mkdirSync(path.join(evidenceDir, 'cache'))
|
||||
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
|
||||
|
||||
const sessionProcess = spawn(
|
||||
'xvfb-run',
|
||||
[
|
||||
'--auto-servernum',
|
||||
'dbus-run-session',
|
||||
'--',
|
||||
process.execPath,
|
||||
scriptPath,
|
||||
insideSessionFlag,
|
||||
evidenceDir
|
||||
],
|
||||
{
|
||||
cwd: projectDir,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
GTK_IM_MODULE: 'ibus',
|
||||
IBUS_ENABLE_SYNC_MODE: '1',
|
||||
LANG: process.env.LANG || 'C.UTF-8',
|
||||
QT_IM_MODULE: 'ibus',
|
||||
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
|
||||
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
|
||||
XDG_RUNTIME_DIR: runtimeDir,
|
||||
XMODIFIERS: '@im=ibus'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
if (!sessionProcess.pid) {
|
||||
throw new Error('xvfb-run did not return a PID')
|
||||
}
|
||||
console.error(`[terminal-ime] started isolated X11 session PID ${sessionProcess.pid}`)
|
||||
const exitCode = await waitForExit(sessionProcess)
|
||||
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
|
||||
if (remaining.length > 0) {
|
||||
throw new Error(`Owned X11 session processes survived cleanup: ${remaining.join('; ')}`)
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
const insideSession = process.argv[2] === insideSessionFlag
|
||||
try {
|
||||
if (insideSession && !process.argv[3]) {
|
||||
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
|
||||
}
|
||||
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
|
||||
} catch (error) {
|
||||
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const STYLED_SCROLLBAR_CLASSES = new Set(
|
||||
'scrollbar-sleek scrollbar-editor worktree-sidebar-scrollbar'.split(' ')
|
||||
)
|
||||
// Why: vertical scroll is where native scrollbar drift keeps recurring. The
|
||||
// guard intentionally ignores horizontal-only overflow.
|
||||
const VERTICAL_SCROLL_CLASSES = new Set(
|
||||
'overflow-auto overflow-scroll overflow-y-auto overflow-y-scroll'.split(' ')
|
||||
)
|
||||
const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll'])
|
||||
|
||||
export function plainClassName(token) {
|
||||
const normalizedToken = token.startsWith('!') ? token.slice(1) : token
|
||||
const parts = []
|
||||
let bracketDepth = 0
|
||||
let currentPart = ''
|
||||
|
||||
for (const char of normalizedToken) {
|
||||
if (char === '[') {
|
||||
bracketDepth += 1
|
||||
} else if (char === ']') {
|
||||
bracketDepth = Math.max(0, bracketDepth - 1)
|
||||
}
|
||||
|
||||
if (char === ':' && bracketDepth === 0) {
|
||||
parts.push(currentPart)
|
||||
currentPart = ''
|
||||
continue
|
||||
}
|
||||
currentPart += char
|
||||
}
|
||||
|
||||
parts.push(currentPart)
|
||||
const className = parts.at(-1) ?? ''
|
||||
return className.startsWith('!') ? className.slice(1) : className
|
||||
}
|
||||
|
||||
function classTokenParts(token) {
|
||||
const variants = []
|
||||
let bracketDepth = 0
|
||||
let currentPart = ''
|
||||
|
||||
for (const char of token.startsWith('!') ? token.slice(1) : token) {
|
||||
if (char === '[') {
|
||||
bracketDepth += 1
|
||||
} else if (char === ']') {
|
||||
bracketDepth = Math.max(0, bracketDepth - 1)
|
||||
}
|
||||
if (char === ':' && bracketDepth === 0) {
|
||||
variants.push(currentPart)
|
||||
currentPart = ''
|
||||
continue
|
||||
}
|
||||
currentPart += char
|
||||
}
|
||||
|
||||
return { className: plainClassName(token), variants: variants.filter(Boolean) }
|
||||
}
|
||||
|
||||
function classTokens(text) {
|
||||
return text.split(/\s+/).filter(Boolean).map(classTokenParts)
|
||||
}
|
||||
|
||||
function sameVariants(left, right) {
|
||||
return left.length === right.length && left.every((variant, index) => variant === right[index])
|
||||
}
|
||||
|
||||
function literalHasScrollbarForVertical(text, verticalToken) {
|
||||
return classTokens(text).some((candidate) => {
|
||||
if (!STYLED_SCROLLBAR_CLASSES.has(candidate.className)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function uncoveredVerticalClass(text) {
|
||||
return classTokens(text).find((token) => {
|
||||
return (
|
||||
VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function reportAt(node, filePath, sourceFile, text) {
|
||||
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
||||
return {
|
||||
filePath,
|
||||
line: position.line + 1,
|
||||
column: position.character + 1,
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
function stringLiteralTexts(node) {
|
||||
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return [node.text]
|
||||
}
|
||||
if (!ts.isTemplateExpression(node)) {
|
||||
return []
|
||||
}
|
||||
return [node.head.text, ...node.templateSpans.map((span) => span.literal.text)]
|
||||
}
|
||||
|
||||
function collectClassLiteralReports(node, filePath, sourceFile) {
|
||||
const reports = []
|
||||
|
||||
function visit(current) {
|
||||
for (const text of stringLiteralTexts(current)) {
|
||||
const uncovered = uncoveredVerticalClass(text)
|
||||
if (uncovered) {
|
||||
reports.push(reportAt(current, filePath, sourceFile, uncovered.className))
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(current, visit)
|
||||
}
|
||||
|
||||
visit(node)
|
||||
return reports
|
||||
}
|
||||
|
||||
function expressionHasStyledScrollbarLiteral(node) {
|
||||
let hasStyledScrollbar = false
|
||||
|
||||
function visit(current) {
|
||||
if (hasStyledScrollbar) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
stringLiteralTexts(current).some((text) =>
|
||||
classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className))
|
||||
)
|
||||
) {
|
||||
hasStyledScrollbar = true
|
||||
return
|
||||
}
|
||||
// Why: a scrollbar literal that only renders on some branches must not be
|
||||
// treated as covering an unconditional inline overflow. Skip conditional
|
||||
// and short-circuit expressions when proving unconditional coverage.
|
||||
if (ts.isConditionalExpression(current)) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
ts.isBinaryExpression(current) &&
|
||||
(current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
|
||||
current.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
|
||||
current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)
|
||||
) {
|
||||
return
|
||||
}
|
||||
ts.forEachChild(current, visit)
|
||||
}
|
||||
|
||||
visit(node)
|
||||
return hasStyledScrollbar
|
||||
}
|
||||
|
||||
function propertyNameText(name) {
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) {
|
||||
return name.text
|
||||
}
|
||||
if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) {
|
||||
return name.expression.text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function styleValueIsVerticalScroll(propertyName, value) {
|
||||
const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 0) {
|
||||
return false
|
||||
}
|
||||
if (propertyName === 'overflowY' || propertyName === 'overflow-y') {
|
||||
return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0])
|
||||
}
|
||||
if (propertyName !== 'overflow') {
|
||||
return false
|
||||
}
|
||||
const verticalValue = parts.length > 1 ? parts[1] : parts[0]
|
||||
return VERTICAL_SCROLL_STYLE_VALUES.has(verticalValue)
|
||||
}
|
||||
|
||||
function collectStyleReports(node, filePath, sourceFile) {
|
||||
const reports = []
|
||||
|
||||
function visit(current) {
|
||||
if (ts.isPropertyAssignment(current)) {
|
||||
const propertyName = propertyNameText(current.name)
|
||||
for (const value of propertyName ? stringLiteralTexts(current.initializer) : []) {
|
||||
if (styleValueIsVerticalScroll(propertyName, value)) {
|
||||
reports.push(reportAt(current, filePath, sourceFile, 'inline vertical scroll'))
|
||||
}
|
||||
}
|
||||
ts.forEachChild(current.initializer, visit)
|
||||
return
|
||||
}
|
||||
ts.forEachChild(current, visit)
|
||||
}
|
||||
|
||||
visit(node)
|
||||
return reports
|
||||
}
|
||||
|
||||
function jsxAttributeName(attribute) {
|
||||
return ts.isIdentifier(attribute.name) ? attribute.name.text : undefined
|
||||
}
|
||||
|
||||
function jsxAttributeExpression(attribute) {
|
||||
if (ts.isStringLiteral(attribute.initializer)) {
|
||||
return attribute.initializer
|
||||
}
|
||||
if (attribute.initializer && ts.isJsxExpression(attribute.initializer)) {
|
||||
return attribute.initializer.expression
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function spreadPropExpressions(node, propName) {
|
||||
if (
|
||||
ts.isParenthesizedExpression(node) ||
|
||||
ts.isAsExpression(node) ||
|
||||
ts.isSatisfiesExpression(node)
|
||||
) {
|
||||
return spreadPropExpressions(node.expression, propName)
|
||||
}
|
||||
if (ts.isConditionalExpression(node)) {
|
||||
return [
|
||||
...spreadPropExpressions(node.whenTrue, propName),
|
||||
...spreadPropExpressions(node.whenFalse, propName)
|
||||
]
|
||||
}
|
||||
if (ts.isBinaryExpression(node)) {
|
||||
return [
|
||||
...spreadPropExpressions(node.left, propName),
|
||||
...spreadPropExpressions(node.right, propName)
|
||||
]
|
||||
}
|
||||
if (!ts.isObjectLiteralExpression(node)) {
|
||||
return []
|
||||
}
|
||||
return node.properties.flatMap((property) => {
|
||||
if (ts.isSpreadAssignment(property)) {
|
||||
return spreadPropExpressions(property.expression, propName)
|
||||
}
|
||||
if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === propName) {
|
||||
return [property.initializer]
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function jsxElementReports(node, filePath, sourceFile) {
|
||||
const reports = []
|
||||
let classExpression
|
||||
const styleExpressions = []
|
||||
|
||||
for (const attribute of node.attributes.properties) {
|
||||
if (ts.isJsxSpreadAttribute(attribute)) {
|
||||
// Why: at runtime React applies attributes in source order, so a later
|
||||
// spread that supplies className overrides an earlier explicit className.
|
||||
const spreadClassExpression = spreadPropExpressions(attribute.expression, 'className').at(-1)
|
||||
if (spreadClassExpression) {
|
||||
classExpression = spreadClassExpression
|
||||
}
|
||||
styleExpressions.push(...spreadPropExpressions(attribute.expression, 'style'))
|
||||
} else if (jsxAttributeName(attribute) === 'className') {
|
||||
classExpression = jsxAttributeExpression(attribute)
|
||||
} else if (jsxAttributeName(attribute) === 'style') {
|
||||
const expression = jsxAttributeExpression(attribute)
|
||||
if (expression) {
|
||||
styleExpressions.push(expression)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (classExpression) {
|
||||
reports.push(...collectClassLiteralReports(classExpression, filePath, sourceFile))
|
||||
}
|
||||
if (classExpression && expressionHasStyledScrollbarLiteral(classExpression)) {
|
||||
return reports
|
||||
}
|
||||
for (const expression of styleExpressions) {
|
||||
reports.push(...collectStyleReports(expression, filePath, sourceFile))
|
||||
}
|
||||
return reports
|
||||
}
|
||||
|
||||
export function reportUnstyledScrollbars(filePath, sourceText) {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX
|
||||
)
|
||||
const reports = []
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
||||
reports.push(...jsxElementReports(node, filePath, sourceFile))
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return reports
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const BUILD_IDENTITY_RE = /\b(?:const|let|var)\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/
|
||||
export const WRITE_KEY_RE = /\b(?:const|let|var)\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BUILD_IDENTITY_RE, WRITE_KEY_RE } from './telemetry-bundle-constant-patterns.mjs'
|
||||
|
||||
describe('telemetry bundle constant patterns', () => {
|
||||
it.each(['const', 'let', 'var'])('accepts %s declarations', (declaration) => {
|
||||
expect(`${declaration} BUILD_IDENTITY = "rc"`).toMatch(BUILD_IDENTITY_RE)
|
||||
expect(`${declaration} WRITE_KEY = "phc_example-key_123"`).toMatch(WRITE_KEY_RE)
|
||||
})
|
||||
|
||||
it('rejects assignments and invalid values', () => {
|
||||
expect('BUILD_IDENTITY = "rc"').not.toMatch(BUILD_IDENTITY_RE)
|
||||
expect('const BUILD_IDENTITY = "dev"').not.toMatch(BUILD_IDENTITY_RE)
|
||||
expect('const WRITE_KEY = null').not.toMatch(WRITE_KEY_RE)
|
||||
expect('const WRITE_KEY = "example-key"').not.toMatch(WRITE_KEY_RE)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
// Mirrors the complete pre/post stripTerminalControl paths at their production size caps.
|
||||
// Compares legacy, slice-run, and production-adaptive control stripping.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import nodeModule from 'node:module'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
if (!process.execArgv.includes('--experimental-transform-types')) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--experimental-transform-types', '--no-warnings', import.meta.filename],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
nodeModule.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
|
||||
const candidate = new URL(`${specifier}.ts`, context.parentURL)
|
||||
if (existsSync(fileURLToPath(candidate))) {
|
||||
return { url: candidate.href, shortCircuit: true }
|
||||
}
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
}
|
||||
})
|
||||
|
||||
const PRODUCTION_SOURCE = readFileSync(
|
||||
new URL('../../src/shared/terminal-control-stripping.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
for (const marker of [
|
||||
'export function stripTerminalControl(data: string): string',
|
||||
'strippedInBlock === CONTROL_DENSITY_FALLBACK_COUNT',
|
||||
'output += withoutAnsi.slice(runStart, index)',
|
||||
// Retuning either density constant changes which fixtures sit above/below the trigger, so
|
||||
// pin the literals rather than the names: a silent retune would leave the sub-threshold
|
||||
// fixture measuring a boundary that no longer exists.
|
||||
'const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64',
|
||||
'const CONTROL_DENSITY_FALLBACK_COUNT = 32'
|
||||
]) {
|
||||
if (!PRODUCTION_SOURCE.includes(marker)) {
|
||||
throw new Error(`terminal-control-stripping.ts no longer contains \`${marker}\``)
|
||||
}
|
||||
}
|
||||
|
||||
const { stripTerminalControl: stripAdaptive } = await import(
|
||||
new URL('../../src/shared/terminal-control-stripping.ts', import.meta.url).href
|
||||
)
|
||||
|
||||
const ESC = String.fromCharCode(0x1b)
|
||||
const BEL = String.fromCharCode(0x07)
|
||||
@@ -15,6 +63,9 @@ const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp(
|
||||
const HISTORY_LIMIT = 300
|
||||
const SCAN_LIMIT = 4096
|
||||
const SAMPLE_ID_LENGTH = 24
|
||||
// Mirrors terminal-control-stripping.ts; the marker guard above fails if either is retuned.
|
||||
const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64
|
||||
const CONTROL_DENSITY_FALLBACK_COUNT = 32
|
||||
const ITERATIONS = Number(process.env.ORCA_STRIP_BENCH_ITERATIONS ?? '501')
|
||||
let resultChecksum = 0
|
||||
let validatedPairs = 0
|
||||
@@ -75,6 +126,25 @@ function stripSliceRuns(data) {
|
||||
return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart)
|
||||
}
|
||||
|
||||
function adaptiveFallbackIndex(data) {
|
||||
const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '')
|
||||
let strippedInBlock = 0
|
||||
let blockEnd = 64
|
||||
for (let index = 0; index < withoutAnsi.length; index += 1) {
|
||||
if (index === blockEnd) {
|
||||
strippedInBlock = 0
|
||||
blockEnd += 64
|
||||
}
|
||||
if (isStrippedCode(withoutAnsi.charCodeAt(index))) {
|
||||
strippedInBlock += 1
|
||||
if (strippedInBlock === 32) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function fixedSampleId(sampleId) {
|
||||
return `sample:${sampleId}`.padEnd(SAMPLE_ID_LENGTH, '_').slice(0, SAMPLE_ID_LENGTH)
|
||||
}
|
||||
@@ -95,6 +165,26 @@ function makeTuiFixture(length, sampleId, strippedControl) {
|
||||
return text + 'x'.repeat(length - text.length)
|
||||
}
|
||||
|
||||
// 31 controls per 64-unit block: one below the fallback trigger, so the adaptive path keeps
|
||||
// slice-run bookkeeping on a shape dense enough to lose to the per-character legacy. This is the
|
||||
// worst surviving case; it exists so the narrowed adverse window stays visible instead of hiding
|
||||
// behind the 50% fixture, where the fallback fires and wins.
|
||||
function makeSubThresholdDenseFixture(length, sampleId, strippedControl) {
|
||||
const id = fixedSampleId(sampleId)
|
||||
const units = []
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const blockOffset = index % CONTROL_DENSITY_BLOCK_CODE_UNITS
|
||||
if (index < id.length) {
|
||||
units.push(id[index])
|
||||
} else if (blockOffset % 2 === 1 && blockOffset < (CONTROL_DENSITY_FALLBACK_COUNT - 1) * 2) {
|
||||
units.push(strippedControl)
|
||||
} else {
|
||||
units.push(String.fromCharCode(97 + (index % 26)))
|
||||
}
|
||||
}
|
||||
return units.join('')
|
||||
}
|
||||
|
||||
function makeDenseFixture(length, sampleId, strippedControl) {
|
||||
const prefix = `\x1b[35m${fixedSampleId(sampleId)}`
|
||||
const suffix = '\x1b[0m'
|
||||
@@ -120,33 +210,35 @@ function consumeOutput(output) {
|
||||
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
|
||||
) {
|
||||
const IMPLEMENTATIONS = [
|
||||
['perChar', stripPerChar, '\x01'],
|
||||
['sliceRuns', stripSliceRuns, '\x02'],
|
||||
['adaptive', stripAdaptive, '\x03']
|
||||
]
|
||||
|
||||
function recordRotation(fixture, sampleId, lead, samples) {
|
||||
const inputs = IMPLEMENTATIONS.map(([name, strip, control]) => ({
|
||||
name,
|
||||
strip,
|
||||
input: fixture.make(sampleId, control)
|
||||
}))
|
||||
if (inputs.some(({ input }) => input.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)
|
||||
const results = new Map()
|
||||
for (let offset = 0; offset < inputs.length; offset += 1) {
|
||||
const entry = inputs[(lead + offset) % inputs.length]
|
||||
results.set(entry.name, measure(entry.strip, entry.input))
|
||||
}
|
||||
if (perCharResult.output !== sliceRunsResult.output) {
|
||||
const outputs = [...results.values()].map(({ output }) => output)
|
||||
if (new Set(outputs).size !== 1) {
|
||||
throw new Error(`strip mismatch for ${fixture.label}, sample ${sampleId}`)
|
||||
}
|
||||
consumeOutput(perCharResult.output)
|
||||
consumeOutput(sliceRunsResult.output)
|
||||
for (const [name, result] of results) {
|
||||
consumeOutput(result.output)
|
||||
samples[name].push(result.elapsed)
|
||||
}
|
||||
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
|
||||
@@ -171,32 +263,68 @@ const fixtures = [
|
||||
label: `${SCAN_LIMIT} scan ${denseControlPercent}% C0`,
|
||||
length: SCAN_LIMIT,
|
||||
make: (sampleId, control) => makeDenseFixture(SCAN_LIMIT, sampleId, control)
|
||||
},
|
||||
{
|
||||
label: `${SCAN_LIMIT} scan 31/block C0`,
|
||||
length: SCAN_LIMIT,
|
||||
make: (sampleId, control) => makeSubThresholdDenseFixture(SCAN_LIMIT, sampleId, control)
|
||||
}
|
||||
]
|
||||
|
||||
const selectorFixtures = [
|
||||
{ label: '31 controls', data: `${'\x01'.repeat(31)}${'a'.repeat(33)}`, expected: -1 },
|
||||
{ label: '32 controls', data: `${'\x01'.repeat(32)}${'a'.repeat(32)}`, expected: 31 },
|
||||
{
|
||||
label: 'block reset at 64',
|
||||
data: `${'\x01'.repeat(31)}${'a'.repeat(33)}${'\x01'.repeat(32)}`,
|
||||
expected: 95
|
||||
},
|
||||
{
|
||||
label: 'late dense block',
|
||||
data: `${'a'.repeat(64 * 3)}${'\x01'.repeat(32)}tail`,
|
||||
expected: 223
|
||||
},
|
||||
{
|
||||
label: 'routine TUI',
|
||||
data: makeTuiFixture(SCAN_LIMIT, 'selector', '\x01'),
|
||||
expected: -1
|
||||
},
|
||||
{
|
||||
label: '31/block never triggers',
|
||||
data: makeSubThresholdDenseFixture(SCAN_LIMIT, 'selector', '\x01'),
|
||||
expected: -1
|
||||
}
|
||||
]
|
||||
for (const fixture of selectorFixtures) {
|
||||
const actual = adaptiveFallbackIndex(fixture.data)
|
||||
if (actual !== fixture.expected) {
|
||||
throw new Error(`${fixture.label} fallback index ${actual}, expected ${fixture.expected}`)
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Complete stripTerminalControl path. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS} (${ITERATIONS * 3} rotated samples/implementation, median)`)
|
||||
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)}`
|
||||
`${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('adaptive', 11)} ${pad('vs legacy', 10)} ${pad('vs slice', 9)}`
|
||||
)
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const samples = { perChar: [], sliceRuns: [] }
|
||||
const samples = { perChar: [], sliceRuns: [], adaptive: [] }
|
||||
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)
|
||||
for (let lead = 0; lead < IMPLEMENTATIONS.length; lead += 1) {
|
||||
recordRotation(fixture, `${index}:lead-${lead}`, lead, samples)
|
||||
}
|
||||
}
|
||||
const perChar = median(samples.perChar)
|
||||
const sliceRuns = median(samples.sliceRuns)
|
||||
const adaptive = median(samples.adaptive)
|
||||
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)}`
|
||||
`${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(adaptive * 1000).toFixed(1)} us`, 11)} ${pad(`${(perChar / adaptive).toFixed(2)}x`, 10)} ${pad(`${(sliceRuns / adaptive).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`)
|
||||
console.log(
|
||||
`\nvalidated=${validatedPairs} measured rotations, result checksum=${resultChecksum >>> 0}`
|
||||
)
|
||||
console.log(`selector checks=${selectorFixtures.length}`)
|
||||
console.log('Production calls are bounded to 4096, 4096, 300, and 301 code units.')
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('terminal IME e2e workflow', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
|
||||
)
|
||||
|
||||
it('runs for xterm patch and terminal IME regression changes', () => {
|
||||
expect(workflow.on.pull_request.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
|
||||
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers.ts',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.test.ts',
|
||||
'src/renderer/src/components/terminal-pane/terminal-ime-*',
|
||||
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts',
|
||||
'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts',
|
||||
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
|
||||
'tests/e2e/terminal-ime-*.ts'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('installs native IBus Hangul and X11 input tools', () => {
|
||||
const runs = workflow.jobs['linux-x11'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const installRun = runs.find((run) => run.includes('apt-get install'))
|
||||
|
||||
expect(installRun).toBeDefined()
|
||||
expect(installRun).toContain('ibus-hangul')
|
||||
expect(installRun).toContain('xdotool')
|
||||
expect(installRun).toContain('xfwm4')
|
||||
expect(installRun).toContain('xvfb')
|
||||
expect(installRun).toContain('dbus-x11')
|
||||
expect(installRun).toContain('dconf-gsettings-backend')
|
||||
expect(installRun).toContain('libglib2.0-bin')
|
||||
})
|
||||
|
||||
it('runs deterministic boundaries before the real IBus suite', () => {
|
||||
const runs = workflow.jobs['linux-x11'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const deterministicIndex = runs.findIndex((run) =>
|
||||
run.includes('terminal-ime-exact-byte.spec.ts')
|
||||
)
|
||||
const nativeIndex = runs.findIndex((run) => run.includes('test:e2e:terminal-ime-native'))
|
||||
|
||||
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(nativeIndex).toBeGreaterThan(deterministicIndex)
|
||||
})
|
||||
|
||||
it('keeps IBus lifecycle scoped to owned processes', () => {
|
||||
const runner = readFileSync(
|
||||
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(runner).toContain(
|
||||
"['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']"
|
||||
)
|
||||
expect(runner).toContain("spawn('xfwm4', ['--compositor=off']")
|
||||
expect(runner).toContain("['initial-input-mode', 'hangul']")
|
||||
expect(runner).toContain("['hangul-keyboard', '2']")
|
||||
expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')")
|
||||
expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')")
|
||||
expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs')
|
||||
expect(runner).toMatch(
|
||||
/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/
|
||||
)
|
||||
expect(runner).not.toContain("'--replace'")
|
||||
expect(runner).not.toContain('killall')
|
||||
expect(runner).not.toContain('pkill')
|
||||
})
|
||||
|
||||
it('bounds blocking native input commands', () => {
|
||||
const nativeSpec = readFileSync(
|
||||
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: iterateTerminalOutputFrameChunks, which every byte of remote terminal
|
||||
// output passes through on its way to a mobile/remote-desktop multiplex stream.
|
||||
//
|
||||
// The pre-fix loop was `for (const part of data)`: V8 materializes a fresh 1-2 code
|
||||
// unit string per code point, then measureClipboardTextByteLength re-walked that
|
||||
// string through codePointAt to get its UTF-8 width, and the chunk was rebuilt with
|
||||
// `chunk += part`. The gate in front of it (terminalStreamByteLengthExceeds) ran the
|
||||
// same per-code-point walk over the whole payload a second time.
|
||||
//
|
||||
// The fix: one charCodeAt scan computing UTF-8 width inline, slices for chunk text,
|
||||
// and bounded byte probes when UTF-16 length alone cannot prove fit or overflow.
|
||||
//
|
||||
// BOTH arms run the complete production path, encodeTerminalStreamText included, and
|
||||
// their emitted frames (base64 + seq + opcode) are compared before any timing, so an
|
||||
// arm that split differently or renumbered a seq cannot be reported as a win.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import nodeModule from 'node:module'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// terminal-stream-protocol.ts declares a TS enum, which Node's default strip-only
|
||||
// loader rejects; re-exec once with type transformation rather than re-modelling
|
||||
// the opcodes here (a hand copy could drift from the wire contract).
|
||||
if (!process.execArgv.includes('--experimental-transform-types')) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--experimental-transform-types', '--no-warnings', import.meta.filename],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
// The app's TS sources import siblings without an extension; Node's ESM resolver needs it.
|
||||
nodeModule.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
|
||||
const candidate = new URL(`${specifier}.ts`, context.parentURL)
|
||||
if (existsSync(fileURLToPath(candidate))) {
|
||||
return { url: candidate.href, shortCircuit: true }
|
||||
}
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
}
|
||||
})
|
||||
|
||||
const ITERATIONS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ITERATIONS ?? '40')
|
||||
const GATE_ITERATIONS = Number(process.env.ORCA_FRAME_GATE_BENCH_ITERATIONS ?? '2000')
|
||||
const WARMUP = Number(process.env.ORCA_FRAME_CHUNK_BENCH_WARMUP ?? '8')
|
||||
const ROUNDS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ROUNDS ?? '6')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_FRAME_CHUNK_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_FRAME_GATE_BENCH_ITERATIONS', GATE_ITERATIONS],
|
||||
['ORCA_FRAME_CHUNK_BENCH_WARMUP', WARMUP],
|
||||
['ORCA_FRAME_CHUNK_BENCH_ROUNDS', ROUNDS]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
if (ROUNDS % 2 !== 0) {
|
||||
throw new Error(`ORCA_FRAME_CHUNK_BENCH_ROUNDS must be even so each arm leads equally`)
|
||||
}
|
||||
|
||||
const CHUNK_SOURCE = readFileSync(
|
||||
new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const CLIPBOARD_SOURCE = readFileSync(
|
||||
new URL('../../src/shared/clipboard-text.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// Match executable source markers so a stale benchmark fails instead of misleading.
|
||||
for (const [source, label, marker] of [
|
||||
[
|
||||
CHUNK_SOURCE,
|
||||
'terminal-output-frame-chunks.ts',
|
||||
'export function exceedsTerminalStreamChunkBytes(data: string): boolean'
|
||||
],
|
||||
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS'],
|
||||
[
|
||||
CHUNK_SOURCE,
|
||||
'terminal-output-frame-chunks.ts',
|
||||
'terminalStreamByteLength(data.slice(start, end))'
|
||||
],
|
||||
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'const text = data.slice(chunkStart, end)'],
|
||||
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'data.charCodeAt(index + 1)'],
|
||||
[CLIPBOARD_SOURCE, 'clipboard-text.ts', 'export function measureClipboardTextByteLength('],
|
||||
[CLIPBOARD_SOURCE, 'clipboard-text.ts', 'text.codePointAt(index)']
|
||||
]) {
|
||||
if (!source.includes(marker)) {
|
||||
throw new Error(`${label} no longer contains \`${marker}\`; this benchmark is stale`)
|
||||
}
|
||||
}
|
||||
if (CHUNK_SOURCE.includes('for (const part of data)')) {
|
||||
throw new Error(
|
||||
'terminal-output-frame-chunks.ts still iterates code points as strings; this benchmark is stale'
|
||||
)
|
||||
}
|
||||
|
||||
const { TERMINAL_STREAM_CHUNK_BYTES } = await import(
|
||||
new URL('../../src/shared/terminal-multiplex-flow-control.ts', import.meta.url).href
|
||||
)
|
||||
const { measureClipboardTextByteLength } = await import(
|
||||
new URL('../../src/shared/clipboard-text.ts', import.meta.url).href
|
||||
)
|
||||
const { TerminalStreamOpcode, encodeTerminalStreamJson, encodeTerminalStreamText } = await import(
|
||||
new URL('../../src/shared/terminal-stream-protocol.ts', import.meta.url).href
|
||||
)
|
||||
const { exceedsTerminalStreamChunkBytes, iterateTerminalOutputFrameChunks } = await import(
|
||||
new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url).href
|
||||
)
|
||||
|
||||
function previousGate(data) {
|
||||
return (
|
||||
data.length > TERMINAL_STREAM_CHUNK_BYTES ||
|
||||
Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES
|
||||
)
|
||||
}
|
||||
|
||||
// Pre-fix arm: the exact code that shipped, including the second full walk in the gate.
|
||||
function* iterateBefore(data, meta) {
|
||||
const rawLength = meta?.rawLength ?? data.length
|
||||
if (meta?.transformed || rawLength !== data.length) {
|
||||
yield {
|
||||
opcode: TerminalStreamOpcode.OutputSpan,
|
||||
bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }),
|
||||
seq: meta?.seq
|
||||
}
|
||||
return
|
||||
}
|
||||
if (
|
||||
!measureClipboardTextByteLength(data, { stopAfterBytes: TERMINAL_STREAM_CHUNK_BYTES })
|
||||
.exceededLimit
|
||||
) {
|
||||
yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq }
|
||||
return
|
||||
}
|
||||
const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length
|
||||
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number'
|
||||
const startSeq = canPreserveChunkSeq ? meta.seq - rawLength : undefined
|
||||
let chunk = ''
|
||||
let chunkBytes = 0
|
||||
let chunkStartOffset = 0
|
||||
let offset = 0
|
||||
let delayedChunk = null
|
||||
|
||||
const takeChunk = () => {
|
||||
if (!chunk) {
|
||||
return null
|
||||
}
|
||||
const chunkSeq = canPreserveChunkSeq ? startSeq + chunkStartOffset + chunk.length : undefined
|
||||
const current = { text: chunk, seq: chunkSeq }
|
||||
chunk = ''
|
||||
chunkBytes = 0
|
||||
chunkStartOffset = offset
|
||||
return current
|
||||
}
|
||||
|
||||
for (const part of data) {
|
||||
const partBytes = measureClipboardTextByteLength(part).byteLength
|
||||
if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) {
|
||||
const nextChunk = takeChunk()
|
||||
if (nextChunk) {
|
||||
if (shouldDelayFinalSeq) {
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
|
||||
}
|
||||
delayedChunk = nextChunk
|
||||
} else {
|
||||
yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq }
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk += part
|
||||
chunkBytes += partBytes
|
||||
offset += part.length
|
||||
}
|
||||
const finalChunk = takeChunk()
|
||||
if (shouldDelayFinalSeq) {
|
||||
if (finalChunk) {
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
|
||||
}
|
||||
delayedChunk = finalChunk
|
||||
}
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (finalChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq }
|
||||
}
|
||||
}
|
||||
|
||||
// The multiplex stream consumes every frame's bytes/seq/opcode; charge both arms for it.
|
||||
let frameChecksum = 0
|
||||
function drain(iterate, data, meta) {
|
||||
let frames = 0
|
||||
let bytes = 0
|
||||
let seqSum = 0
|
||||
for (const frame of iterate(data, meta)) {
|
||||
frames += 1
|
||||
bytes += frame.bytes.byteLength
|
||||
seqSum += frame.seq ?? 0
|
||||
}
|
||||
frameChecksum = Math.imul(frameChecksum ^ (frames + bytes + seqSum), 16777619) >>> 0
|
||||
return frames
|
||||
}
|
||||
|
||||
function describeFrames(iterate, data, meta) {
|
||||
const shapes = []
|
||||
for (const frame of iterate(data, meta)) {
|
||||
shapes.push(
|
||||
`${Buffer.from(frame.bytes).toString('base64')}|${frame.seq ?? 'u'}|${frame.opcode ?? 'u'}`
|
||||
)
|
||||
}
|
||||
return shapes.join('\n')
|
||||
}
|
||||
|
||||
const SURROGATE_PAIR = '\u{1f600}'
|
||||
const LONE_HIGH = '\ud83d'
|
||||
|
||||
function repeatTo(unit, codeUnits) {
|
||||
let out = ''
|
||||
while (out.length < codeUnits) {
|
||||
out += unit
|
||||
}
|
||||
return out.slice(0, out.length - (out.length % unit.length))
|
||||
}
|
||||
|
||||
// A realistic agent-TUI line: SGR runs, a wide glyph, a currency sign, an emoji.
|
||||
const TUI_LINE =
|
||||
'\u001b[35m\u273b Thinking\u001b[0m about the \u20ac plan \u{1f600} 42 passed, 0 failed\r\n'
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'ascii 4KiB (typical batch)',
|
||||
data: 'x'.repeat(4 * 1024),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
label: `ascii ${TERMINAL_STREAM_CHUNK_BYTES}B (at cap)`,
|
||||
data: 'x'.repeat(TERMINAL_STREAM_CHUNK_BYTES),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
label: 'ascii 64KiB (batch cap, 2 chunks)',
|
||||
data: 'x'.repeat(64 * 1024),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
label: 'mixed TUI 64KiB (2 chunks)',
|
||||
data: repeatTo(TUI_LINE, 64 * 1024),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
// seq without an explicit rawLength: the batcher's ordinary shape.
|
||||
label: 'mixed TUI 64KiB (implicit raw)',
|
||||
data: repeatTo(TUI_LINE, 64 * 1024),
|
||||
meta: () => ({ seq: 5_000_000 })
|
||||
},
|
||||
{
|
||||
label: 'emoji 64KiB (4-byte, 2 chunks)',
|
||||
data: repeatTo(SURROGATE_PAIR, 32 * 1024),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
label: 'lone surrogates 64KiB',
|
||||
data: repeatTo(LONE_HIGH, 64 * 1024),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
label: 'late-wide gate miss (3 chunks)',
|
||||
data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(32_000)}`,
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
label: 'ascii 512KiB (snapshot chunking)',
|
||||
data: 'x'.repeat(512 * 1024),
|
||||
meta: () => undefined
|
||||
}
|
||||
]
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
// Why interleaved with an alternating lead: running one arm's whole batch first lets
|
||||
// CPU frequency drift correlate with whichever arm is being measured. Elsewhere in this
|
||||
// effort that alone reported 23.3x for a real 6.7x.
|
||||
function measureInterleaved(data, meta) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
drain(iterateBefore, data, meta)
|
||||
drain(iterateTerminalOutputFrameChunks, data, meta)
|
||||
}
|
||||
const beforeSamples = []
|
||||
const afterSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
const runBefore = () => {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
drain(iterateBefore, data, meta)
|
||||
}
|
||||
beforeSamples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
const runAfter = () => {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
drain(iterateTerminalOutputFrameChunks, data, meta)
|
||||
}
|
||||
afterSamples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
if (round % 2 === 0) {
|
||||
runBefore()
|
||||
runAfter()
|
||||
} else {
|
||||
runAfter()
|
||||
runBefore()
|
||||
}
|
||||
}
|
||||
return { beforeMs: median(beforeSamples), afterMs: median(afterSamples) }
|
||||
}
|
||||
|
||||
let gateChecksum = 0
|
||||
|
||||
function drainGate(gate, data) {
|
||||
gateChecksum = Math.imul(gateChecksum ^ (gate(data) ? 1 : 0), 16777619) >>> 0
|
||||
}
|
||||
|
||||
function measureGateInterleaved(data) {
|
||||
for (let index = 0; index < WARMUP * 10; index += 1) {
|
||||
drainGate(previousGate, data)
|
||||
drainGate(exceedsTerminalStreamChunkBytes, data)
|
||||
}
|
||||
const previousSamples = []
|
||||
const boundedSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
const run = (gate, samples) => {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < GATE_ITERATIONS; index += 1) {
|
||||
drainGate(gate, data)
|
||||
}
|
||||
samples.push((performance.now() - start) / GATE_ITERATIONS)
|
||||
}
|
||||
if (round % 2 === 0) {
|
||||
run(previousGate, previousSamples)
|
||||
run(exceedsTerminalStreamChunkBytes, boundedSamples)
|
||||
} else {
|
||||
run(exceedsTerminalStreamChunkBytes, boundedSamples)
|
||||
run(previousGate, previousSamples)
|
||||
}
|
||||
}
|
||||
return { previousMs: median(previousSamples), boundedMs: median(boundedSamples) }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('iterateTerminalOutputFrameChunks, per flushed batch. Lower is better.')
|
||||
console.log(
|
||||
`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (alternating lead, per-arm median)`
|
||||
)
|
||||
console.log(
|
||||
`${pad('fixture', 34)} ${pad('frames', 7)} ${pad('per-part', 11)} ${pad('scanned', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
let comparedFixtures = 0
|
||||
for (const fixture of fixtures) {
|
||||
const meta = fixture.meta(fixture.data)
|
||||
const before = describeFrames(iterateBefore, fixture.data, meta)
|
||||
const after = describeFrames(iterateTerminalOutputFrameChunks, fixture.data, meta)
|
||||
if (before !== after) {
|
||||
throw new Error(`frames differ for ${fixture.label}`)
|
||||
}
|
||||
const frames = before.split('\n').length
|
||||
// Guard against a fixture that never reaches the chunking loop; then both arms would
|
||||
// just be measuring the gate and the comparison above would prove nothing about it.
|
||||
if (fixture.data.length > TERMINAL_STREAM_CHUNK_BYTES && frames < 2) {
|
||||
throw new Error(`${fixture.label} never split; fixture does not exercise the chunk loop`)
|
||||
}
|
||||
comparedFixtures += 1
|
||||
const { beforeMs, afterMs } = measureInterleaved(fixture.data, meta)
|
||||
console.log(
|
||||
`${pad(fixture.label, 34)} ${pad(frames, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nvalidated=${comparedFixtures} fixtures frame-identical before timing, checksum=${frameChecksum >>> 0}`
|
||||
)
|
||||
const gateFixtures = [
|
||||
{ label: 'ascii 4KiB fit proof', data: 'x'.repeat(4 * 1024) },
|
||||
{
|
||||
label: 'three-byte exact-cap fit proof',
|
||||
data: '\u20ac'.repeat(TERMINAL_STREAM_CHUNK_BYTES / 3)
|
||||
},
|
||||
{
|
||||
label: 'late-wide fit after probes',
|
||||
data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(11_000)}`
|
||||
},
|
||||
{
|
||||
label: 'late-wide miss during probes',
|
||||
data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(32_000)}`
|
||||
},
|
||||
{ label: 'ascii 64KiB overflow proof', data: 'x'.repeat(64 * 1024) }
|
||||
]
|
||||
|
||||
console.log('\nTerminal frame-fit gate only. Lower is better.')
|
||||
console.log(
|
||||
`${pad('fixture', 34)} ${pad('result', 8)} ${pad('whole scan', 12)} ${pad('bounded', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
for (const fixture of gateFixtures) {
|
||||
const previous = previousGate(fixture.data)
|
||||
const bounded = exceedsTerminalStreamChunkBytes(fixture.data)
|
||||
if (previous !== bounded) {
|
||||
throw new Error(`gate result differs for ${fixture.label}`)
|
||||
}
|
||||
const { previousMs, boundedMs } = measureGateInterleaved(fixture.data)
|
||||
console.log(
|
||||
`${pad(fixture.label, 34)} ${pad(bounded ? 'miss' : 'fit', 8)} ${pad(`${(previousMs * 1000).toFixed(2)} us`, 12)} ${pad(`${(boundedMs * 1000).toFixed(2)} us`, 11)} ${pad(boundedMs > 0 ? `${(previousMs / boundedMs).toFixed(2)}x` : 'n/a', 9)}`
|
||||
)
|
||||
}
|
||||
console.log(`gate fixtures=${gateFixtures.length}, checksum=${gateChecksum >>> 0}`)
|
||||
console.log(
|
||||
'Every byte of remote terminal output crosses this function; the batcher flushes at\nTERMINAL_OUTPUT_BATCH_MAX_BYTES (64 KiB) or every 5 ms, so a busy remote agent pane\nruns it tens of times a second per subscribed stream.'
|
||||
)
|
||||
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmarks the production terminal byte-measurement exports at their real budgets: the output
|
||||
// batcher push and the snapshot budget scan. Every scenario asserts whether production invoked
|
||||
// Buffer.byteLength, so implementation drift cannot preserve stale speedup claims.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import fs from 'node:fs'
|
||||
import nodeModule from 'node:module'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
if (!process.execArgv.includes('--experimental-transform-types')) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--experimental-transform-types', '--no-warnings', import.meta.filename],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
// The app's TS sources import siblings without an extension; Node's ESM resolver needs it.
|
||||
nodeModule.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
|
||||
const candidate = new URL(`${specifier}.ts`, context.parentURL)
|
||||
if (fs.existsSync(fileURLToPath(candidate))) {
|
||||
return { url: candidate.href, shortCircuit: true }
|
||||
}
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
}
|
||||
})
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '../..')
|
||||
const ITERATIONS = Number(process.env.ORCA_BYTE_LENGTH_BENCH_ITERATIONS ?? '61')
|
||||
let resultChecksum = 0
|
||||
let validatedPairs = 0
|
||||
|
||||
if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) {
|
||||
throw new Error(`ORCA_BYTE_LENGTH_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`)
|
||||
}
|
||||
|
||||
function readSource(relative) {
|
||||
return fs.readFileSync(path.join(ROOT, relative), 'utf8')
|
||||
}
|
||||
|
||||
const TERMINAL_SOURCE = readSource('src/main/runtime/rpc/methods/terminal.ts')
|
||||
|
||||
function requireCallForm(source, needle, label) {
|
||||
if (!source.includes(needle)) {
|
||||
throw new Error(`${label} is stale: expected call form \`${needle}\` was not found`)
|
||||
}
|
||||
}
|
||||
|
||||
const { TERMINAL_OUTPUT_BATCH_MAX_BYTES, TERMINAL_STREAM_CHUNK_BYTES } = await import(
|
||||
new URL('../../src/shared/terminal-multiplex-flow-control.ts', import.meta.url).href
|
||||
)
|
||||
const { measureClipboardTextByteLength } = await import(
|
||||
new URL('../../src/shared/clipboard-text.ts', import.meta.url).href
|
||||
)
|
||||
const {
|
||||
MIN_NATIVE_BYTE_LENGTH_CODE_UNITS,
|
||||
measureTerminalStreamByteLength,
|
||||
terminalStreamByteLengthExceeds
|
||||
} = await import(
|
||||
new URL('../../src/main/runtime/rpc/terminal-stream-byte-length.ts', import.meta.url).href
|
||||
)
|
||||
|
||||
const REQUESTED_SNAPSHOT_BYTE_BUDGET = (() => {
|
||||
const match = /const REQUESTED_SNAPSHOT_BYTE_BUDGET = ([^\n]+)/.exec(TERMINAL_SOURCE)
|
||||
if (!match) {
|
||||
throw new Error('terminal.ts is stale: REQUESTED_SNAPSHOT_BYTE_BUDGET is gone')
|
||||
}
|
||||
return Number(new Function(`return (${match[1].trim()})`)())
|
||||
})()
|
||||
|
||||
requireCallForm(TERMINAL_SOURCE, 'measureTerminalStreamByteLength(data, {', 'terminal.ts')
|
||||
requireCallForm(TERMINAL_SOURCE, 'stopAfterBytes: remainingBudget', 'terminal.ts')
|
||||
requireCallForm(
|
||||
TERMINAL_SOURCE,
|
||||
'terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET)',
|
||||
'terminal.ts'
|
||||
)
|
||||
|
||||
const nativeByteLength = Buffer.byteLength
|
||||
function runWithNativeCallCount(fn) {
|
||||
let calls = 0
|
||||
Buffer.byteLength = (...args) => {
|
||||
calls += 1
|
||||
return Reflect.apply(nativeByteLength, Buffer, args)
|
||||
}
|
||||
try {
|
||||
return { output: fn(), calls }
|
||||
} finally {
|
||||
Buffer.byteLength = nativeByteLength
|
||||
}
|
||||
}
|
||||
|
||||
// ---- OLD ARM: the production implementation terminal.ts called before this change.
|
||||
const legacyMeasure = measureClipboardTextByteLength
|
||||
const legacyExceeds = (data, maxBytes) =>
|
||||
measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit
|
||||
|
||||
// ---- Fixtures. Deterministic, seeded, and varied per sample so V8 cannot hoist.
|
||||
function mulberry32(seed) {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0
|
||||
let t = state
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
// Realistic agent-TUI output: mostly ASCII with SGR runs, box drawing, and emoji status
|
||||
// glyphs, plus a per-sample marker so no two measured strings are identical.
|
||||
function makeTerminalText(codeUnits, sampleId) {
|
||||
const random = mulberry32(sampleId * 2654435761)
|
||||
const lines = [
|
||||
'[35m✻ Thinking…[0m\r\n',
|
||||
' ⏺ Running tests… 42 passed, 0 failed\r\n',
|
||||
'│ src/main/runtime/rpc/methods/terminal.ts │\r\n',
|
||||
' ✅ build succeeded in 12.4s — café naïve\r\n',
|
||||
'[32m+ added line[0m\r\n'
|
||||
]
|
||||
let text = `sample:${sampleId}\r\n`
|
||||
while (text.length < codeUnits) {
|
||||
text += lines[Math.floor(random() * lines.length)]
|
||||
}
|
||||
return text.slice(0, codeUnits)
|
||||
}
|
||||
|
||||
// Keystroke echo and tiny interactive writes: the shapes a PTY emits between key presses.
|
||||
function makeInteractiveText(codeUnits, sampleId) {
|
||||
const random = mulberry32(sampleId * 40503)
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789 ./-_'
|
||||
let text = ''
|
||||
while (text.length < codeUnits) {
|
||||
text += alphabet[Math.floor(random() * alphabet.length)]
|
||||
}
|
||||
return text.slice(0, codeUnits)
|
||||
}
|
||||
|
||||
// Trim to just under a BYTE budget so the legacy arm runs its full scan without tripping the limit.
|
||||
function makeTerminalTextUnderBytes(byteBudget, sampleId) {
|
||||
let text = makeTerminalText(byteBudget, sampleId)
|
||||
while (Buffer.byteLength(text, 'utf8') > byteBudget) {
|
||||
text = text.slice(0, Math.floor(text.length * (byteBudget / Buffer.byteLength(text, 'utf8'))))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// The TRUE adversary for the exceeds gate: stay at the code-unit cap so `length > maxBytes`
|
||||
// cannot short-circuit, but pack 3-byte BMP scalars so the legacy scan bails out after only a
|
||||
// THIRD of the string while Buffer.byteLength still walks all of it.
|
||||
function makeEarlyTripText(byteBudget, sampleId) {
|
||||
const tripUnits = Math.ceil((byteBudget + 1) / 3)
|
||||
const marker = String.fromCharCode(0x4e00 + (sampleId % 4096))
|
||||
const prefix = `${marker}${'走'.repeat(tripUnits - 1)}`
|
||||
return `${prefix}${'a'.repeat(byteBudget - tripUnits)}`
|
||||
}
|
||||
|
||||
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 consume(value) {
|
||||
resultChecksum = Math.imul(resultChecksum ^ (value | 0), 16777619) >>> 0
|
||||
}
|
||||
|
||||
// Small inputs are far below timer resolution, so batch them: build `repeats` distinct
|
||||
// samples, time the whole loop, and report per-call cost. Consuming the running total
|
||||
// inside the timed region keeps V8 from hoisting the calls out.
|
||||
function runScenario(scenario) {
|
||||
const repeats = scenario.repeats ?? 1
|
||||
const samples = { legacy: [], next: [] }
|
||||
const runArm = (fn, inputs) => {
|
||||
const start = performance.now()
|
||||
let total = 0
|
||||
for (const input of inputs) {
|
||||
total += scenario.checksum(fn(input))
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
return { elapsed, total }
|
||||
}
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
// Alternate which arm leads on every iteration so cache/JIT warmup is shared evenly.
|
||||
for (const legacyFirst of index % 2 === 0 ? [true, false] : [false, true]) {
|
||||
const batch = index * 2 + (legacyFirst ? 0 : 1)
|
||||
const inputs = []
|
||||
for (let repeat = 0; repeat < repeats; repeat += 1) {
|
||||
inputs.push(scenario.make(batch * repeats + repeat))
|
||||
}
|
||||
const legacyOutputs = inputs.map(scenario.legacy)
|
||||
const observed = runWithNativeCallCount(() => inputs.map(scenario.next))
|
||||
for (let inputIndex = 0; inputIndex < inputs.length; inputIndex += 1) {
|
||||
const input = inputs[inputIndex]
|
||||
const legacyOutput = legacyOutputs[inputIndex]
|
||||
const nextOutput = observed.output[inputIndex]
|
||||
if (!scenario.equal(legacyOutput, nextOutput)) {
|
||||
throw new Error(
|
||||
`${scenario.label}: arms disagree on ${JSON.stringify(input.slice(0, 40))}`
|
||||
)
|
||||
}
|
||||
scenario.assertResult(legacyOutput, input)
|
||||
validatedPairs += 1
|
||||
}
|
||||
scenario.assertNativeCalls(inputs.length, observed.calls)
|
||||
let legacyResult
|
||||
let nextResult
|
||||
if (legacyFirst) {
|
||||
legacyResult = runArm(scenario.legacy, inputs)
|
||||
nextResult = runArm(scenario.next, inputs)
|
||||
} else {
|
||||
nextResult = runArm(scenario.next, inputs)
|
||||
legacyResult = runArm(scenario.legacy, inputs)
|
||||
}
|
||||
consume(legacyResult.total)
|
||||
consume(nextResult.total)
|
||||
samples.legacy.push(legacyResult.elapsed / repeats)
|
||||
samples.next.push(nextResult.elapsed / repeats)
|
||||
}
|
||||
}
|
||||
return { legacy: median(samples.legacy), next: median(samples.next) }
|
||||
}
|
||||
|
||||
const measurementEqual = (a, b) =>
|
||||
a.byteLength === b.byteLength && a.exceededLimit === b.exceededLimit
|
||||
const measurementChecksum = (m) => m.byteLength + (m.exceededLimit ? 1 : 0)
|
||||
const booleanChecksum = (value) => (value ? 1 : 0)
|
||||
|
||||
// Every scenario states which production branch it expects. Native fixtures require exactly one
|
||||
// Buffer.byteLength call per input; fallback fixtures require none.
|
||||
function requireBranch(expected) {
|
||||
return (inputCount, calls) => {
|
||||
const expectedCalls = expected === 'nativeFastPath' ? inputCount : 0
|
||||
if (calls !== expectedCalls) {
|
||||
throw new Error(
|
||||
`expected the production ${expected} branch (${expectedCalls} Buffer.byteLength calls), got ${calls}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const batchScenario = (label, make, options = {}) => ({
|
||||
label,
|
||||
make,
|
||||
repeats: options.repeats,
|
||||
legacy: (input) => legacyMeasure(input, { stopAfterBytes: TERMINAL_OUTPUT_BATCH_MAX_BYTES }),
|
||||
next: (input) =>
|
||||
measureTerminalStreamByteLength(input, {
|
||||
stopAfterBytes: TERMINAL_OUTPUT_BATCH_MAX_BYTES
|
||||
}),
|
||||
equal: measurementEqual,
|
||||
checksum: measurementChecksum,
|
||||
assertResult: (out, input) => options.assert?.(out, input),
|
||||
assertNativeCalls: requireBranch(options.branch)
|
||||
})
|
||||
|
||||
const gateScenario = (label, budget, make, options = {}) => ({
|
||||
label,
|
||||
make,
|
||||
repeats: options.repeats,
|
||||
legacy: (input) => legacyExceeds(input, budget),
|
||||
next: (input) => terminalStreamByteLengthExceeds(input, budget),
|
||||
equal: (a, b) => a === b,
|
||||
checksum: booleanChecksum,
|
||||
assertResult: (out, input) => options.assert?.(out, input),
|
||||
assertNativeCalls: requireBranch(options.branch)
|
||||
})
|
||||
|
||||
const scenarios = [
|
||||
batchScenario('batcher push 8KiB', (sampleId) => makeTerminalText(8 * 1024, sampleId), {
|
||||
branch: 'nativeFastPath',
|
||||
assert: (out) => {
|
||||
if (out.exceededLimit) {
|
||||
throw new Error('batcher push 8KiB should stay under the batch budget')
|
||||
}
|
||||
}
|
||||
}),
|
||||
batchScenario(
|
||||
'batcher push over budget',
|
||||
// Oversized on purpose: this is the case where the arms MUST both return the partial count.
|
||||
(sampleId) => makeTerminalText(3 * TERMINAL_OUTPUT_BATCH_MAX_BYTES, sampleId),
|
||||
{
|
||||
branch: 'scanFallback',
|
||||
assert: (out) => {
|
||||
if (!out.exceededLimit) {
|
||||
throw new Error('over-budget fixture never exceeded the limit')
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
// TRUE WORST CASE for the batcher push. The guard only takes the native count when
|
||||
// length*3 <= stopAfterBytes, which PROVES the limit cannot trip, so the fast path can never
|
||||
// pay for both a Buffer.byteLength and a scan. What is left is the shape where the native
|
||||
// call replaces the fewest scan iterations: a chunk sitting just above the code-unit floor.
|
||||
batchScenario(
|
||||
`batcher push ${MIN_NATIVE_BYTE_LENGTH_CODE_UNITS}B (floor)`,
|
||||
(sampleId) => makeInteractiveText(MIN_NATIVE_BYTE_LENGTH_CODE_UNITS, sampleId),
|
||||
{ branch: 'nativeFastPath', repeats: 4096 }
|
||||
),
|
||||
// Below the floor the new arm deliberately keeps the scan, so it is the legacy code exactly.
|
||||
batchScenario('batcher push 4B keystroke', (sampleId) => makeInteractiveText(4, sampleId), {
|
||||
branch: 'scanFallback',
|
||||
repeats: 4096
|
||||
}),
|
||||
gateScenario(
|
||||
`snapshot scan ${(REQUESTED_SNAPSHOT_BYTE_BUDGET / (1024 * 1024)).toFixed(0)}MiB`,
|
||||
REQUESTED_SNAPSHOT_BYTE_BUDGET,
|
||||
(sampleId) => makeTerminalTextUnderBytes(REQUESTED_SNAPSHOT_BYTE_BUDGET, sampleId),
|
||||
{
|
||||
branch: 'nativeFastPath',
|
||||
assert: (out) => {
|
||||
if (out) {
|
||||
throw new Error('snapshot fixture should sit under the budget so the full scan runs')
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
// TRUE WORST CASE for the boolean gate: at the code-unit cap so `length > maxBytes` cannot
|
||||
// short-circuit, but 3-byte scalars let the legacy scan bail out a THIRD of the way in while
|
||||
// Buffer.byteLength still walks the whole string. This is where the new arm can actually lose.
|
||||
gateScenario(
|
||||
'snapshot gate early-trip',
|
||||
REQUESTED_SNAPSHOT_BYTE_BUDGET,
|
||||
(sampleId) => makeEarlyTripText(REQUESTED_SNAPSHOT_BYTE_BUDGET, sampleId),
|
||||
{
|
||||
branch: 'nativeFastPath',
|
||||
assert: (out, input) => {
|
||||
if (!out) {
|
||||
throw new Error('early-trip gate fixture must exceed the budget')
|
||||
}
|
||||
if (input.length > REQUESTED_SNAPSHOT_BYTE_BUDGET) {
|
||||
throw new Error('early-trip fixture must not hit the code-unit short circuit')
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
gateScenario(
|
||||
'chunk gate early-trip',
|
||||
TERMINAL_STREAM_CHUNK_BYTES,
|
||||
(sampleId) => makeEarlyTripText(TERMINAL_STREAM_CHUNK_BYTES, sampleId),
|
||||
{
|
||||
branch: 'nativeFastPath',
|
||||
assert: (out, input) => {
|
||||
if (!out) {
|
||||
throw new Error('early-trip chunk fixture must exceed the budget')
|
||||
}
|
||||
if (input.length > TERMINAL_STREAM_CHUNK_BYTES) {
|
||||
throw new Error('early-trip fixture must not hit the code-unit short circuit')
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
gateScenario(
|
||||
`chunk gate ${TERMINAL_STREAM_CHUNK_BYTES / 1024}KiB`,
|
||||
TERMINAL_STREAM_CHUNK_BYTES,
|
||||
(sampleId) => makeTerminalTextUnderBytes(TERMINAL_STREAM_CHUNK_BYTES, sampleId),
|
||||
{
|
||||
branch: 'nativeFastPath',
|
||||
assert: (out) => {
|
||||
if (out) {
|
||||
throw new Error('chunk gate fixture should sit under the chunk budget')
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
const formatTime = (ms) =>
|
||||
ms >= 0.001 ? `${(ms * 1000).toFixed(1)} us` : `${(ms * 1e6).toFixed(1)} ns`
|
||||
console.log('Production terminal byte-measurement paths. Lower is better.')
|
||||
console.log(
|
||||
`iterations=${ITERATIONS} (${ITERATIONS * 2} counterbalanced batches/scenario, per-arm medians)`
|
||||
)
|
||||
console.log(`${pad('scenario', 30)} ${pad('legacy', 12)} ${pad('new', 12)} ${pad('speedup', 9)}`)
|
||||
for (const scenario of scenarios) {
|
||||
const { legacy, next } = runScenario(scenario)
|
||||
console.log(
|
||||
`${pad(scenario.label, 30)} ${pad(formatTime(legacy), 12)} ${pad(formatTime(next), 12)} ${pad(`${(legacy / next).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
// Small-chunk sweep across real interactive PTY write sizes. The floor makes everything below
|
||||
// MIN_NATIVE_BYTE_LENGTH_CODE_UNITS byte-identical to the legacy scan, so those rows must land
|
||||
// at ~1.00x; anything materially below that is a regression the change would be shipping.
|
||||
{
|
||||
console.log(
|
||||
`\nbatcher push small-chunk sweep (stopAfterBytes=${TERMINAL_OUTPUT_BATCH_MAX_BYTES}):`
|
||||
)
|
||||
console.log(
|
||||
`${pad('bytes', 10)} ${pad('legacy', 12)} ${pad('new', 12)} ${pad('speedup', 9)} branch`
|
||||
)
|
||||
for (const codeUnits of [4, 8, 16, 64, 256, 1024, 4096]) {
|
||||
const expectedBranch =
|
||||
codeUnits >= MIN_NATIVE_BYTE_LENGTH_CODE_UNITS ? 'nativeFastPath' : 'scanFallback'
|
||||
const { legacy, next } = runScenario(
|
||||
batchScenario(`sweep ${codeUnits}`, (sampleId) => makeInteractiveText(codeUnits, sampleId), {
|
||||
branch: expectedBranch,
|
||||
repeats: Math.max(64, Math.min(4096, Math.ceil(2 ** 18 / codeUnits)))
|
||||
})
|
||||
)
|
||||
const branch = expectedBranch === 'nativeFastPath' ? 'native' : 'scan (unchanged)'
|
||||
console.log(
|
||||
`${pad(`${codeUnits} B`, 10)} ${pad(formatTime(legacy), 12)} ${pad(formatTime(next), 12)} ${pad(`${(legacy / next).toFixed(2)}x`, 9)} ${branch}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`)
|
||||
@@ -227,44 +227,6 @@ function setCatalogEntry(catalog, key, value) {
|
||||
cursor[parts.at(-1)] = value
|
||||
}
|
||||
|
||||
function deleteCatalogEntry(catalog, key) {
|
||||
const parts = key.split('.')
|
||||
const stack = []
|
||||
let cursor = catalog
|
||||
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (
|
||||
typeof cursor?.[part] !== 'object' ||
|
||||
cursor[part] === null ||
|
||||
Array.isArray(cursor[part])
|
||||
) {
|
||||
return false
|
||||
}
|
||||
stack.push([cursor, part])
|
||||
cursor = cursor[part]
|
||||
}
|
||||
|
||||
const leafKey = parts.at(-1)
|
||||
if (!Object.hasOwn(cursor, leafKey)) {
|
||||
return false
|
||||
}
|
||||
|
||||
delete cursor[leafKey]
|
||||
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
||||
const [parent, part] = stack[index]
|
||||
const child = parent[part]
|
||||
if (
|
||||
typeof child === 'object' &&
|
||||
child !== null &&
|
||||
!Array.isArray(child) &&
|
||||
Object.keys(child).length === 0
|
||||
) {
|
||||
delete parent[part]
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function collectLocaleParityIssues(enCatalog, localeCatalog) {
|
||||
const enEntries = flattenCatalogEntries(enCatalog)
|
||||
const localeEntries = flattenCatalogEntries(localeCatalog)
|
||||
@@ -286,30 +248,6 @@ function collectLocaleParityIssues(enCatalog, localeCatalog) {
|
||||
return { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches }
|
||||
}
|
||||
|
||||
function repairLocaleParity(enCatalog, localeCatalog) {
|
||||
const { enEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
collectLocaleParityIssues(enCatalog, localeCatalog)
|
||||
let changed = 0
|
||||
|
||||
for (const key of missingInLocale) {
|
||||
setCatalogEntry(localeCatalog, key, enEntries.get(key))
|
||||
changed += 1
|
||||
}
|
||||
|
||||
for (const key of extraInLocale) {
|
||||
if (deleteCatalogEntry(localeCatalog, key)) {
|
||||
changed += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of interpolationMismatches) {
|
||||
setCatalogEntry(localeCatalog, key, enEntries.get(key))
|
||||
changed += 1
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
function referencesMissingFallbacks(missing) {
|
||||
return missing.filter((reference) => typeof reference.fallback !== 'string')
|
||||
}
|
||||
@@ -344,23 +282,18 @@ function applyMissingEnglishEntries(catalog, missing) {
|
||||
return changed
|
||||
}
|
||||
|
||||
function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
|
||||
const { localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
collectLocaleParityIssues(enCatalog, localeCatalog)
|
||||
|
||||
if (
|
||||
missingInLocale.length > 0 ||
|
||||
extraInLocale.length > 0 ||
|
||||
interpolationMismatches.length > 0
|
||||
) {
|
||||
console.error(`Locale catalog parity failed for ${localeName}.json.`)
|
||||
if (missingInLocale.length > 0) {
|
||||
console.error('')
|
||||
console.error(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
|
||||
if (missingInLocale.length > 20) {
|
||||
console.error(`...and ${missingInLocale.length - 20} more missing keys`)
|
||||
}
|
||||
}
|
||||
// Why: feature PRs own English declarations; absent target leaves deliberately
|
||||
// use i18next's existing English fallback until a localization PR supplies them.
|
||||
console.log(
|
||||
`${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.`
|
||||
)
|
||||
|
||||
if (extraInLocale.length > 0 || interpolationMismatches.length > 0) {
|
||||
console.error(`Locale catalog validation failed for ${localeName}.json.`)
|
||||
if (extraInLocale.length > 0) {
|
||||
console.error('')
|
||||
console.error(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
|
||||
@@ -380,7 +313,7 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
|
||||
return 1
|
||||
}
|
||||
|
||||
console.log(`Verified locale parity for ${localeName}.json (${localeEntries.size} keys).`)
|
||||
console.log(`Verified ${localeEntries.size} existing ${localeName}.json entries.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -527,19 +460,10 @@ export async function main(root = process.cwd(), options = parseArgs(process.arg
|
||||
const localeName = fileName.replace(/\.json$/, '')
|
||||
const localeCatalogPath = path.join(localesDir, fileName)
|
||||
const localeCatalog = JSON.parse(await fs.readFile(localeCatalogPath, 'utf8'))
|
||||
if (options.fix) {
|
||||
const repaired = repairLocaleParity(catalog, localeCatalog)
|
||||
if (repaired > 0) {
|
||||
await fs.writeFile(localeCatalogPath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
|
||||
console.log(`Repaired ${fileName} parity (${repaired} key update(s)).`)
|
||||
}
|
||||
}
|
||||
const exitCode = verifyLocaleParity(catalog, localeName, localeCatalog)
|
||||
const exitCode = verifyLocaleCatalog(catalog, localeName, localeCatalog)
|
||||
if (exitCode !== 0) {
|
||||
if (!options.fix) {
|
||||
console.error('')
|
||||
console.error('Run `pnpm run sync:localization-catalog` to repair locale parity.')
|
||||
}
|
||||
console.error('')
|
||||
console.error('Fix or retire the existing target entry in a localization PR.')
|
||||
return exitCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function makeProject({ sourceText, enCatalog = {}, esCatalog = {} }) {
|
||||
}
|
||||
|
||||
describe('verify-localization-catalog', () => {
|
||||
it('bootstraps missing catalog entries from string fallbacks', async () => {
|
||||
it('bootstraps English entries without fabricating target translations', async () => {
|
||||
const { root, localesDir } = makeProject({
|
||||
sourceText:
|
||||
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n"
|
||||
@@ -45,12 +45,10 @@ describe('verify-localization-catalog', () => {
|
||||
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
|
||||
auto: { example: { greeting: 'Hello {{name}}' } }
|
||||
})
|
||||
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
|
||||
auto: { example: { greeting: 'Hello {{name}}' } }
|
||||
})
|
||||
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({})
|
||||
})
|
||||
|
||||
it('repairs stale locale keys and interpolation mismatches', async () => {
|
||||
it('never overwrites mismatched translations or removes target-only entries', async () => {
|
||||
const { root, localesDir } = makeProject({
|
||||
sourceText:
|
||||
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
|
||||
@@ -63,13 +61,29 @@ describe('verify-localization-catalog', () => {
|
||||
}
|
||||
})
|
||||
|
||||
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
|
||||
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
|
||||
|
||||
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
|
||||
auto: { example: { greeting: 'Hello {{name}}' } }
|
||||
auto: {
|
||||
example: { greeting: 'Hola' },
|
||||
stale: { removed: 'Viejo' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts sparse target catalogs when existing placeholders match', async () => {
|
||||
const { root } = makeProject({
|
||||
sourceText:
|
||||
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
|
||||
enCatalog: {
|
||||
auto: { example: { greeting: 'Hello {{name}}', untranslated: 'English only' } }
|
||||
},
|
||||
esCatalog: { auto: { example: { greeting: 'Hola {{name}}' } } }
|
||||
})
|
||||
|
||||
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
|
||||
})
|
||||
|
||||
it('does not invent values for keys without string fallbacks', async () => {
|
||||
const { root, localesDir } = makeProject({
|
||||
sourceText:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { promisify } from 'node:util'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const EN_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
|
||||
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
|
||||
|
||||
function flattenCatalog(value, prefix = '', entries = new Map()) {
|
||||
if (typeof value === 'string') {
|
||||
entries.set(prefix, value)
|
||||
return entries
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return entries
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
flattenCatalog(child, prefix ? `${prefix}.${key}` : key, entries)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function placeholders(value) {
|
||||
return [...(value.match(PLACEHOLDER_RE) ?? [])].sort().join('|')
|
||||
}
|
||||
|
||||
export function compareExtraction(extractedCatalog, englishCatalog) {
|
||||
const extracted = flattenCatalog(extractedCatalog)
|
||||
const english = flattenCatalog(englishCatalog)
|
||||
const dynamicDefaults = [...extracted.entries()]
|
||||
.filter(([, value]) => value.length === 0)
|
||||
.map(([key]) => key)
|
||||
const missingFromEnglish = [...extracted.keys()].filter((key) => !english.has(key))
|
||||
const orphans = [...english.keys()].filter((key) => !extracted.has(key))
|
||||
const fallbackDrift = []
|
||||
const placeholderMismatches = []
|
||||
|
||||
for (const [key, extractedValue] of extracted) {
|
||||
const englishValue = english.get(key)
|
||||
if (
|
||||
extractedValue.length === 0 ||
|
||||
englishValue === undefined ||
|
||||
englishValue === extractedValue
|
||||
) {
|
||||
continue
|
||||
}
|
||||
fallbackDrift.push(key)
|
||||
if (placeholders(extractedValue) !== placeholders(englishValue)) {
|
||||
placeholderMismatches.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
extracted,
|
||||
dynamicDefaults,
|
||||
missingFromEnglish,
|
||||
orphans,
|
||||
fallbackDrift,
|
||||
placeholderMismatches
|
||||
}
|
||||
}
|
||||
|
||||
function printKeys(label, keys) {
|
||||
if (keys.length === 0) {
|
||||
return
|
||||
}
|
||||
console.error(`${label}:`)
|
||||
for (const key of keys.slice(0, 20)) {
|
||||
console.error(` ${key}`)
|
||||
}
|
||||
if (keys.length > 20) {
|
||||
console.error(` ...and ${keys.length - 20} more`)
|
||||
}
|
||||
}
|
||||
|
||||
async function extractToTemporaryCatalog(root, tempDir) {
|
||||
const cliPath = path.join(root, 'node_modules', 'i18next-cli', 'dist', 'esm', 'cli.js')
|
||||
const outputPattern = path.join(tempDir, '{{language}}.json')
|
||||
// Why: extraction output is evidence for this check, not another committed
|
||||
// catalog that feature authors must keep synchronized.
|
||||
await execFileAsync(process.execPath, [cliPath, 'extract', '--sync-primary', '--quiet'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
|
||||
}
|
||||
})
|
||||
return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8'))
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd()) {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'orca-i18next-extraction-'))
|
||||
|
||||
try {
|
||||
const [extractedCatalog, englishCatalog] = await Promise.all([
|
||||
extractToTemporaryCatalog(root, tempDir),
|
||||
fs.readFile(path.join(root, EN_CATALOG_PATH), 'utf8').then(JSON.parse)
|
||||
])
|
||||
const result = compareExtraction(extractedCatalog, englishCatalog)
|
||||
|
||||
console.log(
|
||||
`Extracted ${result.extracted.size} keys; ${result.dynamicDefaults.length} dynamic defaults are report-only, ${result.orphans.length} existing English entries are not statically referenced, and ${result.fallbackDrift.length} inline defaults differ.`
|
||||
)
|
||||
|
||||
if (result.missingFromEnglish.length > 0 || result.placeholderMismatches.length > 0) {
|
||||
printKeys('Extracted keys missing from en.json', result.missingFromEnglish)
|
||||
printKeys('Extracted defaults with incompatible placeholders', result.placeholderMismatches)
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { compareExtraction } from './verify-localization-extraction.mjs'
|
||||
|
||||
describe('verify-localization-extraction', () => {
|
||||
it('reports legacy drift without requiring a committed disposition database', () => {
|
||||
const result = compareExtraction(
|
||||
{ menu: { open: 'Open now {{name}}' } },
|
||||
{ menu: { open: 'Open {{name}}', legacy: 'Legacy copy' } }
|
||||
)
|
||||
|
||||
expect(result.orphans).toEqual(['menu.legacy'])
|
||||
expect(result.fallbackDrift).toEqual(['menu.open'])
|
||||
expect(result.placeholderMismatches).toEqual([])
|
||||
})
|
||||
|
||||
it('reports dynamic defaults without treating empty extractor values as catalog copy', () => {
|
||||
const result = compareExtraction(
|
||||
{ menu: { dynamic: '' } },
|
||||
{ menu: { dynamic: '{{count}} items' } }
|
||||
)
|
||||
|
||||
expect(result.dynamicDefaults).toEqual(['menu.dynamic'])
|
||||
expect(result.fallbackDrift).toEqual([])
|
||||
expect(result.placeholderMismatches).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects undeclared keys even when their defaults are dynamic', () => {
|
||||
const result = compareExtraction({ menu: { dynamic: '' } }, {})
|
||||
|
||||
expect(result.missingFromEnglish).toEqual(['menu.dynamic'])
|
||||
})
|
||||
|
||||
it('rejects missing English declarations and incompatible placeholders', () => {
|
||||
const result = compareExtraction(
|
||||
{
|
||||
menu: {
|
||||
missing: 'Missing',
|
||||
open: 'Open {{name}}'
|
||||
}
|
||||
},
|
||||
{ menu: { open: 'Open {{path}}' } }
|
||||
)
|
||||
|
||||
expect(result.missingFromEnglish).toEqual(['menu.missing'])
|
||||
expect(result.placeholderMismatches).toEqual(['menu.open'])
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,7 @@ import { join, resolve } from 'node:path'
|
||||
// `node_modules`). If electron-builder ever drops it, promote this to a
|
||||
// direct devDependency in package.json.
|
||||
import { extractFile, listPackage } from '@electron/asar'
|
||||
import { BUILD_IDENTITY_RE, WRITE_KEY_RE } from './telemetry-bundle-constant-patterns.mjs'
|
||||
|
||||
// Why resolve from import.meta.url instead of cwd: a release runner (or a
|
||||
// developer debugging locally) may invoke this script from a non-root cwd.
|
||||
@@ -118,20 +119,8 @@ for (const m of asarMatches) {
|
||||
// Why these regexes: electron-vite's `define` block substitutes the bare
|
||||
// identifiers `ORCA_BUILD_IDENTITY` and `ORCA_POSTHOG_WRITE_KEY` with their
|
||||
// JSON-stringified values at build time. `src/main/telemetry/client.ts`
|
||||
// then assigns those into module-local consts named `BUILD_IDENTITY` and
|
||||
// `WRITE_KEY`. electron-vite's main config is not minified (Vite default for
|
||||
// Electron main builds), so Rollup emits the substituted constants verbatim
|
||||
// as `const BUILD_IDENTITY = "stable";`. Match that exact emitted shape so a
|
||||
// regression — e.g. the env var unset and the substitution falling back to
|
||||
// literal `null` — fails the grep instead of slipping through as a falsy-
|
||||
// but-stringy value. NOTE: if `build.minify` is ever enabled on the main
|
||||
// bundle, esbuild/terser will rename top-level consts and this regex must
|
||||
// be revisited (or replaced with a value-based assertion).
|
||||
//
|
||||
// WRITE_KEY char class includes `_` and `-` because PostHog project API
|
||||
// keys use URL-safe base64 alphabet beyond `phc_`.
|
||||
const BUILD_IDENTITY_RE = /const\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/
|
||||
const WRITE_KEY_RE = /const\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/
|
||||
// then assigns those into module-local declarations named `BUILD_IDENTITY`
|
||||
// and `WRITE_KEY`. Rollup may preserve `const` or lower it to `var`.
|
||||
|
||||
function verifyAsar(asarPath) {
|
||||
console.log(`Verifying ${asarPath}`)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import process from 'node:process'
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
|
||||
const SUBSCRIBERS = Number.parseInt(process.env.ORCA_ZUSTAND_BENCH_SUBSCRIBERS ?? '2500', 10)
|
||||
const WRITES = Number.parseInt(process.env.ORCA_ZUSTAND_BENCH_WRITES ?? '2000', 10)
|
||||
const MAX_MILLISECONDS_PER_WRITE = Number.parseFloat(
|
||||
process.env.ORCA_ZUSTAND_BENCH_MAX_MS_PER_WRITE ?? '5'
|
||||
)
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_ZUSTAND_BENCH_SUBSCRIBERS', SUBSCRIBERS],
|
||||
['ORCA_ZUSTAND_BENCH_WRITES', WRITES],
|
||||
['ORCA_ZUSTAND_BENCH_MAX_MS_PER_WRITE', MAX_MILLISECONDS_PER_WRITE]
|
||||
]) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${name} must be positive, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function measureRound() {
|
||||
const stableProjection = Object.freeze({ activeRepoId: 'repo-1' })
|
||||
const store = createStore(() => ({ unrelatedWrite: 0, stableProjection }))
|
||||
let selectorRuns = 0
|
||||
let renderInvalidations = 0
|
||||
const unsubscribe = Array.from({ length: SUBSCRIBERS }, () => {
|
||||
let previous = store.getState().stableProjection
|
||||
return store.subscribe((state) => {
|
||||
selectorRuns += 1
|
||||
const next = state.stableProjection
|
||||
if (!Object.is(previous, next)) {
|
||||
renderInvalidations += 1
|
||||
}
|
||||
previous = next
|
||||
})
|
||||
})
|
||||
|
||||
const start = performance.now()
|
||||
for (let index = 1; index <= WRITES; index += 1) {
|
||||
store.setState({ unrelatedWrite: index })
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
for (const release of unsubscribe) {
|
||||
release()
|
||||
}
|
||||
return { elapsed, selectorRuns, renderInvalidations }
|
||||
}
|
||||
|
||||
measureRound()
|
||||
const rounds = Array.from({ length: 5 }, measureRound).sort(
|
||||
(left, right) => left.elapsed - right.elapsed
|
||||
)
|
||||
const median = rounds[2]
|
||||
const expectedSelectorRuns = SUBSCRIBERS * WRITES
|
||||
const millisecondsPerWrite = median.elapsed / WRITES
|
||||
|
||||
if (median.selectorRuns !== expectedSelectorRuns) {
|
||||
throw new Error(
|
||||
`Expected ${expectedSelectorRuns} selector runs, observed ${median.selectorRuns}; update the fan-out model.`
|
||||
)
|
||||
}
|
||||
if (median.renderInvalidations !== 0) {
|
||||
throw new Error(
|
||||
`${median.renderInvalidations} unrelated writes changed a stable selector result.`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Zustand fan-out: ${SUBSCRIBERS} subscribers × ${WRITES} unrelated writes = ${expectedSelectorRuns.toLocaleString()} selector runs`
|
||||
)
|
||||
console.log(
|
||||
`Median ${median.elapsed.toFixed(2)} ms total, ${millisecondsPerWrite.toFixed(4)} ms/write, 0 render invalidations`
|
||||
)
|
||||
|
||||
if (process.argv.includes('--check') && millisecondsPerWrite > MAX_MILLISECONDS_PER_WRITE) {
|
||||
console.error(
|
||||
`Zustand fan-out exceeded ${MAX_MILLISECONDS_PER_WRITE.toFixed(2)} ms/write. Inspect selector work and store subscription growth.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -9,7 +9,9 @@
|
||||
"../src/main/agent-hooks/hooks-json-read.ts",
|
||||
"../src/main/agent-hooks/installer-utils.ts",
|
||||
"../src/main/agent-hooks/installer-utils-remote.ts",
|
||||
"../src/main/agent-hooks/local-agent-cli-presence.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
@@ -27,6 +29,7 @@
|
||||
"../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-managed-home-resource-copy-marker.ts",
|
||||
"../src/main/codex/codex-hook-identity.ts",
|
||||
"../src/main/codex/codex-hook-trust-grant.ts",
|
||||
"../src/main/codex/codex-managed-trust-reconciliation.ts",
|
||||
@@ -67,6 +70,7 @@
|
||||
"../src/main/kimi/kimi-hook-config-toml.ts",
|
||||
"../src/main/openclaude/hook-service.ts",
|
||||
"../src/main/rolling-file-backup.ts",
|
||||
"../src/main/startup/hydrate-shell-path.ts",
|
||||
"../src/main/runtime/runtime-metadata.ts",
|
||||
"../src/main/win32-utils.ts"
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"../electron.vite.config.*",
|
||||
"../build-plugins/**/*",
|
||||
"../src/main/**/*",
|
||||
"../src/renderer/src/lib/skill-freshness-display-status.ts",
|
||||
"../src/preload/**/*",
|
||||
"../src/shared/**/*",
|
||||
"../src/relay/**/*",
|
||||
|
||||
@@ -15,6 +15,8 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
|
||||
execArgv: ['--no-experimental-webstorage'],
|
||||
include: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.test.tsx',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 380 KiB |
File diff suppressed because it is too large
Load Diff
@@ -243,9 +243,10 @@ Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votr
|
||||
|
||||
- **Discord :** Rejoignez la communauté sur **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X :** Suivez **[@orca_build](https://x.com/orca_build)** pour les news et annonces.
|
||||
- **WeChat :** Les groupes 1 et 2 sont complets — vous pouvez rejoindre le troisième.
|
||||
- **WeChat :** Si le groupe 5 est complet, vous pouvez rejoindre le groupe 6.
|
||||
|
||||
<img src="../assets/wechat-qr.jpg" alt="QR code WeChat de la communauté Orca" width="160" />
|
||||
<img src="../assets/wechat-qr.jpg" alt="QR code WeChat groupe 5 de la communauté Orca" width="160" style="margin-right: 16px;" />
|
||||
<img src="../assets/wechat-qr-group6.jpg" alt="QR code WeChat groupe 6 de la communauté Orca" width="160" />
|
||||
|
||||
- **Feedback & idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues).
|
||||
- **Confidentialité :** Voir la [doc confidentialité & télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie.
|
||||
|
||||
@@ -235,9 +235,10 @@ yay -S stably-orca-bin
|
||||
|
||||
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
|
||||
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
|
||||
- **微信:** 其他群已满,现在加入第 5 群。
|
||||
- **微信:** 如果第 5 群已满,可加入第 6 群。
|
||||
|
||||
<img src="../assets/wechat-qr.jpg" alt="Orca 社区微信群二维码" width="160" />
|
||||
<img src="../assets/wechat-qr.jpg" alt="Orca 社区微信第 5 群二维码" width="160" style="margin-right: 16px;" />
|
||||
<img src="../assets/wechat-qr-group6.jpg" alt="Orca 社区微信第 6 群二维码" width="160" />
|
||||
|
||||
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
|
||||
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,576 @@
|
||||
# Preserve active orchestration work across contract updates
|
||||
|
||||
Status: implementation validation
|
||||
|
||||
Date: 2026-07-28
|
||||
|
||||
## Decision
|
||||
|
||||
Do not block updates, restart active workers, or revive the retired scheduler.
|
||||
|
||||
On first launch after the contract update, atomically adopt the durable
|
||||
pre-Run graph into one ordinary lightweight Run. Preserve every Task,
|
||||
Dispatch, message, gate, terminal, PTY, process, worktree, and prompt
|
||||
identifier. Mark only the pre-update Dispatch attempts as legacy-contract
|
||||
attempts and accept the exact command shapes already pasted into those
|
||||
workers through a narrow adapter.
|
||||
|
||||
This is state migration plus a per-Dispatch protocol adapter. It is not a
|
||||
second scheduler:
|
||||
|
||||
- the migrated Run owns the preserved graph and coordinator inbox;
|
||||
- legacy attempts keep their original protocol until they settle;
|
||||
- every new attempt uses the current protocol, even on a preserved Task; and
|
||||
- no placement, polling, concurrency, or deadline loop is resurrected.
|
||||
|
||||
## User-visible outcome
|
||||
|
||||
- The update installs immediately.
|
||||
- Existing coordinator and worker terminals keep the same process, PTY,
|
||||
worktree, prompt, Task ID, Dispatch ID, and history.
|
||||
- Orca does not inject text, focus a tab, or start a replacement editor.
|
||||
- The legacy graph appears in an automatically adopted, normal Run instead of
|
||||
being stranded in `run_legacy_local`.
|
||||
- An original live worker with retained launch proof can keep using the
|
||||
`heartbeat`, `worker_done`, `escalation`, `ask`, and `check` commands in its
|
||||
existing prompt.
|
||||
- An original live coordinator can keep listing and settling preserved Tasks,
|
||||
dispatch remaining work with current commands, check direct legacy mail, and
|
||||
use the pre-cutover `reply` CLI guidance.
|
||||
- Unprovable, stale, ambiguous, or already-settled rows remain inspectable and
|
||||
unmistakably read-only. They never advertise Reply or Ack actions.
|
||||
- A pre-update WSL process whose older launcher did not propagate the hidden
|
||||
token remains lifecycle read-only. Its assignment, process, terminal, and
|
||||
filesystem work remain valid and inspectable.
|
||||
|
||||
The retired `orchestration run`, `coordinator-start`, and `coordinator-stop`
|
||||
scheduler commands remain retired. Their in-memory loop cannot be reconstructed
|
||||
truthfully. The coordinator agent and all durable work remain accessible; it
|
||||
explicitly dispatches any ready work that was never assigned before the update.
|
||||
|
||||
## Evidence and constraints
|
||||
|
||||
- Schema v7 preserved old `messages`, `tasks`, `dispatch_contexts`, and
|
||||
`decision_gates` by assigning them to `run_legacy_local`.
|
||||
- The exact prompt at `8b154d686` used old `send`, `ask`, and consuming
|
||||
`check` shapes. It omitted `worker_done --outcome`; a subject equal to
|
||||
`Failed` or beginning `Failed:` represented failure. Its escalation carried
|
||||
`taskId` but no `dispatchId`.
|
||||
- `reply` existed in the old CLI and message formatter guidance, not in that
|
||||
pinned worker prompt.
|
||||
- Restored `OrcaRuntime` PTY records currently set `launchToken: null`, and
|
||||
provider `listProcesses()` does not expose the launch token. Handles and
|
||||
pane IDs alone therefore cannot authorize compatibility.
|
||||
- Managed hooks source the owner-only endpoint file on every invocation, so an
|
||||
old process reaches the new hook server after restart. The hook cache retains
|
||||
the prior authenticated pane/launch-token association and distinguishes
|
||||
hydrated rows from events observed by the current runtime.
|
||||
- Older builds did not propagate `ORCA_AGENT_LAUNCH_TOKEN` through `WSLENV`, and
|
||||
a running process cannot acquire it retroactively.
|
||||
- The CLI transport mutation receipt is keyed by rotating runtime
|
||||
authentication. It cannot deduplicate a legacy mutation across restart.
|
||||
- Current invalid-capability lifecycle sends deliberately remain as auditable
|
||||
rejected messages. Compatibility must not silently change that behavior.
|
||||
- PR #11142 fixes background-tab materialization for newly dispatched workers.
|
||||
It does not recover a pre-fix worker whose PTY survived an update after its
|
||||
renderer tab binding was lost; workspace activation can otherwise mistake
|
||||
that worker for a sleeping provider session and launch `codex resume`.
|
||||
- The packaged Windows launcher keeps its Electron-as-Node child alive for the
|
||||
lifetime of a blocking CLI command. A seamless design cannot assume that a
|
||||
bounded RPC wait releases the installed executable.
|
||||
|
||||
## Non-negotiable invariants
|
||||
|
||||
1. Adoption never spawns, writes, signals, stops, focuses, or injects a
|
||||
terminal and never mutates the assigned filesystem. It may restore one
|
||||
persisted background tab/leaf binding for the exact already-live PTY.
|
||||
2. Every durable row and identifier survives adoption; only its owning Run and
|
||||
explicit protocol metadata change.
|
||||
3. Compatibility is write-once per Dispatch. Migration never classifies a
|
||||
current retry as legacy.
|
||||
4. A legacy mutation requires one uniquely resolved legacy attempt or
|
||||
coordinator principal plus current live proof of its original process tree.
|
||||
5. Caller-supplied handles, pane keys, Task IDs, Dispatch IDs, or remote
|
||||
attachments never create authority.
|
||||
6. Current Dispatch behavior remains unchanged, including auditable rejected
|
||||
lifecycle messages, capabilities, process incarnation, Run Delivery, and
|
||||
question semantics.
|
||||
7. Legacy direct mail and current Run Delivery are disjoint, even when their
|
||||
Dispatches share one migrated Task and Run.
|
||||
8. A B-era disconnect may replay accepted work; it may not lose mail or create
|
||||
a second question, reply, completion, or settlement. A cut after an A-era
|
||||
ask was already answered is explicitly ambiguous because A persisted no
|
||||
invocation identity; recovery replays its answer through legacy check before
|
||||
a same-text question is asked again.
|
||||
9. Different reply bodies for the same source and principal conflict instead
|
||||
of creating multiple replies.
|
||||
10. Folder workspaces, SSH, WSL, remote runtimes, macOS, Linux, and Windows
|
||||
follow the same proof rules. Missing proof degrades to inspection without
|
||||
invalidating the retained assignment or filesystem work.
|
||||
|
||||
## Storage and atomic adoption
|
||||
|
||||
Raise the schema version and add:
|
||||
|
||||
```text
|
||||
dispatch_contexts.contract_version INTEGER NOT NULL
|
||||
dispatch_contexts.launch_token_hash TEXT
|
||||
messages.delivery_contract TEXT NOT NULL
|
||||
CHECK(delivery_contract IN ('legacy_direct', 'current_delivery', 'audit_only'))
|
||||
legacy_adoptions(
|
||||
source_run_id PRIMARY KEY,
|
||||
adopted_run_id UNIQUE NOT NULL,
|
||||
scheduler_state_lost INTEGER NOT NULL,
|
||||
adopted_at
|
||||
)
|
||||
legacy_compatibility_principals(
|
||||
id PRIMARY KEY,
|
||||
run_id NOT NULL,
|
||||
dispatch_id,
|
||||
role CHECK(role IN ('worker', 'coordinator')),
|
||||
host_scope NOT NULL,
|
||||
terminal_handle NOT NULL,
|
||||
pane_key NOT NULL,
|
||||
launch_token_hash NOT NULL,
|
||||
process_incarnation,
|
||||
status CHECK(status IN ('committed', 'settled', 'revoked')),
|
||||
UNIQUE(role, run_id, dispatch_id)
|
||||
)
|
||||
legacy_operation_receipts(
|
||||
principal_id,
|
||||
operation_key,
|
||||
method,
|
||||
payload_hash,
|
||||
effect_id,
|
||||
response_json,
|
||||
completed_at,
|
||||
PRIMARY KEY(principal_id, operation_key)
|
||||
)
|
||||
legacy_mail_receipts(
|
||||
principal_id,
|
||||
message_id,
|
||||
acknowledged_at,
|
||||
PRIMARY KEY(principal_id, message_id)
|
||||
)
|
||||
```
|
||||
|
||||
The schema migration runs in one `BEGIN IMMEDIATE` transaction:
|
||||
|
||||
1. create a persisted random Run ID and `legacy_adoptions` marker if one does
|
||||
not already exist;
|
||||
2. create an ordinary `legacy = 0` Run with a recovery objective;
|
||||
3. move all `run_legacy_local` Tasks, Dispatches, decision gates, messages,
|
||||
and question threads to it without changing their IDs, sequence, status, or
|
||||
content;
|
||||
4. mark pre-existing direct mail `legacy_direct` and hard-cutover rejection
|
||||
rows `audit_only`;
|
||||
5. mark a Dispatch legacy only when it predates the contract column and lacks
|
||||
a current dispatch capability; and
|
||||
6. fence any impossible outstanding legacy Delivery, move retained Delivery
|
||||
history to the adopted Run, assert every Task/Dispatch/gate/message/question/
|
||||
Delivery Run ID agrees, and leave `run_legacy_local` as an empty read-only
|
||||
audit tombstone.
|
||||
|
||||
Every new Dispatch and message writes the current contract explicitly.
|
||||
Repeated startup, a partially initialized fixture, and WAL recovery reuse the
|
||||
persisted adoption marker. Reset and fresh-database paths cannot synthesize
|
||||
legacy authority.
|
||||
|
||||
## Durable principals and live authority
|
||||
|
||||
After the hook server and PTY inventory are ready, a one-shot adoption service
|
||||
may commit compatibility principals. It does not mutate terminal processes or
|
||||
input.
|
||||
|
||||
Before renderer hydration, legacy Dispatch evidence also fences automatic
|
||||
provider resume for the exact sleeping pane. Once the relevant local daemon or
|
||||
SSH relay has authoritative inventory, Orca intersects the durable handle,
|
||||
pane, PTY ID, PTY incarnation, workspace, and host owner. A unique live match
|
||||
is adopted into its original tab/leaf as inactive background state while the
|
||||
coordinator tab remains active. Missing, stale, reused, cross-host, or
|
||||
ambiguous evidence stays fenced and inspectable. Ordinary provider resume is
|
||||
unblocked only when authoritative inventory proves that exact PTY exited.
|
||||
|
||||
At hook-server startup, copy the validated hydrated status rows into an
|
||||
immutable main-process-only startup snapshot before the server listens or
|
||||
binds the new listener. Later live events update a separate current-runtime
|
||||
observation map and cannot overwrite the pre-update commitment source. A
|
||||
current event is preferred but is not required before the worker's first
|
||||
post-restart lifecycle command when controller inventory proves the exact
|
||||
original PTY and process incarnation are live.
|
||||
|
||||
For each candidate it resolves the union of:
|
||||
|
||||
- durable Dispatch assignee and pane;
|
||||
- equivalent-pane aliases;
|
||||
- remote attachment and host ownership;
|
||||
- coordinator candidates from active legacy coordinator rows, Task creator
|
||||
handles, Dispatch coordinator/parent provenance, and direct-message
|
||||
sender/recipient history;
|
||||
- the restored terminal inventory and process incarnation; and
|
||||
- the hydrated, previously authenticated hook pane/launch-token association.
|
||||
|
||||
The candidate commits only when that union identifies exactly one role and
|
||||
row. The orchestration database stores a SHA-256 token commitment, never the
|
||||
raw token. The existing owner-only hook status cache is an input to the
|
||||
one-time commitment, but a hydrated status is not enough to mutate.
|
||||
|
||||
Every compatibility call then requires:
|
||||
|
||||
- a hidden launch token and pane inherited by the calling process;
|
||||
- the immutable pre-update hook commitment, plus either a matching
|
||||
current-runtime hook event or exact current controller proof of the original
|
||||
PTY and process incarnation;
|
||||
- a live PTY with the committed handle, host scope, and process incarnation;
|
||||
- the committed principal and exact active legacy Dispatch or adopted Run; and
|
||||
- an operation-specific unique match.
|
||||
|
||||
The current hook event or exact controller inventory proves that the process
|
||||
survived the restart; equality with the pre-update commitment proves it is the
|
||||
same launch. If the hook cache was absent, the provider cannot report the
|
||||
proof, the token changed, or any candidate is ambiguous, the row remains
|
||||
read-only.
|
||||
|
||||
For new Dispatches, persist the launch-token hash at dispatch time so later
|
||||
contract updates do not depend on hook-cache recovery. Never return or log raw
|
||||
tokens. Add explicit RPC-envelope, structured-log, error, and CLI JSON
|
||||
redaction tests.
|
||||
|
||||
Host scope is runtime-owned:
|
||||
|
||||
- local: stable local execution-host identity;
|
||||
- WSL: stable native execution-host identity plus distro;
|
||||
- SSH: saved-host authority plus remote pane/process identity; and
|
||||
- federated runtime: authenticated peer fingerprint plus remote attachment.
|
||||
|
||||
`pickRemoteCliEnv`, SSH relay forwarding, WSLENV, and the legacy in-process SSH
|
||||
fallback must carry the hidden evidence without exposing it in command text or
|
||||
logs. A local runtime never accepts a raw remote token as local authority.
|
||||
Pre-update WSL processes without the token cannot use the lifecycle adapter.
|
||||
|
||||
For SSH, the host passthrough and in-process fallback stamp the saved target,
|
||||
`SshRelaySession` connection incarnation, and attachment provenance from the
|
||||
authenticated runtime channel; caller environment cannot choose them. WSL
|
||||
stamps the runtime-owned native host and distro in both the full bridge and
|
||||
fallback bridge. The verifier intersects those stamps with the committed host
|
||||
scope before considering token or pane evidence.
|
||||
|
||||
## Mixed-contract Run and mail routing
|
||||
|
||||
The adopted Run is a normal current Run. Tasks belong to it, so a current retry
|
||||
of a preserved Task naturally routes to the same Run without a cross-Run Task
|
||||
reference.
|
||||
|
||||
Message delivery is selected by `messages.delivery_contract`, not by Run ID:
|
||||
|
||||
- old and adapter-created direct messages are `legacy_direct`;
|
||||
- current sends are `current_delivery`; and
|
||||
- migration rejections and non-actionable history are `audit_only`.
|
||||
|
||||
Current Run Delivery creation, filtering, waiting, and acknowledgment select
|
||||
only `current_delivery`. Legacy `check` selects only the exact principal's
|
||||
`legacy_direct` address. History and inbox may show all three with explicit
|
||||
labels but never attach actions to `audit_only`. Mixed legacy/current attempts
|
||||
under one Task are covered across send, ask, check, reply, and settlement.
|
||||
|
||||
The original coordinator is automatically associated with the adopted Run
|
||||
after its principal proves live. `run-use` may bind that same principal without
|
||||
converting legacy Dispatches. A different coordinator cannot silently take
|
||||
over while a legacy attempt is active; an explicit recovery takeover is a
|
||||
separate current-contract action and revokes legacy coordinator mutation
|
||||
authority before rebinding.
|
||||
|
||||
When that coordinator uses old direct `check`, the adapter also performs a
|
||||
non-consuming existence check for `current_delivery` mail. It never mixes the
|
||||
messages into legacy output; it prints the exact current Run check command and,
|
||||
after that command creates a Delivery, the exact acknowledgment form. This
|
||||
keeps an unchanged coordinator informed about current retries without
|
||||
weakening current Delivery semantics.
|
||||
|
||||
## Exact legacy adapter
|
||||
|
||||
The CLI keeps accepting the old flags. It sends a hidden compatibility
|
||||
envelope; the runtime, not the CLI, decides whether legacy behavior applies.
|
||||
|
||||
Version B generates a random hidden `compatibilityInvocationId` once per CLI
|
||||
process and reuses it for every transport reconnect by that invocation.
|
||||
`legacy_operation_receipts.operation_key` is this invocation ID for B calls.
|
||||
The method and canonical payload hash must also match.
|
||||
|
||||
Attested compatibility calls bypass the rotating-auth generic mutation ledger
|
||||
after the contract fence routes them to the adapter. Their only dedupe boundary
|
||||
is the compatibility-principal receipt transaction described here; current
|
||||
calls continue through the existing generic ledger unchanged.
|
||||
|
||||
Version A had no invocation ID. Cross-cutover reconstruction is therefore
|
||||
limited to effects with a durable semantic identity: final settlement per
|
||||
Dispatch, still-pending ask per normalized question/options/recipient, reply
|
||||
per source/principal/body, and read recovery per message. Heartbeat and
|
||||
escalation remain at-least-once across an A-to-B manual retry because
|
||||
collapsing identical commands could suppress a legitimate later event. Tests
|
||||
and guidance do not promise cross-cutover exactly-once behavior for those
|
||||
repeatable signals.
|
||||
|
||||
An A-era ask that was already answered before its output was lost is also
|
||||
ambiguous. B does not guess whether the same text means retry or a new
|
||||
question. Its answer is in the durable recovery cohort and the error tells the
|
||||
agent to run the exact legacy check command first. After that answer is printed
|
||||
and acknowledged, a new B invocation with identical text creates a new
|
||||
question. If multiple identical A threads exist, ask fails ambiguous and
|
||||
recovery check drains them by message sequence; no arbitrary thread is chosen.
|
||||
|
||||
### Lifecycle `send`
|
||||
|
||||
- `heartbeat` and `worker_done`: resolve Task + Dispatch from the payload, then
|
||||
intersect them with the verified worker principal.
|
||||
- `escalation`: resolve Task + verified worker principal to exactly one active
|
||||
legacy Dispatch because the old prompt supplied no Dispatch ID.
|
||||
- Preserve the old direct coordinator recipient after verifying it belongs to
|
||||
the adopted Run principal.
|
||||
- Infer missing `worker_done` outcome as `failed` only when the trimmed subject
|
||||
is `Failed` or begins `Failed:`; otherwise infer `succeeded`.
|
||||
- Normalize to the current internal settlement model.
|
||||
|
||||
Compatibility validation happens before message insertion. Current lifecycle
|
||||
handling is not refactored through this prevalidation: its existing
|
||||
invalid-capability audit message behavior remains locked by regression tests.
|
||||
|
||||
The DB commits the direct message, lifecycle effect, and compatibility receipt
|
||||
in one transaction. If a pre-update `worker_done` already settled the Dispatch,
|
||||
the adapter reconstructs the matching semantic receipt from the existing
|
||||
message and settlement. The persisted A settlement is authoritative: the
|
||||
pinned A reconciler recorded even a `Failed:` subject as completed, so B
|
||||
returns that original completed result rather than retroactively changing Task
|
||||
or dependency state. Only a not-yet-accepted B call applies the corrected
|
||||
failure inference. A conflicting retry fails.
|
||||
|
||||
### `check`
|
||||
|
||||
Legacy check is at-least-once:
|
||||
|
||||
1. a read RPC returns the exact unacknowledged `legacy_direct` message IDs
|
||||
without changing them;
|
||||
2. the CLI prints the complete response; and
|
||||
3. a hidden ack atomically records receipts and marks those exact rows read.
|
||||
|
||||
A cut after read and before ack replays the same IDs. `--peek` and `--all`
|
||||
remain read-only.
|
||||
|
||||
Version A could mark a message read before losing its response. On adoption,
|
||||
every eligible read direct message connected to a still-active legacy attempt
|
||||
and lacking a post-adoption receipt is inserted into a durable recovery cohort
|
||||
using `legacy_mail_receipts` with `acknowledged_at = NULL`. Legacy check drains
|
||||
that stable cohort in bounded sequence-ordered pages before ordinary unread
|
||||
mail. Each page is labeled
|
||||
`[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`, replays until its post-print
|
||||
ack, and then advances. The cohort never grows after adoption, so every
|
||||
eligible row is covered without an unbounded response. Settled-at-cutover and
|
||||
unrelated historical mail do not enter it.
|
||||
|
||||
### `ask`
|
||||
|
||||
Legacy ask is an idempotent mutation followed by a resumable read:
|
||||
|
||||
1. create or find the direct legacy gate/question by verified Dispatch,
|
||||
normalized question, options, and recipient;
|
||||
2. atomically commit its message/thread plus operation receipt and return the
|
||||
stable question ID;
|
||||
3. wait in bounded read-only slices using that ID; and
|
||||
4. after printing the answer, hidden-ack that exact answer.
|
||||
|
||||
On first adoption, existing pending decision-gate threads are indexed into the
|
||||
same semantic identity. A retry resumes them instead of creating another gate.
|
||||
|
||||
### `reply`
|
||||
|
||||
Legacy reply requires the source thread, the verified coordinator principal,
|
||||
and a unique question owned by the adopted Run. One transaction inserts the
|
||||
reply, resolves the exact gate, marks the exact source, and completes the
|
||||
receipt.
|
||||
|
||||
An existing pre-update reply with the same source, principal, and body is
|
||||
reconstructed as success. A second body for the same source/principal is a
|
||||
conflict, not another reply.
|
||||
|
||||
### Coordinator and Task commands
|
||||
|
||||
After automatic binding, unchanged Task listing, Task settlement, dispatch,
|
||||
and gate inspection use the adopted Run. A new attempt receives a current
|
||||
preamble, capability, launch-token commitment, and current message contract.
|
||||
No unrelated Task can be created until the migrated coordinator binding is
|
||||
proven or explicitly taken over.
|
||||
|
||||
## Windows update behavior
|
||||
|
||||
No compatibility mutation holds a DB transaction while waiting. Windows
|
||||
legacy `ask` commits the question, prints its stable ID and exact resume
|
||||
command, then exits before a long wait whenever the installed launcher cannot
|
||||
prove that the executable is replaceable.
|
||||
|
||||
The native packaged launcher sets an internal
|
||||
`ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER=1` marker for its Electron-as-Node child.
|
||||
After the DB commit, legacy `ask` writes and flushes the stable question ID and
|
||||
the exact resume command, then exits with a documented `75` resume-required
|
||||
status. The command name comes only from the validated inherited
|
||||
`ORCA_CLI_COMMAND` packaged enum: `orca` for native packaged terminals or
|
||||
`orca-ide` for packaged WSL. Arbitrary environment text is rejected rather
|
||||
than rendered. The launcher propagates status 75. No long wait starts on this
|
||||
path.
|
||||
|
||||
This compatibility path makes the accepted question recoverable without
|
||||
claiming Windows updater or uninstaller process ownership. Generic executable
|
||||
drain, replacement, and uninstall behavior remains separate updater
|
||||
reliability work and requires its own Windows artifact proof.
|
||||
|
||||
## Rendering and authoritative guidance
|
||||
|
||||
Formatting is authority-aware:
|
||||
|
||||
- `[LEGACY COMPATIBILITY]`: live and attested; show only supported commands;
|
||||
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`: one bounded cutover replay;
|
||||
- `[LEGACY READ-ONLY]`: retained but unactionable; no Reply or Ack hint; and
|
||||
- current messages: unchanged.
|
||||
|
||||
Update `skill-guides/orchestration.md` and regenerate its bundled source. The
|
||||
guide explains automatic adoption, exact continuity, the retired scheduler
|
||||
boundary, proof failures, current retries, and the Windows ask resume fallback.
|
||||
It must not tell agents to restart a worker whose Dispatch was adopted.
|
||||
|
||||
Formatting cannot use database provenance alone; current liveness and
|
||||
attestation are runtime facts. Recovery may reveal the exact already-live PTY
|
||||
once as an inactive background tab. It never creates a PTY or process, writes
|
||||
input, switches workspaces, focuses, splits, or injects a terminal.
|
||||
|
||||
## Deterministic verification
|
||||
|
||||
### Schema and authority
|
||||
|
||||
- Migrate exact pre-v7, hard-cutover, current, partial, WAL, reset, and fresh
|
||||
fixtures; preserve IDs and row counts and reuse one adopted Run.
|
||||
- Assert Task, Dispatch, decision-gate, message, question-thread, and retained
|
||||
Delivery Run ownership agrees; outstanding legacy Deliveries are fenced.
|
||||
- Prove current retries in the adopted Run remain current across repeated
|
||||
migrations.
|
||||
- Test unique worker/coordinator import from prior hook commitment plus current
|
||||
live hook, and every forged, stale, wrong-host, wrong-pane, wrong-process,
|
||||
duplicate-token, duplicate-role, equivalent-pane, and remote-attachment
|
||||
ambiguity.
|
||||
- Race a current-runtime hook before adoption and prove the immutable startup
|
||||
commitment is neither overwritten nor confused with live proof.
|
||||
- Cover manual coordinators with no `coordinator_runs` row and reject
|
||||
conflicting candidates from Task, Dispatch, and message provenance.
|
||||
- Assert all legacy authority failures occur before message/receipt/gate/state
|
||||
effects while current invalid-capability rejection remains unchanged.
|
||||
- Assert tokens are absent from RPC logs, errors, CLI output, and orchestration
|
||||
storage.
|
||||
|
||||
### Exact old behavior and replay
|
||||
|
||||
- Replay the pinned prompt's heartbeat, successful and `Failed:`
|
||||
`worker_done`, task-only escalation, ask, and consuming check.
|
||||
- Replay the old formatter's reply command separately.
|
||||
- Cover every operation once after B and once accepted by A immediately before
|
||||
the cutover where a durable semantic identity exists, including the bounded
|
||||
recovery replay for an A check and preservation of A's persisted settlement.
|
||||
- Prove one B invocation reuses its invocation ID across reconnect, while
|
||||
separate identical heartbeat/escalation invocations remain distinct.
|
||||
- Inject real transport cuts after check read, ask commit, reply commit, and
|
||||
completion commit; prove stable IDs and one receipt/effect.
|
||||
- Page a recovery cohort larger than one response limit across repeated
|
||||
disconnects and prove every eligible row appears until ack, then never again.
|
||||
- Test mixed legacy/current attempts for every send/check/ask/reply route and
|
||||
prove current Delivery cannot consume legacy direct mail and old coordinator
|
||||
check only advertises the exact non-consuming current Delivery command.
|
||||
|
||||
### Live two-launch E2E
|
||||
|
||||
The focused renderer/daemon restart regression reconstructs the rc.3-era
|
||||
orphan artifact in a shared candidate profile: a durable legacy Dispatch,
|
||||
sleeping provider record, and live daemon PTY with its visual binding removed.
|
||||
It proves startup restores the exact PID, PTY/incarnation, handle, tab/leaf,
|
||||
Task, and Dispatch; emits no provider-resume spawn or interruption; preserves
|
||||
coordinator focus; and remains inert after switching away and back. This
|
||||
candidate/candidate fixture covers the renderer regression deterministically
|
||||
but does not replace the distinct installed-A/candidate-B packaged cutover
|
||||
journey below. It does not claim packaged Windows updater behavior.
|
||||
|
||||
Use distinct installed A and B artifacts plus an external append-only fake
|
||||
agent spawn ledger:
|
||||
|
||||
1. launch A with coordinator and worker terminals and record the replaceable A
|
||||
desktop/main PID, runtime ID, version, and artifact hash separately from the
|
||||
durable daemon, coordinator, and worker identities;
|
||||
2. accept one command with durable semantic identity on A—pending ask, reply,
|
||||
completion, or check—cut its response, close A without stopping the daemon
|
||||
or fake agent, install B, and prove B owns the relaunched runtime;
|
||||
3. assert A's desktop/main process and runtime are gone, B has a different
|
||||
desktop/main PID, runtime ID, version, and artifact hash, while the daemon
|
||||
plus coordinator/worker PID and start time, PTY/session, terminal handle,
|
||||
tab/leaf/pane, worktree, Task, and Dispatch remain byte-identical;
|
||||
4. prove that exact pending ask, reply, completion, or check resumes or
|
||||
deduplicates on B; do not use heartbeat, escalation, or an already-answered
|
||||
A ask as an exactly-once oracle;
|
||||
5. run the remaining exact old lifecycle commands and settle the same
|
||||
Dispatch;
|
||||
6. switch away and back and assert no second spawn/tab and no
|
||||
`Conversation interrupted`; and
|
||||
7. create a current attempt on preserved state and prove current grammar.
|
||||
|
||||
The append-only spawn ledger and an append-only interruption ledger, daemon
|
||||
inventory, runtime terminal inventory, and every viewer DOM independently
|
||||
prove one launch, process, handle, tab, and no `Conversation interrupted`.
|
||||
Transport fault injection cuts the real CLI connection after check output but
|
||||
before ack, and after ask, reply, and completion commit. Re-run the byte-exact A
|
||||
argv under B and assert stable IDs plus exactly one message, gate, reply,
|
||||
settlement, and receipt.
|
||||
|
||||
Retain #11142's local visible-inactive test and extend its oracle beyond DOM
|
||||
counting with the ledgers and host inventory.
|
||||
|
||||
Run separate remote journeys:
|
||||
|
||||
- headed paired server A-to-B with a surviving external client;
|
||||
- headless `orca serve` A-to-B with the same surviving-client oracle;
|
||||
- both prove the remote PID/PTy continuity, both viewer states, zero
|
||||
client-local handles/tabs, and wrong-host evidence with zero effects;
|
||||
- Docker SSH restart/reconnect with a remote proof file binding the process,
|
||||
pane, and token commitment;
|
||||
- physical Windows WSL coverage proving the correct distro and rejecting a
|
||||
wrong-distro token; and
|
||||
- a git-independent folder workspace parameter through the same adoption
|
||||
journey.
|
||||
|
||||
Packaged Windows updater continuity is outside this migration gate. A separate
|
||||
updater change must prove candidate-built A/B install, executable release,
|
||||
resume, and normal uninstall behavior on Windows before making that guarantee.
|
||||
|
||||
## Implementation slices
|
||||
|
||||
1. Schema, atomic Run adoption, per-row contract provenance, and mixed mailbox.
|
||||
2. Hook-commitment import, live principal verifier, host scopes, and redaction.
|
||||
3. Atomic compatibility receipts plus lifecycle send/escalation.
|
||||
4. Check read/ack and cutover recovery replay.
|
||||
5. Ask create/resume, reply conflict semantics, and coordinator binding.
|
||||
6. CLI hidden envelope, SSH/WSL forwarding, Windows resume fallback.
|
||||
7. Authority-aware formatting and the version-matched orchestration guide.
|
||||
8. Two-launch, remote, platform, and #11142 regression coverage.
|
||||
9. Focused unit/type/lint/E2E checks and final adversarial review.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- Updates are never blocked.
|
||||
- Every durable legacy Task is owned by an ordinary visible Run.
|
||||
- Every provably original live legacy Dispatch can finish through its existing
|
||||
prompt without changing process or worktree.
|
||||
- New attempts use the current contract and mailbox in that same Run.
|
||||
- No authority comes from IDs or handles alone.
|
||||
- No adoption step starts, stops, writes, signals, focuses, splits, or injects
|
||||
a terminal. Exact live-PTY recovery may restore one inactive background
|
||||
renderer tab/leaf binding.
|
||||
- B-era disconnects replay or deduplicate instead of losing or duplicating
|
||||
work; A-era repeatable signals and already-answered asks follow the explicit
|
||||
ambiguity recovery rules.
|
||||
- Lost scheduler memory is reported precisely without stranding durable state.
|
||||
- Local/background, remote, SSH, WSL, folder, and Windows guarantees have
|
||||
deterministic proof.
|
||||
- The PR contains this plan, focused implementation, and no unrelated changes.
|
||||
@@ -0,0 +1,238 @@
|
||||
# Workspace Space Scan Resource Bounds
|
||||
|
||||
## Problem
|
||||
|
||||
Resource Manager scans every non-prunable worktree to calculate workspace disk usage. A fleet with
|
||||
hundreds of worktrees can make Orca and the host unresponsive while the scan is active.
|
||||
|
||||
Two independent resource failures are possible:
|
||||
|
||||
- macOS and Linux run recursive `du` processes for several worktrees at once. Competing full-tree
|
||||
metadata walks can saturate local storage and stall unrelated applications.
|
||||
- Windows, unsupported `du` variants, timeouts, and filesystem errors use a portable recursive
|
||||
walker. The old walker allocated a promise and retained result node for every entry without a
|
||||
capacity limit, so a large worktree could exhaust the main or relay process heap.
|
||||
|
||||
The July 27 incident occurred with 298 Orca worktrees. Renderer telemetry remained far below its
|
||||
heap limit, while quitting Orca immediately restored host responsiveness. The installed binary also
|
||||
contained the uncapped portable fallback.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`src/main/workspace-space-analysis.ts` admitted up to three worktree scans per repository while
|
||||
scanning two repositories concurrently. That allowed as many as six local `du` traversals.
|
||||
|
||||
Both the desktop portable walker and `src/relay/workspace-space-scan.ts` recursively called
|
||||
`Promise.all(entries.map(...))`. Filesystem operation limiters capped active `stat` and `readdir`
|
||||
calls, but they did not cap queued promises, retained paths, directory arrays, or aggregate result
|
||||
nodes. A failed `du` scan then repeated the same traversal through this higher-memory path.
|
||||
|
||||
Shared fixed-worker traversal and capacity primitives already exist, but the active desktop and
|
||||
relay scanners did not use them.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep the host responsive while scanning hundreds of local worktrees.
|
||||
- Bound portable traversal memory before admitting filesystem entries.
|
||||
- Preserve size totals, symlink behavior, top-level compaction, partial failures, cancellation, and
|
||||
progress reporting.
|
||||
- Apply equivalent bounds to local, folder-workspace, WSL, and SSH relay paths.
|
||||
- Preserve the current WSL-aware Git worktree listing and abort signals.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Make a 298-worktree scan finish quickly.
|
||||
- Change Resource Manager UI, progress copy, deletion rules, or scan persistence.
|
||||
- Exclude `node_modules`, build output, or other directories from reported size.
|
||||
- Change Git worktree discovery or cleanup behavior.
|
||||
- Introduce polling, caching, or background automatic scans.
|
||||
|
||||
## Design
|
||||
|
||||
### Local disk admission
|
||||
|
||||
Create one scan-wide limiter for local worktree traversal. Repository discovery and remote scans can
|
||||
remain concurrent, but every local worktree must acquire the single local slot before invoking
|
||||
`du` or the portable walker.
|
||||
|
||||
The slot is global to one Resource Manager scan, not per repository. This changes the maximum local
|
||||
full-tree traversal count from six to one. Cancellation rejects queued admissions and stops the
|
||||
active child process through the existing `AbortSignal`.
|
||||
|
||||
### Remote fallback admission
|
||||
|
||||
The desktop-side request-by-request SSH fallback runs its traversal inside the desktop main
|
||||
process, so its budget is charged against Orca's heap rather than the remote host. Repository and
|
||||
worktree concurrency alone would let six of these traversals hold six independent budgets at once.
|
||||
A second scan-wide limiter admits at most two, capping aggregate admission at 2 × 64 MiB instead of
|
||||
6 × 64 MiB. Bulk relay scans stay outside this limiter: their traversal memory lives on the remote
|
||||
host, one hard capacity per request.
|
||||
|
||||
### Fixed-worker portable traversal
|
||||
|
||||
Use `scanWorkspaceSpaceEntryTree` for desktop local fallback, desktop remote-provider fallback, and
|
||||
relay fallback scans. The traversal:
|
||||
|
||||
- owns only the configured number of live entry jobs;
|
||||
- preserves source order and aggregate sizes;
|
||||
- uses iterative directory frames instead of recursive promise fanout;
|
||||
- stops on cancellation and removes its abort listener;
|
||||
- treats unreadable or disappearing entries as partial failures;
|
||||
- does not follow symlink targets.
|
||||
|
||||
Top-level `du` result processing uses `mapWithConcurrency` so unusually wide workspace roots do not
|
||||
allocate one live operation per entry.
|
||||
|
||||
### Capacity admission
|
||||
|
||||
Every portable entry is admitted through `WorkspaceSpaceScanBudget` before retention:
|
||||
|
||||
- maximum entries in any one directory listing: 100,000;
|
||||
- maximum estimated live scan state per worktree traversal: 64 MiB.
|
||||
|
||||
The entry cap is per listing rather than per traversal because only a single directory's width is
|
||||
fixed by directory shape. A traversal-wide entry counter is charged by every worker holding a
|
||||
listing at once, so its verdict scales with the configured concurrency: at 48 workers, 48 × 2,100
|
||||
files (100,848 entries) was rejected while 100 × 1,500 (150,100 entries, 50% more) was admitted.
|
||||
Aggregate live retention stays bounded by the 64 MiB byte cap, which all concurrent listings share.
|
||||
|
||||
The retained-byte estimate includes entry name UTF-16 storage plus conservative per-entry object
|
||||
overhead, and each listing's parent path once. The parent path is charged per listing rather than
|
||||
per entry because a listing's entries all share a single parent-path string; charging it per entry
|
||||
multiplied it by the directory's width, so the 64 MiB cap tracked checkout depth instead of live
|
||||
heap and rejected the layouts above once the worktree path passed ~58 characters. These are
|
||||
admission limits, not post-allocation observations.
|
||||
|
||||
The budget measures what the traversal is holding **right now**, not what it has ever seen. A
|
||||
directory listing is charged when admitted and released once every one of its entries has been
|
||||
dispatched, so the caps bound the widest concurrent frontier rather than total tree size. This
|
||||
distinction decides real workspaces: a cumulative counter charged an ordinary 76,788-entry Orca
|
||||
worktree 61.2 MiB of its 64 MiB cap — 4% headroom, and tipping over purely because a longer branch
|
||||
name lengthens every absolute path. The same worktree peaks between 4 MiB and 8 MiB of live state.
|
||||
|
||||
Neither cap may depend on where a user checks out their worktrees, so the estimate charges each
|
||||
absolute path once per listing rather than once per entry. A live cap depends only on directory
|
||||
shape, so it rejects genuinely pathological layouts (one directory holding six figures of entries)
|
||||
and nothing else.
|
||||
|
||||
Top-level directory enumeration for the `du` path uses the same budget. A capacity error must not
|
||||
fall through into the portable walker, because that would repeat an already rejected traversal.
|
||||
|
||||
### Failure behavior
|
||||
|
||||
- Desktop local scan: return an unavailable worktree row with the capacity error. `classifyError`
|
||||
maps `WorkspaceSpaceScanCapacityError` to `unavailable` rather than `error`, because the workspace
|
||||
is intact and readable, just too large to size safely. The Resource Manager renders that row as
|
||||
"Unavailable" instead of "Failed".
|
||||
- SSH relay scan: reject the bulk scan; the desktop provider converts it to an unavailable row.
|
||||
- Generic `du` failure: use the bounded portable fallback.
|
||||
- Cancellation: propagate the scan-cancelled error rather than converting it to a row failure.
|
||||
- Missing or unreadable entries below the capacity limit: preserve existing partial-result
|
||||
behavior.
|
||||
|
||||
### Platform and workspace parity
|
||||
|
||||
- macOS/Linux: one local `du` at a time; bounded Node fallback.
|
||||
- Windows: one local bounded Node traversal at a time.
|
||||
- WSL: retain `getLocalProjectWorktreeGitOptions` and its selected distro for worktree discovery;
|
||||
UNC/native filesystem traversal uses the same local admission and capacity bounds.
|
||||
- SSH: prefer the bulk relay scan; both the relay and the request-by-request compatibility fallback
|
||||
use the shared capacity model.
|
||||
- Folder workspaces: the synthetic main worktree passes through the same local limiter and scanner.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Resource Manager starts one deduplicated scan and creates an abort controller.
|
||||
2. Repository workers list worktrees with existing host-specific Git options and the scan signal.
|
||||
3. Remote worktrees continue through provider concurrency. Local worktrees wait for the scan-wide
|
||||
local slot.
|
||||
4. POSIX local or relay scans try `du`.
|
||||
5. Top-level entries are admitted against the scan budget and projected with bounded concurrency.
|
||||
6. If `du` fails generically, the fixed-worker portable traversal scans within the same limits.
|
||||
7. Capacity failures become unavailable rows; successful results keep existing compaction and
|
||||
progress behavior.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Only reduce `du` concurrency
|
||||
|
||||
This protects local storage but leaves Windows, SSH compatibility fallback, and failed `du` scans
|
||||
capable of unbounded heap growth.
|
||||
|
||||
### Only add traversal capacity limits
|
||||
|
||||
This prevents OOM but still permits several recursive `du` processes to compete for local storage,
|
||||
which does not address the observed whole-host stall.
|
||||
|
||||
### Remove the portable fallback
|
||||
|
||||
This would fail Windows scans and POSIX environments where `du -d` is unavailable or errors during
|
||||
active filesystem churn.
|
||||
|
||||
### Skip dependency and build directories
|
||||
|
||||
This would make Resource Manager under-report the directories users most often want to reclaim.
|
||||
|
||||
## Measurement and Performance Budget
|
||||
|
||||
The deterministic regression measurements are:
|
||||
|
||||
- peak simultaneous local `du` calls across repositories: exactly one;
|
||||
- peak simultaneous desktop-side SSH fallback traversals across repositories: at most two;
|
||||
- portable traversal live entry jobs: no more than its configured worker count;
|
||||
- portable entries in any one directory listing: at most 100,000;
|
||||
- estimated live portable state: at most 64 MiB per worktree; an ordinary 76,788-entry worktree
|
||||
peaks between 4 MiB and 8 MiB;
|
||||
- progress and final row counts remain unchanged for scans below the limits.
|
||||
|
||||
A follow-up diagnostic span should record strategy (`du` or portable), worktree duration, fallback
|
||||
reason, admitted entry count, estimated retained bytes, and peak local scan concurrency. It must
|
||||
record counts and identifiers rather than raw paths or directory trees.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Desktop integration: force the portable path over its entry budget and assert an unavailable row.
|
||||
- Relay integration: force the portable path over its entry budget and assert a capacity failure on
|
||||
both the Windows/portable and POSIX `du` entry points.
|
||||
- Remote fallback concurrency: hold six SSH fallback traversals across two repositories and assert
|
||||
no more than two run at once.
|
||||
- Concurrency: start local worktrees from two repositories, hold the first `du`, and assert the
|
||||
second does not start until the first completes.
|
||||
- Shared traversal: verify worker peak, source order, exact-limit success, over-limit failure, deep
|
||||
trees, partial failures, and cancellation cleanup.
|
||||
- Existing desktop coverage: local results, symlinks, progress, cancellation, WSL routing, SSH bulk
|
||||
scans, disconnected SSH, `du` timeout fallback, and IPC deduplication.
|
||||
- Run `pnpm run typecheck` and `pnpm run build`.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Connect the shared fixed-worker traversal and capacity budget to desktop and relay scanners.
|
||||
2. Add the scan-wide single local traversal slot.
|
||||
3. Add focused capacity and concurrency regression tests.
|
||||
4. Ship without a migration or feature flag; behavior below the limits is unchanged.
|
||||
5. Monitor capacity failures and scan duration before considering a higher local concurrency.
|
||||
|
||||
## Risks
|
||||
|
||||
- Scanning hundreds of worktrees takes longer because local work is serialized. The feature already
|
||||
streams progress and allows users to leave the page, and host responsiveness takes priority over
|
||||
scan throughput.
|
||||
- A legitimate worktree above the capacity limit is reported unavailable instead of partially
|
||||
sized. Failing closed avoids presenting an incomplete size as safe deletion evidence. With a live
|
||||
cap this now requires a pathological directory rather than merely a large repository.
|
||||
- Because the local slot is acquired inside the per-repository worker pool, a local worktree waiting
|
||||
on the slot still occupies one of the six in-flight scan slots. On fleets mixing local and SSH
|
||||
repositories this can delay remote repositories behind serialized local work, even though remote
|
||||
scans never contend for the local disk.
|
||||
- Remote hosts can still process concurrent worktrees, but each relay request has an independent
|
||||
hard capacity and remote concurrency does not contend with the user's local disk.
|
||||
|
||||
## Validation
|
||||
|
||||
- Focused tests: 36 passed across desktop analysis, IPC, relay, shared traversal, scan budget, and
|
||||
concurrency primitives.
|
||||
- TypeScript: all node, CLI, and web projects passed.
|
||||
- Production build: relay targets, CLI, Electron, renderer, web projection, and macOS native
|
||||
components completed successfully.
|
||||
- No live 298-worktree rescan is required for correctness validation; the concurrency and capacity
|
||||
properties are deterministic tests.
|
||||
@@ -208,6 +208,12 @@ export const electronViteConfig: UserConfig = {
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
|
||||
// can't take down the main process (issue #7547).
|
||||
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked
|
||||
// main thread (macOS 26 AppKit scene-update deadlock) and can record
|
||||
// the stall for the next launch to report.
|
||||
'main-thread-hang-watchdog-entry': resolve(
|
||||
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
|
||||
),
|
||||
// Why: run under ELECTRON_RUN_AS_NODE while the caller blocks on
|
||||
// spawnSync — codex app-server trust grants need a live event loop
|
||||
// but must finish before a Codex pane launch proceeds.
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
results.appendChild(li)
|
||||
document.title = 'probes:' + results.children.length
|
||||
}
|
||||
function markNavigationInvocation(name) {
|
||||
var marker = document.createElement('meta')
|
||||
marker.dataset.navigationProbeInvoked = name
|
||||
marker.content = 'true'
|
||||
document.head.appendChild(marker)
|
||||
}
|
||||
|
||||
// Probe 1: fetch() exfiltration — must be blocked by connect-src 'none'.
|
||||
var cookieValue = 'x'
|
||||
@@ -73,6 +79,7 @@
|
||||
// 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 () {
|
||||
markNavigationInvocation('top-navigation')
|
||||
try {
|
||||
window.top.location.href = 'https://example.com/'
|
||||
setTimeout(function () {
|
||||
@@ -84,6 +91,7 @@
|
||||
})
|
||||
|
||||
document.getElementById('navigate-self').addEventListener('click', function () {
|
||||
markNavigationInvocation('self-navigation')
|
||||
try {
|
||||
window.location.href = 'https://example.com/self-navigation'
|
||||
setTimeout(function () {
|
||||
@@ -95,6 +103,7 @@
|
||||
})
|
||||
|
||||
document.getElementById('navigate-anchor-form').addEventListener('click', function () {
|
||||
markNavigationInvocation('anchor-form-navigation')
|
||||
var anchor = document.createElement('a')
|
||||
anchor.href = 'https://example.com/anchor-navigation'
|
||||
anchor.textContent = 'Navigation probe'
|
||||
@@ -111,6 +120,7 @@
|
||||
})
|
||||
|
||||
document.getElementById('navigate-meta').addEventListener('click', function () {
|
||||
markNavigationInvocation('meta-refresh-navigation')
|
||||
var refresh = document.createElement('meta')
|
||||
refresh.httpEquiv = 'refresh'
|
||||
refresh.content = '0;url=https://example.com/meta-refresh'
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'i18next-cli'
|
||||
|
||||
const output =
|
||||
process.env.ORCA_I18N_EXTRACTION_OUTPUT ?? 'tmp/localization-extraction/{{language}}.json'
|
||||
|
||||
export default defineConfig({
|
||||
locales: ['en'],
|
||||
extract: {
|
||||
input: ['src/**/*.{js,jsx,ts,tsx,mts,cts}'],
|
||||
ignore: [
|
||||
'**/*.test.*',
|
||||
'**/*.spec.*',
|
||||
'**/__tests__/**',
|
||||
'**/__snapshots__/**',
|
||||
'**/assets/**'
|
||||
],
|
||||
output,
|
||||
defaultNS: false,
|
||||
functions: ['t', '*.t', 'translate', 'translateMain'],
|
||||
useTranslationNames: ['useTranslation'],
|
||||
sort: true,
|
||||
disablePlurals: true,
|
||||
removeUnusedKeys: true
|
||||
}
|
||||
})
|
||||
@@ -15,12 +15,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
|
||||
import {
|
||||
displayHostEndpoint,
|
||||
endpointPort,
|
||||
endpointScheme,
|
||||
normalizeHostEndpoint
|
||||
} from '../../../src/transport/host-endpoint'
|
||||
import { displayHostEndpoint } from '../../../src/transport/host-endpoint'
|
||||
import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit'
|
||||
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
|
||||
import type { HostProfile } from '../../../src/transport/types'
|
||||
|
||||
@@ -68,27 +64,24 @@ export default function EditHostScreen() {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const fallbackPort = host ? endpointPort(host.endpoint) : undefined
|
||||
const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws'
|
||||
|
||||
const normalizedEndpoint = useMemo(
|
||||
() => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }),
|
||||
[address, fallbackPort, fallbackScheme]
|
||||
const endpointEdit = useMemo(
|
||||
() => (host ? resolveHostEndpointEdit(host.endpoint, address) : null),
|
||||
[address, host]
|
||||
)
|
||||
|
||||
const nameTrimmed = name.trim()
|
||||
const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name
|
||||
const endpointChanged =
|
||||
host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint
|
||||
const endpointChanged = endpointEdit?.kind === 'changed'
|
||||
const canSave =
|
||||
host != null &&
|
||||
endpointEdit != null &&
|
||||
nameTrimmed.length > 0 &&
|
||||
normalizedEndpoint.ok &&
|
||||
endpointEdit.kind !== 'invalid' &&
|
||||
(nameChanged || endpointChanged) &&
|
||||
!saving
|
||||
|
||||
async function handleSave() {
|
||||
if (!host || !hostId || savingRef.current) {
|
||||
if (!host || !hostId || !endpointEdit || savingRef.current) {
|
||||
return
|
||||
}
|
||||
const nextName = name.trim()
|
||||
@@ -96,14 +89,14 @@ export default function EditHostScreen() {
|
||||
setSaveError('Enter a name.')
|
||||
return
|
||||
}
|
||||
if (!normalizedEndpoint.ok) {
|
||||
setSaveError(normalizedEndpoint.error)
|
||||
if (endpointEdit.kind === 'invalid') {
|
||||
setSaveError(endpointEdit.error)
|
||||
return
|
||||
}
|
||||
|
||||
const willRename = nextName !== host.name
|
||||
const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint
|
||||
if (!willRename && !willUpdateEndpoint) {
|
||||
const nextEndpoint = endpointEdit.kind === 'changed' ? endpointEdit.endpoint : undefined
|
||||
if (!willRename && nextEndpoint === undefined) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
@@ -117,7 +110,7 @@ export default function EditHostScreen() {
|
||||
// other, and a host removed mid-edit throws instead of no-oping.
|
||||
await updateHostNameAndEndpoint(host.id, {
|
||||
...(willRename ? { name: nextName } : {}),
|
||||
...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {})
|
||||
...(nextEndpoint !== undefined ? { endpoint: nextEndpoint } : {})
|
||||
})
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
|
||||
@@ -140,7 +133,7 @@ export default function EditHostScreen() {
|
||||
setSaving(false)
|
||||
router.back()
|
||||
|
||||
if (willUpdateEndpoint) {
|
||||
if (nextEndpoint !== undefined) {
|
||||
// Why: reconnect is a follow-on side effect of a save that already
|
||||
// committed — its failure or a hang must not be reported as a save
|
||||
// failure or block navigating back.
|
||||
@@ -247,12 +240,12 @@ export default function EditHostScreen() {
|
||||
(or 6768).
|
||||
</Text>
|
||||
|
||||
{normalizedEndpoint.ok ? (
|
||||
{endpointEdit == null ? null : endpointEdit.kind !== 'invalid' ? (
|
||||
<Text style={styles.preview} numberOfLines={2}>
|
||||
Connects to {normalizedEndpoint.endpoint}
|
||||
Connects to {endpointEdit.endpoint}
|
||||
</Text>
|
||||
) : address.trim().length > 0 ? (
|
||||
<Text style={styles.previewError}>{normalizedEndpoint.error}</Text>
|
||||
<Text style={styles.previewError}>{endpointEdit.error}</Text>
|
||||
) : null}
|
||||
|
||||
{saveError ? <Text style={styles.errorText}>{saveError}</Text> : null}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Animated, AppState, Linking, type AppStateStatus } from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {
|
||||
Animated,
|
||||
AppState,
|
||||
Linking,
|
||||
type AppStateStatus,
|
||||
BackHandler,
|
||||
FlatList,
|
||||
Image,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItem
|
||||
} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user