chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:05:33 +08:00
commit e25d789156
8165 changed files with 2004905 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
/config/scripts/create-draft-release.mjs text eol=lf
/config/scripts/orca-dev.mjs text eol=lf
/config/scripts/latest-stable-release.mjs text eol=lf
/config/scripts/publish-complete-draft-releases.mjs text eol=lf
/config/scripts/release-rc-history.mjs text eol=lf
/config/scripts/run-internal-dev-setup.mjs text eol=lf
/config/scripts/verify-cli-bin.mjs text eol=lf
/config/scripts/verify-release-required-assets.mjs text eol=lf
+134
View File
@@ -0,0 +1,134 @@
# Contributing to Orca
Thanks for contributing to Orca.
## Before You Start
- Keep changes scoped to a clear user-facing improvement, bug fix, or refactor.
- Orca targets macOS, Linux, and Windows. Every change must stay compatible with all three platforms unless the code is explicitly guarded by a runtime platform check.
- For keyboard shortcuts, use runtime platform checks in renderer code and `CmdOrCtrl` in Electron menu accelerators.
- For shortcut labels, show `⌘` and `⇧` on macOS, and `Ctrl+` and `Shift+` on Linux and Windows.
- For file paths, use Node or Electron path utilities such as `path.join`.
- Orca must work against local repositories, remote servers, and SSH worktrees. Do not assume a process, file, credential, shell, or network path exists only on the local machine.
- Orca supports many CLI agents, integrations, and git providers. Keep generic behavior provider-neutral; guard integration-specific logic behind explicit checks.
- Keep changes well-engineered and performant: follow existing architecture, avoid unnecessary work in hot paths, clean up owned resources, and use concrete module names.
- For UI work, follow [`docs/STYLEGUIDE.md`](../docs/STYLEGUIDE.md), use the tokens and shadcn primitives it specifies, and verify polished behavior across platforms, light/dark mode, and SSH latency.
## Local Setup
```bash
pnpm install
pnpm dev
```
## Branch Naming
Use a clear, descriptive branch name that reflects the change.
Good examples:
- `fix/ctrl-backspace-delete-word`
- `feat/shift-enter-newline`
- `chore/update-contributor-guide`
Avoid vague names like `test`, `misc`, or `changes`.
## Before Opening a PR
Run the same checks that CI runs:
```bash
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
Add high-quality tests for behavior changes and bug fixes. Prefer tests that would actually catch a regression, not shallow coverage that only exercises the happy path.
If your change affects UI or interaction behavior, verify it on the platforms it could impact.
## Type Declarations: Prefer `.ts` Over `.d.ts`
Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for ambient shims (e.g., `env.d.ts`, `vite/client.d.ts`). TypeScript's `skipLibCheck: true` setting applies globally, including to our own `.d.ts` files, which means any unresolved type reference in a `.d.ts` silently becomes `any` at its call sites. Write your types in `.ts` files so the compiler actually checks them.
CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-typecheck-hole.md`.
## Pull Requests
Each pull request should:
- explain the user-visible change
- stay focused on a single topic when possible
- include screenshots or screen recordings for new UI or behavior changes
- include high-quality tests when behavior changes or bug fixes warrant them
- include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk
- mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes
- **Include your X (Twitter) handle!** We love giving shoutouts to our contributors when we merge features on [@orca_build](https://x.com/orca_build).
If there is no visual change, say that explicitly in the PR description.
## Release Process
Version bumps, tags, and releases are maintainer-managed. Do not include release version changes in a normal contribution unless a maintainer asks for them.
### Cutting a release (maintainers)
All releases are cut from the **Cut Release** GitHub Actions workflow. There is no local `pnpm release:*` script — running releases locally is too easy to get wrong (dirty tree, wrong branch, stale main).
**To cut a release:**
1. Open [Actions → Cut Release](../../actions/workflows/release-cut.yml).
2. Click **Run workflow** and pick:
- **kind**: one of `rc`, `patch`, `minor`, `major`.
- **ref**: the branch, tag, or SHA to build from. Defaults to `main`.
3. Run it.
The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and runs the multi-platform build + publish inline.
**How the next version is chosen:**
All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable_ release, ignoring any RCs in between.
- `kind=rc` + last tag was stable (e.g. `v1.3.14`) → `v1.3.15-rc.0`.
- `kind=rc` + active RC series (e.g. `v1.3.15-rc.2`) → `v1.3.15-rc.3`.
- `kind=patch` + latest stable `v1.3.14``v1.3.15` (regardless of any intermediate RCs).
- `kind=minor` + latest stable `v1.3.14``v1.4.0`.
- `kind=major` + latest stable `v1.3.14``v2.0.0`.
**Safety guarantees:**
- Stable releases are refused if the new version isn't strictly greater than the latest published stable. This is the only rule `electron-updater` actually needs — it compares semver within the `latest` channel, so a regressing stable is the one thing that breaks auto-update for fresh installs.
- Complete RC draft releases created by the release workflow are published before cutting a new tag only when the draft tag was built from the current release ref. Stale drafts are skipped so fixes cut a fresh RC instead of exposing old artifacts.
- If the latest RC tag exists but is still draft-only or missing its GitHub Release, the workflow resumes that tag only when it was built from the current release ref. Otherwise the next RC number is cut.
- RC numbering also considers release commits on `main`, so deleting a stale tag does not let a later cut reuse the same RC number.
- Off-main releases (when `ref` is not the tip of `main`) only push the tag. `main` is never mutated from a non-main ref, so you can safely release an older commit without polluting history.
- When `ref` is the tip of `main`, the version-bump commit is fast-forwarded onto `main` so local `package.json` stays in sync with what's shipped.
**Common scenarios:**
- **Normal release:** `kind=patch`, `ref=main`.
- **"A bad commit just landed on main, release the commit before it":** `kind=patch`, `ref=<good-sha>`. `main` is left alone; the tag points at the good SHA. Fix forward on `main` afterward.
- **One-off RC for a feature branch:** `kind=rc`, `ref=<branch-or-sha>`. Produces an RC tag that does not touch `main`.
- **Minor or major bump:** `kind=minor` or `kind=major`.
The scheduled 2x/day RC cron in [`release-rc.yml`](../../actions/workflows/release-rc.yml) is independent and continues to run automatically from `main`.
## Release Channels
The public Homebrew cask tracks stable desktop releases:
```bash
brew install --cask stablyai/orca/orca
```
Release candidates use a separate cask token:
```bash
brew install --cask stablyai/orca/orca@rc
```
The two casks conflict because both install `Orca.app`. Switch channels with a
normal `brew uninstall --cask` followed by the install for the other channel.
Do not use `--zap` unless you intentionally want to remove local Orca state.
+52
View File
@@ -0,0 +1,52 @@
name: Bug report
description: Report a problem with Orca
title: '[Bug]: '
type: Bug
labels:
- bug
body:
- type: markdown
attributes:
value: |
Use this form for Orca bugs. Include enough detail for someone else to reproduce the issue.
- type: dropdown
id: os
attributes:
label: Operating system
options:
- macOS
- Windows
- Linux
- Other
validations:
required: true
- type: input
id: orca_version
attributes:
label: Orca version
description: Include the exact Orca version if you can. You can find it in `Orca > About Orca`.
placeholder: v0.14.2
validations:
required: false
- type: textarea
id: details
attributes:
label: Details
description: Describe the bug in your own words. Include a short summary, reproduction steps, and anything else that seems relevant.
placeholder: |
Short summary:
Orca crashes when ...
What happened?
How can we reproduce it?
1. ...
2. ...
3. ...
Anything else that might help:
validations:
required: true
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -0,0 +1,37 @@
name: Feature request
description: Suggest an improvement for Orca
title: '[Feature]: '
type: Feature
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Use this form for new features or workflow improvements.
- type: textarea
id: problem
attributes:
label: Problem or use case
description: What are you trying to do, and what is missing today? Include a short summary here too.
placeholder: It is hard to switch between the same few repositories throughout the day.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe the change you want to see
placeholder: Add a pinned repositories section to the home screen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives or additional context
description: Workarounds, related tools, mockups, or anything else that helps
validations:
required: false
+11
View File
@@ -0,0 +1,11 @@
name: Other
description: Report another Orca issue type
title: '[Other]: '
body:
- type: textarea
id: details
attributes:
label: Details
description: Share the context, request, or issue. Include a short summary here too.
validations:
required: true
+29
View File
@@ -0,0 +1,29 @@
## Summary
Describe the user-visible change.
## Screenshots
- Add screenshots or a screen recording for any new or changed UI behavior.
- If there is no visual change, say `No visual change`.
## Testing
- [ ] `pnpm lint`
- [ ] `pnpm typecheck`
- [ ] `pnpm test`
- [ ] `pnpm build`
- [ ] Added or updated high-quality tests that would catch regressions, or explained why tests were not needed
## AI Review Report
Summarize the code review you ran with your AI coding agent. Include the main risks it checked, what it flagged, and what you changed or verified as a result.
Confirm that the review explicitly checked cross-platform compatibility for macOS, Linux, and Windows, including shortcuts, labels, paths, shell behavior, and any Electron-specific platform differences touched by this PR.
## Security Audit
Provide a basic security audit summary from your AI coding agent. Call out any input handling, command execution, path handling, auth, secrets, dependency, or IPC risks that were reviewed, plus any follow-up needed.
## Notes
Call out any platform-specific behavior, risks, or follow-up work.
@@ -0,0 +1,112 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
const repository = process.env.GITHUB_REPOSITORY ?? 'stablyai/orca'
const token = process.env.GITHUB_TOKEN
const outputPath = process.env.DOWNLOADS_BADGE_PATH ?? 'docs/assets/readme-downloads.svg'
const headers = {
Accept: 'application/vnd.github+json',
'User-Agent': 'orca-readme-downloads-badge',
'X-GitHub-Api-Version': '2022-11-28'
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
async function fetchJson(url) {
const response = await fetch(url, { headers })
if (!response.ok) {
throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}`)
}
return response.json()
}
async function getTotalReleaseDownloads() {
let page = 1
let total = 0
while (true) {
const releases = await fetchJson(
`https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`
)
if (releases.length === 0) {
return total
}
for (const release of releases) {
if (release.draft) {
continue
}
for (const asset of release.assets ?? []) {
total += asset.download_count ?? 0
}
}
page += 1
}
}
function formatDownloads(total) {
if (total < 1000) {
return String(total)
}
if (total < 1_000_000) {
return `${Math.round(total / 1000)}k`
}
const rounded = total / 1_000_000
return `${rounded >= 10 ? Math.round(rounded) : rounded.toFixed(1)}m`
}
function textWidth(label) {
return Math.ceil(label.length * 7.1 + 10)
}
function escapeXml(value) {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
function renderBadge(value) {
const label = 'downloads'
const leftWidth = textWidth(label)
const rightWidth = textWidth(value)
const width = leftWidth + rightWidth
const labelX = leftWidth / 2
const valueX = leftWidth + rightWidth / 2
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${escapeXml(label)}: ${escapeXml(value)}">
<title>${escapeXml(label)}: ${escapeXml(value)}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="${width}" height="20" rx="3"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="${leftWidth}" height="20" fill="#555"/>
<rect x="${leftWidth}" width="${rightWidth}" height="20" fill="#4c1"/>
<rect width="${width}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="${labelX}" y="15" fill="#010101" fill-opacity=".3">${escapeXml(label)}</text>
<text x="${labelX}" y="14">${escapeXml(label)}</text>
<text x="${valueX}" y="15" fill="#010101" fill-opacity=".3">${escapeXml(value)}</text>
<text x="${valueX}" y="14">${escapeXml(value)}</text>
</g>
</svg>
`
}
const total = await getTotalReleaseDownloads()
const badge = renderBadge(formatDownloads(total))
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, badge)
console.log(`Rendered ${outputPath} from ${total} downloads.`)
+197
View File
@@ -0,0 +1,197 @@
name: Computer-use e2e
on:
pull_request:
paths:
- '.github/workflows/computer-e2e.yml'
- 'config/electron-builder.config.cjs'
- 'config/scripts/build-computer-macos.mjs'
- 'config/scripts/computer-e2e-workflow.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
- 'config/scripts/computer-use-smoke.test.mjs'
- 'config/scripts/daemon-boot-smoke.mjs'
- 'config/scripts/windows-daemon-workspace-close-repro.mjs'
- 'config/scripts/verify-computer-native.mjs'
# Why: the native-smoke job boots the built terminal daemon under plain
# Node, so any change to the daemon bundle graph or the main build must
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
- 'electron.vite.config.ts'
- 'build-plugins/**'
- 'src/main/daemon/**'
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
- 'native/computer-use-windows/**'
- 'skills/computer-use/SKILL.md'
- 'src/cli/**'
- 'src/main/computer/**'
- 'src/main/runtime/rpc/dispatcher.ts'
- 'src/main/runtime/rpc/errors.ts'
- 'src/main/runtime/rpc/methods/computer*.ts'
- 'src/shared/computer-use-*.ts'
- 'tests/e2e/computer-linux.e2e.ts'
- 'tests/e2e/computer-mac.e2e.ts'
- 'tests/e2e/computer-mac-safari.e2e.ts'
- 'tests/e2e/computer-windows.e2e.ts'
- 'tests/e2e/computer-windows-store.e2e.ts'
- 'tests/e2e/helpers/computer-cli-driver.ts'
- 'tests/e2e/helpers/computer-driver.ts'
- 'tests/e2e/vitest.config.ts'
workflow_dispatch:
schedule:
- cron: '0 6 * * *'
jobs:
native-smoke:
if: github.event_name == 'pull_request'
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
# Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
# permission on Linux runners; node-pty's install fallback then fails
# before this smoke job can exercise the native package.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
- run: >-
pnpm vitest run
config/scripts/build-windows-cli-launcher.test.mjs
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
src/main/computer/computer-provider-lifecycle.test.ts
src/main/computer/computer-provider-unavailable-message.test.ts
src/main/computer/sidecar-client.test.ts
src/main/computer/macos-native-provider-client.test.ts
src/main/computer/macos-native-provider-socket.test.ts
src/main/computer/macos-computer-use-permissions.test.ts
src/main/computer/macos-computer-use-permission-status.test.ts
src/main/computer/desktop-script-provider-client.test.ts
src/main/computer/desktop-script-provider-cache.test.ts
src/main/computer/desktop-script-provider-actions.test.ts
src/main/computer/desktop-script-provider-cache-lifecycle.test.ts
src/main/computer/desktop-script-provider-errors.test.ts
src/main/computer/desktop-script-provider-action-errors.test.ts
src/shared/computer-use-error-recovery.test.ts
src/shared/computer-use-key-spec.test.ts
src/cli/format.test.ts
src/cli/handlers/computer.test.ts
src/cli/handlers/computer-action-routing.test.ts
src/cli/handlers/computer-action-validation.test.ts
src/cli/handlers/computer-state-formatting.test.ts
src/cli/specs/computer.test.ts
src/cli/index.test.ts
src/main/runtime/rpc/dispatcher-computer-errors.test.ts
src/main/runtime/rpc/errors.test.ts
src/main/runtime/rpc/methods/computer.test.ts
src/main/runtime/rpc/methods/computer-actions.test.ts
src/cli/runtime/envelope-schema.test.ts
src/shared/remote-runtime-client.test.ts
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
# Why: boot the BUILT daemon-entry under plain Node the way production
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
# (leaked electron require) while every other check passed; this fails
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
- name: Daemon boot smoke
run: node config/scripts/daemon-boot-smoke.mjs
# Why: workspace removal overlaps graceful renderer teardown with the
# forced main-process sweep; exercise that exact pair against real ConPTY.
- name: Windows daemon workspace-close repro
if: runner.os == 'Windows'
run: node config/scripts/windows-daemon-workspace-close-repro.mjs
- if: runner.os == 'Linux'
env:
ORCA_COMPUTER_E2E: '1'
ACCESSIBILITY_ENABLED: '1'
run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
- if: runner.os == 'Windows'
env:
ORCA_COMPUTER_E2E: '1'
run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts
mac:
# macOS Accessibility and Screen Recording require user-granted TCC entries.
# Keep this on manual/scheduled permission-bearing runners instead of PR CI.
if: github.event_name != 'pull_request'
runs-on: macos-15
env:
ORCA_COMPUTER_E2E: '1'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
- run: pnpm build:computer-macos
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts
linux:
if: github.event_name != 'pull_request'
runs-on: ubuntu-22.04
env:
ORCA_COMPUTER_E2E: '1'
ACCESSIBILITY_ENABLED: '1'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool
# Why: keep scheduled Linux e2e on the same native install path as PR
# smoke and pr.yml's verify job.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
windows:
if: github.event_name != 'pull_request'
runs-on: windows-latest
env:
ORCA_COMPUTER_E2E: '1'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts
@@ -0,0 +1,97 @@
name: Daemon Relocation Spike
# Why: Phase 1 of the Windows update-survival work relocates the terminal
# daemon's file closure out of the install dir so it survives an update. Before
# implementing it in the app, this spike finds the MINIMAL set of packaged files
# that a copied Orca.exe (run as node) needs to start the daemon and drive a
# ConPTY session from a relocated directory, holding no install-dir file locks.
# Runs from the feature branch via push (workflow_dispatch only works on the
# default branch); main is never touched.
on:
push:
branches:
- Jinwoo-H/windows-update-survival
paths:
- 'tools/daemon-relocation-spike/**'
- '.github/workflows/daemon-relocation-spike.yml'
workflow_dispatch: {}
permissions:
contents: read
concurrency:
group: daemon-relocation-spike-${{ github.ref }}
cancel-in-progress: true
jobs:
spike:
name: relocation file-set spike
runs-on: windows-2022
timeout-minutes: 40
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only reads the repo to build/run the spike; it never pushes.
persist-credentials: false
- 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: Install dependencies
run: pnpm install --frozen-lockfile
# Why: cache the unpacked build keyed on the inputs that affect it, so
# spike-script-only edits skip the ~15 min electron-builder build.
- name: Cache unpacked build
id: cache-unpacked
uses: actions/cache@v4
with:
path: dist/win-unpacked
key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
- name: Build unpacked app
if: steps.cache-unpacked.outputs.cache-hit != 'true'
run: pnpm run build:unpack
- name: Run relocation spike (all tiers)
shell: pwsh
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$log = "artifacts/spike-output.log"
$tiers = @('full', 'no-gpu', 'minimal')
$any = $false
foreach ($tier in $tiers) {
"===== TIER: $tier =====" | Tee-Object -FilePath $log -Append
$work = Join-Path $env:RUNNER_TEMP "spike-$tier"
# --keep-work-dir so the per-tier daemon stdout/stderr logs survive
# for the artifact upload (the spike otherwise removes work-dir),
# which is what diagnoses a tier that fails to reach ready.
node tools/daemon-relocation-spike/spike.mjs `
--app-dir dist/win-unpacked `
--work-dir "$work" `
--tier $tier `
--keep-work-dir 2>&1 | Tee-Object -FilePath $log -Append
if ($LASTEXITCODE -eq 0) { $any = $true }
}
if (-not $any) { throw "No tier passed the relocation spike" }
- name: Upload spike output
if: always()
uses: actions/upload-artifact@v7
with:
name: daemon-relocation-spike-output
path: |
artifacts/spike-output.log
${{ runner.temp }}/spike-*/**/*.log
retention-days: 7
if-no-files-found: warn
+175
View File
@@ -0,0 +1,175 @@
name: E2E
on:
workflow_call:
inputs:
ref:
description: Ref to check out (defaults to the calling workflow's ref)
required: false
type: string
workflow_dispatch:
inputs:
ref:
description: Ref to check out (defaults to the workflow ref)
required: false
type: string
schedule:
# Why: GitHub cron uses UTC; these slots map to 10am and 3pm
# America/Phoenix for the default-branch E2E run.
- cron: '0 17,22 * * *'
jobs:
build:
name: build e2e app
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
# Why: the E2E build compiles native modules via node-gyp. Mirrors the
# install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
# errors.
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: building once avoids five parallel electron-vite builds inside
# Playwright globalSetup, which otherwise contends for CPU/RAM on OSS
# runners before the sharded tests even start.
- name: Build Electron app for E2E
run: npx electron-vite build --mode e2e
- name: Upload E2E build output
uses: actions/upload-artifact@v7
with:
name: e2e-build-out
path: out/
retention-days: 1
if-no-files-found: error
e2e:
name: e2e ${{ matrix.shard_name }}
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- shard: '1/10'
shard_name: 1-of-10
- shard: '2/10'
shard_name: 2-of-10
- shard: '3/10'
shard_name: 3-of-10
- shard: '4/10'
shard_name: 4-of-10
- shard: '5/10'
shard_name: 5-of-10
- shard: '6/10'
shard_name: 6-of-10
- shard: '7/10'
shard_name: 7-of-10
- shard: '8/10'
shard_name: 8-of-10
- shard: '9/10'
shard_name: 9-of-10
- shard: '10/10'
shard_name: 10-of-10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
# Why: pnpm install rebuilds native modules, and those postinstall
# scripts still need the Linux toolchain even though this shard reuses
# the prebuilt Electron output.
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
# Why: Electron on Linux needs an X display even when the app
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
# virtual framebuffer so Chromium can initialize without a real display.
- name: Install xvfb
run: sudo apt-get install -y 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
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
# release.yml so the invariant "this workaround is Linux-only" is
# consistent across all three workflows, even though this job
# currently pins runs-on: ubuntu-latest.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download E2E build output
uses: actions/download-artifact@v8
with:
name: e2e-build-out
path: out/
# Why: the Electron suite is wall-clock constrained on OSS runners, but
# multiple Electron apps on one Xvfb VM contend on git/Chromium resources.
# Sharding keeps each VM at one Playwright worker while splitting the
# headless suite across separate runners.
# SKIP_BUILD makes Playwright globalSetup reuse the single build job's
# artifact instead of starting five concurrent electron-vite builds.
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
# launches but never creates a BrowserWindow.
- name: Run E2E tests (${{ matrix.shard_name }})
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e --shard=${{ matrix.shard }}
# Why: Playwright retains traces/screenshots only on failure. Uploading
# them as an artifact makes post-mortem debugging on CI possible without
# re-running locally.
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-traces-${{ matrix.shard_name }}
path: test-results/
retention-days: 7
if-no-files-found: ignore
@@ -0,0 +1,96 @@
name: Golden E2E Experiment
on:
pull_request:
paths:
- '.github/workflows/golden-e2e-experiment.yml'
- 'package.json'
- 'tests/e2e/golden-core-flows.spec.ts'
- 'tests/e2e/fixtures/terminal-emoji-table.md'
- 'tests/e2e/helpers/**'
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
- 'src/renderer/src/lib/pane-manager/**'
workflow_dispatch:
inputs:
ref:
description: Ref to check out (defaults to the workflow ref)
required: false
type: string
jobs:
golden-e2e:
name: golden e2e ${{ matrix.platform }} experiment
runs-on: ${{ matrix.os }}
timeout-minutes: 30
env:
NODE_OPTIONS: --max-old-space-size=4096
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
platform: linux
- os: macos-15
platform: mac
# Why: Windows golden E2E is temporarily disabled on CI while its
# flaky runner-only failures are investigated.
# - os: windows-latest
# platform: windows
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: pull_request merge refs disappear after merge, but experiment
# reruns still need a stable commit to checkout.
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }}
- name: Install native build tools
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y build-essential python3 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
# Why: Linux golden E2E uses the same native install path as PR/release CI,
# which needs pnpm to bypass its non-executable bundled gyp_main.py.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Electron app for E2E
run: npx electron-vite build --mode e2e
- name: Run golden E2E tests on Linux
if: runner.os == 'Linux'
run: |
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
- name: Run golden E2E tests on macOS
if: runner.os == 'macOS'
run: |
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: golden-${{ matrix.platform }}-playwright-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore
+199
View File
@@ -0,0 +1,199 @@
name: Homebrew Cask Bump
# Why: keep the stablyai/homebrew-orca tap in sync with GitHub Releases.
# Triggers:
# - workflow_call → release-cut.yml finished publishing a stable tag
# - workflow_dispatch → manual backfill for a specific tag
#
# The job computes the sha256 of orca-macos-arm64.dmg + orca-macos-x64.dmg
# from the release, templates the channel-specific cask in this repo with the
# new version and hashes, then PRs the result into stablyai/homebrew-orca.
# Stable tags update Casks/orca.rb; RC tags update Casks/orca@rc.rb.
#
# Why no `release.published` trigger: other products shipped from this repo
# (e.g. mobile under `mobile-v*`) also publish GitHub Releases. A blanket
# `release.published` trigger would fire this workflow for any of them and
# attempt to download `orca-macos-*.dmg` from a non-desktop release, at
# best 404ing and at worst (if the filter were weaker) rewriting the cask
# to a non-desktop version. The tap is only ever bumped by the desktop
# release pipeline calling this workflow directly.
on:
workflow_call:
inputs:
tag:
description: Release tag to sync (e.g. v1.3.25)
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: Release tag to sync (e.g. v1.3.25)
required: true
type: string
jobs:
bump-cask:
runs-on: ubuntu-latest
# Why: only desktop tags have the macOS DMGs this workflow downloads.
# A mobile tag such as `mobile-v0.0.1` must never rewrite either cask.
if: >
inputs.tag != '' &&
startsWith(inputs.tag, 'v')
permissions:
contents: read
steps:
- name: Checkout orca
uses: actions/checkout@v6
- name: Resolve tag and version
id: tag
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ -n "$INPUT_TAG" ]]; then
tag="$INPUT_TAG"
else
tag="$RELEASE_TAG"
fi
version="${tag#v}"
echo "tag=$tag" >>"$GITHUB_OUTPUT"
echo "version=$version" >>"$GITHUB_OUTPUT"
- name: Resolve cask target
id: cask
env:
TAG: ${{ steps.tag.outputs.tag }}
VERSION: ${{ steps.tag.outputs.version }}
run: |
set -euo pipefail
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
token="orca@rc"
branch_token="orca-rc"
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
token="orca"
branch_token="orca"
else
echo "::error::Unsupported desktop tag shape: $TAG" >&2
exit 1
fi
path="Casks/${token}.rb"
if [[ ! -f "$path" ]]; then
echo "::error::Missing cask template: $path" >&2
exit 1
fi
echo "token=$token" >>"$GITHUB_OUTPUT"
echo "path=$path" >>"$GITHUB_OUTPUT"
echo "branch=bump/${branch_token}-${VERSION}" >>"$GITHUB_OUTPUT"
echo "title=${token} ${VERSION}" >>"$GITHUB_OUTPUT"
- name: Download DMGs and compute sha256
id: hash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
mkdir -p dmgs
gh release download "$TAG" \
--repo "${{ github.repository }}" \
--pattern 'orca-macos-arm64.dmg' \
--pattern 'orca-macos-x64.dmg' \
--dir dmgs
arm=$(sha256sum dmgs/orca-macos-arm64.dmg | awk '{print $1}')
intel=$(sha256sum dmgs/orca-macos-x64.dmg | awk '{print $1}')
echo "arm=$arm" >>"$GITHUB_OUTPUT"
echo "intel=$intel" >>"$GITHUB_OUTPUT"
- name: Render updated cask file
env:
VERSION: ${{ steps.tag.outputs.version }}
ARM_SHA: ${{ steps.hash.outputs.arm }}
INTEL_SHA: ${{ steps.hash.outputs.intel }}
CASK_PATH: ${{ steps.cask.outputs.path }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, re, pathlib
p = pathlib.Path(os.environ["CASK_PATH"])
src = p.read_text()
src = re.sub(r'version "[^"]+"',
f'version "{os.environ["VERSION"]}"', src, count=1)
src = re.sub(r'sha256 arm:\s*"[0-9a-f]+",\s*\n\s*intel:\s*"[0-9a-f]+"',
'sha256 arm: "{arm}",\n intel: "{intel}"'.format(
arm=os.environ["ARM_SHA"],
intel=os.environ["INTEL_SHA"]),
src, count=1)
p.write_text(src)
print(src)
PY
# Why: reuse the existing buf0-bot GitHub App (also used by
# track-community-prs.yaml) instead of minting a new PAT. The app is
# installed org-wide, so it has access to stablyai/homebrew-orca
# automatically. GITHUB_TOKEN cannot push cross-repo; a short-lived
# installation token can.
- name: Generate buf0-bot token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: 2590194
private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }}
owner: stablyai
repositories: homebrew-orca
- name: Checkout tap
uses: actions/checkout@v6
with:
repository: stablyai/homebrew-orca
path: tap
token: ${{ steps.app-token.outputs.token }}
- name: Copy cask into tap and open PR
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
VERSION: ${{ steps.tag.outputs.version }}
TAG: ${{ steps.tag.outputs.tag }}
CASK_PATH: ${{ steps.cask.outputs.path }}
BRANCH: ${{ steps.cask.outputs.branch }}
TITLE: ${{ steps.cask.outputs.title }}
run: |
set -euo pipefail
mkdir -p "tap/$(dirname "$CASK_PATH")"
cp "$CASK_PATH" "tap/$CASK_PATH"
cd tap
# Why: commit author must match the buf0-bot app so commits are
# attributed to the bot in the tap's history / PR UI.
git config user.name "buf0-bot[bot]"
git config user.email "252831055+buf0-bot[bot]@users.noreply.github.com"
if git diff --quiet; then
echo "Cask already up-to-date for $TAG; nothing to do."
exit 0
fi
branch="$BRANCH"
git checkout -b "$branch"
git add "$CASK_PATH"
git commit -m "$TITLE"
git push -u origin "$branch" --force
# Why: repeated backfills can race an existing PR branch, so edit
# the PR title if creation reports that one already exists.
gh pr create \
--title "$TITLE" \
--body "Automated bump of $CASK_PATH from stablyai/orca release $TAG." \
--base main \
--head "$branch" \
|| gh pr edit "$branch" --title "$TITLE"
# Why: squash-merge immediately rather than --auto. The tap has no
# required checks or branch protection, and `gh pr merge --auto`
# only activates when there's something to wait on — on a repo
# with no gates it silently no-ops, leaving the PR open forever.
gh pr merge "$branch" --squash --delete-branch
+61
View File
@@ -0,0 +1,61 @@
name: Label Issues by OS
on:
issues:
types:
- opened
- edited
- reopened
permissions:
issues: write
jobs:
apply-os-label:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Apply OS label from issue form
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const issue = context.payload.issue;
const body = issue.body || "";
const osMatch = body.match(/^### Operating system\s+(.+)$/m);
if (!osMatch) {
core.info("No operating system field found. Skipping.");
return;
}
const selectedOs = osMatch[1].trim().toLowerCase();
const osLabelMap = {
macos: "os:macos",
windows: "os:Windows",
linux: "os:linux",
};
const desiredLabel = osLabelMap[selectedOs];
if (!desiredLabel) {
core.info(`No OS label configured for "${selectedOs}". Skipping.`);
return;
}
const managedLabels = Object.values(osLabelMap);
const existingLabels = issue.labels.map((label) =>
typeof label === "string" ? label : label.name,
);
const labelsToKeep = existingLabels.filter(
(label) => !managedLabels.includes(label),
);
const nextLabels = [...new Set([...labelsToKeep, desiredLabel])];
await github.rest.issues.setLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: nextLabels,
});
core.info(`Applied label ${desiredLabel} to issue #${issue.number}.`);
@@ -0,0 +1,138 @@
name: Linux Wayland GPU Sandbox
on:
pull_request:
paths:
- .github/workflows/linux-wayland-gpu-sandbox.yml
- config/scripts/linux-wayland-renderer-diagnostics.mjs
- config/scripts/linux-wayland-terminal-exercise.mjs
- config/scripts/linux-wayland-validation-watchdog.mjs
- config/scripts/verify-linux-wayland-gpu-sandbox.mjs
- src/main/startup/configure-process.ts
- src/main/startup/configure-process.test.ts
- src/preload/api-types.ts
- src/preload/index.ts
- src/renderer/src/components/terminal-pane/pty-connection.ts
- src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts
- src/renderer/src/web/web-preload-api.ts
workflow_dispatch:
jobs:
verify:
name: wayland terminal input
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Install native build tools and Wayland compositor
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh weston
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: mirrors pr.yml so native module rebuilds do not use pnpm's
# non-executable bundled gyp_main.py on Linux runners.
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Prepare dependency install
run: |
if [ -e node_modules ]; then
ls -ld node_modules
rm -rf node_modules
fi
- name: Install dependencies
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
# runners while creating the root node_modules. Use the normal resolver
# path, then verify package metadata stayed unchanged.
run: |
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
git diff --exit-code package.json pnpm-lock.yaml
- name: Start Weston
run: |
set -euo pipefail
export XDG_RUNTIME_DIR="$RUNNER_TEMP/xdg-runtime"
mkdir -p "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
unset DISPLAY
weston --backend=headless-backend.so --socket=wayland-1 --idle-time=0 --log="$RUNNER_TEMP/weston.log" &
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" >> "$GITHUB_ENV"
echo "WAYLAND_DISPLAY=wayland-1" >> "$GITHUB_ENV"
echo "XDG_SESSION_TYPE=wayland" >> "$GITHUB_ENV"
echo "ELECTRON_OZONE_PLATFORM_HINT=wayland" >> "$GITHUB_ENV"
echo "ORCA_WAYLAND_GPU_VERBOSE=1" >> "$GITHUB_ENV"
for _ in $(seq 1 50); do
if [ -S "$XDG_RUNTIME_DIR/wayland-1" ]; then
break
fi
sleep 0.2
done
test -S "$XDG_RUNTIME_DIR/wayland-1"
- name: Reproduce terminal input freeze without the workaround
id: reproduce_base
if: github.event_name == 'pull_request'
# Why: still collect fixed-path Wayland evidence when the base repro
# stops reproducing; the final gate below fails the job in that case.
continue-on-error: true
timeout-minutes: 10
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}"
# Why: once the PR base includes this workaround, the partial
# checkout cannot reconstruct an unfixed Wayland negative control.
if git show "${{ github.event.pull_request.base.sha }}:src/main/startup/configure-process.ts" | grep -Eq "appendSwitch\\(['\"]disable-gpu-sandbox['\"]\\)"; then
echo "The base build already contains the Linux Wayland GPU sandbox workaround; skipping unfixed-path reproduction."
exit 0
fi
# Why: keep the new verifier scripts, but run them against the
# unfixed production terminal/GPU path instead of a hybrid checkout.
git checkout "${{ github.event.pull_request.base.sha }}" -- \
src/main/startup/configure-process.ts \
src/preload/api-types.ts \
src/preload/index.ts \
src/renderer/src/components/terminal-pane/pty-connection.ts \
src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts \
src/renderer/src/web/web-preload-api.ts
rm -rf out
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs --mode=expect-repro
- name: Verify terminal input under Wayland GPU sandbox workaround
if: ${{ !cancelled() }}
timeout-minutes: 10
run: |
set -euo pipefail
git checkout --force "$GITHUB_SHA"
rm -rf out
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs
- name: Require base reproduction
if: ${{ github.event_name == 'pull_request' && steps.reproduce_base.outcome != 'success' }}
run: |
echo "The unfixed base build did not reproduce the Wayland terminal input failure."
exit 1
- name: Upload Weston log
if: always()
uses: actions/upload-artifact@v7
with:
name: weston-log
path: ${{ runner.temp }}/weston.log
retention-days: 7
if-no-files-found: ignore
@@ -0,0 +1,114 @@
name: Mobile Android Release
# Why a separate workflow from iOS: iOS releases go through App Store review,
# which can take days. Decoupling the triggers lets an Android release ship
# immediately without waiting on iOS, and vice versa.
on:
push:
tags:
- 'mobile-android-v*'
workflow_dispatch:
inputs:
release_version:
description: 'Optional exact mobile marketing version assertion, e.g. 0.0.22'
required: false
type: string
publish_github_release:
description: 'Create or update the mobile-android-v<version> GitHub Release'
required: false
default: true
type: boolean
jobs:
android-build:
runs-on: ubuntu-latest
# Why: the "Create GitHub Release" step below uses the default GITHUB_TOKEN
# to call `gh release create`, which requires contents:write. Without this,
# the job builds the APK fine but fails at release time with
# "HTTP 403: Resource not accessible by integration".
permissions:
contents: write
defaults:
run:
working-directory: mobile
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Resolve release version and version code
id: release
env:
MOBILE_ANDROID_RELEASE_VERSION: ${{ github.event.inputs.release_version }}
MOBILE_ANDROID_PUBLISH_RELEASE: ${{ github.event.inputs.publish_github_release }}
run: node scripts/prepare-android-release.mjs
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Expo prebuild
run: npx expo prebuild --platform android --no-install
- name: Build Android release APK
run: cd android && ./gradlew assembleRelease
- name: Upload APK artifact
uses: actions/upload-artifact@v7
with:
name: orca-mobile-apk
path: mobile/android/app/build/outputs/apk/release/*.apk
- name: Ensure GitHub release tag
if: steps.release.outputs.publish_release == 'true' && !startsWith(github.ref, 'refs/tags/mobile-android-v')
run: |
set -euo pipefail
tag="${{ steps.release.outputs.tag }}"
if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then
echo "Tag $tag already exists"
exit 0
fi
git tag "$tag" "$GITHUB_SHA"
git push origin "refs/tags/$tag"
- name: Create GitHub Release
if: steps.release.outputs.publish_release == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="${{ steps.release.outputs.tag }}"
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload "$tag" \
--repo "$GITHUB_REPOSITORY" \
--clobber \
android/app/build/outputs/apk/release/*.apk
else
gh release create "$tag" \
--repo "$GITHUB_REPOSITORY" \
--title "Orca Mobile Android $tag" \
--prerelease \
--latest=false \
--generate-notes \
android/app/build/outputs/apk/release/*.apk
fi
+134
View File
@@ -0,0 +1,134 @@
name: Mobile iOS Release
# Why a separate workflow from Android: iOS releases go through App Store
# review, which can take days. Decoupling the triggers lets an Android release
# ship immediately without waiting on iOS, and vice versa.
on:
push:
tags:
- 'mobile-ios-v*'
workflow_dispatch:
inputs:
bump_patch_version:
description: 'Bump the iOS marketing version patch number before release. Tick this after a version has shipped to the App Store (the release fails fast if the current version''s train is already closed).'
required: false
default: false
type: boolean
release_version:
description: 'Optional exact iOS marketing version override, e.g. 0.0.15'
required: false
type: string
testflight_changelog:
description: 'Optional TestFlight external tester changelog'
required: false
type: string
jobs:
ios-build:
# GitHub-hosted macOS runner: required for Xcode. Expo SDK 55's
# expo-modules-core declares swift_version 6.0 and uses Swift 6 syntax
# (@MainActor isolation). Xcode 16.x (macos-15) can't even parse it
# ("unknown attribute 'MainActor'"), so we need Xcode 26.x → macos-26.
runs-on: macos-26
defaults:
run:
working-directory: mobile
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
# Xcode 26.x ships the Swift 6.x toolchain Expo SDK 55 requires.
xcode-version: '26.5'
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup Ruby and fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: mobile
- name: Resolve release version and build number
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
MOBILE_IOS_RELEASE_VERSION: ${{ github.event.inputs.release_version }}
MOBILE_IOS_BUMP_PATCH_VERSION: ${{ github.event.inputs.bump_patch_version }}
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: bundle exec fastlane ios prepare_release_version version:"$MOBILE_IOS_RELEASE_VERSION" bump_patch:"$MOBILE_IOS_BUMP_PATCH_VERSION"
- name: Expo prebuild
run: npx expo prebuild --platform ios --no-install
- name: Install CocoaPods
run: npx pod-install ios
# Why: `-allowProvisioningUpdates` + the App Store Connect API key can
# create/refresh provisioning profiles, but it cannot recreate the
# distribution certificate's PRIVATE KEY across runs. So we import a
# pre-exported distribution .p12 (created once via Apple Developer) into a
# throwaway keychain. The keychain is ephemeral to the runner and torn
# down with the VM; nothing secret is written to the repo.
- name: Import distribution certificate
env:
IOS_DIST_CERT_P12: ${{ secrets.IOS_DIST_CERT_P12 }}
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/orca-signing.keychain-db"
# Random per-run keychain password; never persisted.
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
CERT_PATH="$RUNNER_TEMP/orca-dist-cert.p12"
echo "$IOS_DIST_CERT_P12" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$IOS_DIST_CERT_PASSWORD" \
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
# Allow codesign/xcodebuild to use the key without an interactive prompt.
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
# Put our keychain in the search list so xcodebuild can find the identity.
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
rm -f "$CERT_PATH"
- name: Build and upload to TestFlight
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TESTFLIGHT_CHANGELOG: ${{ github.event.inputs.testflight_changelog }}
# Keep fastlane non-interactive and quiet about analytics in CI.
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: bundle exec fastlane ios release
- name: Upload .ipa artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: orca-mobile-ipa
path: mobile/build/*.ipa
if-no-files-found: ignore
+63
View File
@@ -0,0 +1,63 @@
name: Mobile Checks
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- 'mobile/**'
# Why: the mobile terminal link parsers are conformance-tested against
# these shared fixtures; desktop-side fixture edits must re-run this suite.
- 'src/shared/terminal-file-link-conformance.ts'
jobs:
verify:
runs-on: ubuntu-latest
defaults:
run:
working-directory: mobile
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: the mobile typecheck imports shared types from ../src/shared, and
# some of those files import runtime deps (tweetnacl, ws) resolved from
# the repo-root node_modules. Without a root install, tsc fails with
# "Cannot find module 'tweetnacl'/'ws'". Mobile is a separate pnpm project
# (not in the root workspace), so this is a distinct install.
# --ignore-scripts skips the root postinstall (Electron native-module
# rebuild) which is irrelevant to a type-only check and would only add
# time and failure surface on this ubuntu mobile runner.
- name: Install root dependencies
working-directory: .
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: npx tsc --noEmit
- name: Test
run: pnpm test
- name: Lint
run: pnpm lint
- name: Check formatting
run: pnpm format:check
+154
View File
@@ -0,0 +1,154 @@
name: PR Checks
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: pnpm's bundled node-gyp ships gyp_main.py without execute
# permission, which breaks native module builds (e.g. node-pty's
# postinstall) with "/bin/sh: gyp_main.py: Permission denied".
# Pin the fallback to the lockfile's node-gyp version so CI stays
# reproducible while forcing pnpm to bypass its broken bundled copy.
# Gate on runner.os == 'Linux' to match release.yml — the
# npm-global path layout this step assumes is POSIX-shaped, and the
# failure has only been observed on Linux runners. Today this job
# pins runs-on: ubuntu-latest so the guard is a no-op, but it
# prevents a silent break if a Windows/macOS matrix is added later.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Prepare dependency install
run: |
if [ -e node_modules ]; then
ls -ld node_modules
rm -rf node_modules
fi
- name: Install dependencies
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
# runners while creating the root node_modules. Use the normal resolver
# path, then verify package metadata stayed unchanged.
run: |
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
git diff --exit-code package.json pnpm-lock.yaml
- name: Lint
run: pnpm exec oxlint --format github
- name: Check styled scrollbars
run: pnpm check:styled-scrollbars
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
# ratchet forbids ADDING a new suppression (inline disable or mobile max
# bump). Existing oversized files are grandfathered in
# config/max-lines-baseline.txt, which may only shrink — new bypasses fail
# here with a clear message instead of silently growing the debt.
- name: Enforce max-lines ratchet (no new bypasses)
run: pnpm run check:max-lines-ratchet
# 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)
if [ -n "$matches" ]; then
echo "::error::Project-owned .d.ts files are not allowed under src/preload or src/shared."
echo "Move type declarations into a .ts file so skipLibCheck does not hide errors."
echo "See docs/preload-typecheck-hole.md."
echo "Found:"
echo "$matches"
exit 1
fi
- name: Check feature wall asset budget
run: pnpm check:feature-wall-assets
- name: Verify macOS entitlements
run: pnpm verify:macos-entitlements
- name: Typecheck
run: pnpm typecheck
# Why: real old Git diagnostics differ from mocked errors. Keep the
# fallback predicates executable across the baseline, transition, and
# current command shapes so a newly added flag cannot silently regress.
- name: Verify Git binary compatibility matrix
run: |
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$RUNNER_TEMP/git-2.25.5"
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
for spec in \
"alpine/git:edge-2.38.1|2.38.1" \
"alpine/git:v2.49.1|2.49.1"; do
image="${spec%%|*}"
version="${spec#*|}"
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
done
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
# it for Node so orchestration tests can load the native module.
- name: Rebuild better-sqlite3 for Node
run: pnpm rebuild better-sqlite3
# Why: install intentionally blocks Electron's package postinstall, but
# some unit tests import `electron` under Node and require path.txt.
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Test
run: pnpm test
- name: Build unpacked app
run: pnpm build:unpack
# Why: the packaged CLI runs outside Electron's asar integration, so
# bare runtime imports must be present in the package itself. Run from a
# temp copy so Node cannot mask missing package deps with repo node_modules.
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
+57
View File
@@ -0,0 +1,57 @@
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
name: Pullfrog
run-name: ${{ inputs.name || github.workflow }}
on:
workflow_dispatch:
inputs:
prompt:
type: string
description: Agent prompt
name:
type: string
description: Run name
permissions:
contents: read
jobs:
pullfrog:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run agent
uses: pullfrog/pullfrog@v0
with:
prompt: ${{ inputs.prompt }}
env:
# add at least one provider API key
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_GENERATIVE_AI_API_KEY:
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_REGION: us-east-1
# BEDROCK_MODEL_ID: <bedrock-model-id>
# for Google Vertex AI (https://docs.pullfrog.com/vertex)
# VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
# GOOGLE_CLOUD_PROJECT: my-project
# VERTEX_LOCATION: global
# VERTEX_MODEL_ID: <vertex-model-id>
@@ -0,0 +1,48 @@
name: README Downloads Badge
on:
workflow_dispatch:
schedule:
- cron: '17 */6 * * *'
release:
types:
- published
- edited
- deleted
permissions:
contents: write
concurrency:
group: readme-downloads-badge
cancel-in-progress: false
jobs:
update:
# Why: this workflow commits to main, so forks should not create divergent badge commits.
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
- name: Render downloads badge
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/render-readme-downloads-badge.mjs
- name: Commit badge update
run: |
set -euo pipefail
if git diff --quiet -- docs/assets/readme-downloads.svg; then
echo "Downloads badge is already current."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add docs/assets/readme-downloads.svg
git commit -m "Update README downloads badge"
git push origin main
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
name: Release macOS Build
run-name: Mac release build ${{ inputs.tag }} (${{ inputs.release_run_id }})
on:
workflow_dispatch:
inputs:
tag:
description: Release tag whose draft should receive macOS artifacts
required: true
type: string
release_run_id:
description: release-cut workflow run that requested this build
required: true
type: string
permissions:
contents: write
concurrency:
group: release-mac-build-${{ inputs.tag }}
cancel-in-progress: false
jobs:
build-mac:
if: github.repository == 'stablyai/orca'
# Why: this workflow is outside the SignPath signing run, so Blacksmith
# cannot enter Windows artifact provenance while mac notarization gets the
# faster runner.
runs-on: blacksmith-6vcpu-macos-15
timeout-minutes: 60
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: refs/tags/${{ inputs.tag }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Cache the Electron binary + electron-builder tool downloads (notarytool,
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-mac-
# Why: pnpm install triggers electron's postinstall, which downloads the
# Electron binary from GitHub release assets. GitHub's download CDN
# occasionally returns 504s that fail the whole release. Retry on
# failure so transient network errors don't require a manual re-run.
- name: Install dependencies
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
- name: Verify macOS signing environment
run: node config/scripts/verify-macos-release-env.mjs
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why: `plutil -lint` accepts duplicate plist keys, but `codesign`
# rejects duplicate entitlements after the expensive app build.
- name: Verify macOS entitlements
run: pnpm verify:macos-entitlements
# Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`)
# requires the build identity to be the literal string `stable` or `rc`,
# substituted by electron-vite's `define` block at build time.
- name: Classify release tag for telemetry build identity
id: tag-classify
shell: bash
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
# Why the optional trailing identifier: suffixed side-branch RCs
# (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry
# identity as plain RCs.
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then
identity=rc
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
identity=stable
else
echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact"
exit 1
fi
echo "identity=$identity" >>"$GITHUB_OUTPUT"
echo "Classified $TAG as $identity"
- name: Build app
run: pnpm build:release
env:
# Why: Vite's web build crossed Node's default old-space ceiling on
# the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft.
NODE_OPTIONS: --max-old-space-size=4096
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
- name: Gate runtime file-watcher process isolation
run: |
# Why: #8212 is a native-process crash contract. Prove both the Node
# host and the exact Electron runtime survive SIGSEGV before packaging.
node config/scripts/runtime-file-watcher-fault-harness.mjs
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
- name: Gate SSH relay watcher process isolation
run: |
# Why: the remote native watcher shares a daemon with live PTYs.
# Kill only its child and require both PTY and watch recovery before packaging.
node config/scripts/relay-watcher-fault-harness.mjs
- name: Publish release artifacts (macOS)
uses: nick-fields/retry@v4
with:
timeout_minutes: 45
max_attempts: 3
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Verify release remains draft after artifact upload
# Why: the macOS build must never be the actor that exposes a partial
# release. If an uploader or GitHub transition flips draft early, fail
# this job so release-cut never publishes the release.
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")"
# Why: release upload must validate the draft before it is publicly visible.
draft="$(jq -e -r --arg tag "$TAG" '
map(select(.tag_name == $tag))
| if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end
' <<<"$releases_json")" || {
echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing."
exit 1
}
if [[ "$draft" != "true" ]]; then
echo "::error::Release $TAG was published during the mac artifact upload."
exit 1
fi
# Why post-publish for macOS: electron-builder packs and uploads in a
# single `--publish always` invocation, so there is no cheap insertion
# point between pack and upload without splitting those steps.
- name: Verify telemetry constants present in app.asar
run: node config/scripts/verify-telemetry-constants.mjs
+148
View File
@@ -0,0 +1,148 @@
name: Terminal Perf
on:
workflow_dispatch:
inputs:
ref:
description: Ref to check out (defaults to the workflow ref)
required: false
type: string
grep:
description: Optional Playwright grep for a targeted terminal perf run
required: false
type: string
report_path:
description: Path for the generated terminal perf JSON report
required: false
type: string
frame_count:
description: Number of prompt redraw frames per typing measurement
required: false
type: string
frame_interval_ms:
description: Milliseconds between synthetic OpenCode redraw frames
required: false
type: string
pressure_output_chars:
description: Bytes/chars each ACK-backpressured real PTY should emit
required: false
type: string
scale_panes:
description: Comma-separated same-workspace pane counts
required: false
type: string
scale_cross_workspace_panes:
description: Comma-separated hidden cross-workspace pane counts
required: false
type: string
scale_pressure_panes:
description: Comma-separated active real-PTY pressure pane counts
required: false
type: string
scale_hidden_pressure_panes:
description: Comma-separated hidden real-PTY pressure pane counts
required: false
type: string
schedule:
# Why: keep the expensive scale run off PRs, but exercise it daily so
# terminal throughput regressions are caught before users report typing lag.
- cron: '30 8 * * *'
jobs:
terminal-perf:
name: terminal scale perf
runs-on: ubuntu-latest
timeout-minutes: 45
env:
NODE_OPTIONS: --max-old-space-size=4096
ORCA_E2E_FORWARD_APP_LOGS: '1'
ORCA_E2E_TERMINAL_PERF_REPORT_PATH: ${{ inputs.report_path || 'test-results/terminal-scale-perf-report.json' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build tools and xvfb
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: this scheduled/manual workflow uses the same native install path as
# PR and E2E CI, which needs pnpm to bypass its bundled gyp_main.py.
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Electron app for terminal perf
run: npx electron-vite build --mode e2e
- name: Run terminal scale perf report gate
env:
ORCA_TERMINAL_PERF_GREP: ${{ inputs.grep }}
ORCA_TERMINAL_PERF_FRAME_COUNT: ${{ inputs.frame_count }}
ORCA_TERMINAL_PERF_FRAME_INTERVAL_MS: ${{ inputs.frame_interval_ms }}
ORCA_TERMINAL_PERF_PRESSURE_OUTPUT_CHARS: ${{ inputs.pressure_output_chars }}
ORCA_TERMINAL_PERF_SCALE_PANES: ${{ inputs.scale_panes }}
ORCA_TERMINAL_PERF_SCALE_CROSS_WORKSPACE_PANES: ${{ inputs.scale_cross_workspace_panes }}
ORCA_TERMINAL_PERF_SCALE_PRESSURE_PANES: ${{ inputs.scale_pressure_panes }}
ORCA_TERMINAL_PERF_SCALE_HIDDEN_PRESSURE_PANES: ${{ inputs.scale_hidden_pressure_panes }}
run: |
set -euo pipefail
args=()
if [ -n "${ORCA_TERMINAL_PERF_GREP:-}" ]; then
args+=(--grep "$ORCA_TERMINAL_PERF_GREP")
fi
if [ -n "${ORCA_TERMINAL_PERF_FRAME_COUNT:-}" ]; then
export ORCA_E2E_OPENCODE_FRAME_COUNT="$ORCA_TERMINAL_PERF_FRAME_COUNT"
fi
if [ -n "${ORCA_TERMINAL_PERF_FRAME_INTERVAL_MS:-}" ]; then
export ORCA_E2E_OPENCODE_FRAME_INTERVAL_MS="$ORCA_TERMINAL_PERF_FRAME_INTERVAL_MS"
fi
if [ -n "${ORCA_TERMINAL_PERF_PRESSURE_OUTPUT_CHARS:-}" ]; then
export ORCA_E2E_OPENCODE_PRESSURE_OUTPUT_CHARS="$ORCA_TERMINAL_PERF_PRESSURE_OUTPUT_CHARS"
fi
if [ -n "${ORCA_TERMINAL_PERF_SCALE_PANES:-}" ]; then
export ORCA_E2E_OPENCODE_SCALE_PANES="$ORCA_TERMINAL_PERF_SCALE_PANES"
fi
if [ -n "${ORCA_TERMINAL_PERF_SCALE_CROSS_WORKSPACE_PANES:-}" ]; then
export ORCA_E2E_OPENCODE_SCALE_CROSS_WORKSPACE_PANES="$ORCA_TERMINAL_PERF_SCALE_CROSS_WORKSPACE_PANES"
fi
if [ -n "${ORCA_TERMINAL_PERF_SCALE_PRESSURE_PANES:-}" ]; then
export ORCA_E2E_OPENCODE_SCALE_PRESSURE_PANES="$ORCA_TERMINAL_PERF_SCALE_PRESSURE_PANES"
fi
if [ -n "${ORCA_TERMINAL_PERF_SCALE_HIDDEN_PRESSURE_PANES:-}" ]; then
export ORCA_E2E_OPENCODE_SCALE_HIDDEN_PRESSURE_PANES="$ORCA_TERMINAL_PERF_SCALE_HIDDEN_PRESSURE_PANES"
fi
xvfb-run --auto-servernum env SKIP_BUILD=1 pnpm run test:e2e:terminal-perf:scale:report -- "${args[@]}"
- name: Upload terminal perf report
if: always()
uses: actions/upload-artifact@v7
with:
name: terminal-scale-perf-report
path: ${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}
retention-days: 14
if-no-files-found: warn
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: terminal-scale-perf-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore
+119
View File
@@ -0,0 +1,119 @@
name: Track Community PRs
on:
pull_request_target:
types: [opened, reopened, ready_for_review]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to backfill or retry'
required: true
type: number
permissions:
contents: read
jobs:
track-community-pr:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Generate bufo-bot token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: 2590194
private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }}
owner: stablyai
- name: Add PR to project
uses: actions/github-script@v8
env:
PROJECT_OWNER: stablyai
PROJECT_NUMBER: '13'
INTERNAL_TEAM_SLUG: stably-eng
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const projectOwner = process.env.PROJECT_OWNER;
const projectNumber = Number(process.env.PROJECT_NUMBER);
const internalTeamSlug = process.env.INTERNAL_TEAM_SLUG;
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.eventName === 'workflow_dispatch'
? Number(context.payload.inputs.pr_number)
: context.payload.pull_request.number;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
const author = pr.user.login;
const skippedAuthors = new Set([
'github-actions[bot]',
'dependabot[bot]',
]);
if (skippedAuthors.has(author)) {
core.info(`Skipping bot PR author ${author}.`);
return;
}
let isInternalAuthor = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: projectOwner,
team_slug: internalTeamSlug,
username: author,
});
isInternalAuthor = membership.data.state === 'active';
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
if (isInternalAuthor) {
core.info(`Skipping internal PR author ${author}.`);
return;
}
const projectResult = await github.graphql(
`
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
}
}
}
`,
{ owner: projectOwner, number: projectNumber },
);
const projectId = projectResult.organization.projectV2.id;
await github.graphql(
`
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {
projectId: $projectId,
contentId: $contentId
}) {
item {
id
}
}
}
`,
{
projectId,
contentId: pr.node_id,
},
);
core.info(`Added PR #${pr.number} (${pr.html_url}) to project ${projectOwner}/${projectNumber}.`);
+145
View File
@@ -0,0 +1,145 @@
name: Windows Update-Survival E2E
# Why: proves what happens to terminal sessions across a real Windows app update
# by installing one released version, updating to another, and asserting on the
# packaged artifacts (daemon survival, terminal interactivity, zero console
# flashes). This MUST run on a disposable CI Windows: electron-builder's oneClick
# installer uninstalls the registry-registered copy of the app before every
# install, so running the harness on a machine with a real Orca install would
# delete it. A fresh runner has no Orca registered, so it is the only safe home
# for install-based testing. Manual dispatch only.
# Why: workflow_dispatch only works once a workflow is on the default branch.
# To exercise this from the unmerged feature branch WITHOUT touching main, a
# push trigger scoped to that exact branch runs the workflow from the branch's
# own tree. The paths filter keeps it to harness/workflow edits so ordinary
# commits don't spend 30 min of Windows runner time. Push runs have no inputs,
# so every parameter falls back to a default below.
on:
push:
branches:
- Jinwoo-H/windows-update-survival
paths:
- 'tools/win-update-e2e/**'
- '.github/workflows/win-update-e2e.yml'
workflow_dispatch:
inputs:
from_tag:
description: Release tag to install first (e.g. v1.4.124-rc.8)
required: true
type: string
to_tag:
description: Release tag to update to (e.g. v1.4.124-rc.9)
required: true
type: string
expect:
description: Expected outcome profile
required: true
type: choice
default: cold-restore
options:
- cold-restore
- survival
asset_pattern:
description: Installer asset glob
required: false
type: string
default: '*windows-setup.exe'
soak_seconds:
description: Post-relaunch console-window watch duration
required: false
type: string
default: '60'
permissions:
contents: read
# Why: cancel a superseded run when iterating — a new push to the branch makes
# the in-flight Windows job obsolete, so free the runner immediately.
concurrency:
group: win-update-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
update-e2e:
name: update ${{ inputs.from_tag || 'v1.4.124-rc.8' }} -> ${{ inputs.to_tag || 'v1.4.124-rc.9' }} (${{ inputs.expect || 'cold-restore' }})
runs-on: windows-2022
timeout-minutes: 30
env:
FROM_TAG: ${{ inputs.from_tag || 'v1.4.124-rc.8' }}
TO_TAG: ${{ inputs.to_tag || 'v1.4.124-rc.9' }}
EXPECT: ${{ inputs.expect || 'cold-restore' }}
ASSET_PATTERN: ${{ inputs.asset_pattern || '*windows-setup.exe' }}
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only reads the repo and downloads a release; it never pushes.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: the harness only needs its runtime deps (the Playwright Electron
# driver); it drives already-built installer artifacts, so no app build.
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: fail fast with a readable message if a tag has no matching Windows
# installer, rather than deep inside the harness.
- name: Download installers
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
New-Item -ItemType Directory -Force artifacts/from | Out-Null
New-Item -ItemType Directory -Force artifacts/to | Out-Null
gh release download "$env:FROM_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/from
gh release download "$env:TO_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/to
$from = Get-ChildItem artifacts/from -Filter *.exe | Select-Object -First 1
$to = Get-ChildItem artifacts/to -Filter *.exe | Select-Object -First 1
if (-not $from) { throw "No installer matching '$env:ASSET_PATTERN' in $env:FROM_TAG" }
if (-not $to) { throw "No installer matching '$env:ASSET_PATTERN' in $env:TO_TAG" }
"FROM_EXE=$($from.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"TO_EXE=$($to.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# Why: no --install-dir here. A CI runner has no real Orca registered, so
# the harness installs to the default per-user location and owns it — the
# isolated-install machinery exists only to (imperfectly) protect a real
# developer install, which does not apply on a throwaway runner.
- name: Run update-survival harness
id: harness
shell: pwsh
env:
# Why: on a driving failure the harness dumps a screenshot + DOM state
# here so the actual post-onboarding UI is visible in the artifacts.
ORCA_E2E_DIAG_DIR: artifacts/diag
run: |
$log = "artifacts/harness-output.log"
node tools/win-update-e2e/run.mjs `
--from "$env:FROM_EXE" `
--to "$env:TO_EXE" `
--expect "$env:EXPECT" `
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
- name: Upload harness output
if: always()
uses: actions/upload-artifact@v7
with:
name: win-update-e2e-output
# Why: log + diagnostics only, never the multi-hundred-MB installers.
path: |
artifacts/harness-output.log
artifacts/diag/**
retention-days: 7
if-no-files-found: warn
@@ -0,0 +1,116 @@
name: Windows Update-Survival E2E (branch build)
# Why: proves the Phase 1 daemon-host relocation actually makes terminal
# sessions SURVIVE a Windows update. Builds an (unsigned) installer FROM THIS
# BRANCH, then runs the win-update-e2e harness with --expect survival: install,
# open a terminal, silently update over it, relaunch, and assert the daemon PID
# is unchanged and the pre-update terminal is still interactive with no console
# flashing. Runs from the feature branch via push (workflow_dispatch only works
# on the default branch); main is never touched. A CI runner is the only safe
# place to install/update — see the relocation post-mortem.
on:
push:
branches:
- Jinwoo-H/windows-update-survival
paths:
- 'src/main/daemon/**'
- 'src/main/pty/**'
- 'tools/win-update-e2e/**'
- 'config/electron-builder.config.cjs'
- '.github/workflows/win-update-survival-e2e.yml'
workflow_dispatch:
inputs:
expect:
description: Expected outcome profile
required: true
type: choice
default: survival
options:
- survival
- cold-restore
permissions:
contents: read
concurrency:
group: win-update-survival-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
survival:
name: survival (branch build over itself)
runs-on: windows-2022
timeout-minutes: 50
env:
EXPECT: ${{ inputs.expect || 'survival' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only builds and runs the harness locally; it never pushes.
persist-credentials: false
- 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: Install dependencies
run: pnpm install --frozen-lockfile
# Why: cache the built installer keyed on the inputs that affect it, so a
# harness-only edit skips the ~20 min electron-builder build. The daemon
# relocation code lives under src/, so changing it correctly rebuilds.
- name: Cache branch installer
id: cache-installer
uses: actions/cache@v4
with:
path: dist/orca-windows-setup.exe
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }}
- name: Build Windows installer (unsigned)
if: steps.cache-installer.outputs.cache-hit != 'true'
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
pnpm run build:desktop
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
# Why: install the branch build, open a terminal, update the SAME build
# over it, and assert the relocated daemon survives. Same build on both
# sides isolates the survival mechanism (NSIS kill sweep + userData daemon
# + adoption) from cross-version staleness policy. No --install-dir: a CI
# runner has no real Orca to protect.
- name: Run survival harness
id: harness
shell: pwsh
env:
ORCA_E2E_DIAG_DIR: artifacts/diag
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$exe = "dist/orca-windows-setup.exe"
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
$log = "artifacts/survival-output.log"
node tools/win-update-e2e/run.mjs `
--from "$exe" `
--to "$exe" `
--expect "$env:EXPECT" `
--soak-seconds 60 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
- name: Upload survival output
if: always()
uses: actions/upload-artifact@v7
with:
name: win-update-survival-output
path: |
artifacts/survival-output.log
artifacts/diag/**
retention-days: 7
if-no-files-found: warn
@@ -0,0 +1,360 @@
# Windows inner-binary signing rehearsal.
#
# Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed
# inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires
# a two-request flow: sign the unpacked PE files first, then build the NSIS
# installer from the signed tree, then sign the installer. This workflow
# rehearses that entire flow from a branch, end to end, without publishing
# anything — so the release pipeline on main is never at risk while we verify.
#
# Runs only via manual dispatch. Use the test-signing policy for iteration
# (auto-approved test certificate) and release-signing to rehearse the
# production flow (requires manual SignPath approvals). Rehearsed successfully
# with test-signing (run 28987534795) and release-signing (run 28988432001).
name: Windows signing rehearsal
on:
workflow_dispatch:
inputs:
signing-policy-slug:
description: SignPath signing policy
type: choice
default: test-signing
options:
- test-signing
- release-signing
inner-artifact-configuration-slug:
description: SignPath artifact configuration for the inner-binaries zip
type: string
default: windows-inner-binaries-zip
jobs:
rehearse:
# Why: SignPath origin verification requires GitHub-hosted runners, and
# windows-2022 matches the real release job's build environment.
runs-on: windows-2022
timeout-minutes: 360
permissions:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: nothing here pushes; keep the token out of .git/config.
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-win-
- name: Install dependencies
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: rehearsal builds are never published, so the official-build
# secrets (telemetry key, diagnostics URL) are intentionally omitted.
- name: Build app
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
- name: Package unpacked Windows app
shell: pwsh
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --dir --publish never
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/win-unpacked/Orca.exe')) {
throw 'electron-builder --dir did not produce dist/win-unpacked/Orca.exe'
}
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe, signed vendor DLLs) must
# keep their original signer, so they are excluded from the request.
- name: Stage unsigned PE files for inner signing
id: stage-inner
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
$list = New-Object System.Collections.Generic.List[string]
$skipped = New-Object System.Collections.Generic.List[string]
Get-ChildItem -Path $root -Recurse -File |
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
if ($signature.Status -eq 'Valid') {
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
return
}
$destination = Join-Path $stage.FullName $relative
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
Copy-Item -Path $_.FullName -Destination $destination -Force
$list.Add($relative)
}
if (-not ($list -contains 'Orca.exe')) {
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
}
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
}
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
$list | ForEach-Object { Write-Host " $_" }
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-unsigned-${{ github.run_id }}
path: signing-stage/**
if-no-files-found: error
- name: Install SignPath PowerShell module
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
if ($useResourceGet) {
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSResourceRepository -PSGallery -Trusted
} else {
Set-PSResourceRepository -Name PSGallery -Trusted
}
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
} else {
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
}
Import-Module SignPath -ErrorAction Stop
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
- name: Submit inner binaries signing request
id: submit-inner-signing
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
artifact-configuration-slug: ${{ inputs.inner-artifact-configuration-slug || 'windows-inner-binaries-zip' }}
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
wait-for-completion: false
- name: Download signed inner binaries from SignPath
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-ApiToken $env:SIGNPATH_API_TOKEN `
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-inner.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 3600
New-Item -ItemType Directory -Path signed-inner -Force
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
# Why: copy back strictly by the staged list so a layout mismatch in the
# returned artifact fails loudly instead of silently shipping a mix of
# signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$failures = New-Object System.Collections.Generic.List[string]
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
Select-Object -First 1
if ($null -eq $signed) {
$failures.Add("missing from signed artifact: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
$failures.Add("returned without a signature: $relative")
continue
}
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
}
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
}
- name: Build NSIS installer from signed unpacked app
shell: pwsh
run: |
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
}
- name: Upload unsigned Windows installer for SignPath
id: upload-unsigned-installer
uses: actions/upload-artifact@v7
with:
name: orca-windows-installer-unsigned-${{ github.run_id }}
path: dist/orca-windows-setup.exe
if-no-files-found: error
- name: Submit Windows installer signing request
id: submit-installer-signing
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
artifact-configuration-slug: github-actions-windows-installer
github-artifact-id: ${{ steps.upload-unsigned-installer.outputs.artifact-id }}
wait-for-completion: false
- name: Download signed Windows installer from SignPath
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-installer-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-ApiToken $env:SIGNPATH_API_TOKEN `
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-windows.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 14400
New-Item -ItemType Directory -Path signed-windows -Force
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
# Why: signing changes installer bytes, so the updater metadata must be
# regenerated exactly like the release job's staging step does.
- name: Stage signed Windows installer and regenerate updater metadata
shell: pwsh
run: |
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
if ($null -eq $signedInstaller) {
throw 'Signed Windows installer was not returned by SignPath.'
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$installer = Get-Item 'dist/orca-windows-setup.exe'
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
$stream = [System.IO.File]::OpenRead($installer.FullName)
try {
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hash = [Convert]::ToBase64String($sha512.ComputeHash($stream))
} finally {
if ($null -ne $sha512) { $sha512.Dispose() }
$stream.Dispose()
}
$latestYml = Get-Content -Path 'dist/latest.yml' -Raw
$latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', {
param($match)
"$($match.Groups[1].Value)sha512: $hash"
})
$latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)"
$latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)"
Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline
# Why: this is the pass/fail heart of the rehearsal — the same evidence
# check that will later gate releases (see PR #7170). Test certificates
# do not chain to a trusted root, so Status=Valid is only required for
# release-signing runs; test-signing runs require a present signature.
- name: Verify signatures end to end
shell: pwsh
env:
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }}
run: |
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
$requireValid = $env:SIGNING_POLICY -eq 'release-signing'
function Test-Signature([string]$label, [string]$path) {
$signature = Get-AuthenticodeSignature -FilePath $path
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $label, $subject
$script:report.Add($line)
Write-Host $line
if ($null -eq $signature.SignerCertificate -or $signature.Status -eq 'NotSigned') {
$script:failures.Add("unsigned: $label")
} elseif ($script:requireValid -and $signature.Status -ne 'Valid') {
$script:failures.Add("not Valid under release-signing: $label ($($signature.Status))")
} elseif ($script:requireValid -and $subject -notlike '*CN=SignPath Foundation*') {
$script:failures.Add("unexpected signer: $label ($subject)")
}
}
Test-Signature 'installer: orca-windows-setup.exe' 'dist/orca-windows-setup.exe'
# Extract the signed installer and verify the files a user actually
# gets on disk — including the exact file from issue #7785.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
$root = Resolve-Path 'extracted-app'
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$path = Join-Path $root $relative
if (-not (Test-Path $path)) {
$failures.Add("missing from installer payload: $relative")
continue
}
Test-Signature "installed: $relative" $path
}
Set-Content -Path 'signing-evidence.txt' -Value ($report -join "`n")
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signing rehearsal failed with $($failures.Count) problems."
}
Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries plus the installer are signed."
- name: Upload rehearsal evidence and installer
if: always()
uses: actions/upload-artifact@v7
with:
name: windows-signing-rehearsal-${{ inputs.signing-policy-slug || 'test-signing' }}-${{ github.run_id }}
path: |
signing-evidence.txt
inner-signing-list.txt
dist/orca-windows-setup.exe
dist/orca-windows-setup.exe.blockmap
dist/latest.yml
retention-days: 7
+127
View File
@@ -0,0 +1,127 @@
# Build artifacts
tsconfig.*.tsbuildinfo
# TypeScript emit artifacts next to sources (tsc produced these accidentally;
# real source lives in .ts/.tsx). Hand-authored declaration files are
# re-included below.
src/**/*.js
src/**/*.d.ts
/electron.vite.config.js
/electron.vite.config.d.ts
!src/main/types/hosted-git-info.d.ts
!src/preload/api-types.d.ts
!src/preload/index.d.ts
!src/renderer/src/env.d.ts
!src/renderer/src/mermaid.d.ts
!src/types/build-constants.d.ts
# Dependencies
node_modules/
# Build output
dist/
dist-electron/
out/
/build/
release/
native/**/.build/
# pnpm
.pnpm-store/
package-lock.json
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
.serena/
*.swp
*.swo
*~
# OS
*.stackdump
.DS_Store
Thumbs.db
# Lint/cache
.oxlintcache
# Logs
*.log
*.log.*
npm-debug.log*
pnpm-debug.log*
# Coverage
coverage/
# Prod release scan output (accidental adds)
prod-release-scan-*.md
# Benchmark run output
/tools/benchmarks/results/*.json
/.bench-fixtures/
# Temp
tmp/
.tmp/
design-docs/
.context/
.atl/
# Machine-local agent hook endpoint files may contain auth tokens.
/agent-hooks/
# Local-only design/planning docs (not checked in).
# Durable docs must live in one of the allow-listed locations below.
docs/**
!docs/
!docs/assets/
!docs/assets/**
!docs/readme/
!docs/readme/**
!docs/reference/
!docs/reference/**
# Keep generated React perf scan ledgers local; durable docs still use docs/reference.
docs/reference/react-performance-audit.md
!docs/STYLEGUIDE.md
!docs/mobile-terminal-shortcut-bar.md
# Stably CLI (only docs/ are tracked)
.stably/*
!.stably/docs/
.playwright-cli
.validate-ui-screenshots/
validation-screenshots/
.stably-browser
# PR verification evidence screenshots are referenced from notes but should not
# be committed.
notes/artifacts/
# Playwright
test-results/
playwright-report/
# Agent skill installations (machine-local, populated by agent tooling)
/.claude/skills/
/.agents/skills/
/skills-lock.json
# Agent hook runtime endpoints (machine-local secrets)
/agent-hooks/
validation-screenshots/
# Localization bootstrap cache (regenerated by bootstrap:*-catalog)
src/renderer/src/i18n/locales/.zh-catalog-cache.json
src/renderer/src/i18n/locales/.ko-catalog-cache.json
src/renderer/src/i18n/locales/.ja-catalog-cache.json
src/renderer/src/i18n/locales/.es-catalog-cache.json
# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md
tools/benchmarks/results/terminal-pipeline-*.json
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env sh
pnpm exec lint-staged
+2
View File
@@ -0,0 +1,2 @@
shamefully-hoist=true
minimum-release-age=4320
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schemas/oxfmt/v0.41.0/config.json",
"singleQuote": true,
"semi": false,
"printWidth": 100,
"trailingComma": "none"
}
+103
View File
@@ -0,0 +1,103 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "react", "react-hooks", "react-perf", "unicorn"],
"categories": {
"correctness": "error"
},
"rules": {
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "error",
"react/require-render-return": "error",
"react/jsx-curly-brace-presence": [
"error",
{ "props": "never", "children": "never", "propElementValues": "always" }
],
"react/jsx-filename-extension": ["error", { "extensions": [".tsx", ".jsx"] }],
"react/jsx-fragments": "error",
"react/jsx-key": "error",
"react/jsx-no-constructed-context-values": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-no-useless-fragment": ["error", { "allowExpressions": true }],
"react/jsx-pascal-case": "error",
"react/no-object-type-as-default-prop": "error",
"react/self-closing-comp": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"typescript/array-type": "error",
"typescript/consistent-indexed-object-style": "error",
"typescript/consistent-type-assertions": "error",
"typescript/consistent-type-definitions": ["error", "type"],
"typescript/consistent-type-imports": "error",
"typescript/no-explicit-any": ["error", { "ignoreRestArgs": true }],
"typescript/no-import-type-side-effects": "error",
"typescript/no-unnecessary-boolean-literal-compare": "error",
"typescript/no-unnecessary-template-expression": "error",
"typescript/no-unsafe-function-type": "warn",
"typescript/prefer-function-type": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-optional-chain": "error",
"typescript/switch-exhaustiveness-check": [
"error",
{ "allowDefaultCaseForExhaustiveSwitch": false }
],
"curly": "error",
"no-unneeded-ternary": "error",
"no-useless-return": "error",
"prefer-template": "error",
"unicorn/consistent-empty-array-spread": "error",
"unicorn/error-message": "error",
"unicorn/no-array-fill-with-reference-type": "warn",
"unicorn/no-array-reverse": "error",
"unicorn/no-instanceof-builtins": "error",
"unicorn/no-useless-promise-resolve-reject": "error",
"unicorn/prefer-array-find": "error",
"unicorn/prefer-array-flat-map": "warn",
"unicorn/prefer-array-index-of": "error",
"unicorn/prefer-at": "error",
"unicorn/prefer-date-now": "error",
"unicorn/prefer-includes": "error",
"unicorn/prefer-import-meta-properties": "error",
"unicorn/prefer-math-min-max": "error",
"unicorn/prefer-negative-index": "error",
"unicorn/prefer-node-protocol": "error",
"unicorn/prefer-number-properties": "error",
"unicorn/prefer-object-from-entries": "error",
"unicorn/prefer-regexp-test": "warn",
"unicorn/prefer-ternary": "error",
"unicorn/throw-new-error": "error"
},
"overrides": [
{
"files": ["**/*.ts"],
"rules": {
"max-lines": ["error", { "max": 300, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["**/*.tsx"],
"rules": {
"max-lines": ["error", { "max": 400, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["**/*.mjs"],
"rules": {
"max-lines": ["error", { "max": 600, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"],
"rules": {
"max-lines": ["error", { "max": 800, "skipBlankLines": true, "skipComments": true }]
}
}
],
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
}
+57
View File
@@ -0,0 +1,57 @@
# AGENTS.md
## Design System
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
## Code Comments: Document the "Why", Briefly
When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does.
Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point.
## Lint Rules: Do Not Disable Max Lines
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`.
## File and Module Naming
Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuff` for files, folders, or modules. They carry zero info and tend to become dumping grounds. Name files after what they _actually_ contain — prefer the concrete domain concept (e.g. `tab-group-state.ts`, `terminal-orphan-cleanup.ts`) over the generic role (`tabs-helpers.ts`, `terminal-utils.ts`). If you find yourself reaching for `helpers`, the file probably has more than one responsibility and should be split, or there's a better name hiding in the code that describes what the functions operate on.
## Worktree Safety
Always use the primary working directory (the worktree) for all file reads and edits. Never follow absolute paths from subagent results that point to the main repo.
## Cross-Platform Support
Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior behind runtime checks:
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
## SSH Use Case
All changes must consider the SSH use case. Don't assume local-only execution.
## Git Binary Compatibility
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
When adding or changing a Git command:
- Check when every subcommand and option was introduced. For newer behavior, keep a baseline-compatible fallback or degrade safely.
- Use `GitCapabilityCache` with a narrow unsupported-error predicate so recurring operations do not retry a known-invalid command. Do not rely only on `git --version`; wrappers such as `simple-git` do not remove host-version differences.
- Scope capability state to the host that executes Git: native, WSL distro, SSH provider, or relay connection. Cover the first fallback, later cached calls, concurrent probes, and relevant host isolation in tests.
- Keep the real-binary compatibility contract in PR CI current. When adopting a newer Git feature, add its version boundary so the preferred command and fallback both run against representative Git releases.
- Preserve commands that begin with global Git options such as `-c` before the subcommand, including auto-maintenance suppression used by worktree-create fetches.
## Git Provider Compatibility
Source-control and review changes must consider GitLab and other supported git providers, not only GitHub. Keep provider-specific behavior behind explicit checks, and avoid GitHub-only naming for generic review concepts.
## GitHub CLI Usage
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
## Type Declarations: Prefer `.ts` Over `.d.ts`
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+49
View File
@@ -0,0 +1,49 @@
cask "orca" do
arch arm: "arm64", intel: "x64"
version "1.3.24"
sha256 arm: "fc707f290ff3b631b7b7947bf339885b61a43d2e89475997c125b61268ed4966",
intel: "5f677c13a08f7a5740442e29d388285a86488c8c1f7aa5f10a8721a2c6ede8e4"
url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg",
verified: "github.com/stablyai/orca/"
name "Orca"
desc "IDE for orchestrating AI coding agents across terminals and worktrees"
homepage "https://onorca.dev/"
livecheck do
url :url
strategy :github_latest
end
# Why: electron-updater (src/main/updater.ts) handles in-place updates by
# writing a new Orca.app into /Applications. Marking the cask auto_updates
# tells Homebrew not to compete with the in-app updater — `brew upgrade`
# becomes a no-op unless the user passes --greedy, and brew's version
# metadata stays aligned with whatever the app has swapped itself to.
auto_updates true
conflicts_with cask: "orca@rc"
depends_on macos: :big_sur
app "Orca.app"
# Why: expose the bundled `orca` CLI on PATH at install time (Homebrew symlinks
# this into its already-on-PATH bin dir). Without it, the CLI is only registered
# by the in-app "Install CLI" action, which a headless host can never trigger —
# so `orca serve` on a server would be unreachable from the shell. The shim
# resolves the real app by walking symlinks, so the Homebrew symlink works.
binary "#{appdir}/Orca.app/Contents/Resources/bin/orca"
# Why: Orca writes user data under ~/.orca (worktrees, agent state) and
# Electron's standard userData directories. Zap removes everything the app
# creates during normal use so `brew uninstall --zap` is a clean slate.
zap trash: [
"~/.orca",
"~/Library/Application Support/Orca",
"~/Library/Caches/com.stablyai.orca",
"~/Library/Caches/com.stablyai.orca.ShipIt",
"~/Library/HTTPStorages/com.stablyai.orca",
"~/Library/Preferences/com.stablyai.orca.plist",
"~/Library/Saved Application State/com.stablyai.orca.savedState",
]
end
+57
View File
@@ -0,0 +1,57 @@
cask "orca@rc" do
arch arm: "arm64", intel: "x64"
version "1.4.36-rc.3"
sha256 arm: "563b6b14323fc9d5489299c82442d514bc12cabffc9d06d3964ed572af4b3955",
intel: "457088c7021f07de1a419197f7b2bd00092741ad4727d4fef3d86af38a6831e7"
url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg",
verified: "github.com/stablyai/orca/"
name "Orca RC"
desc "IDE for orchestrating AI coding agents across terminals and worktrees"
homepage "https://onorca.dev/"
livecheck do
url "https://github.com/stablyai/orca"
regex(/^v?(\d+(?:\.\d+)+-rc\.\d+)$/i)
strategy :github_releases do |json, regex|
json.map do |release|
next if release["draft"]
next unless release["prerelease"]
match = release["tag_name"]&.match(regex)
next if match.blank?
match[1]
end
end
end
# Why: RC installs should follow Orca's prerelease-aware updater instead of
# waiting for Homebrew metadata churn between frequent release candidates.
auto_updates true
conflicts_with cask: "orca"
depends_on macos: :big_sur
app "Orca.app"
# Why: expose the bundled `orca` CLI on PATH at install time (Homebrew symlinks
# this into its already-on-PATH bin dir). Without it, the CLI is only registered
# by the in-app "Install CLI" action, which a headless host can never trigger —
# so `orca serve` on a server would be unreachable from the shell. The shim
# resolves the real app by walking symlinks, so the Homebrew symlink works.
binary "#{appdir}/Orca.app/Contents/Resources/bin/orca"
# Why: Orca writes user data under ~/.orca (worktrees, agent state) and
# Electron's standard userData directories. Zap removes everything the app
# creates during normal use so `brew uninstall --zap` is a clean slate.
zap trash: [
"~/.orca",
"~/Library/Application Support/Orca",
"~/Library/Caches/com.stablyai.orca",
"~/Library/Caches/com.stablyai.orca.ShipIt",
"~/Library/HTTPStorages/com.stablyai.orca",
"~/Library/Preferences/com.stablyai.orca.plist",
"~/Library/Saved Application State/com.stablyai.orca.savedState",
]
end
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lovecast Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+266
View File
@@ -0,0 +1,266 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub stars" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" alt="Total downloads across all releases" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Follow Orca on X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
</p>
<p align="center">
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
</p>
<p align="center">
<strong>The AI Orchestrator for 100x builders.</strong><br/>
Run Codex, ClaudeCode, OpenCode or Pi side-by-side — each in its own worktree, tracked in one place.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Download Orca</ins></a></h3>
<p align="center">
<img src="docs/assets/readme-hero.jpg" alt="Orca desktop app running agents in parallel worktrees, with the Orca mobile companion app in the corner" width="960" />
</p>
## Features
<table>
<tr>
<td width="50%" valign="middle">
### Mobile Companion
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="docs/assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="docs/assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop with the mobile companion app" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Parallel Worktrees
Fan one prompt across five agents, each in its own isolated git worktree — compare the results and merge the winner.
[Docs →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="docs/assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/parallel-worktrees.jpg" alt="Parallel worktree orchestration" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Terminal Splits
Ghostty-class terminals with WebGL rendering, infinite splits, and scrollback that survives restarts.
[Docs →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="docs/assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="docs/assets/feature-wall/terminal-splits.jpg" alt="Terminal splits" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Design Mode
Click any UI element in a real Chromium window to send its HTML, CSS, and a cropped screenshot straight into your agent's prompt.
[Docs →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="docs/assets/feature-wall/design-mode.gif" type="image/gif"><img src="docs/assets/feature-wall/design-mode.jpg" alt="Embedded browser and Design Mode" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub &amp; Linear, Native
Browse PRs, issues, and project boards in-app — open a worktree from any task and review without a context switch.
[Docs →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="docs/assets/feature-wall/github-linear.gif" type="image/gif"><img src="docs/assets/feature-wall/github-linear.jpg" alt="GitHub and Linear task workflows in Orca" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### SSH Worktrees
Run agents on a beefy remote box with full file editing, git, and terminals — auto-reconnect and port forwarding included.
[Docs →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="docs/assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/ssh-worktrees.jpg" alt="Remote worktrees over SSH" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Annotate AI Diffs
Drop comments on any diff line and ship them back to the agent — review, edit, and commit without leaving Orca.
[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="docs/assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="docs/assets/feature-wall/annotate-diff.jpg" alt="Annotate AI-generated diffs" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Drag Files to Agents
VS Code's editor with autosave everywhere — drag files or images straight into an agent prompt.
[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="docs/assets/feature-wall/file-drag.gif" type="image/gif"><img src="docs/assets/feature-wall/file-drag.jpg" alt="Drag files and images into an agent prompt" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
Agents drive Orca too — script every workflow with `orca worktree create`, `snapshot`, `click`, and `fill`.
[Docs →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="docs/assets/feature-wall/orca-cli.gif" type="image/gif"><img src="docs/assets/feature-wall/orca-cli.jpg" alt="Script Orca from the CLI" width="100%" /></picture></a>
</td>
</tr>
</table>
**Also in the box:**
- **[Quick open](https://www.onorca.dev/docs/model/quick-open)** — Search across worktrees, files, agents, commands, and repo context without leaving your flow.
- **[Account switcher &amp; usage tracking](https://www.onorca.dev/docs/agents/usage-tracking)** — See Claude and Codex usage and rate-limit resets, and hot-swap accounts without re-logging in.
- **[Rich repo previews](https://www.onorca.dev/docs/editing/markdown)** — Preview Markdown, images, PDFs, and repo docs in the workspace.
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Let agents operate desktop apps and visible UI when a workflow needs real interaction.
- **[Notifications and unread state](https://www.onorca.dev/docs/notifications)** — Know when an agent finishes or needs attention, then mark threads unread to come back later.
- **And many, many more** — we ship daily, so this list is perpetually behind. The [changelog](https://github.com/stablyai/orca/releases) is the real feature list.
---
## Supported Agents
Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Devin logo" width="16" valign="middle" /> Devin</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ any CLI agent</kbd>
</p>
---
## Install
### Desktop — macOS, Windows, Linux
- **[Download from onOrca.dev](https://onorca.dev/download)**
- Or grab a build directly: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest)
- Running `orca serve` on a headless Linux server? See the [headless Linux server guide](docs/reference/headless-linux-server.md).
_Or via a package manager:_
```bash
# macOS (Homebrew)
brew install --cask stablyai/orca/orca
# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
```
### Mobile Companion — iOS, Android
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
## Community &amp; Support
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group.
<img src="docs/assets/wechat-qr.png" alt="WeChat QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr-backup.jpg" alt="Backup WeChat QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
- **Show Support:** [Star](https://github.com/stablyai/orca) this repo to follow along with our daily ships.
---
## Developing
Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTING.md) guide.
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca contributors" />
</a>
<p align="center">
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
</p>
## License
Orca is free and open source under the [MIT License](LICENSE).
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`stablyai/orca`
- 原始仓库:https://github.com/stablyai/orca
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+130
View File
@@ -0,0 +1,130 @@
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup'
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
// chunk gained `require("electron")` (an import edge added in #7642), and the
// daemon is forked as a plain-Node process where electron cannot be required.
// Nothing in CI executes the built daemon-entry under plain Node, so the leak
// stayed invisible until an adopted old daemon died. This guard fails the
// build when any chunk reachable from a plain-Node fork entry requires
// electron, and smoke-loads daemon-entry under plain Node to prove its module
// graph still resolves.
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
// forked daemon, parcel-watcher and computer sidecars, and the CLI-run
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls'
] as const
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
function collectReachableChunks(
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): OutputChunk[] {
const seen = new Set<string>()
const reachable: OutputChunk[] = []
const stack = [entry.fileName]
while (stack.length > 0) {
const fileName = stack.pop() as string
if (seen.has(fileName)) {
continue
}
seen.add(fileName)
const chunk = byFileName.get(fileName)
if (!chunk) {
continue
}
reachable.push(chunk)
for (const imported of [...chunk.imports, ...chunk.dynamicImports]) {
stack.push(imported)
}
}
return reachable
}
function assertNoElectronRequire(
entryName: string,
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): void {
for (const chunk of collectReachableChunks(entry, byFileName)) {
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
throw new Error(
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
)
}
}
}
// Why: proves the whole daemon-entry graph resolves under plain Node (no
// unresolved requires). require("electron") does not throw in a dev tree with
// node_modules present, so the static scan above — not this smoke — is the
// electron regression guard; this only catches gross load failures.
function smokeLoadDaemonEntry(outputDir: string): void {
const entryPath = join(outputDir, 'daemon-entry.js')
const result = spawnSync(process.execPath, [entryPath], {
encoding: 'utf8',
timeout: 15_000
})
if (result.error) {
throw new Error(
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
`${result.error.message}`
)
}
const stderr = result.stderr ?? ''
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (!stderr.includes('Usage: daemon-entry')) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
)
}
}
export function createPlainNodeEntryGuardPlugin(): Plugin {
return {
name: 'orca-plain-node-entry-guard',
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
// every rebuild, and the guard only needs to gate produced builds.
if (this.meta.watchMode) {
return
}
const chunks = Object.values(bundle).filter(
(item): item is OutputChunk => item.type === 'chunk'
)
const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]))
const entryByName = new Map<string, OutputChunk>()
for (const chunk of chunks) {
if (chunk.isEntry && chunk.name) {
entryByName.set(chunk.name, chunk)
}
}
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName)
}
}
if (entryByName.has('daemon-entry') && options.dir) {
smokeLoadDaemonEntry(options.dir)
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york-v4",
"tailwind": {
"config": "",
"css": "src/renderer/src/assets/main.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"rsc": false,
"tsx": true,
"aliases": {
"utils": "@/lib/utils",
"components": "@/components",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+4
View File
@@ -0,0 +1,4 @@
provider: github
owner: stablyai
repo: orca
updaterCacheDirName: orca-updater
+511
View File
@@ -0,0 +1,511 @@
const { chmodSync, existsSync, readdirSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const { join, resolve } = require('node:path')
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs')
const {
assertPackagedDaemonEntryExists,
verifyPackagedDaemonEntryBoots
} = require('./scripts/verify-packaged-daemon-entry.cjs')
const {
createPackagedRuntimeNodeModuleResources,
prunePackagedRuntimeNodeModules,
verifyPackagedMainRuntimeDeps
} = require('./packaged-runtime-node-modules.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
}
// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
// apps. Keeping relay assets as extraResources makes them real directories
// instead of paths hidden inside app.asar.
const relayExtraResource = {
from: 'out/relay',
to: 'relay'
}
// Why: the main bundle, packaged CLI, SSH paths, and speech worker all execute
// from package directories where pnpm's symlink farm is absent. Copy the exact
// runtime dependency closure to Resources/node_modules so bare require() calls
// do not fall through to a developer checkout's node_modules.
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
}
const linuxSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-linux-${arch}',
to: 'node_modules/sherpa-onnx-linux-${arch}'
}
const winSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-win-x64',
to: 'node_modules/sherpa-onnx-win-x64'
}
/** @type {import('electron-builder').Configuration} */
module.exports = {
appId: 'com.stablyai.orca',
productName: 'Orca',
directories: {
buildResources: 'resources/build'
},
files: [
'!**/.vscode/*',
// Why: these repo-only inputs are either bundled into out/ or copied via
// extraResources. Shipping them in app.asar bloats the desktop bundle.
'!src{,/**/*}',
'!config{,/**/*}',
'!docs{,/**/*}',
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
'!tests{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js',
'!electron.vite.config.{js,ts,mjs,cjs}',
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
'!tsconfig.json',
// Why: feature-wall media is copied via extraResources so runtime can read
// it from process.resourcesPath; exclude the source copy from app.asar.
'!resources/onboarding/feature-wall/**'
],
// Why: the CLI entry-point lives in out/cli/ but imports shared modules
// from out/shared/ and local hook mutators from out/main/. These paths must be
// unpacked so that Node's require() can resolve the cross-directory imports
// when the CLI runs outside the asar archive.
// Why: daemon-entry.js is forked as a separate Node.js process and must be
// accessible on disk (not inside the asar archive) for child_process.fork().
// Why: the CLI is compiled by tsc (not bundled), so its runtime imports
// resolve at runtime via Node's normal module lookup. The shim launches
// the CLI with ELECTRON_RUN_AS_NODE, which bypasses Electron's asar
// integration — dependencies inside the asar archive are invisible to
// require(). Unpack CLI runtime deps so they resolve from
// app.asar.unpacked/node_modules/.
// Why: remote runtime connections use WebSocket + E2EE from the packaged CLI
// before the GUI process starts, so those deps need the same treatment.
// Why: out/package.json pins compiled output to CommonJS so parent
// package.json files with type=module cannot change the packaged CLI loader.
// Why: sherpa-onnx native bindings (platform-specific subpackages) must be
// unpacked because they ship .node addons + .dylib/.so files that cannot be
// dlopen()'d from inside the asar archive.
asarUnpack: [
'out/package.json',
'out/cli/**',
'out/shared/**',
'out/main/agent-hooks/**',
'out/main/antigravity/**',
'out/main/claude/**',
'out/main/codex/**',
'out/main/copilot/**',
'out/main/cursor/**',
'out/main/droid/**',
'out/main/gemini/**',
'out/main/grok/**',
'out/main/hermes/**',
'out/main/win32-utils.js',
'out/main/daemon-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
'node_modules/tweetnacl/**',
'node_modules/zod/**',
'node_modules/yaml/**',
'node_modules/sherpa-onnx*/**'
],
afterPack: async (context) => {
const resourcesDir =
context.electronPlatformName === 'darwin'
? join(
context.appOutDir,
`${context.packager.appInfo.productFilename}.app`,
'Contents',
'Resources'
)
: join(context.appOutDir, 'resources')
if (!existsSync(resourcesDir)) {
return
}
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
verifyPackagedMainRuntimeDeps(resourcesDir)
// Why: boot the packaged daemon-entry under plain Node, but only for the
// slice matching the packaging host's arch — daemon-entry.js is JS, yet it
// require()s the native (N-API) node-pty for the TARGET arch, which the host
// Node cannot load cross-arch. `Arch` enum: ia32=0, x64=1, armv7l=2,
// arm64=3, universal=4 (universal contains the host slice, so run it).
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
const hostArchEnum = archEnumByNodeArch[process.arch]
if (context.arch === hostArchEnum || context.arch === 4) {
verifyPackagedDaemonEntryBoots(resourcesDir)
} else {
// Why: a cross-arch slice can't be booted by the host Node, but the
// unpacked entry must still exist — its absence is a layout regression
// regardless of arch, so only the boot is skipped, not the check.
assertPackagedDaemonEntryExists(resourcesDir)
console.log(
`[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})`
)
}
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
for (const filename of readdirSync(resourcesDir)) {
if (!filename.startsWith('agent-browser-')) {
continue
}
// Why: the upstream package has inconsistent executable bits across
// platform binaries (notably darwin-x64). child_process.execFile needs
// the copied binary to be executable in packaged apps.
chmodSync(join(resourcesDir, filename), 0o755)
}
if (context.electronPlatformName === 'darwin') {
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
await signMacNotificationStatusHelper(
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
context.packager
)
}
},
win: {
executableName: 'Orca',
// Why: Windows installers are signed after electron-builder packaging by
// SignPath, so the packager cannot infer the updater publisherName.
signtoolOptions: {
publisherName: 'SignPath Foundation'
},
extraResources: [
...commonExtraResources,
winSpeechNativeResource,
{
from: 'resources/win32/bin/orca.cmd',
to: 'bin/orca.cmd'
},
{
from: 'native/windows-cli-launcher/.build/orca.exe',
to: 'bin/orca.exe'
},
{
from: 'node_modules/agent-browser/bin/agent-browser-win32-x64.exe',
to: 'agent-browser-win32-x64.exe'
},
{
from: 'native/computer-use-windows/runtime.ps1',
to: 'computer-use-windows/runtime.ps1'
},
featureWallResources
]
},
nsis: {
artifactName: 'orca-windows-setup.${ext}',
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
createDesktopShortcut: 'always',
// Why: on a real uninstall, stop and remove the relocated terminal daemon
// (which lives outside the install dir under LOCALAPPDATA by design). Guarded
// by ${isUpdated} inside so it never runs during an update's uninstallOldVersion.
include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh')
},
mac: {
icon: 'resources/build/icon.icns',
entitlements: 'resources/build/entitlements.mac.plist',
entitlementsInherit: 'resources/build/entitlements.mac.plist',
extendInfo: {
NSAppleEventsUsageDescription:
'Orca allows terminal-launched developer tools to automate local apps when you request it.',
NSBluetoothAlwaysUsageDescription:
'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.',
NSBluetoothPeripheralUsageDescription:
'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.',
NSCameraUsageDescription: "Application requests access to the device's camera.",
NSLocationUsageDescription:
'Orca allows terminal-launched developer tools to access location when you request it.',
NSLocalNetworkUsageDescription:
'Orca allows terminal-launched developer tools to discover and connect to local development servers when you request it.',
NSMicrophoneUsageDescription: "Application requests access to the device's microphone.",
NSAudioCaptureUsageDescription:
'Orca allows terminal-launched developer tools to capture desktop audio when you request it.',
NSBonjourServices: ['_http._tcp', '_https._tcp'],
NSDocumentsFolderUsageDescription:
"Application requests access to the user's Documents folder.",
NSDownloadsFolderUsageDescription:
"Application requests access to the user's Downloads folder."
},
// Why: local macOS validation builds should launch without Apple release
// credentials. Hardened runtime + notarization stay enabled only on the
// explicit release path so production artifacts remain strict while dev
// artifacts do not fail with broken ad-hoc launch behavior.
hardenedRuntime: isMacRelease,
notarize: isMacRelease,
extraResources: [
...commonExtraResources,
macSpeechNativeResource,
{
from: 'resources/darwin/bin/orca',
to: 'bin/orca'
},
{
from: 'node_modules/agent-browser/bin/agent-browser-darwin-${arch}',
to: 'agent-browser-darwin-${arch}'
},
// Why: serve-sim resolves its helper binary and camera assets relative
// to dist/serve-sim.js, so the whole package must be a real resource dir.
{
from: 'node_modules/serve-sim',
to: 'serve-sim'
},
{
from: 'native/computer-use-macos/.build/release/Orca Computer Use.app',
to: 'Orca Computer Use.app'
},
featureWallResources
],
// Why: the notification-status helper must execute from Contents/MacOS —
// on macOS 26 UNUserNotificationCenter aborts (bundleProxyForCurrentProcess
// is nil) for executables launched out of Contents/Resources (#7929).
extraFiles: [
{
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
}
],
target: [
{
target: 'dmg',
arch: ['x64', 'arm64']
},
{
target: 'zip',
arch: ['x64', 'arm64']
}
]
},
// Why: release builds should fail if signing is unavailable instead of
// silently downgrading to ad-hoc artifacts that look shippable in CI logs.
forceCodeSigning: isMacRelease,
dmg: {
artifactName: 'orca-macos-${arch}.${ext}'
},
linux: {
// Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca.
// The Linux installer should not claim those system package/file names.
executableName: 'orca-ide',
// Why: the icns source lets electron-builder emit standard hicolor PNG
// sizes; a single 1024px PNG is ignored by some Linux docks/launchers.
icon: 'resources/build/icon.icns',
desktop: {
entry: {
// Why: Electron reports WM_CLASS=orca for the visible Linux window;
// GNOME docks need an exact match to group it with orca-ide.desktop.
StartupWMClass: 'orca'
}
},
extraResources: [
...commonExtraResources,
linuxSpeechNativeResource,
{
from: 'resources/linux/bin/orca-ide',
to: 'bin/orca-ide'
},
{
from: 'node_modules/agent-browser/bin/agent-browser-linux-${arch}',
to: 'agent-browser-linux-${arch}'
},
{
from: 'native/computer-use-linux/runtime.py',
to: 'computer-use-linux/runtime.py'
},
featureWallResources
],
target: ['AppImage', 'deb'],
maintainer: 'stablyai',
category: 'Utility'
},
appImage: {
artifactName: isLinuxArm64Release ? 'orca-linux-arm64.${ext}' : 'orca-linux.${ext}'
},
deb: {
packageName: 'orca-ide',
artifactName: 'orca-ide_${version}_${arch}.${ext}',
// Why: xvfb lets the bundled `orca serve` CLI run browser panes on a headless
// Linux host — Chromium needs a display server even for offscreen rendering,
// and serve starts Xvfb itself when present (see ensure-virtual-display.ts).
depends: [
'python3',
'python3-gi',
'gir1.2-atspi-2.0',
'at-spi2-core',
'xdotool',
'xclip',
'xvfb'
],
// Why: symlink the bundled CLI onto PATH at install time so `orca-ide serve`
// works on a headless host. The in-app CLI registration (CliInstaller) is
// GUI-triggered and can never run on a server, so without this the CLI is
// unreachable from the shell on exactly the hosts that need it.
afterInstall: 'resources/linux/packaging/after-install.sh',
afterRemove: 'resources/linux/packaging/after-remove.sh'
},
rpm: {
packageName: 'orca-ide',
artifactName: 'orca-ide-${version}.${arch}.${ext}',
// Why: see deb depends. RPM distros ship Xvfb as xorg-x11-server-Xvfb (there
// is no `xvfb` package), so the name differs from the deb here.
depends: [
'python3',
'python3-gobject',
'at-spi2-core',
'xdotool',
'xclip',
'xorg-x11-server-Xvfb'
],
// Why: same headless CLI-on-PATH registration as deb; rpm runs these via fpm.
afterInstall: 'resources/linux/packaging/after-install.sh',
afterRemove: 'resources/linux/packaging/after-remove.sh'
},
beforeBuild: electronBuilderNativeRebuild,
// Why: must be true so that electron-builder rebuilds native modules
// (node-pty) for each target architecture when producing dual-arch macOS
// builds (x64 + arm64). With npmRebuild disabled, CI on an arm64 runner
// packages arm64 binaries into the x64 DMG, causing "posix_spawnp failed"
// on Intel Macs. The beforeBuild hook performs Orca's targeted rebuild and
// returns false so electron-builder does not rebuild optional cpu-features.
npmRebuild: true,
publish: {
provider: 'github',
owner: 'stablyai',
repo: 'orca',
releaseType: 'release'
}
}
function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) {
if (electronPlatformName === 'win32') {
return
}
for (const launcherName of ['orca', 'orca-ide']) {
const launcherPath = join(resourcesDir, 'bin', launcherName)
if (!existsSync(launcherPath)) {
continue
}
// Why: packaged Unix installs expose these extraResources as public shell
// commands, and source/packager mode drift must not ship a non-executable CLI.
chmodSync(launcherPath, 0o755)
}
}
function chmodMacServeSimHelpers(resourcesDir, electronPlatformName) {
if (electronPlatformName !== 'darwin') {
return
}
const helperPaths = [
join(resourcesDir, 'serve-sim', 'bin', 'serve-sim-bin'),
join(resourcesDir, 'serve-sim', 'dist', 'simcam', 'serve-sim-camera-helper'),
join(resourcesDir, 'node_modules', 'serve-sim', 'bin', 'serve-sim-bin'),
join(resourcesDir, 'node_modules', 'serve-sim', 'dist', 'simcam', 'serve-sim-camera-helper')
]
for (const helperPath of helperPaths) {
if (existsSync(helperPath)) {
chmodSync(helperPath, 0o755)
}
}
}
async function signMacComputerUseHelper(helperAppPath, packager) {
if (!existsSync(helperAppPath)) {
if (isMacRelease) {
throw new Error(`Missing Orca Computer Use helper app at ${helperAppPath}`)
}
return
}
const codeSigningInfo =
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
? await packager.codeSigningInfo.value
: null
const identity =
process.env.ORCA_COMPUTER_MACOS_SIGN_IDENTITY ??
process.env.CSC_NAME ??
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
(isMacRelease ? null : '-')
if (!identity) {
throw new Error('Missing signing identity for Orca Computer Use helper app')
}
// Why: TCC grants attach to this nested app's code identity. Sign it before
// the outer Orca.app is sealed so production builds preserve that identity.
execFileSync('codesign', codesignArgs(identity, helperAppPath), { stdio: 'inherit' })
execFileSync('codesign', ['--verify', '--deep', '--strict', helperAppPath], {
stdio: 'inherit'
})
}
async function signMacNotificationStatusHelper(helperPath, packager) {
if (!existsSync(helperPath)) {
if (isMacRelease) {
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
}
return
}
const codeSigningInfo =
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
? await packager.codeSigningInfo.value
: null
const identity =
process.env.CSC_NAME ??
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
(isMacRelease ? null : '-')
if (!identity) {
throw new Error('Missing signing identity for orca-notification-status helper')
}
// Why: macOS keys notification records to the code-signing identifier; the
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
// (and any later) `codesign --force` derives the correct identifier. Sign
// before the outer Orca.app is sealed, like the computer-use helper.
const args = ['--force', '--sign', identity]
if (isMacRelease) {
args.push('--options', 'runtime', '--timestamp')
}
args.push(helperPath)
execFileSync('codesign', args, { stdio: 'inherit' })
execFileSync('codesign', ['--verify', '--strict', helperPath], { stdio: 'inherit' })
}
function codesignArgs(identity, targetPath) {
const args = ['--force', '--deep', '--sign', identity]
if (isMacRelease) {
args.push(
'--options',
'runtime',
'--timestamp',
'--entitlements',
resolve(__dirname, '../resources/build/entitlements.computer-use.mac.plist')
)
}
args.push(targetPath)
return args
}
function findInstalledMacSigningIdentity(keychainFile) {
try {
const output = execFileSync(
'security',
['find-identity', '-v', '-p', 'codesigning', ...(keychainFile ? [keychainFile] : [])],
{
encoding: 'utf8'
}
)
const releaseMatch =
output.match(/"([^"]*Developer ID Application:[^"]+)"/) ??
output.match(/"([^"]*Apple Distribution:[^"]+)"/)
if (releaseMatch?.[1]) {
return releaseMatch[1]
}
if (!isMacRelease) {
return output.match(/"([^"]*Apple Development:[^"]+)"/)?.[1] ?? null
}
} catch {}
return null
}
+106
View File
@@ -0,0 +1,106 @@
# Localization Audit
This is the pre-work artifact for migrating Orca to a localized UI. The goal is
to make coverage repeatable: every detected user-facing string is either moved
behind the localization layer or explicitly excluded with a reason.
## Coverage Contract
Coverage means all strings matching the audit scope below are accounted for:
- JSX text rendered in the renderer.
- Accessibility and form attributes such as `aria-label`, `ariaLabel`, `alt`,
`placeholder`, `title`, `label`, `description`, `subtitle`, and `tooltip`.
- User-facing object metadata such as Settings search `title`, `description`,
`keywords`, labels, badges, helper text, and tooltips.
- User-facing calls such as `toast.success(...)`, `toast.error(...)`, browser
`alert(...)`, `confirm(...)`, and `prompt(...)`.
The audit intentionally does not treat these as localization misses unless they
are surfaced directly as UI copy:
- Terminal output, agent output, git output, provider API errors, and shell
commands.
- File paths, URLs, environment variables, telemetry event names, IDs, and
protocol names.
- Developer logs, internal diagnostics, test fixtures, and snapshots.
- Brand, provider, model, command, and product names that should remain exact.
## Inventory Command
Generate a machine-readable inventory:
```sh
node config/scripts/audit-localization-coverage.mjs --json --output tmp/localization-candidates.json
```
Generate a reviewable Markdown inventory:
```sh
node config/scripts/audit-localization-coverage.mjs --markdown --output tmp/localization-candidates.md
```
Run the maintained coverage gate:
```sh
pnpm run verify:localization-coverage
```
Sync catalog keys after adding or removing `translate(...)` calls:
```sh
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 coverage gate compares current candidates against
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
new candidates fail the check and must be localized or added with a reviewed
reason in the same change.
The script scans `src/renderer/src` by default. That is the primary UI surface.
Use `--source-root src` for a wider audit when checking renderer-adjacent shared
copy, then classify non-renderer findings carefully because many are diagnostics
or external tool text.
## Migration States
Each candidate should end in one of these states:
- `localized`: the component reads the string from the locale catalog.
- `excluded`: the string is intentionally not localized, with a reason from the
coverage contract.
- `deferred`: the string is user-facing but belongs to a later PR wave.
`deferred` is acceptable for planning, but not for the localization coverage
gate.
## PR Waves
Recommended migration order:
1. Infrastructure, English catalog, language setting, and language selector.
2. Settings shell, Settings search metadata, and Appearance.
3. App shell, sidebars, titlebar, status bar, command surfaces, and global
dialogs/toasts.
4. Task pages, source control, hosted review, and provider-specific UI.
5. Terminal chrome, onboarding, feature tips, mobile, browser, and remaining
secondary surfaces.
## Proof Strategy
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.
3. Runtime coverage: pseudo-localization and real locale smoke tests show no
obvious English leftovers or layout clipping in core screens.
Subagent or human review should verify ambiguous exclusions, but the scanner is
the coverage source of truth.
@@ -0,0 +1 @@
[]
+359
View File
@@ -0,0 +1,359 @@
# Files/globs currently allowed to exceed the oxlint `max-lines` budget.
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
inline mobile/src/constants/marine-creatures.ts
inline src/cli/handlers/automations.ts
inline src/cli/handlers/orchestration.ts
inline src/cli/help.ts
inline src/cli/index.test.ts
inline src/main/agent-hooks/server.test.ts
inline src/main/agent-hooks/server.ts
inline src/main/amp/hook-service.ts
inline src/main/antigravity/hook-service.ts
inline src/main/attribution/terminal-attribution.ts
inline src/main/automations/external-manager.ts
inline src/main/automations/hermes-cron-output.ts
inline src/main/browser/agent-browser-bridge.test.ts
inline src/main/browser/agent-browser-bridge.ts
inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-guest-ui.ts
inline src/main/browser/browser-manager-grab.test.ts
inline src/main/browser/browser-manager.test.ts
inline src/main/browser/browser-manager.ts
inline src/main/browser/browser-screencast-stream.ts
inline src/main/browser/browser-session-registry.ts
inline src/main/browser/cdp-bridge.ts
inline src/main/browser/cdp-ws-proxy.ts
inline src/main/browser/grab-guest-script.ts
inline src/main/browser/snapshot-engine.ts
inline src/main/claude-accounts/runtime-auth-service.test.ts
inline src/main/claude-accounts/runtime-auth-service.ts
inline src/main/claude-accounts/service.test.ts
inline src/main/claude-accounts/service.ts
inline src/main/claude-usage/scanner.ts
inline src/main/claude-usage/store.ts
inline src/main/cli/cli-installer.test.ts
inline src/main/cli/cli-installer.ts
inline src/main/cli/wsl-cli-installer.ts
inline src/main/codex-accounts/runtime-home-service.test.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.test.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex-usage/scanner.ts
inline src/main/codex-usage/store.test.ts
inline src/main/codex-usage/store.ts
inline src/main/codex/config-toml-trust.test.ts
inline src/main/codex/config-toml-trust.ts
inline src/main/codex/hook-service.test.ts
inline src/main/codex/hook-service.ts
inline src/main/copilot/hook-service.ts
inline src/main/daemon/client.ts
inline src/main/daemon/daemon-health.ts
inline src/main/daemon/daemon-init.test.ts
inline src/main/daemon/daemon-init.ts
inline src/main/daemon/daemon-pty-adapter.test.ts
inline src/main/daemon/daemon-pty-adapter.ts
inline src/main/daemon/daemon-server.ts
inline src/main/daemon/pty-subprocess.test.ts
inline src/main/daemon/pty-subprocess.ts
inline src/main/daemon/session.ts
inline src/main/daemon/shell-ready.ts
inline src/main/git/remove-worktree.test.ts
inline src/main/git/repo.ts
inline src/main/git/runner.ts
inline src/main/git/status.test.ts
inline src/main/git/status.ts
inline src/main/git/worktree.test.ts
inline src/main/git/worktree.ts
inline src/main/github/client.test.ts
inline src/main/github/client.ts
inline src/main/github/issues.ts
inline src/main/github/pr-refresh-coordinator.test.ts
inline src/main/github/pr-refresh-coordinator.ts
inline src/main/github/project-view.ts
inline src/main/github/project-view/mutations.ts
inline src/main/github/work-item-details.ts
inline src/main/gitlab/client-mr.test.ts
inline src/main/gitlab/client.ts
inline src/main/gitlab/issues.ts
inline src/main/gitlab/work-item-details.ts
inline src/main/hermes/hook-service.ts
inline src/main/hooks.test.ts
inline src/main/hooks.ts
inline src/main/index.ts
inline src/main/ipc/browser.ts
inline src/main/ipc/crash-reporting.ts
inline src/main/ipc/filesystem-auth.ts
inline src/main/ipc/filesystem-mutations.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.test.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/github.test.ts
inline src/main/ipc/github.ts
inline src/main/ipc/gitlab.ts
inline src/main/ipc/linear.ts
inline src/main/ipc/notifications.test.ts
inline src/main/ipc/notifications.ts
inline src/main/ipc/pet.ts
inline src/main/ipc/preflight.test.ts
inline src/main/ipc/pty.test.ts
inline src/main/ipc/pty.ts
inline src/main/ipc/remote-workspace.ts
inline src/main/ipc/repos-remote.test.ts
inline src/main/ipc/repos.ts
inline src/main/ipc/runtime-environments.test.ts
inline src/main/ipc/ssh.test.ts
inline src/main/ipc/ssh.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/ipc/worktrees.test.ts
inline src/main/ipc/worktrees.ts
inline src/main/jira/client.ts
inline src/main/jira/issues.ts
inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/client.ts
inline src/main/linear/issues.ts
inline src/main/linear/projects.ts
inline src/main/memory/collector.ts
inline src/main/opencode-usage/scanner.ts
inline src/main/opencode-usage/store.ts
inline src/main/opencode/hook-service.ts
inline src/main/persistence.test.ts
inline src/main/persistence.ts
inline src/main/ports/advertised-url-watcher.ts
inline src/main/ports/local-workspace-port-scanner.ts
inline src/main/project-groups/nested-repo-discovery.ts
inline src/main/providers/local-pty-provider.test.ts
inline src/main/providers/local-pty-provider.ts
inline src/main/providers/local-pty-shell-ready.test.ts
inline src/main/providers/local-pty-shell-ready.ts
inline src/main/providers/ssh-git-provider.test.ts
inline src/main/providers/ssh-git-provider.ts
inline src/main/rate-limits/claude-fetcher.test.ts
inline src/main/rate-limits/claude-fetcher.ts
inline src/main/rate-limits/claude-pty.ts
inline src/main/rate-limits/codex-fetcher.ts
inline src/main/rate-limits/service.test.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.test.ts
inline src/main/runtime/orca-runtime-files.ts
inline src/main/runtime/orca-runtime-git.ts
inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/orchestration/coordinator.ts
inline src/main/runtime/orchestration/db.ts
inline src/main/runtime/rpc/methods/files.ts
inline src/main/runtime/rpc/methods/git.ts
inline src/main/runtime/rpc/methods/github.ts
inline src/main/runtime/rpc/methods/orchestration.test.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/runtime/rpc/methods/terminal.ts
inline src/main/runtime/rpc/terminal-multiplex.test.ts
inline src/main/runtime/runtime-rpc.test.ts
inline src/main/runtime/runtime-rpc.ts
inline src/main/source-control/hosted-review-creation.ts
inline src/main/speech/model-manager.ts
inline src/main/speech/stt-service.ts
inline src/main/ssh/ssh-channel-multiplexer.ts
inline src/main/ssh/ssh-connection.test.ts
inline src/main/ssh/ssh-connection.ts
inline src/main/ssh/ssh-relay-deploy.ts
inline src/main/ssh/ssh-relay-session.ts
inline src/main/star-nag/service.test.ts
inline src/main/text-generation/commit-message-text-generation.test.ts
inline src/main/text-generation/commit-message-text-generation.ts
inline src/main/updater.test.ts
inline src/main/updater.ts
inline src/main/window/attach-main-window-services.ts
inline src/main/window/createMainWindow.test.ts
inline src/main/window/createMainWindow.ts
inline src/main/workspace-space-analysis.ts
inline src/preload/api-types.ts
inline src/preload/index.ts
inline src/relay/agent-hook-server.ts
inline src/relay/dispatcher.ts
inline src/relay/external-automations-handler.ts
inline src/relay/fs-handler.ts
inline src/relay/git-handler.test.ts
inline src/relay/git-handler.ts
inline src/relay/pty-handler.test.ts
inline src/relay/pty-handler.ts
inline src/relay/relay.ts
inline src/relay/workspace-space-scan.ts
inline src/renderer/src/App.tsx
inline src/renderer/src/components/GitHubItemDialog.tsx
inline src/renderer/src/components/GitLabItemDialog.tsx
inline src/renderer/src/components/JiraIssueWorkspace.tsx
inline src/renderer/src/components/LinearIssueWorkspace.tsx
inline src/renderer/src/components/LinearItemDrawer.tsx
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
inline src/renderer/src/components/PullRequestPage.tsx
inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
inline src/renderer/src/components/UpdateCard.tsx
inline src/renderer/src/components/WorktreeJumpPalette.tsx
inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/browser-pane/BrowserPane.tsx
inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/EditorContent.tsx
inline src/renderer/src/components/editor/IpynbViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/editor/MonacoEditor.tsx
inline src/renderer/src/components/editor/editor-autosave-controller.ts
inline src/renderer/src/components/editor/ipynb-parse.ts
inline src/renderer/src/components/editor/useEditorPanelContentState.ts
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/github-project/ProjectCell.tsx
inline src/renderer/src/components/github-project/ProjectPicker.tsx
inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
inline src/renderer/src/components/linear-project-view-surfaces.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
inline src/renderer/src/components/right-sidebar/FileExplorer.tsx
inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
inline src/renderer/src/components/right-sidebar/SourceControl.tsx
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
inline src/renderer/src/components/right-sidebar/index.tsx
inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/AgentsPane.tsx
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/settings/SettingsFormControls.tsx
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
inline src/renderer/src/components/sidebar/WorktreeCard.tsx
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
inline src/renderer/src/components/sidebar/WorktreeList.tsx
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
inline src/renderer/src/components/stats/usage-overview-model.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/tab-bar/TabBar.tsx
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
inline src/renderer/src/components/task-page-cache-selectors.ts
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
inline src/renderer/src/components/terminal-pane/pty-connection.test.ts
inline src/renderer/src/components/terminal-pane/pty-connection.ts
inline src/renderer/src/components/terminal-pane/pty-transport.test.ts
inline src/renderer/src/components/terminal-pane/pty-transport.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
inline src/renderer/src/hooks/useComposerState.ts
inline src/renderer/src/hooks/useEditorExternalWatch.ts
inline src/renderer/src/hooks/useIpcEvents.test.ts
inline src/renderer/src/hooks/useIpcEvents.ts
inline src/renderer/src/hooks/useIssueMetadata.ts
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
inline src/renderer/src/lib/active-agent-note-send.test.ts
inline src/renderer/src/lib/file-type-icons.ts
inline src/renderer/src/lib/pane-manager/pane-manager.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/lib/terminal-links.ts
inline src/renderer/src/lib/worktree-activation.test.ts
inline src/renderer/src/lib/worktree-activation.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
inline src/renderer/src/runtime/runtime-file-client.test.ts
inline src/renderer/src/runtime/runtime-file-client.ts
inline src/renderer/src/runtime/runtime-git-client.ts
inline src/renderer/src/runtime/runtime-linear-client.ts
inline src/renderer/src/runtime/sync-runtime-graph.test.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-runtime-session.test.ts
inline src/renderer/src/runtime/web-runtime-session.ts
inline src/renderer/src/runtime/web-session-tabs-sync.test.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.test.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.test.ts
inline src/renderer/src/store/slices/browser.ts
inline src/renderer/src/store/slices/diffComments.ts
inline src/renderer/src/store/slices/editor.test.ts
inline src/renderer/src/store/slices/editor.ts
inline src/renderer/src/store/slices/github.test.ts
inline src/renderer/src/store/slices/github.ts
inline src/renderer/src/store/slices/hosted-review.ts
inline src/renderer/src/store/slices/jira.ts
inline src/renderer/src/store/slices/linear.test.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/repos.ts
inline src/renderer/src/store/slices/store-cascades.test.ts
inline src/renderer/src/store/slices/store-session-cascades.test.ts
inline src/renderer/src/store/slices/tabs.test.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/terminals.ts
inline src/renderer/src/store/slices/ui.test.ts
inline src/renderer/src/store/slices/ui.ts
inline src/renderer/src/store/slices/workspace-cleanup.ts
inline src/renderer/src/store/slices/worktrees.test.ts
inline src/renderer/src/store/slices/worktrees.ts
inline src/renderer/src/web/web-preload-api.test.ts
inline src/renderer/src/web/web-preload-api.ts
inline src/renderer/src/web/web-runtime-client.ts
inline src/shared/agent-hook-listener.test.ts
inline src/shared/agent-hook-listener.ts
inline src/shared/automation-schedules.ts
inline src/shared/commit-message-agent-spec.ts
inline src/shared/constants.ts
inline src/shared/github-project-types.ts
inline src/shared/keybindings.test.ts
inline src/shared/keybindings.ts
inline src/shared/marine-creatures.ts
inline src/shared/remote-runtime-client.ts
inline src/shared/runtime-types.ts
inline src/shared/source-control-ai.ts
inline src/shared/telemetry-events.ts
inline src/shared/text-search.ts
inline src/shared/types.ts
inline tests/e2e/helpers/terminal.ts
inline tests/e2e/terminal-panes.spec.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/index.tsx
mobile-config app/h/*/session/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/h/*/tasks.tsx
mobile-config app/index.tsx
mobile-config app/pair-scan.tsx
mobile-config app/terminal-settings.tsx
mobile-config app/troubleshoot.tsx
mobile-config scripts/mock-server.ts
mobile-config scripts/repro-terminal-colors.ts
mobile-config scripts/repro-worktree-startup-stream.ts
mobile-config src/browser/MobileBrowserPane.tsx
mobile-config src/components/CustomKeyModal.tsx
mobile-config src/components/NewWorktreeModal.tsx
mobile-config src/components/mobile-rich-markdown-editor-html.ts
mobile-config src/terminal/terminal-accessory-keys.ts
mobile-config src/terminal/terminal-webview-html.ts
mobile-config src/transport/rpc-client.ts
+23
View File
@@ -0,0 +1,23 @@
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
!macroend
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": [],
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"jsPlugins": [{ "name": "react-doctor", "specifier": "oxlint-plugin-react-doctor" }],
"rules": {
"react-doctor/no-adjust-state-on-prop-change": "warn",
"react-doctor/no-derived-state-effect": "warn",
"react-doctor/no-initialize-state": "warn"
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"rules": {
"typescript/switch-exhaustiveness-check": [
"error",
{ "allowDefaultCaseForExhaustiveSwitch": false }
]
}
}
+423
View File
@@ -0,0 +1,423 @@
const {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
rmSync
} = require('node:fs')
const { dirname, join, resolve } = require('node:path')
const { builtinModules, createRequire } = require('node:module')
const projectDir = resolve(__dirname, '..')
const requireFromProject = createRequire(join(projectDir, 'package.json'))
const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'@electron-toolkit/utils',
'@linear/sdk',
'@parcel/watcher',
'electron-updater',
'i18next',
'jsonc-parser',
'node-pty',
'posthog-node',
// serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too.
'serve-sim',
'qrcode',
'ssh2',
'tweetnacl',
'ws',
'yaml',
'zod'
]
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
darwin: 'darwin-',
linux: 'linux-',
win32: 'win32-'
}
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
const PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM = {
darwin: 'watcher-darwin',
linux: 'watcher-linux',
win32: 'watcher-win32'
}
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
const NODE_BUILTINS = new Set([
...builtinModules,
...builtinModules.map((moduleName) => `node:${moduleName}`)
])
function packageNameFromSpecifier(specifier) {
if (specifier.startsWith('@')) {
const [scope, name] = specifier.split('/')
return scope && name ? `${scope}/${name}` : specifier
}
return specifier.split('/')[0]
}
function isPackagedExternalSpecifier(specifier) {
return (
!specifier.startsWith('.') &&
!specifier.startsWith('/') &&
specifier !== 'electron' &&
!NODE_BUILTINS.has(specifier)
)
}
function resolvePackageJsonPath(packageName, fromDir = projectDir) {
const nested = join(fromDir, 'node_modules', packageName, 'package.json')
if (existsSync(nested)) {
return nested
}
// Why: published serve-sim has no "." export (only ./middleware and ./state), so
// require.resolve('serve-sim') fails even though the package is present for bridge exec.
if (packageName === 'serve-sim') {
const direct = join(projectDir, 'node_modules', 'serve-sim', 'package.json')
if (existsSync(direct)) {
return direct
}
}
try {
return requireFromProject.resolve(`${packageName}/package.json`, { paths: [fromDir] })
} catch {
let entryPath
try {
entryPath = requireFromProject.resolve(packageName, { paths: [fromDir] })
} catch {
throw new Error(`Could not resolve package ${packageName} from ${fromDir}`)
}
let dir = dirname(entryPath)
while (dir !== dirname(dir)) {
const packageJsonPath = join(dir, 'package.json')
if (existsSync(packageJsonPath)) {
return packageJsonPath
}
dir = dirname(dir)
}
throw new Error(`Could not find package.json for ${packageName}`)
}
}
function readPackage(packageName, fromDir = projectDir) {
const packageJsonPath = resolvePackageJsonPath(packageName, fromDir)
const packageDir = realpathSync(dirname(packageJsonPath))
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
return {
name: packageJson.name ?? packageName,
packageDir,
dependencies: Object.keys(packageJson.dependencies ?? {})
}
}
function isKnownOmittedServeSimDependency(packageName, fromDir) {
if (packageName !== 'inspect-webkit') {
return false
}
const serveSimPackageJsonPath = join(projectDir, 'node_modules', 'serve-sim', 'package.json')
if (!existsSync(serveSimPackageJsonPath)) {
return false
}
try {
return realpathSync(fromDir) === realpathSync(dirname(serveSimPackageJsonPath))
} catch {
return false
}
}
function collectPackagedRuntimePackages() {
const packages = new Map()
const visit = (packageName, fromDir = projectDir) => {
if (packageName === 'electron' || packages.has(packageName)) {
return
}
let packageInfo
try {
packageInfo = readPackage(packageName, fromDir)
} catch (error) {
// Why: serve-sim declares inspect-webkit, but current installs omit it.
// Keep that escape hatch narrow so broken packages still fail packaging.
if (isKnownOmittedServeSimDependency(packageName, fromDir)) {
return
}
throw error
}
if (packages.has(packageInfo.name)) {
return
}
packages.set(packageInfo.name, packageInfo.packageDir)
for (const dependencyName of packageInfo.dependencies) {
visit(dependencyName, packageInfo.packageDir)
}
}
for (const packageName of PACKAGED_RUNTIME_PACKAGE_ROOTS) {
visit(packageName)
}
// Why: @parcel/watcher loads its native .node addon from a platform-specific
// optionalDependency (e.g. @parcel/watcher-linux-x64-glibc) that the
// dependencies graph above never reaches. Include the ones installed for the
// build's supported architectures; afterPack pruning trims non-target
// platforms. Without this the packaged main bundle's import of
// '@parcel/watcher' resolves at runtime but throws loading its binary.
const parcelWatcherDir = packages.get('@parcel/watcher')
if (parcelWatcherDir) {
const parcelWatcherPackage = JSON.parse(
readFileSync(join(parcelWatcherDir, 'package.json'), 'utf8')
)
for (const optionalName of Object.keys(parcelWatcherPackage.optionalDependencies ?? {})) {
try {
visit(optionalName)
} catch {
// Optional platform subpackage is not installed for this build; skip it.
}
}
}
return [...packages.entries()].sort(([left], [right]) => left.localeCompare(right))
}
function createPackagedRuntimeNodeModuleResources() {
return collectPackagedRuntimePackages().map(([packageName, packageDir]) => ({
from: packageDir,
to: join('node_modules', ...packageName.split('/'))
}))
}
function normalizeAsarEntryPath(entry) {
return entry.replace(/\\/g, '/').replace(/^\/+/, '')
}
function findAsarEntry(entries, expectedPath) {
return entries.find((entry) => normalizeAsarEntryPath(entry) === expectedPath)
}
function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/asar')) {
const asarPath = join(resourcesDir, 'app.asar')
if (!existsSync(asarPath)) {
return
}
const mainFiles = ['out/main/index.js', 'out/main/agent-hooks/managed-agent-hook-controls.js']
const entries = asar.listPackage(asarPath)
const missing = new Set()
for (const file of mainFiles) {
const entry = findAsarEntry(entries, file)
if (!entry) {
throw new Error(`Packaged main file ${file} was not found in ${asarPath}`)
}
// Why: @electron/asar lists entries with host separators; Windows returns
// backslashes, and extractFile expects that same host-style path.
const internalPath = entry.replace(/^[\\/]+/, '')
const source = asar.extractFile(asarPath, internalPath).toString('utf8')
for (const match of source.matchAll(/require\(["']([^"']+)["']\)/g)) {
const specifier = match[1]
if (!isPackagedExternalSpecifier(specifier)) {
continue
}
const packageName = packageNameFromSpecifier(specifier)
if (!existsSync(join(resourcesDir, 'node_modules', ...packageName.split('/')))) {
missing.add(packageName)
}
}
}
if (missing.size > 0) {
throw new Error(
`Packaged main bundle has bare runtime imports without copied node_modules: ${[
...missing
].join(', ')}`
)
}
}
function normalizeNodePtyWindowsArch(electronArch) {
if (electronArch === 'x64' || electronArch === 1) {
return 'x64'
}
if (electronArch === 'arm64' || electronArch === 3) {
return 'arm64'
}
return process.arch === 'arm64' ? 'arm64' : 'x64'
}
function findNodePtyConptySourceDir(nodePtyDir, windowsArch) {
const conptyRoot = join(nodePtyDir, 'third_party', 'conpty')
if (!existsSync(conptyRoot)) {
throw new Error(`Packaged node-pty is missing ${conptyRoot}`)
}
for (const entry of readdirSync(conptyRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue
}
const sourceDir = join(conptyRoot, entry.name, `win10-${windowsArch}`)
if (existsSync(sourceDir)) {
return sourceDir
}
}
throw new Error(`Packaged node-pty has no ConPTY payload for win10-${windowsArch}`)
}
function ensurePackagedNodePtyConptyRuntime(nodePtyDir, electronArch) {
const releaseDir = join(nodePtyDir, 'build', 'Release')
if (!existsSync(join(releaseDir, 'conpty.node'))) {
return
}
const runtimeDir = join(releaseDir, 'conpty')
const missingRuntimeFiles = NODE_PTY_CONPTY_RUNTIME_FILES.filter(
(filename) => !existsSync(join(runtimeDir, filename))
)
if (missingRuntimeFiles.length === 0) {
return
}
const windowsArch = normalizeNodePtyWindowsArch(electronArch)
const sourceDir = findNodePtyConptySourceDir(nodePtyDir, windowsArch)
mkdirSync(runtimeDir, { recursive: true })
for (const filename of missingRuntimeFiles) {
const sourceFile = join(sourceDir, filename)
if (!existsSync(sourceFile)) {
throw new Error(`Packaged node-pty is missing ${sourceFile}`)
}
// Why: node-pty's Windows addon loads conpty.dll relative to conpty.node,
// but its install script can run before electron-builder gathers resources.
copyFileSync(sourceFile, join(runtimeDir, filename))
}
}
function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch) {
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
if (!existsSync(nodePtyDir)) {
return
}
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
if (allowedPrebuildPrefix) {
const prebuildsDir = join(nodePtyDir, 'prebuilds')
if (existsSync(prebuildsDir)) {
for (const entry of readdirSync(prebuildsDir, { withFileTypes: true })) {
if (entry.isDirectory() && !entry.name.startsWith(allowedPrebuildPrefix)) {
rmSync(join(prebuildsDir, entry.name), { recursive: true, force: true })
}
}
}
}
if (electronPlatformName === 'win32') {
ensurePackagedNodePtyConptyRuntime(nodePtyDir, electronArch)
} else {
// Why: conpty is Windows-only and node-pty resolves runtime binaries from
// build/Release or prebuilds/<platform>-<arch>, not third_party/conpty.
rmSync(join(nodePtyDir, 'third_party', 'conpty'), { recursive: true, force: true })
rmSync(join(nodePtyDir, 'deps', 'winpty'), { recursive: true, force: true })
}
}
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
if (!existsSync(parcelDir)) {
return
}
// Why: we package every installed @parcel/watcher-<platform> optional
// subpackage (supportedArchitectures fetches all), but each build only needs
// its own platform's binary. Keep the core package and the matching platform
// subpackages; drop the rest so a Linux serve doesn't ship macOS/Windows .node.
const keepPrefix = PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM[electronPlatformName]
for (const entry of readdirSync(parcelDir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === 'watcher') {
continue
}
// Why: only ever prune the watcher's own platform subpackages. Guards against
// nuking an unrelated @parcel/* runtime dep if one is added to the roots later.
if (!entry.name.startsWith('watcher-')) {
continue
}
if (keepPrefix && entry.name.startsWith(keepPrefix)) {
continue
}
rmSync(join(parcelDir, entry.name), { recursive: true, force: true })
}
}
function prunePackagedRuntimeTypeDeclarations(resourcesDir) {
const nodeModulesDir = join(resourcesDir, 'node_modules')
if (!existsSync(nodeModulesDir)) {
return
}
pruneMatchingFiles(nodeModulesDir, (filename) => TYPE_DECLARATION_ARTIFACT_RE.test(filename))
}
function prunePackagedSherpaOnnx(resourcesDir, electronPlatformName) {
if (electronPlatformName !== 'darwin') {
return
}
const nodeModulesDir = join(resourcesDir, 'node_modules')
if (!existsSync(nodeModulesDir)) {
return
}
for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('sherpa-onnx-darwin-')) {
continue
}
const packageDir = join(nodeModulesDir, entry.name)
const packageEntries = readdirSync(packageDir)
const hasVersionedOnnxRuntime = packageEntries.some((filename) =>
VERSIONED_ONNXRUNTIME_DYLIB_RE.test(filename)
)
if (hasVersionedOnnxRuntime) {
// Why: darwin sherpa-onnx binaries link to the versioned ONNX Runtime
// install name; the unversioned dylib is a duplicate fallback copy.
rmSync(join(packageDir, 'libonnxruntime.dylib'), { force: true })
}
}
}
function prunePackagedZodSources(resourcesDir) {
// Why: Zod's src tree is TypeScript source only selected by the @zod/source
// condition; packaged runtime import/require paths resolve to built JS.
rmSync(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true, force: true })
}
function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, electronArch) {
prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
prunePackagedParcelWatcher(resourcesDir, electronPlatformName)
prunePackagedRuntimeTypeDeclarations(resourcesDir)
prunePackagedSherpaOnnx(resourcesDir, electronPlatformName)
prunePackagedZodSources(resourcesDir)
}
function pruneMatchingFiles(directory, shouldPrune) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const entryPath = join(directory, entry.name)
if (entry.isDirectory()) {
pruneMatchingFiles(entryPath, shouldPrune)
} else if (entry.isFile() && shouldPrune(entry.name)) {
rmSync(entryPath, { force: true })
}
}
}
module.exports = {
PACKAGED_RUNTIME_PACKAGE_ROOTS,
createPackagedRuntimeNodeModuleResources,
findAsarEntry,
isPackagedExternalSpecifier,
packageNameFromSpecifier,
prunePackagedNodePty,
prunePackagedParcelWatcher,
prunePackagedRuntimeNodeModules,
prunePackagedSherpaOnnx,
prunePackagedRuntimeTypeDeclarations,
prunePackagedZodSources,
verifyPackagedMainRuntimeDeps
}
@@ -0,0 +1,21 @@
diff --git a/package.json b/package.json
index c697677b35e736e23702c1abb357b3445c57d949..0a9f8b69e5a6f4ff7f54dbf437cf91bb1e3d3b61 100644
--- a/package.json
+++ b/package.json
@@ -7,8 +7,15 @@
"url": "https://xtermjs.org/"
},
"main": "lib/addon-ligatures.js",
- "module": "lib/addon-ligatures.mjs",
+ "module": "lib/addon-ligatures.js",
"types": "typings/addon-ligatures.d.ts",
+ "exports": {
+ ".": {
+ "types": "./typings/addon-ligatures.d.ts",
+ "import": "./lib/addon-ligatures.js",
+ "default": "./lib/addon-ligatures.js"
+ }
+ },
"repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-ligatures",
"engines": {
"node": ">8.0.0"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+386
View File
@@ -0,0 +1,386 @@
diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc3500acacae 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -5,9 +5,6 @@
],
'conditions': [
['OS=="win"', {
- 'msvs_configuration_attributes': {
- 'SpectreMitigation': 'Spectre'
- },
'msvs_settings': {
'VCCLCompilerTool': {
'AdditionalOptions': [
diff --git a/deps/winpty/src/winpty.gyp b/deps/winpty/src/winpty.gyp
index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8360130ef 100644
--- a/deps/winpty/src/winpty.gyp
+++ b/deps/winpty/src/winpty.gyp
@@ -10,7 +10,7 @@
# make -j4 CXX=i686-w64-mingw32-g++ LDFLAGS="-static -static-libgcc -static-libstdc++"
'variables': {
- 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && GetCommitHash.bat")',
+ 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && .\\GetCommitHash.bat")',
},
'target_defaults' : {
'defines' : [
@@ -22,7 +22,7 @@
'include_dirs': [
# Add the 'src/gen' directory to the include path and force gyp to
# run the script (re)generating the version header.
- '<!(cmd /c "cd shared && UpdateGenVersion.bat <(WINPTY_COMMIT_HASH)")',
+ '<!(cmd /c "cd shared && .\\UpdateGenVersion.bat <(WINPTY_COMMIT_HASH)")',
]
},
'targets' : [
@@ -40,9 +40,6 @@
'-lshell32',
'-luser32',
],
- 'msvs_configuration_attributes': {
- 'SpectreMitigation': 'Spectre'
- },
'msvs_settings': {
# Specify this setting here to override a setting from somewhere
# else, such as node's common.gypi.
@@ -142,9 +139,6 @@
'-ladvapi32',
'-luser32',
],
- 'msvs_configuration_attributes': {
- 'SpectreMitigation': 'Spectre'
- },
'msvs_settings': {
# Specify this setting here to override a setting from somewhere
# else, such as node's common.gypi.
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
--- a/lib/unixTerminal.js
+++ b/lib/unixTerminal.js
@@ -28,8 +28,12 @@ var native = utils_1.loadNativeModule('pty');
var pty = native.module;
var helperPath = native.dir + '/spawn-helper';
helperPath = path.resolve(__dirname, helperPath);
-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
+if (!helperPath.includes('app.asar.unpacked')) {
+ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
+}
+if (!helperPath.includes('node_modules.asar.unpacked')) {
+ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
+}
var DEFAULT_FILE = 'sh';
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
var utils_1 = require("./utils");
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
var shellPid = parseInt(process.argv[2], 10);
-var consoleProcessList = getConsoleProcessList(shellPid);
+var consoleProcessList;
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+}
+catch (_a) {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send({ consoleProcessList: consoleProcessList });
process.exit(0);
//# sourceMappingURL=conpty_console_list_agent.js.map
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644
--- a/src/conpty_console_list_agent.ts
+++ b/src/conpty_console_list_agent.ts
@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils';
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
const shellPid = parseInt(process.argv[2], 10);
-const consoleProcessList = getConsoleProcessList(shellPid);
+let consoleProcessList: number[];
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+} catch {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send!({ consoleProcessList });
process.exit(0);
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -23,7 +23,9 @@
#include <errno.h>
#include <string.h>
#include <stdlib.h>
+#include <stdio.h>
#include <unistd.h>
+#include <string>
#include <thread>
#include <sys/types.h>
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
+struct pty_spawn_error {
+ const char* step;
+ int errnum;
+ std::string detail_name;
+ std::string detail_value;
+};
+
+static std::string
+pty_format_spawn_error(const pty_spawn_error&);
+
static void
pty_posix_spawn(char** argv, char** env,
const struct termios *termp,
const struct winsize *winp,
int* master,
pid_t* pid,
- int* err);
+ pty_spawn_error* err);
#endif
struct DelBuf {
@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
argv[i + 3] = strdup(arg.c_str());
}
- int err = -1;
- pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err);
- if (err != 0) {
- throw Napi::Error::New(napiEnv, "posix_spawnp failed.");
+ pty_spawn_error spawn_error = { NULL, 0, "", "" };
+ pty_posix_spawn(argv, env, term, &winp, &master, &pid, &spawn_error);
+ if (spawn_error.errnum != 0) {
+ std::string spawn_message = pty_format_spawn_error(spawn_error);
+ throw Napi::Error::New(napiEnv, spawn_message);
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
+static const char*
+pty_errno_name(int errnum) {
+ switch (errnum) {
+ case E2BIG: return "E2BIG";
+ case EACCES: return "EACCES";
+ case EAGAIN: return "EAGAIN";
+ case EMFILE: return "EMFILE";
+ case ENFILE: return "ENFILE";
+ case ENOENT: return "ENOENT";
+ case ENOMEM: return "ENOMEM";
+ default: return "errno";
+ }
+}
+
+static void
+pty_set_spawn_error(pty_spawn_error* err,
+ const char* step,
+ int errnum,
+ const char* detail_name = NULL,
+ const char* detail_value = NULL) {
+ err->step = step;
+ err->errnum = errnum;
+ err->detail_name = detail_name ? detail_name : "";
+ err->detail_value = detail_value ? detail_value : "";
+}
+
+static std::string
+pty_format_spawn_error(const pty_spawn_error& err) {
+ char errno_buf[64];
+ snprintf(errno_buf, sizeof(errno_buf), "%d", err.errnum);
+
+ std::string message = "node-pty: ";
+ message += err.step ? err.step : "unknown";
+ message += " failed: ";
+ message += pty_errno_name(err.errnum);
+ message += " (errno ";
+ message += errno_buf;
+ message += ", ";
+ message += strerror(err.errnum);
+ message += ")";
+
+ if (!err.detail_name.empty()) {
+ message += " - ";
+ message += err.detail_name;
+ message += "='";
+ message += err.detail_value;
+ message += "'";
+ }
+
+ return message;
+}
+
static void
pty_posix_spawn(char** argv, char** env,
const struct termios *termp,
const struct winsize *winp,
int* master,
pid_t* pid,
- int* err) {
- int low_fds[3];
+ pty_spawn_error* err) {
+ int low_fds[3] = {-1, -1, -1};
size_t count = 0;
+ int res = -1;
+ int slave = -1;
+ posix_spawn_file_actions_t acts;
+ bool acts_initialized = false;
+ posix_spawnattr_t attrs;
+ bool attrs_initialized = false;
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env,
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
- return;
+ pty_set_spawn_error(err, "posix_openpt", errno);
+ goto done;
+ }
+
+ res = grantpt(*master);
+ if (res == -1) {
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
}
- int res = grantpt(*master) || unlockpt(*master);
+ res = unlockpt(*master);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "unlockpt", errno);
+ goto done;
}
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
- int slave;
char slave_pty_name[128];
res = ioctl(*master, TIOCPTYGNAME, slave_pty_name);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "ioctl_TIOCPTYGNAME", errno);
+ goto done;
}
slave = open(slave_pty_name, O_RDWR | O_NOCTTY);
if (slave == -1) {
- return;
+ pty_set_spawn_error(err, "open_slave", errno, "slave", slave_pty_name);
+ goto done;
}
if (termp) {
res = tcsetattr(slave, TCSANOW, termp);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "tcsetattr", errno, "slave", slave_pty_name);
+ goto done;
};
}
if (winp) {
res = ioctl(slave, TIOCSWINSZ, winp);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "ioctl_TIOCSWINSZ", errno, "slave", slave_pty_name);
+ goto done;
}
}
- posix_spawn_file_actions_t acts;
- posix_spawn_file_actions_init(&acts);
+ res = posix_spawn_file_actions_init(&acts);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawn_file_actions_init", res);
+ goto done;
+ }
+ acts_initialized = true;
posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO);
posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO);
posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO);
posix_spawn_file_actions_addclose(&acts, slave);
posix_spawn_file_actions_addclose(&acts, *master);
- posix_spawnattr_t attrs;
- posix_spawnattr_init(&attrs);
- *err = posix_spawnattr_setflags(&attrs, flags);
- if (*err != 0) {
+ res = posix_spawnattr_init(&attrs);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_init", res);
+ goto done;
+ }
+ attrs_initialized = true;
+ res = posix_spawnattr_setflags(&attrs, flags);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_setflags", res);
goto done;
}
sigset_t signal_set;
/* Reset all signal the child to their default behavior */
sigfillset(&signal_set);
- *err = posix_spawnattr_setsigdefault(&attrs, &signal_set);
- if (*err != 0) {
+ res = posix_spawnattr_setsigdefault(&attrs, &signal_set);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_setsigdefault", res);
goto done;
}
/* Reset the signal mask for all signals */
sigemptyset(&signal_set);
- *err = posix_spawnattr_setsigmask(&attrs, &signal_set);
- if (*err != 0) {
+ res = posix_spawnattr_setsigmask(&attrs, &signal_set);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawnattr_setsigmask", res);
goto done;
}
do
- *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
- while (*err == EINTR);
+ res = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
+ while (res == EINTR);
+ if (res != 0) {
+ pty_set_spawn_error(err, "posix_spawn", res, "helper", argv[0]);
+ }
done:
- posix_spawn_file_actions_destroy(&acts);
- posix_spawnattr_destroy(&attrs);
+ if (acts_initialized) {
+ posix_spawn_file_actions_destroy(&acts);
+ }
+ if (attrs_initialized) {
+ posix_spawnattr_destroy(&attrs);
+ }
+ if (slave != -1) {
+ close(slave);
+ }
+ if (err->errnum != 0 && *master != -1) {
+ close(*master);
+ *master = -1;
+ }
- for (; count > 0; count--) {
- close(low_fds[count]);
+ for (size_t i = 0; i <= count && i < 3; i++) {
+ if (low_fds[i] != -1) {
+ close(low_fds[i]);
+ }
}
}
#endif
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,619 @@
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', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate'])
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
'ariaLabel',
'aria-label',
'aria-description',
'alt',
'description',
'emptyText',
'helperText',
'keywords',
'label',
'message',
'placeholder',
'subtitle',
'text',
'title',
'toggleDescription',
'tooltip'
])
const USER_VISIBLE_OBJECT_KEYS = new Set([
'ariaLabel',
'badge',
'description',
'emptyText',
'error',
'helperText',
'keywords',
'label',
'message',
'placeholder',
'subtitle',
'title',
'toggleDescription',
'tooltip'
])
const USER_VISIBLE_FUNCTION_NAMES = new Set([
'alert',
'confirm',
'prompt',
'showError',
'showToast'
])
const USER_VISIBLE_OBJECT_METHODS = new Set([
'error',
'info',
'loading',
'message',
'promise',
'success',
'warning'
])
const USER_VISIBLE_OBJECT_NAMES = new Set(['toast'])
function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
if (
relative.endsWith('.d.ts') ||
relative.includes('.test.') ||
relative.includes('.spec.') ||
relative.includes('/__tests__/')
) {
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)))
}
continue
}
if (
entry.isFile() &&
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
!isSkippedFile(root, fullPath)
) {
files.push(fullPath)
}
}
return files
}
function hasHumanLanguageText(text) {
const trimmed = text.replace(/\s+/g, ' ').trim()
if (trimmed.length < 2) {
return false
}
if (/^[\d\s!-/:-@[-`{-~]+$/.test(trimmed)) {
return false
}
return /[A-Za-z\u00C0-\u024F\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(trimmed)
}
function compactText(text) {
return text.replace(/\s+/g, ' ').trim()
}
function lineAndColumn(sourceFile, node) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
return { line: position.line + 1, column: position.character + 1 }
}
function propertyNameText(name) {
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
return name.text
}
if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) {
return name.expression.text
}
return undefined
}
function expressionNameText(node) {
if (ts.isIdentifier(node)) {
return node.text
}
if (ts.isPropertyAccessExpression(node)) {
return `${expressionNameText(node.expression) ?? ''}.${node.name.text}`.replace(/^\./, '')
}
return undefined
}
function stringParts(node) {
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return [{ text: node.text, dynamic: false }]
}
if (!ts.isTemplateExpression(node)) {
return []
}
return [
{ text: node.head.text, dynamic: true },
...node.templateSpans.map((span) => ({ text: span.literal.text, dynamic: true }))
]
}
function isInsideLocalizationCall(node) {
let current = node.parent
while (current) {
if (ts.isCallExpression(current)) {
const name = expressionNameText(current.expression)
if (name && LOCALIZATION_CALL_NAMES.has(name.split('.').at(-1) ?? name)) {
return true
}
}
current = current.parent
}
return false
}
function isJsxAttributeValue(node) {
const parent = node.parent
if (!parent) {
return undefined
}
if (ts.isJsxAttribute(parent)) {
return propertyNameText(parent.name)
}
if (parent && ts.isJsxExpression(parent) && parent.parent && ts.isJsxAttribute(parent.parent)) {
return propertyNameText(parent.parent.name)
}
return undefined
}
function ancestorJsxAttributeName(node) {
let current = node.parent
while (current) {
if (ts.isJsxAttribute(current)) {
return propertyNameText(current.name)
}
if (
ts.isJsxExpression(current) ||
ts.isConditionalExpression(current) ||
ts.isParenthesizedExpression(current) ||
ts.isBinaryExpression(current)
) {
current = current.parent
continue
}
return undefined
}
return undefined
}
function isRenderedJsxExpression(node) {
let current = node.parent
while (current) {
if (ts.isJsxExpression(current)) {
return (
ts.isJsxElement(current.parent) ||
ts.isJsxFragment(current.parent) ||
ts.isJsxSelfClosingElement(current.parent)
)
}
if (
ts.isConditionalExpression(current) ||
ts.isParenthesizedExpression(current) ||
ts.isTemplateExpression(current) ||
ts.isNoSubstitutionTemplateLiteral(current)
) {
if (ts.isConditionalExpression(current) && current.condition === node) {
return false
}
current = current.parent
continue
}
if (ts.isBinaryExpression(current)) {
if (current.operatorToken.kind !== ts.SyntaxKind.PlusToken) {
return false
}
current = current.parent
continue
}
return false
}
return false
}
function nearestObjectPropertyName(node) {
let current = node.parent
while (current) {
if (ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) {
return propertyNameText(current.name)
}
if (ts.isObjectLiteralExpression(current) || ts.isArrayLiteralExpression(current)) {
current = current.parent
continue
}
return undefined
}
return undefined
}
function hasAncestorObjectPropertyName(node, names) {
let current = node.parent
while (current) {
if (
(ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) &&
names.has(propertyNameText(current.name) ?? '')
) {
return true
}
current = current.parent
}
return false
}
function nearestAncestorObjectPropertyName(node) {
let current = node.parent
while (current) {
if (ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) {
return propertyNameText(current.name)
}
current = current.parent
}
return undefined
}
function findAncestor(node, predicate) {
let current = node.parent
while (current) {
if (predicate(current)) {
return current
}
current = current.parent
}
return undefined
}
function isUserVisibleCallArgument(node) {
const call = findAncestor(node, ts.isCallExpression)
if (!call) {
return false
}
const expressionName = expressionNameText(call.expression)
if (!expressionName) {
return false
}
const parts = expressionName.split('.')
const methodName = parts.at(-1)
const objectName = parts.at(-2)
return (
USER_VISIBLE_FUNCTION_NAMES.has(expressionName) ||
USER_VISIBLE_FUNCTION_NAMES.has(methodName ?? '') ||
(objectName !== undefined &&
USER_VISIBLE_OBJECT_NAMES.has(objectName) &&
USER_VISIBLE_OBJECT_METHODS.has(methodName ?? ''))
)
}
function classifyStringNode(node) {
if (hasAncestorObjectPropertyName(node, new Set(['className', 'classNames']))) {
return undefined
}
if (
findAncestor(
node,
(ancestor) =>
ts.isBinaryExpression(ancestor) && ancestor.operatorToken.kind !== ts.SyntaxKind.PlusToken
)
) {
return undefined
}
const jsxAttributeName = isJsxAttributeValue(node)
if (jsxAttributeName) {
return USER_VISIBLE_JSX_ATTRIBUTES.has(jsxAttributeName)
? `jsx-attribute:${jsxAttributeName}`
: undefined
}
const ancestorAttributeName = ancestorJsxAttributeName(node)
if (ancestorAttributeName) {
return USER_VISIBLE_JSX_ATTRIBUTES.has(ancestorAttributeName)
? `jsx-attribute:${ancestorAttributeName}`
: undefined
}
if (ts.isJsxText(node)) {
return 'jsx-text'
}
const objectPropertyName = nearestObjectPropertyName(node)
if (objectPropertyName && !USER_VISIBLE_OBJECT_KEYS.has(objectPropertyName)) {
return undefined
}
const ancestorObjectPropertyName = nearestAncestorObjectPropertyName(node)
if (ancestorObjectPropertyName && !USER_VISIBLE_OBJECT_KEYS.has(ancestorObjectPropertyName)) {
return undefined
}
if (isRenderedJsxExpression(node)) {
return 'jsx-expression'
}
if (isUserVisibleCallArgument(node)) {
return 'user-visible-call'
}
if (objectPropertyName) {
return `object-property:${objectPropertyName}`
}
return undefined
}
function areaForFile(relativePath) {
const rendererPrefix = 'src/renderer/src/'
if (!relativePath.startsWith(rendererPrefix)) {
return relativePath.split('/').slice(0, 2).join('/')
}
const withoutPrefix = relativePath.slice(rendererPrefix.length)
const parts = withoutPrefix.split('/')
if (parts[0] === 'components' && parts[1]) {
return `renderer/${parts[1]}`
}
return `renderer/${parts[0] ?? 'root'}`
}
export function collectLocalizationCandidates(filePath, sourceText, root = process.cwd()) {
const sourceKind =
filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
sourceKind
)
const reports = []
const relativePath = normalizePath(root, filePath)
function pushReport(node, kind, text, dynamic = false) {
const value = compactText(text)
if (!hasHumanLanguageText(value) || isInsideLocalizationCall(node)) {
return
}
const position = lineAndColumn(sourceFile, node)
reports.push({
area: areaForFile(relativePath),
filePath: relativePath,
start: node.getStart(sourceFile),
end: node.getEnd(),
line: position.line,
column: position.column,
kind,
text: value,
dynamic
})
}
function visit(node) {
if (ts.isJsxText(node)) {
pushReport(node, 'jsx-text', node.text)
return
}
const kind = classifyStringNode(node)
if (kind) {
for (const part of stringParts(node)) {
pushReport(node, kind, part.text, part.dynamic)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return reports
}
function groupByArea(reports) {
const groups = new Map()
for (const report of reports) {
const group = groups.get(report.area) ?? { area: report.area, count: 0, files: new Map() }
group.count += 1
group.files.set(report.filePath, (group.files.get(report.filePath) ?? 0) + 1)
groups.set(report.area, group)
}
return [...groups.values()].sort((left, right) => right.count - left.count)
}
function formatReports(_root, reports) {
return reports
.map(
(report) =>
`${report.filePath}:${report.line}:${report.column} ${report.kind}: ${JSON.stringify(report.text)}`
)
.join('\n')
}
function formatMarkdownReport(reports) {
const groups = groupByArea(reports)
const lines = [
'# Localization Candidate Inventory',
'',
`Generated: ${new Date().toISOString()}`,
'',
`Total candidates: ${reports.length}`,
'',
'## Area Summary',
''
]
for (const group of groups) {
lines.push(`- ${group.area}: ${group.count} candidates across ${group.files.size} files`)
}
lines.push('', '## Candidates', '')
for (const group of groups) {
lines.push(`### ${group.area}`, '')
for (const report of reports.filter((entry) => entry.area === group.area)) {
lines.push(
`- \`${report.filePath}:${report.line}:${report.column}\` ${report.kind}: ${JSON.stringify(report.text)}`
)
}
lines.push('')
}
return lines.join('\n')
}
function parseArgs(argv) {
const options = {
allowlistPath: path.join('config', 'localization-coverage-allowlist.json'),
check: false,
format: 'summary',
outputPath: null,
sourceRoot: path.join('src', 'renderer', 'src')
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--json') {
options.format = 'json'
} else if (arg === '--markdown') {
options.format = 'markdown'
} else if (arg === '--check') {
options.check = true
} else if (arg === '--allowlist') {
options.allowlistPath = argv[index + 1] ?? options.allowlistPath
index += 1
} else if (arg === '--output') {
options.outputPath = argv[index + 1] ?? null
index += 1
} else if (arg === '--source-root') {
options.sourceRoot = argv[index + 1] ?? options.sourceRoot
index += 1
}
}
return options
}
function candidateSignature(candidate) {
return JSON.stringify({
filePath: candidate.filePath,
kind: candidate.kind,
text: candidate.text,
dynamic: candidate.dynamic
})
}
function countBySignature(reports) {
const counts = new Map()
for (const report of reports) {
const signature = candidateSignature(report)
counts.set(signature, (counts.get(signature) ?? 0) + 1)
}
return counts
}
async function readAllowlist(root, allowlistPath) {
const absolutePath = path.resolve(root, allowlistPath)
const raw = await fs.readFile(absolutePath, 'utf8')
return JSON.parse(raw)
}
function findNewCandidates(reports, allowlist) {
const allowedCounts = new Map(
allowlist.map((entry) => [
JSON.stringify({
filePath: entry.filePath,
kind: entry.kind,
text: entry.text,
dynamic: entry.dynamic
}),
entry.count
])
)
const seenCounts = countBySignature(reports)
const newCandidates = []
for (const report of reports) {
const signature = candidateSignature(report)
const seenCount = seenCounts.get(signature) ?? 0
const allowedCount = allowedCounts.get(signature) ?? 0
if (seenCount > allowedCount) {
newCandidates.push(report)
seenCounts.set(signature, seenCount - 1)
}
}
return newCandidates
}
export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
const options = parseArgs(argv)
const absoluteSourceRoot = path.resolve(root, options.sourceRoot)
const files = await collectSourceFiles(root, absoluteSourceRoot)
const reports = []
for (const filePath of files) {
const sourceText = await fs.readFile(filePath, 'utf8')
reports.push(...collectLocalizationCandidates(filePath, sourceText, root))
}
if (options.check) {
const allowlist = await readAllowlist(root, options.allowlistPath)
const newCandidates = findNewCandidates(reports, allowlist)
if (newCandidates.length > 0) {
console.error('New unlocalized renderer strings were found.')
console.error('Localize them or add a reviewed exclusion to the localization allowlist.')
console.error('')
console.error(formatReports(root, newCandidates))
return 1
}
console.log(`Localization coverage check passed with ${reports.length} allowlisted candidates.`)
return 0
}
const output =
options.format === 'json'
? `${JSON.stringify(reports, null, 2)}\n`
: options.format === 'markdown'
? `${formatMarkdownReport(reports)}\n`
: `${reports.length} localization candidates in ${files.length} files.\n${groupByArea(
reports
)
.map((group) => `${group.area}: ${group.count}`)
.join('\n')}\n`
if (options.outputPath) {
await fs.mkdir(path.dirname(path.resolve(root, options.outputPath)), { recursive: true })
await fs.writeFile(path.resolve(root, options.outputPath), output)
} else {
process.stdout.write(output)
}
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
@@ -0,0 +1,440 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
compareBenchmarkArtifacts,
formatBenchmarkComparisonMarkdown,
parseBenchmarkComparisonArgs
} from './compare-benchmark-artifacts.mjs'
const scriptPath = 'config/scripts/compare-benchmark-artifacts.mjs'
const tempDirs = []
function makeTempDir() {
const dir = mkdtempSync(join(tmpdir(), 'orca-benchmark-comparison-'))
tempDirs.push(dir)
return dir
}
function writeArtifact(dir, name, artifact) {
const artifactPath = join(dir, name)
writeFileSync(artifactPath, JSON.stringify(artifact))
return artifactPath
}
function comparePaths(baselinePath, candidatePath, extra = {}) {
return compareBenchmarkArtifacts({
baselinePath,
candidatePath,
now: () => new Date('2026-06-21T12:00:00.000Z'),
title: 'Test Compare',
...extra
})
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('benchmark artifact comparison', () => {
it('parses required CLI flags and rejects missing paths', () => {
expect(() => parseBenchmarkComparisonArgs(['--baseline'])).toThrow('--baseline requires a path')
expect(() => parseBenchmarkComparisonArgs(['--candidate'])).toThrow(
'--candidate requires a path'
)
expect(() => parseBenchmarkComparisonArgs(['--candidate', 'candidate.json'])).toThrow(
'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline <path> --candidate <path> [--title <title>] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]'
)
})
it('compares startup-style summary median metrics and skips null candidates', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline.json', {
label: 'baseline',
summaryMedianMs: {
missingLater: 5,
spawnToAppReady: 25,
totalToDidFinishLoad: 100
}
})
const candidatePath = writeArtifact(dir, 'candidate.json', {
label: 'candidate',
summaryMedianMs: {
missingLater: null,
spawnToAppReady: 30,
totalToDidFinishLoad: 40
}
})
const comparison = comparePaths(baselinePath, candidatePath)
expect(comparison.schemaVersion).toBe(1)
expect(comparison.createdAt).toBe('2026-06-21T12:00:00.000Z')
expect(comparison.baseline.label).toBe('baseline')
expect(comparison.candidate.label).toBe('candidate')
expect(
comparison.metrics.find((metric) => metric.key === 'totalToDidFinishLoad')
).toMatchObject({
absoluteDelta: -60,
baseline: 100,
candidate: 40,
percentDelta: -60,
status: 'improved',
unit: 'ms'
})
expect(comparison.metrics.find((metric) => metric.key === 'spawnToAppReady')).toMatchObject({
status: 'regressed'
})
expect(comparison.skippedMetrics).toContainEqual({
key: 'missingLater',
reason: 'missing candidate metric'
})
})
it('compares numeric Playwright annotation metrics and omits metadata fields', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline-playwright.json', {
suites: [
{
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale-same-workspace-50',
description:
'panes=50 frames=60 median=80.0ms worst=120.0ms rendererQueuedChars=1000 samples=1,2'
}
]
}
]
}
]
}
]
}
]
})
const candidatePath = writeArtifact(dir, 'candidate-playwright.json', {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale-same-workspace-50',
description:
'panes=50 frames=60 median=60.0ms worst=100.0ms rendererQueuedChars=800 samples=1,2'
}
]
}
]
}
]
}
]
})
const comparison = comparePaths(baselinePath, candidatePath)
const metricKeys = comparison.metrics.map((metric) => metric.key)
expect(metricKeys).toContain('opencode-scale-same-workspace-50.median')
expect(metricKeys).toContain('opencode-scale-same-workspace-50.rendererQueuedChars')
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.panes')
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.frames')
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.samples')
expect(
comparison.metrics.find((metric) => metric.key === 'opencode-scale-same-workspace-50.median')
).toMatchObject({ status: 'improved', unit: 'ms' })
})
it('aggregates duplicate Playwright scenario metrics before comparison', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline-playwright-duplicates.json', {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-duplicate',
description: 'median=80.0ms rendererQueuedChars=1000'
},
{
type: 'opencode-duplicate',
description: 'median=100.0ms rendererQueuedChars=1400'
}
]
}
]
}
]
}
]
})
const candidatePath = writeArtifact(dir, 'candidate-playwright-duplicates.json', {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-duplicate',
description: 'median=60.0ms rendererQueuedChars=800'
},
{
type: 'opencode-duplicate',
description: 'median=70.0ms rendererQueuedChars=1000'
}
]
}
]
}
]
}
]
})
const comparison = comparePaths(baselinePath, candidatePath)
const duplicateMedianMetrics = comparison.metrics.filter(
(metric) => metric.key === 'opencode-duplicate.median'
)
expect(duplicateMedianMetrics).toHaveLength(1)
expect(duplicateMedianMetrics[0]).toMatchObject({
absoluteDelta: -25,
baseline: 90,
candidate: 65,
percentDelta: -27.8,
status: 'improved',
unit: 'ms'
})
expect(
comparison.metrics.filter((metric) => metric.key === 'opencode-duplicate.rendererQueuedChars')
).toHaveLength(1)
})
it('skips unit mismatches instead of comparing incompatible metrics', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline-playwright-units.json', {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-units',
description: 'median=80.0ms rendererQueuedChars=1000'
}
]
}
]
}
]
}
]
})
const candidatePath = writeArtifact(dir, 'candidate-playwright-units.json', {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-units',
description: 'median=60 rendererQueuedChars=800'
}
]
}
]
}
]
}
]
})
const comparison = comparePaths(baselinePath, candidatePath)
expect(comparison.metrics.map((metric) => metric.key)).toEqual([
'opencode-units.rendererQueuedChars'
])
expect(comparison.skippedMetrics).toContainEqual({
key: 'opencode-units.median',
reason: 'unit mismatch (ms vs count)'
})
})
it('supports higher-is-better metrics for generic summary artifacts', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'generic-baseline.json', {
summary: {
totalBytes: 2048,
totalCpuPercent: { mean: 80 },
throughput: 10
}
})
const candidatePath = writeArtifact(dir, 'generic-candidate.json', {
summary: {
totalBytes: 1024,
totalCpuPercent: { mean: 70 },
throughput: 12
}
})
const comparison = comparePaths(baselinePath, candidatePath, {
higherIsBetter: new Set(['summary.throughput'])
})
expect(comparison.metrics.find((metric) => metric.key === 'summary.throughput')).toMatchObject({
direction: 'higher-is-better',
status: 'improved'
})
expect(comparison.metrics.find((metric) => metric.key === 'summary.totalBytes')).toMatchObject({
unit: 'bytes'
})
expect(
comparison.metrics.find((metric) => metric.key === 'summary.totalCpuPercent.mean')
).toMatchObject({
unit: '%'
})
})
it('writes Markdown and JSON reports from the CLI while printing Markdown', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline.json', {
label: 'baseline',
summaryMedianMs: { totalToDidFinishLoad: 100 }
})
const candidatePath = writeArtifact(dir, 'candidate.json', {
label: 'candidate',
summaryMedianMs: { totalToDidFinishLoad: 40 }
})
const markdownPath = join(dir, 'nested', 'comparison.md')
const jsonPath = join(dir, 'nested', 'comparison.json')
const result = spawnSync(
process.execPath,
[
scriptPath,
'--baseline',
baselinePath,
'--candidate',
candidatePath,
'--title',
'Test Compare',
'--output',
markdownPath,
'--json-output',
jsonPath
],
{ cwd: process.cwd(), encoding: 'utf8' }
)
expect(result.status).toBe(0)
expect(result.stdout).toContain('| Metric | Baseline | Candidate | Delta | Delta % | Result |')
expect(result.stdout).toContain(
'| totalToDidFinishLoad | 100.0ms | 40.0ms | -60.0ms | -60.0% | improved |'
)
expect(existsSync(markdownPath)).toBe(true)
expect(existsSync(jsonPath)).toBe(true)
expect(JSON.parse(readFileSync(jsonPath, 'utf8')).schemaVersion).toBe(1)
})
it('redacts absolute input paths from generated reports', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'absolute-baseline.json', {
label: 'baseline',
summaryMedianMs: { totalToDidFinishLoad: 100 }
})
const candidatePath = writeArtifact(dir, 'absolute-candidate.json', {
label: 'candidate',
summaryMedianMs: { totalToDidFinishLoad: 40 }
})
const markdownPath = join(dir, 'comparison.md')
const jsonPath = join(dir, 'comparison.json')
const result = spawnSync(
process.execPath,
[
scriptPath,
'--baseline',
baselinePath,
'--candidate',
candidatePath,
'--output',
markdownPath,
'--json-output',
jsonPath
],
{ cwd: process.cwd(), encoding: 'utf8' }
)
const json = JSON.parse(readFileSync(jsonPath, 'utf8'))
const markdown = readFileSync(markdownPath, 'utf8')
expect(result.status).toBe(0)
expect(json.baseline.path).toBe('absolute-baseline.json')
expect(json.candidate.path).toBe('absolute-candidate.json')
expect(markdown).not.toContain(dir)
expect(result.stdout).not.toContain(dir)
})
it('escapes artifact-controlled Markdown fields in reports', () => {
const markdown = formatBenchmarkComparisonMarkdown({
title: 'Compare\n[Injected](https://example.test)',
createdAt: '2026-06-21T12:00:00.000Z',
baseline: { label: 'base\n<label>', path: 'base|path', kind: 'summary' },
candidate: { label: 'candidate', path: 'candidate.md', kind: 'summary' },
metrics: [
{
key: 'summary.value|with-pipe',
unit: '',
baseline: 1,
candidate: 2,
absoluteDelta: 1,
percentDelta: 100,
status: 'regressed'
}
],
skippedMetrics: [{ key: 'missing\nmetric', reason: 'missing | candidate' }]
})
expect(markdown).toContain('# Compare \\[Injected\\]\\(https://example\\.test\\)')
expect(markdown).toContain('Baseline: base \\<label\\> (base|path)')
expect(markdown).toContain(
'| summary\\.value\\|with\\-pipe | 1.0 | 2.0 | 1.0 | 100.0% | regressed |'
)
expect(markdown).toContain('- missing metric: missing | candidate')
})
it('fails unsupported artifacts in the CLI', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline.json', { hello: 'world' })
const candidatePath = writeArtifact(dir, 'candidate.json', { hello: 'world' })
const result = spawnSync(
process.execPath,
[scriptPath, '--baseline', baselinePath, '--candidate', candidatePath],
{ cwd: process.cwd(), encoding: 'utf8' }
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('unsupported benchmark artifact')
})
})
+201
View File
@@ -0,0 +1,201 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import {
collectStringLeaves,
repairCatalog,
repairTranslatedValue,
setLeaf,
shouldPreserveEnglishValue
} from './locale-translation-policy.mjs'
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
const LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const LOCALE_CONFIG = {
zh: {
targetLanguage: 'zh-CN',
displayName: 'Simplified Chinese',
cacheFile: '.zh-catalog-cache.json'
},
ko: {
targetLanguage: 'ko',
displayName: 'Korean',
cacheFile: '.ko-catalog-cache.json'
},
ja: {
targetLanguage: 'ja',
displayName: 'Japanese',
cacheFile: '.ja-catalog-cache.json'
},
es: {
targetLanguage: 'es',
displayName: 'Spanish',
cacheFile: '.es-catalog-cache.json'
}
}
function protectPlaceholders(text) {
const tokens = []
const protectedText = text.replace(PLACEHOLDER_RE, (match) => {
const token = `__PH${tokens.length}__`
tokens.push(match)
return token
})
return { protectedText, tokens }
}
function restorePlaceholders(text, tokens) {
let result = text
for (let index = 0; index < tokens.length; index += 1) {
const patterns = [`__PH${index}__`, `__ PH ${index} __`, `__PH ${index}__`, `__ PH${index}__`]
for (const pattern of patterns) {
result = result.replaceAll(pattern, tokens[index])
}
}
return result
}
function shouldSkipTranslation(text) {
return shouldPreserveEnglishValue(text)
}
async function translateText(text, targetLanguage) {
const url = new URL('https://translate.googleapis.com/translate_a/single')
url.searchParams.set('client', 'gtx')
url.searchParams.set('sl', 'en')
url.searchParams.set('tl', targetLanguage)
url.searchParams.set('dt', 't')
url.searchParams.set('q', text)
let lastError
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Translation request failed with status ${response.status}`)
}
const payload = await response.json()
return payload[0].map((part) => part[0]).join('')
} catch (error) {
lastError = error
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)))
}
}
throw lastError
}
async function mapWithConcurrency(items, concurrency, mapper) {
const results = Array.from({ length: items.length })
let nextIndex = 0
async function worker() {
while (nextIndex < items.length) {
const currentIndex = nextIndex
nextIndex += 1
results[currentIndex] = await mapper(items[currentIndex], currentIndex)
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()))
return results
}
async function loadCache(cachePath) {
try {
const raw = JSON.parse(await fs.readFile(cachePath, 'utf8'))
return new Map(Object.entries(raw))
} catch {
return new Map()
}
}
async function saveCache(cachePath, cache) {
const raw = Object.fromEntries(cache.entries())
await fs.writeFile(cachePath, `${JSON.stringify(raw, null, 2)}\n`, 'utf8')
}
function parseLocaleArg(argv) {
const localeFlagIndex = argv.indexOf('--locale')
if (localeFlagIndex !== -1 && argv[localeFlagIndex + 1]) {
return argv[localeFlagIndex + 1]
}
return argv[2]
}
export async function main(root = process.cwd(), locale = parseLocaleArg(process.argv)) {
const config = LOCALE_CONFIG[locale]
if (!config) {
console.error(
`Unsupported locale "${locale}". Supported: ${Object.keys(LOCALE_CONFIG).join(', ')}`
)
return 1
}
const enPath = path.join(root, LOCALES_DIR, 'en.json')
const localePath = path.join(root, LOCALES_DIR, `${locale}.json`)
const cachePath = path.join(root, LOCALES_DIR, config.cacheFile)
const enCatalog = JSON.parse(await fs.readFile(enPath, 'utf8'))
const localeCatalog = structuredClone(enCatalog)
const leaves = collectStringLeaves(enCatalog)
const uniqueValues = [...new Set(leaves.map((leaf) => leaf.value))]
const cache = await loadCache(cachePath)
const toTranslate = uniqueValues.filter(
(value) => !shouldSkipTranslation(value) && !cache.has(value)
)
console.log(
`Translating ${toTranslate.length} unique strings to ${config.displayName} (${cache.size} cached)...`
)
let completed = 0
await mapWithConcurrency(toTranslate, 2, async (value) => {
completed += 1
if (completed % 25 === 0) {
console.log(` ${completed}/${toTranslate.length}`)
await saveCache(cachePath, cache)
}
const { protectedText, tokens } = protectPlaceholders(value)
const translated = await translateText(protectedText, config.targetLanguage)
const restored = restorePlaceholders(translated, tokens)
cache.set(
value,
repairTranslatedValue({ key: '', enValue: value, localeValue: restored, locale })
)
await new Promise((resolve) => setTimeout(resolve, 200))
})
for (const value of uniqueValues) {
if (shouldSkipTranslation(value) && !cache.has(value)) {
cache.set(value, value)
}
}
await saveCache(cachePath, cache)
for (const leaf of leaves) {
const cached = cache.get(leaf.value) ?? leaf.value
setLeaf(
localeCatalog,
leaf.key,
repairTranslatedValue({
key: leaf.key,
enValue: leaf.value,
localeValue: cached,
locale
})
)
}
repairCatalog(enCatalog, localeCatalog, locale)
await fs.writeFile(localePath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
console.log(`Wrote ${localePath}`)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
+12
View File
@@ -0,0 +1,12 @@
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { main as bootstrapLocaleCatalog } from './bootstrap-locale-catalog.mjs'
export async function main(root = process.cwd()) {
return bootstrapLocaleCatalog(root, 'zh')
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
+141
View File
@@ -0,0 +1,141 @@
import { spawnSync } from 'node:child_process'
import { chmodSync, copyFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
const repoRoot = path.resolve(import.meta.dirname, '../..')
const packagePath = path.join(repoRoot, 'native', 'computer-use-macos')
const binaryPath = path.join(packagePath, '.build', 'release', 'orca-computer-use-macos')
const appPath = path.join(packagePath, '.build', 'release', 'Orca Computer Use.app')
const appExecutablePath = path.join(appPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
const appIconPath = path.join(appPath, 'Contents', 'Resources', 'AppIcon.icns')
const entitlementsPath = path.join(
repoRoot,
'resources',
'build',
'entitlements.computer-use.mac.plist'
)
const bundleId = process.env.ORCA_COMPUTER_MACOS_BUNDLE_ID ?? 'com.stablyai.orca.computer-use'
const displayName = 'Orca Computer Use'
const signingIdentity = resolveSigningIdentity()
const universalTriples = ['arm64-apple-macosx', 'x86_64-apple-macosx']
if (process.platform !== 'darwin') {
process.exit(0)
}
buildUniversalBinary()
chmodSync(binaryPath, 0o755)
createHelperApp()
function buildUniversalBinary() {
const builtBinaries = universalTriples.map((triple) => {
run('swift', ['build', '-c', 'release', '--package-path', packagePath, '--triple', triple])
return path.join(packagePath, '.build', triple, 'release', 'orca-computer-use-macos')
})
mkdirSync(path.dirname(binaryPath), { recursive: true })
run('lipo', ['-create', ...builtBinaries, '-output', binaryPath])
}
function createHelperApp() {
rmSync(appPath, { recursive: true, force: true })
mkdirSync(path.dirname(appExecutablePath), { recursive: true })
mkdirSync(path.join(appPath, 'Contents', 'Resources'), { recursive: true })
copyFileSync(binaryPath, appExecutablePath)
copyFileSync(path.join(repoRoot, 'resources', 'build', 'icon.icns'), appIconPath)
chmodSync(appExecutablePath, 0o755)
writeFileSync(path.join(appPath, 'Contents', 'Info.plist'), infoPlist(), 'utf8')
const signer = spawnSync('codesign', codesignArgs(signingIdentity, appPath), { stdio: 'inherit' })
if (signer.signal) {
process.kill(process.pid, signer.signal)
}
if (signer.status !== 0) {
process.exit(signer.status ?? 1)
}
}
function codesignArgs(identity, targetPath) {
const args = ['--force', '--deep', '--sign', identity]
if (process.env.ORCA_MAC_RELEASE === '1' && identity !== '-') {
args.push('--options', 'runtime', '--timestamp', '--entitlements', entitlementsPath)
}
args.push(targetPath)
return args
}
function resolveSigningIdentity() {
const explicitIdentity = process.env.ORCA_COMPUTER_MACOS_SIGN_IDENTITY ?? process.env.CSC_NAME
if (explicitIdentity) {
return explicitIdentity
}
const identities = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], {
encoding: 'utf8'
})
if (identities.status !== 0 || !identities.stdout) {
return '-'
}
const developmentMatch = identities.stdout.match(/"([^"]*Apple Development:[^"]+)"/)
if (process.env.ORCA_MAC_RELEASE !== '1' && developmentMatch) {
return developmentMatch[1]
}
const releaseMatch =
identities.stdout.match(/"([^"]*Developer ID Application:[^"]+)"/) ??
identities.stdout.match(/"([^"]*Apple Distribution:[^"]+)"/)
return releaseMatch?.[1] ?? developmentMatch?.[1] ?? '-'
}
function run(command, args) {
const result = spawnSync(command, args, { stdio: 'inherit' })
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
function infoPlist() {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>orca-computer-use-macos</string>
<key>CFBundleIdentifier</key>
<string>${escapePlist(bundleId)}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleName</key>
<string>${escapePlist(displayName)}</string>
<key>CFBundleDisplayName</key>
<string>${escapePlist(displayName)}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSAccessibilityUsageDescription</key>
<string>Orca Computer Use needs Accessibility permission to read and interact with app interfaces when you ask Orca to use apps.</string>
<key>NSScreenCaptureUsageDescription</key>
<string>Orca Computer Use needs Screen Recording permission to capture app windows when you ask Orca to inspect your screen.</string>
</dict>
</plist>
`
}
function escapePlist(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
if (process.platform === 'win32') {
runNodeScript('config/scripts/build-windows-cli-launcher.mjs')
process.exit(0)
}
if (process.platform !== 'darwin') {
console.log(`[native-build] no macOS native computer build required on ${process.platform}`)
process.exit(0)
}
runPnpmScript('build:computer-macos')
runPnpmScript('build:notification-status-macos')
process.exit(0)
function runPnpmScript(scriptName) {
const npmExecPath = process.env.npm_execpath
const command = npmExecPath
? process.execPath
: process.platform === 'win32'
? 'pnpm.cmd'
: 'pnpm'
const args = npmExecPath ? [npmExecPath, 'run', scriptName] : ['run', scriptName]
const result = spawnSync(command, args, { stdio: 'inherit' })
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.status !== 0 || result.error) {
process.exit(result.status ?? 1)
}
}
function runNodeScript(scriptPath) {
const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' })
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.status !== 0 || result.error) {
process.exit(result.status ?? 1)
}
}
@@ -0,0 +1,97 @@
#!/usr/bin/env node
// Builds the orca-notification-status helper binary.
//
// The helper reads UNUserNotificationCenter settings for the app it ships
// inside (see native/notification-status-macos/main.swift). The target
// CFBundleIdentifier is embedded as a __TEXT,__info_plist section so every
// later `codesign --force` pass (electron-builder's signing, the dev runner's
// ad-hoc deep sign) derives the correct code identifier automatically —
// macOS keys notification records to that identifier.
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const repoRoot = path.resolve(import.meta.dirname, '../..')
const sourcePath = path.join(repoRoot, 'native', 'notification-status-macos', 'main.swift')
const defaultOutputPath = path.join(
repoRoot,
'native',
'notification-status-macos',
'.build',
'release',
'orca-notification-status'
)
if (process.platform !== 'darwin') {
process.exit(0)
}
const args = process.argv.slice(2)
const bundleId = readArg('--bundle-id') ?? 'com.stablyai.orca'
const outputPath = readArg('--output') ?? defaultOutputPath
// Why: dev launches only need the host architecture; release builds ship a
// universal binary matching the app's x64 + arm64 targets.
const singleArch = args.includes('--single-arch')
const workDir = path.join(tmpdir(), `orca-notification-status-${process.pid}`)
mkdirSync(workDir, { recursive: true })
try {
const plistPath = path.join(workDir, 'Info.plist')
writeFileSync(plistPath, embeddedInfoPlist(bundleId), 'utf8')
const triples = singleArch
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
const builtBinaries = triples.map((triple) => {
const output = path.join(workDir, `orca-notification-status-${triple}`)
execFileSync(
'swiftc',
[
'-O',
sourcePath,
'-target',
triple.replace('-apple-macosx', '-apple-macosx11.0'),
'-o',
output,
'-Xlinker',
'-sectcreate',
'-Xlinker',
'__TEXT',
'-Xlinker',
'__info_plist',
'-Xlinker',
plistPath
],
{ stdio: 'inherit' }
)
return output
})
mkdirSync(path.dirname(outputPath), { recursive: true })
if (builtBinaries.length === 1) {
execFileSync('cp', [builtBinaries[0], outputPath])
} else {
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
}
execFileSync('chmod', ['755', outputPath])
} finally {
rmSync(workDir, { recursive: true, force: true })
}
function readArg(name) {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}
function embeddedInfoPlist(identifier) {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>${identifier}</string>
<key>CFBundleName</key>
<string>orca-notification-status</string>
</dict>
</plist>
`
}
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* Bundle the relay daemon and its crash-isolated watcher child per platform.
*
* The relay runs on remote hosts via `node relay.js`, so both outputs use
* self-contained CommonJS bundles with no external dependencies beyond
* Node.js built-ins. Native addons (node-pty, @parcel/watcher) are
* marked external and expected to be installed on the remote or
* gracefully degraded.
*/
import { build } from 'esbuild'
import { createHash } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const __dirname = import.meta.dirname
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const PLATFORMS = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64'
]
const RELAY_VERSION = '0.1.0'
for (const platform of PLATFORMS) {
const outDir = join(ROOT, 'out', 'relay', platform)
mkdirSync(outDir, { recursive: true })
await build({
entryPoints: [RELAY_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'relay.js'),
// Native addons cannot be bundled — they must exist on the remote host.
// The relay gracefully degrades when they are absent.
external: ['node-pty', '@parcel/watcher', 'electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [WATCHER_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'relay-watcher.js'),
external: ['@parcel/watcher'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
// Why: include a content hash so the deploy check detects code changes
// even when RELAY_VERSION hasn't been bumped. Hash both process artifacts
// so a watcher-only change always deploys beside the matching relay host.
const relayContent = readFileSync(join(outDir, 'relay.js'))
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
const hash = createHash('sha256')
.update(relayContent)
.update(watcherContent)
.digest('hex')
.slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
console.log(`Built relay for ${platform}${outDir}/relay.js`)
}
// WSL agent-hook relay: a hooks-only guest receiver launched inside WSL
// distros via wsl.exe. Pure Node built-ins (no node-pty/@parcel/watcher),
// so a single platform-independent bundle suffices; it ships inside the
// Windows app via the same out/relay extraResources mapping.
{
const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
const outDir = join(ROOT, 'out', 'relay', 'wsl')
mkdirSync(outDir, { recursive: true })
await build({
entryPoints: [wslEntry],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'wsl-agent-hook-relay.js'),
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
const content = readFileSync(join(outDir, 'wsl-agent-hook-relay.js'))
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`)
}
console.log('Relay build complete.')
@@ -0,0 +1,56 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
if (process.platform !== 'win32') {
process.exit(0)
}
const repoRoot = resolve(import.meta.dirname, '../..')
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
const compilerPath = findFrameworkCompiler(process.env)
if (!compilerPath) {
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
}
mkdirSync(dirname(outputPath), { recursive: true })
const result = spawnSync(
compilerPath,
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
{ cwd: repoRoot, stdio: 'inherit' }
)
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.error) {
throw result.error
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
function defaultOutputPath(projectRoot) {
return join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe')
}
function findFrameworkCompiler(env) {
const windowsDirectory = env.WINDIR ?? env.SystemRoot
if (!windowsDirectory) {
return null
}
const candidates = [
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
]
return candidates.find((candidate) => existsSync(candidate)) ?? null
}
function readArg(name) {
const index = process.argv.indexOf(name)
return index >= 0 ? process.argv[index + 1] : undefined
}
@@ -0,0 +1,69 @@
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
const itWindows = process.platform === 'win32' ? it : it.skip
const projectRoot = resolve(import.meta.dirname, '../..')
describe('Windows CLI launcher', () => {
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
try {
const resourcesPath = join(appRoot, 'resources')
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
mkdirSync(dirname(cliPath), { recursive: true })
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
writeFileSync(
cliPath,
`process.stdout.write(JSON.stringify({
argv: process.argv.slice(2),
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
nodeOptions: process.env.NODE_OPTIONS ?? null,
orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null
}))\n`,
'utf8'
)
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two'
const powershell = spawnSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
'& $env:ORCA_TEST_LAUNCHER orchestration send --body $env:ORCA_TEST_BODY --json'
],
{
encoding: 'utf8',
env: {
...process.env,
NODE_OPTIONS: '--no-warnings',
ORCA_TEST_BODY: body,
ORCA_TEST_LAUNCHER: launcherPath
}
}
)
expect(powershell.status, powershell.stderr).toBe(0)
expect(JSON.parse(powershell.stdout)).toEqual({
argv: ['orchestration', 'send', '--body', body, '--json'],
electronRunAsNode: '1',
nodeOptions: null,
orcaNodeOptions: '--no-warnings'
})
} finally {
rmSync(appRoot, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,83 @@
#!/usr/bin/env node
import { readdir, stat } from 'node:fs/promises'
import path from 'node:path'
const __dirname = import.meta.dirname
const ROOT = path.join(__dirname, '..', '..')
const FEATURE_WALL_ASSET_DIR = path.join(ROOT, 'resources', 'onboarding', 'feature-wall')
const MAX_BYTES = 11 * 1024 * 1024
const MEDIA_TILE_IDS = [
'tile-01',
'tile-02',
'tile-03',
'tile-04',
'tile-05',
'tile-06',
'tile-07',
'tile-08',
'tile-09',
'tile-10',
'tile-11',
'tile-12'
]
const EXPECTED_FILES = MEDIA_TILE_IDS.flatMap((id) => [
`${id}.gif`,
`${id}.poster.jpg`,
`${id}.recorded-at.json`
])
async function collectFiles(dir) {
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch (error) {
if (error && error.code === 'ENOENT') {
return []
}
throw error
}
const files = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
files.push(...(await collectFiles(fullPath)))
} else if (entry.isFile()) {
files.push(fullPath)
}
}
return files
}
const files = await collectFiles(FEATURE_WALL_ASSET_DIR)
const fileNames = new Set(files.map((file) => path.relative(FEATURE_WALL_ASSET_DIR, file)))
const missingFiles = EXPECTED_FILES.filter((file) => !fileNames.has(file))
if (missingFiles.length > 0) {
// Why: a byte-budget-only check lets an empty asset directory pass, which
// ships the feature tour as text-only cards instead of the recorded media.
console.error(`Feature wall assets are missing: ${missingFiles.join(', ')}`)
process.exit(1)
}
let totalBytes = 0
for (const file of files) {
const fileStat = await stat(file)
totalBytes += fileStat.size
}
if (totalBytes > MAX_BYTES) {
const totalMb = (totalBytes / 1024 / 1024).toFixed(2)
const maxMb = (MAX_BYTES / 1024 / 1024).toFixed(2)
console.error(
`Feature wall assets are ${totalMb} MB, which exceeds the ${maxMb} MB installer budget.`
)
process.exit(1)
}
console.log(
`Feature wall assets: ${(totalBytes / 1024 / 1024).toFixed(2)} MB / ${(
MAX_BYTES /
1024 /
1024
).toFixed(2)} MB`
)
+254
View File
@@ -0,0 +1,254 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
// Ratchet gate for the oxlint `max-lines` rule.
//
// oxlint already fails any file that exceeds max-lines WITHOUT a suppression, so
// the only way a file grows past the budget is by adding an `eslint/oxlint-disable
// max-lines` comment or a per-file `max-lines` bump in mobile/.oxlintrc.json. This
// check freezes the set of files currently allowed to do that (the baseline) and
// fails CI when a NEW bypass appears — the existing over-limit files are
// grandfathered; new ones must split instead. The baseline may only shrink.
const BASELINE_PATH = 'config/max-lines-baseline.txt'
const MOBILE_CONFIG_PATH = 'mobile/.oxlintrc.json'
// These two files legitimately contain the directive text as data (regex, fixtures),
// so scanning them would self-flag. The ratchet does not police itself.
const SELF_FILES = new Set([
'config/scripts/check-max-lines-ratchet.mjs',
'config/scripts/check-max-lines-ratchet.test.mjs'
])
// Default max-lines budgets from .oxlintrc.json (counted lines).
export function defaultLimitForPath(p) {
if (/\.(test|spec)\.(ts|tsx)$/.test(p)) {
return 800
}
if (p.endsWith('.tsx')) {
return 400
}
if (p.endsWith('.mjs')) {
return 600
}
return 300
}
// True if the source contains an eslint/oxlint disable directive listing `max-lines`
// (block or line, bare or compound, with or without a `-- Why:` reason).
export function hasMaxLinesDisable(sourceText) {
const re = /(?:eslint|oxlint)-disable(?:-next-line|-line)?\b([^\n]*)/g
let m
while ((m = re.exec(sourceText)) !== null) {
let rules = m[1]
rules = rules.split('--')[0] // strip the reason
const close = rules.indexOf('*/')
if (close !== -1) {
rules = rules.slice(0, close) // strip block-comment tail
}
if (/\bmax-lines\b/.test(rules)) {
return true
}
}
return false
}
// Per-file `max-lines` bumps in mobile/.oxlintrc.json whose `max` exceeds the
// default for that glob (a lower `max` is stricter, not a bypass).
export function collectMobileBumps(configText) {
const cfg = JSON.parse(configText)
const bumps = []
for (const override of cfg.overrides ?? []) {
const rule = override.rules?.['max-lines']
if (!Array.isArray(rule) || typeof rule[1]?.max !== 'number') {
continue
}
for (const glob of override.files ?? []) {
if (rule[1].max > defaultLimitForPath(glob)) {
bumps.push(`mobile-config ${glob}`)
}
}
}
return bumps
}
export function parseBaseline(text) {
return new Set(
text
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
)
}
export function diffBaseline(current, baseline) {
const cur = new Set(current)
const base = baseline instanceof Set ? baseline : new Set(baseline)
const added = [...cur].filter((e) => !base.has(e)).sort()
const stale = [...base].filter((e) => !cur.has(e)).sort()
return { added, stale }
}
// Collect every current suppression entry from the tracked tree.
export function collectCurrentSuppressions(root = process.cwd()) {
const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mjs'], {
cwd: root,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024
})
.split('\n')
.filter(Boolean)
.filter((f) => !SELF_FILES.has(f))
const entries = []
for (const rel of tracked) {
let src
try {
src = fs.readFileSync(path.join(root, rel), 'utf8')
} catch {
continue
}
if (hasMaxLinesDisable(src)) {
entries.push(`inline ${rel}`)
}
}
const mobileCfgPath = path.join(root, MOBILE_CONFIG_PATH)
if (fs.existsSync(mobileCfgPath)) {
entries.push(...collectMobileBumps(fs.readFileSync(mobileCfgPath, 'utf8')))
}
return entries.sort()
}
function printAddedFailure(added) {
for (const entry of added) {
console.error(`::error::New max-lines bypass not allowed: ${entry}`)
}
console.error('')
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
console.error('│ ❌ max-lines ratchet failed — a NEW file is trying to exceed the line cap. │')
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
console.error('')
console.error(` ${added.length} file(s)/glob(s) newly bypass the oxlint \`max-lines\` rule:`)
console.error('')
for (const entry of added) {
const [kind, ...rest] = entry.split(' ')
const target = rest.join(' ')
const how =
kind === 'inline'
? 'added an eslint/oxlint-disable max-lines comment'
: 'added a per-file max-lines bump in mobile/.oxlintrc.json'
console.error(`${target}\n${how}`)
}
console.error('')
console.error(' Orca caps file size (300 .ts / 400 .tsx / 600 .mjs / 800 test — non-blank,')
console.error(
' non-comment lines). Existing oversized files are grandfathered; NEW ones are not.'
)
console.error('')
console.error(' ✅ Fix it: SPLIT the file into focused modules — do NOT suppress the rule.')
console.error(' See AGENTS.md → "Lint Rules: Do Not Disable Max Lines".')
console.error('')
console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable')
console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`)
console.error('')
}
function printStaleFailure(stale) {
for (const entry of stale) {
console.error(`::error::Stale max-lines baseline entry (prune it): ${entry}`)
}
console.error('')
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
console.error('│ ⚠️ max-lines baseline is out of date — nice work removing a bypass! │')
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
console.error('')
console.error(` ${stale.length} baseline entr(y/ies) no longer have a max-lines suppression.`)
console.error(
' The baseline may only shrink, so these must be removed to keep re-adding blocked:'
)
console.error('')
for (const entry of stale) {
console.error(`${entry}`)
}
console.error('')
console.error(` ✅ Fix it (one command): pnpm check:max-lines-ratchet --prune`)
console.error('')
}
export function main(root = process.cwd()) {
const baselineFile = path.join(root, BASELINE_PATH)
if (!fs.existsSync(baselineFile)) {
console.error(
`::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-max-lines-ratchet.mjs --init`
)
return 1
}
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
const current = collectCurrentSuppressions(root)
const { added, stale } = diffBaseline(current, baseline)
if (added.length > 0) {
printAddedFailure(added)
if (stale.length > 0) {
console.error(
` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)`
)
printStaleFailure(stale)
}
return 1
}
if (stale.length > 0) {
printStaleFailure(stale)
return 1
}
console.log(
`max-lines ratchet OK — ${current.length} grandfathered suppression(s), no new bypasses.`
)
return 0
}
function writeBaseline(root, entries) {
const header = [
'# Files/globs currently allowed to exceed the oxlint `max-lines` budget.',
'# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —',
'# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").',
'# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)',
''
].join('\n')
fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const root = process.cwd()
const arg = process.argv[2]
if (arg === '--init') {
// One-time bootstrap: capture the current suppression set as the baseline.
const entries = collectCurrentSuppressions(root)
writeBaseline(root, entries)
console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`)
process.exit(0)
}
if (arg === '--prune') {
// Remove baseline entries whose suppression is gone (shrink only; never adds).
const current = new Set(collectCurrentSuppressions(root))
const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8'))
const kept = [...baseline].filter((e) => current.has(e)).sort()
const newlyAdded = [...current].filter((e) => !baseline.has(e))
writeBaseline(root, kept)
console.log(
`Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).`
)
if (newlyAdded.length > 0) {
console.error(
`::error::--prune does not add entries; ${newlyAdded.length} new bypass(es) remain — split those files.`
)
process.exit(1)
}
process.exit(0)
}
process.exit(main(root))
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
collectMobileBumps,
defaultLimitForPath,
diffBaseline,
hasMaxLinesDisable,
parseBaseline
} from './check-max-lines-ratchet.mjs'
describe('hasMaxLinesDisable', () => {
it('detects a bare block disable', () => {
expect(hasMaxLinesDisable('/* eslint-disable max-lines */\nexport const a = 1\n')).toBe(true)
})
it('detects the oxlint spelling', () => {
expect(hasMaxLinesDisable('/* oxlint-disable max-lines */\n')).toBe(true)
})
it('detects a disable with a -- Why reason', () => {
expect(hasMaxLinesDisable('/* eslint-disable max-lines -- Why: one owner. */\n')).toBe(true)
})
it('detects a multi-line block where the reason wraps', () => {
const src =
'/* eslint-disable max-lines -- Why: this contract is\n * intentionally centralized. */\nimport x from "y"\n'
expect(hasMaxLinesDisable(src)).toBe(true)
})
it('detects max-lines inside a compound rule list', () => {
expect(hasMaxLinesDisable('/* eslint-disable no-control-regex, max-lines -- Why: x */\n')).toBe(
true
)
expect(hasMaxLinesDisable('/* eslint-disable max-lines, no-control-regex */\n')).toBe(true)
})
it('detects a line-scoped disable', () => {
expect(hasMaxLinesDisable('const a = 1 // eslint-disable-line max-lines\n')).toBe(true)
})
it('ignores a disable for an unrelated rule', () => {
expect(hasMaxLinesDisable('/* eslint-disable no-console */\n')).toBe(false)
})
it('does not treat "max-lines" appearing only in the reason text as a suppression', () => {
// max-lines is after the `--`, so it is prose, not a suppressed rule.
expect(
hasMaxLinesDisable('/* eslint-disable no-console -- we could hit max-lines later */\n')
).toBe(false)
})
it('returns false for ordinary source', () => {
expect(hasMaxLinesDisable('export function f() {\n return 42\n}\n')).toBe(false)
})
})
describe('defaultLimitForPath', () => {
it('uses 800 for tests, 400 for tsx, 600 for mjs, 300 otherwise', () => {
expect(defaultLimitForPath('a/b.test.ts')).toBe(800)
expect(defaultLimitForPath('a/b.spec.tsx')).toBe(800)
expect(defaultLimitForPath('a/b.tsx')).toBe(400)
expect(defaultLimitForPath('a/b.mjs')).toBe(600)
expect(defaultLimitForPath('a/b.ts')).toBe(300)
})
})
describe('collectMobileBumps', () => {
it('captures only overrides whose max exceeds the default for the glob', () => {
const cfg = JSON.stringify({
overrides: [
{ files: ['app/h/*/tasks.tsx'], rules: { 'max-lines': ['error', { max: 14682 }] } }, // bump (>400)
{
files: ['src/terminal/TerminalWebView.tsx'],
rules: { 'max-lines': ['error', { max: 379 }] }
}, // stricter (<400), skip
{ files: ['scripts/mock-server.ts'], rules: { 'max-lines': ['error', { max: 407 }] } } // bump (>300)
]
})
expect(collectMobileBumps(cfg)).toEqual([
'mobile-config app/h/*/tasks.tsx',
'mobile-config scripts/mock-server.ts'
])
})
it('ignores overrides without a max-lines rule', () => {
const cfg = JSON.stringify({
overrides: [{ files: ['a.tsx'], rules: { 'no-console': 'off' } }]
})
expect(collectMobileBumps(cfg)).toEqual([])
})
})
describe('parseBaseline', () => {
it('drops comments and blank lines', () => {
const b = parseBaseline('# header\n\ninline a.ts\nmobile-config x/*.tsx\n')
expect(b).toEqual(new Set(['inline a.ts', 'mobile-config x/*.tsx']))
})
})
describe('diffBaseline', () => {
it('reports added and stale entries', () => {
const { added, stale } = diffBaseline(
['inline b.ts', 'inline c.ts'],
new Set(['inline a.ts', 'inline b.ts'])
)
expect(added).toEqual(['inline c.ts']) // new bypass
expect(stale).toEqual(['inline a.ts']) // suppression removed
})
it('is clean when current matches baseline', () => {
const { added, stale } = diffBaseline(['inline a.ts'], new Set(['inline a.ts']))
expect(added).toEqual([])
expect(stale).toEqual([])
})
})
+431
View File
@@ -0,0 +1,431 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
import { parse, printParseErrorCode } from 'jsonc-parser'
const MANIFEST_PATH = path.join('config', 'reliability-gates.jsonc')
const RED_GREEN_STATUSES = new Set(['missing', 'partial', 'complete', 'not-required'])
const FLAKE_STATUSES = new Set(['not-started', 'unknown', 'soaking', 'stable', 'flaky'])
const PROTECTION_LEVELS = new Set(['none', 'partial', 'active'])
const EVIDENCE_RUN_RESULTS = new Set(['passed', 'failed', 'skipped'])
const EVIDENCE_RUNNERS = new Set(['local', 'ci', 'soak', 'manual'])
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0
}
function hasNonEmptyStringArray(value) {
return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString)
}
function requireNonEmptyString(gate, field, failures) {
if (!isNonEmptyString(gate[field])) {
failures.push(`${gate.id ?? '<unknown>'}: ${field} must be a non-empty string`)
}
}
function requireStringArray(gate, field, failures) {
if (!hasNonEmptyStringArray(gate[field])) {
failures.push(`${gate.id ?? '<unknown>'}: ${field} must be a non-empty string array`)
}
}
function requireStringArrayAllowEmpty(gate, field, failures) {
if (!Array.isArray(gate[field]) || !gate[field].every(isNonEmptyString)) {
failures.push(`${gate.id ?? '<unknown>'}: ${field} must be an array of strings`)
}
}
function requireNonNegativeNumber(record, field, owner, failures) {
if (!Number.isFinite(record[field]) || record[field] < 0) {
failures.push(`${owner}.${field} must be a non-negative number`)
}
}
function validatePolicy(manifest, failures) {
if (!isRecord(manifest.policy)) {
failures.push('policy must be an object')
return new Set()
}
if (!hasNonEmptyStringArray(manifest.policy.maturityLevels)) {
failures.push('policy.maturityLevels must be a non-empty string array')
}
if (!isRecord(manifest.policy.blockingPromotion)) {
failures.push('policy.blockingPromotion must be an object')
} else {
for (const field of ['minimumSoakRuns', 'minimumSoakDays', 'maximumUnexplainedFlakes']) {
requireNonNegativeNumber(
manifest.policy.blockingPromotion,
field,
'policy.blockingPromotion',
failures
)
}
}
return new Set(
Array.isArray(manifest.policy.maturityLevels)
? manifest.policy.maturityLevels.filter(isNonEmptyString)
: []
)
}
function validateRuntimeBudget(gate, failures) {
if (!isRecord(gate.runtimeBudget)) {
failures.push(`${gate.id}: runtimeBudget must be an object`)
return
}
if (!Number.isFinite(gate.runtimeBudget.p95Seconds) || gate.runtimeBudget.p95Seconds <= 0) {
failures.push(`${gate.id}: runtimeBudget.p95Seconds must be a positive number`)
}
if (!isNonEmptyString(gate.runtimeBudget.scope)) {
failures.push(`${gate.id}: runtimeBudget.scope must be a non-empty string`)
}
}
function validateEvidence(gate, field, allowedStatuses, failures) {
const evidence = gate[field]
if (!isRecord(evidence)) {
failures.push(`${gate.id}: ${field} must be an object`)
return
}
if (!allowedStatuses.has(evidence.status)) {
failures.push(`${gate.id}: ${field}.status is invalid`)
}
if (!isNonEmptyString(evidence.evidence)) {
failures.push(`${gate.id}: ${field}.evidence must be a non-empty string`)
}
}
function validatePerformanceBudget(gate, failures) {
if (!isRecord(gate.performanceBudget)) {
failures.push(`${gate.id}: performanceBudget must be an object`)
return
}
if (typeof gate.performanceBudget.required !== 'boolean') {
failures.push(`${gate.id}: performanceBudget.required must be boolean`)
}
if (!isNonEmptyString(gate.performanceBudget.evidence)) {
failures.push(`${gate.id}: performanceBudget.evidence must be a non-empty string`)
}
}
function commandUsesBrittleTestSelector(command) {
return /(?:^|\s)(?:-t|--testNamePattern|--grep)(?:=|\s)/.test(command)
}
function validateCommandCoverage(gate, failures) {
if (!hasNonEmptyStringArray(gate.commands) || !hasNonEmptyStringArray(gate.testFiles)) {
return
}
for (const testFile of gate.testFiles) {
if (!gate.commands.some((command) => command.includes(testFile))) {
failures.push(`${gate.id}: test file is not referenced by any gate command: ${testFile}`)
}
}
}
function validateProtection(gate, failures) {
if (!PROTECTION_LEVELS.has(gate.protection)) {
failures.push(`${gate.id}: protection must be one of none, partial, or active`)
return
}
const hasCommands = hasNonEmptyStringArray(gate.commands)
const hasTestFiles = hasNonEmptyStringArray(gate.testFiles)
if (gate.protection === 'none' && (hasCommands || hasTestFiles)) {
failures.push(`${gate.id}: protection none gates must not declare commands or testFiles`)
}
if (gate.protection === 'partial' && (!hasCommands || !hasTestFiles)) {
failures.push(`${gate.id}: protection partial gates must declare commands and testFiles`)
}
if (gate.protection === 'active') {
if (gate.maturity !== 'blocking') {
failures.push(`${gate.id}: protection active is reserved for blocking gates`)
}
if (!hasCommands || !hasTestFiles) {
failures.push(`${gate.id}: protection active gates must declare commands and testFiles`)
}
if (gate.flakeHistory?.status !== 'stable') {
failures.push(`${gate.id}: protection active gates must have stable flakeHistory`)
}
if (!hasCompleteRedGreenEvidence(gate)) {
failures.push(`${gate.id}: protection active gates must have complete red/green evidence`)
}
}
if (!hasCommands && gate.protection !== 'none') {
failures.push(`${gate.id}: commandless gates must declare protection none`)
}
}
function isIsoDate(value) {
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
}
// Why: cross-field validators run even after an earlier type check flagged a
// malformed field, so coerce to an array before `.includes` to report the error
// instead of throwing on hand-edited manifests.
function asArray(value) {
return Array.isArray(value) ? value : []
}
function hasCompleteRedGreenEvidence(gate) {
return ['complete', 'not-required'].includes(gate.redGreenEvidence?.status)
}
function validateEvidenceRun(gate, run, index, failures) {
const owner = `${gate.id}: evidenceRuns[${index}]`
if (!isRecord(run)) {
failures.push(`${owner} must be an object`)
return
}
if (!isIsoDate(run.date)) {
failures.push(`${owner}.date must be YYYY-MM-DD`)
}
if (!EVIDENCE_RUNNERS.has(run.runner)) {
failures.push(`${owner}.runner must be one of local, ci, soak, or manual`)
}
if (!isNonEmptyString(run.platform)) {
failures.push(`${owner}.platform must be a non-empty string`)
}
if (!EVIDENCE_RUN_RESULTS.has(run.result)) {
failures.push(`${owner}.result must be one of passed, failed, or skipped`)
}
if (!isNonEmptyString(run.command)) {
failures.push(`${owner}.command must be a non-empty string`)
} else if (!asArray(gate.commands).includes(run.command)) {
failures.push(`${owner}.command must match one of the gate commands`)
}
if (!Number.isFinite(run.durationSeconds) || run.durationSeconds < 0) {
failures.push(`${owner}.durationSeconds must be a non-negative number`)
}
if (!isNonEmptyString(run.summary)) {
failures.push(`${owner}.summary must be a non-empty string`)
}
}
function validateEvidenceRuns(gate, failures) {
if (!Array.isArray(gate.evidenceRuns)) {
failures.push(`${gate.id}: evidenceRuns must be an array`)
return
}
gate.evidenceRuns.forEach((run, index) => validateEvidenceRun(gate, run, index, failures))
if (
['partial', 'active'].includes(gate.protection) &&
!gate.evidenceRuns.some((run) => isRecord(run) && run.result === 'passed')
) {
failures.push(`${gate.id}: protection ${gate.protection} gates need a passed evidence run`)
}
if (gate.protection === 'none' && gate.evidenceRuns.length > 0) {
failures.push(`${gate.id}: protection none gates must not declare evidenceRuns`)
}
}
function validateAssertionRef(gate, ref, index, failures) {
const owner = `${gate.id}: assertionRefs[${index}]`
if (!isRecord(ref)) {
failures.push(`${owner} must be an object`)
return
}
if (!isNonEmptyString(ref.file)) {
failures.push(`${owner}.file must be a non-empty string`)
} else if (!asArray(gate.testFiles).includes(ref.file)) {
failures.push(`${owner}.file must be one of the gate testFiles`)
}
if (!hasNonEmptyStringArray(ref.assertions)) {
failures.push(`${owner}.assertions must be a non-empty string array`)
}
}
function validateAssertionRefs(gate, failures) {
if (!Array.isArray(gate.assertionRefs)) {
failures.push(`${gate.id}: assertionRefs must be an array`)
return
}
gate.assertionRefs.forEach((ref, index) => validateAssertionRef(gate, ref, index, failures))
if (['partial', 'active'].includes(gate.protection) && gate.assertionRefs.length === 0) {
failures.push(`${gate.id}: protection ${gate.protection} gates need assertionRefs`)
}
if (gate.protection === 'none' && gate.assertionRefs.length > 0) {
failures.push(`${gate.id}: protection none gates must not declare assertionRefs`)
}
}
function validateCoveredScope(gate, failures) {
requireStringArrayAllowEmpty(gate, 'coveredPlatforms', failures)
requireStringArrayAllowEmpty(gate, 'coveredProviders', failures)
requireNonEmptyString(gate, 'coverageNotes', failures)
if (!Array.isArray(gate.coveredPlatforms) || !Array.isArray(gate.coveredProviders)) {
return
}
for (const platform of gate.coveredPlatforms) {
if (!asArray(gate.platforms).includes(platform)) {
failures.push(`${gate.id}: covered platform is outside risk scope: ${platform}`)
}
}
for (const provider of gate.coveredProviders) {
if (!asArray(gate.providers).includes(provider)) {
failures.push(`${gate.id}: covered provider is outside risk scope: ${provider}`)
}
}
if (
['partial', 'active'].includes(gate.protection) &&
gate.evidenceRuns.some((run) => isRecord(run) && run.result === 'passed')
) {
const evidencePlatforms = new Set(
gate.evidenceRuns
.filter((run) => isRecord(run) && run.result === 'passed')
.map((run) => run.platform)
)
for (const platform of evidencePlatforms) {
if (!gate.coveredPlatforms.includes(platform)) {
failures.push(
`${gate.id}: coveredPlatforms must include passed evidence platform ${platform}`
)
}
}
}
if (
gate.protection === 'none' &&
(gate.coveredPlatforms.length > 0 || gate.coveredProviders.length > 0)
) {
failures.push(`${gate.id}: protection none gates must not declare covered scope`)
}
}
async function fileExists(root, filePath) {
try {
const stat = await fs.stat(path.join(root, filePath))
return stat.isFile()
} catch {
return false
}
}
async function validateGate(gate, maturities, root) {
const failures = []
if (!isRecord(gate)) {
return ['gate entry must be an object']
}
for (const field of ['id', 'title', 'owner', 'layer', 'invariant', 'oracle', 'demotionRule']) {
requireNonEmptyString(gate, field, failures)
}
if (!maturities.has(gate.maturity)) {
failures.push(`${gate.id ?? '<unknown>'}: maturity is invalid`)
}
for (const field of [
'surfaces',
'platforms',
'providers',
'motivatingLinks',
'promotionCriteria'
]) {
requireStringArray(gate, field, failures)
}
if (!Array.isArray(gate.commands) || !gate.commands.every(isNonEmptyString)) {
failures.push(`${gate.id}: commands must be an array of strings`)
} else if (gate.commands.some(commandUsesBrittleTestSelector)) {
failures.push(
`${gate.id}: commands must not rely on title selectors (-t, --grep, or --testNamePattern)`
)
}
if (!Array.isArray(gate.testFiles) || !gate.testFiles.every(isNonEmptyString)) {
failures.push(`${gate.id}: testFiles must be an array of strings`)
} else {
for (const testFile of gate.testFiles) {
if (!(await fileExists(root, testFile))) {
failures.push(`${gate.id}: test file does not exist: ${testFile}`)
}
}
}
validateCommandCoverage(gate, failures)
if (['soak', 'blocking'].includes(gate.maturity)) {
if (!hasNonEmptyStringArray(gate.commands)) {
failures.push(`${gate.id}: ${gate.maturity} gates must declare at least one command`)
}
if (!hasNonEmptyStringArray(gate.testFiles)) {
failures.push(`${gate.id}: ${gate.maturity} gates must declare at least one test file`)
}
if (!['soaking', 'stable'].includes(gate.flakeHistory?.status)) {
failures.push(`${gate.id}: ${gate.maturity} gates must have soaking or stable flakeHistory`)
}
if (!hasCompleteRedGreenEvidence(gate)) {
failures.push(`${gate.id}: ${gate.maturity} gates must have complete red/green evidence`)
}
}
validateRuntimeBudget(gate, failures)
validateEvidence(gate, 'flakeHistory', FLAKE_STATUSES, failures)
validateEvidence(gate, 'redGreenEvidence', RED_GREEN_STATUSES, failures)
validateProtection(gate, failures)
validateEvidenceRuns(gate, failures)
validateAssertionRefs(gate, failures)
validateCoveredScope(gate, failures)
validatePerformanceBudget(gate, failures)
if (!Array.isArray(gate.knownGaps) || !gate.knownGaps.every(isNonEmptyString)) {
failures.push(`${gate.id}: knownGaps must be an array of strings`)
}
return failures
}
export async function main(root = process.cwd()) {
const manifestPath = path.join(root, MANIFEST_PATH)
let raw
try {
raw = await fs.readFile(manifestPath, 'utf8')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`${MANIFEST_PATH}: unable to read manifest (${message})`)
return 1
}
const parseErrors = []
const manifest = parse(raw, parseErrors, { allowTrailingComma: true })
if (parseErrors.length > 0) {
for (const error of parseErrors) {
console.error(
`${MANIFEST_PATH}: JSONC parse error ${printParseErrorCode(error.error)} at offset ${error.offset}`
)
}
return 1
}
const failures = []
if (!isRecord(manifest)) {
failures.push('manifest must be an object')
} else {
if (manifest.schemaVersion !== 1) {
failures.push('schemaVersion must be 1')
}
const maturities = validatePolicy(manifest, failures)
if (!Array.isArray(manifest.gates) || manifest.gates.length === 0) {
failures.push('gates must be a non-empty array')
} else {
const seenIds = new Set()
for (const gate of manifest.gates) {
if (isRecord(gate) && isNonEmptyString(gate.id)) {
if (seenIds.has(gate.id)) {
failures.push(`${gate.id}: duplicate gate id`)
}
seenIds.add(gate.id)
}
failures.push(...(await validateGate(gate, maturities, root)))
}
}
}
if (failures.length > 0) {
console.error(`Reliability gate manifest check failed with ${failures.length} issue(s):`)
for (const failure of failures) {
console.error(`- ${failure}`)
}
return 1
}
console.log(`Reliability gate manifest check passed for ${manifest.gates.length} gate(s).`)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
@@ -0,0 +1,460 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { main } from './check-reliability-gates.mjs'
const tempDirs = []
function makeTempRoot(manifest) {
const root = mkdtempSync(path.join(tmpdir(), 'orca-reliability-gates-'))
tempDirs.push(root)
const configDir = path.join(root, 'config')
mkdirSync(configDir, { recursive: true })
writeFileSync(path.join(configDir, 'reliability-gates.jsonc'), manifest, 'utf8')
writeFileSync(path.join(root, 'some.test.ts'), '', 'utf8')
return root
}
function validManifest(overrides = {}) {
return JSON.stringify(
{
schemaVersion: 1,
policy: {
maturityLevels: ['experimental', 'soak', 'blocking', 'accepted-gap', 'deprecated'],
blockingPromotion: {
minimumSoakRuns: 100,
minimumSoakDays: 14,
maximumUnexplainedFlakes: 0
}
},
gates: [
{
id: 'terminal-session.snapshot-freshness',
title: 'Stale liveness snapshots cannot close newer PTY bindings',
maturity: 'soak',
protection: 'partial',
owner: 'terminal-runtime',
layer: 'renderer-unit',
surfaces: ['terminal lifecycle'],
platforms: ['macos', 'linux', 'windows'],
providers: ['local', 'daemon'],
coveredPlatforms: ['macos'],
coveredProviders: ['local'],
coverageNotes: 'One local macOS unit run covers the local decision-layer fixture only.',
motivatingLinks: ['https://github.com/stablyai/orca/issues/6773'],
invariant: 'A stale snapshot cannot close a newer binding.',
oracle: 'The test rejects reconciliation when the binding is newer than the snapshot.',
commands: ['pnpm exec vitest run some.test.ts'],
testFiles: ['some.test.ts'],
assertionRefs: [
{
file: 'some.test.ts',
assertions: ['stale liveness snapshots do not close newer PTY bindings']
}
],
evidenceRuns: [
{
date: '2026-07-02',
runner: 'local',
platform: 'macos',
command: 'pnpm exec vitest run some.test.ts',
result: 'passed',
durationSeconds: 1.2,
summary: '1 file passed.'
}
],
runtimeBudget: {
p95Seconds: 10,
scope: 'local unit test'
},
flakeHistory: {
status: 'soaking',
evidence: 'Soak history exists.'
},
redGreenEvidence: {
status: 'complete',
evidence: 'Fails when the guard is removed and passes with the fix.'
},
performanceBudget: {
required: true,
evidence: 'Perf measurement is required before blocking promotion.'
},
promotionCriteria: ['Collect soak history.'],
knownGaps: ['Needs an Electron survival test.'],
demotionRule: 'Demote on unexplained flakes.',
...overrides
}
]
},
null,
2
)
}
afterEach(() => {
vi.restoreAllMocks()
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('check-reliability-gates', () => {
it('accepts the checked-in manifest', async () => {
vi.spyOn(console, 'log').mockImplementation(() => {})
await expect(main(process.cwd())).resolves.toBe(0)
})
it('rejects soak gates without executable commands', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(validManifest({ commands: [] }))
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('Reliability gate manifest check failed')
)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('soak gates must declare at least one command')
)
})
it('rejects soak gates without test files', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(validManifest({ testFiles: [] }))
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('soak gates must declare at least one test file')
)
})
it('rejects command-backed gates that select tests by title', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(
validManifest({ maturity: 'experimental', commands: ['pnpm vitest -t flaky-title'] })
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('commands must not rely on title selectors')
)
})
it('rejects gates without a protection level', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const manifest = JSON.parse(validManifest())
delete manifest.gates[0].protection
const root = makeTempRoot(JSON.stringify(manifest, null, 2))
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('protection must be one of none, partial, or active')
)
})
it('rejects commandless gates unless they declare protection none', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(
validManifest({
maturity: 'experimental',
protection: 'partial',
commands: [],
testFiles: [],
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
})
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('commandless gates must declare protection none')
)
})
it('rejects command-backed gates marked as no protection', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(validManifest({ maturity: 'experimental', protection: 'none' }))
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('protection none gates must not declare commands or testFiles')
)
})
it('reserves active protection for stable blocking gates', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(validManifest({ maturity: 'experimental', protection: 'active' }))
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('protection active is reserved for blocking gates')
)
})
it('rejects partial gates without a passed evidence run', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(validManifest({ maturity: 'experimental', evidenceRuns: [] }))
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('protection partial gates need a passed evidence run')
)
})
it('rejects evidence runs whose command is not declared by the gate', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(
validManifest({
maturity: 'experimental',
evidenceRuns: [
{
date: '2026-07-02',
runner: 'local',
platform: 'macos',
command: 'pnpm exec vitest run other.test.ts',
result: 'passed',
durationSeconds: 1,
summary: 'Wrong command.'
}
]
})
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('evidenceRuns[0].command must match one of the gate commands')
)
})
it('rejects protection none gates with evidence runs', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(
validManifest({
maturity: 'experimental',
protection: 'none',
commands: [],
testFiles: [],
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
})
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('protection none gates must not declare evidenceRuns')
)
})
it('rejects partial gates without assertion refs', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(validManifest({ maturity: 'experimental', assertionRefs: [] }))
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('protection partial gates need assertionRefs')
)
})
it('rejects assertion refs outside declared test files', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(
validManifest({
maturity: 'experimental',
assertionRefs: [
{
file: 'other.test.ts',
assertions: ['important invariant']
}
]
})
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('assertionRefs[0].file must be one of the gate testFiles')
)
})
it('rejects protection none gates with assertion refs', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(
validManifest({
maturity: 'experimental',
protection: 'none',
commands: [],
testFiles: [],
evidenceRuns: [],
assertionRefs: [
{
file: 'some.test.ts',
assertions: ['planning entry must not look tested']
}
],
coveredPlatforms: [],
coveredProviders: [],
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
})
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('protection none gates must not declare assertionRefs')
)
})
it('rejects covered scope outside the risk scope', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(
validManifest({
maturity: 'experimental',
coveredPlatforms: ['ios'],
coveredProviders: ['ssh']
})
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('covered platform is outside risk scope: ios')
)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('covered provider is outside risk scope: ssh')
)
})
it('requires coveredPlatforms to include passed evidence platforms', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(
validManifest({
maturity: 'experimental',
coveredPlatforms: ['linux']
})
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('coveredPlatforms must include passed evidence platform macos')
)
})
it('rejects covered scope on protection none gates', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(
validManifest({
maturity: 'experimental',
protection: 'none',
commands: [],
testFiles: [],
evidenceRuns: [],
coveredPlatforms: ['macos'],
coveredProviders: [],
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
})
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('protection none gates must not declare covered scope')
)
})
it('rejects declared test files that do not exist', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(
validManifest({ maturity: 'experimental', testFiles: ['missing.test.ts'] })
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('test file does not exist: missing.test.ts')
)
})
it('rejects executable gates whose commands do not reference declared test files', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const command = 'pnpm exec vitest run'
const root = makeTempRoot(
validManifest({
maturity: 'experimental',
commands: [command],
evidenceRuns: [
{
date: '2026-07-02',
runner: 'local',
platform: 'macos',
command,
result: 'passed',
durationSeconds: 1,
summary: 'Command did not name the declared file.'
}
]
})
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('test file is not referenced by any gate command: some.test.ts')
)
})
it('rejects soak gates without mature flake and red/green evidence', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot(
validManifest({
flakeHistory: { status: 'unknown', evidence: 'No soak yet.' },
redGreenEvidence: { status: 'partial', evidence: 'No saved artifact yet.' }
})
)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('soak gates must have soaking or stable flakeHistory')
)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('soak gates must have complete red/green evidence')
)
})
it('rejects malformed JSONC', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = makeTempRoot('{ "schemaVersion": 1, } trailing')
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('JSONC parse error'))
})
it('rejects missing manifests with a structured error', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const root = mkdtempSync(path.join(tmpdir(), 'orca-reliability-gates-'))
tempDirs.push(root)
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('unable to read manifest'))
})
it('uses policy maturity levels as the source of truth', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const manifest = JSON.parse(validManifest())
manifest.policy.maturityLevels = ['experimental']
const root = makeTempRoot(JSON.stringify(manifest, null, 2))
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('maturity is invalid'))
})
it('rejects malformed blocking promotion policy', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const manifest = JSON.parse(validManifest())
manifest.policy.blockingPromotion.maximumUnexplainedFlakes = -1
const root = makeTempRoot(JSON.stringify(manifest, null, 2))
await expect(main(root)).resolves.toBe(1)
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining(
'policy.blockingPromotion.maximumUnexplainedFlakes must be a non-negative number'
)
)
})
})
@@ -0,0 +1,87 @@
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())
}
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import { plainClassName, reportUnstyledScrollbars } from './check-styled-scrollbars.mjs'
describe('check-styled-scrollbars', () => {
it('reports renderer vertical scroll containers without an Orca scrollbar style', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="max-h-64 overflow-y-auto" /> }'
)
expect(reports).toHaveLength(1)
})
it('accepts obvious styled vertical scroll containers', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(0)
})
it('does not accept nonexistent scrollbar classes as Orca scrollbar styles', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-none" /> }'
)
expect(reports).toHaveLength(1)
})
it('fails closed when a separate class composer argument supplies the scrollbar style', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto', 'scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts static class composer arguments when the same literal is styled', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(0)
})
it('fails closed when a scrollbar class is only conditionally present', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example({ enabled }) { return <div className={cn('overflow-y-auto', enabled && 'scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts conditional branches when overflow and scrollbar live in the same class literal', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example({ enabled }) { return <div className={cn(enabled && 'overflow-y-auto scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(0)
})
it('reports vertical scroll inside arbitrary wrappers when the literal is unstyled', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={identity('overflow-y-auto')} /> }"
)
expect(reports).toHaveLength(1)
})
it('does not require a vertical scrollbar style for horizontal-only overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <pre className="max-w-full overflow-x-auto" /> }'
)
expect(reports).toHaveLength(0)
})
it('does not let responsive scrollbar variants satisfy unconditional overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="overflow-y-auto md:scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(1)
})
it('accepts matching responsive overflow and scrollbar variants', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="md:overflow-y-auto md:scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(0)
})
it('accepts unconditional scrollbar styles for responsive overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="md:overflow-y-auto scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(0)
})
it('reports inline vertical overflow without an Orca scrollbar class', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div style={{ overflowY: 'auto' }} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts inline vertical overflow with a stable Orca scrollbar class', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="scrollbar-editor" style={{ overflow: \'auto\' }} /> }'
)
expect(reports).toHaveLength(0)
})
it('reports inline vertical overflow when the scrollbar class is conditional or short-circuited', () => {
for (const classNameExpression of [
"enabled && 'scrollbar-sleek'",
"enabled ? 'scrollbar-sleek' : undefined",
"enabled || 'scrollbar-sleek'",
"enabled ?? 'scrollbar-sleek'"
]) {
const reports = reportUnstyledScrollbars(
'Example.tsx',
`export function Example({ enabled }) { return <div className={${classNameExpression}} style={{ overflowY: 'auto' }} /> }`
)
expect(reports, classNameExpression).toHaveLength(1)
}
})
it('reports logical inline style spreads without an Orca scrollbar class', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example({ open }) { return <div style={{ ...(open && { overflowY: 'auto' }) }} /> }"
)
expect(reports).toHaveLength(1)
})
it('reports JSX spread className props with unstyled vertical overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div {...{ className: 'overflow-y-auto' }} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts JSX spread className props when the same literal is styled', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div {...{ className: 'overflow-y-auto scrollbar-sleek' }} /> }"
)
expect(reports).toHaveLength(0)
})
it('uses later spread className props over earlier explicit className props', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="scrollbar-sleek" {...{ className: \'overflow-y-auto\' }} /> }'
)
expect(reports).toHaveLength(1)
})
it('supports variant helper className config', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={buttonVariants({ className: 'overflow-y-auto scrollbar-sleek' })} /> }"
)
expect(reports).toHaveLength(0)
})
it('normalizes Tailwind variants and important prefixes before matching', () => {
expect(plainClassName('md:overflow-y-auto')).toBe('overflow-y-auto')
expect(plainClassName('[&:hover]:overflow-y-auto')).toBe('overflow-y-auto')
expect(plainClassName('md:!scrollbar-editor')).toBe('scrollbar-editor')
expect(plainClassName('!scrollbar-editor')).toBe('scrollbar-editor')
})
})
@@ -0,0 +1,155 @@
import { basename } from 'node:path'
import { collectTerminalPerfRows, readJsonReport } from './terminal-perf-report-annotations.mjs'
const reportPaths = process.argv.slice(2)
if (reportPaths[0] === '--') {
reportPaths.shift()
}
if (reportPaths.length === 0) {
console.error(
'Usage: node config/scripts/check-terminal-perf-report-budgets.mjs <playwright-json>...'
)
process.exit(1)
}
// Why: these mirror the e2e regression ceilings so saved JSON reports can fail
// in automation without rerunning Electron or changing the human summary table.
const BUDGETS = {
maxMedianKeyLatencyMs: 75,
maxWorstKeyLatencyMs: 300,
maxRevisitLatencyMs: 300,
maxTimerDriftMs: 150,
maxScrollLatencyMs: 150,
maxRestoreLatencyMs: 1000,
maxRendererQueuedChars: 2 * 1024 * 1024,
maxRendererPeakQueuedChars: 2 * 1024 * 1024,
maxRendererDroppedBacklogs: 0
}
function parseMs(value, fieldName, row, failures) {
if (value == null || value === '') {
return null
}
const match = String(value).match(/^(-?\d+(?:\.\d+)?)ms$/)
if (!match) {
failures.push(`${row.source} ${row.scenario}: ${fieldName} value "${value}" is malformed`)
return null
}
return Number(match[1])
}
function parseCount(value, fieldName, row, failures) {
if (value == null || value === '') {
return null
}
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
failures.push(`${row.source} ${row.scenario}: ${fieldName} value "${value}" is malformed`)
return null
}
return parsed
}
function addMaxFailure(failures, row, label, actual, budget, unit = '') {
if (actual == null || actual <= budget) {
return
}
failures.push(
`${row.source} ${row.scenario}: ${label} ${actual}${unit} exceeded budget ${budget}${unit}`
)
}
function validateRow(row) {
const failures = []
let checkedMetricCount = 0
const addBudgetCheck = (label, actual, budget, unit = '') => {
if (actual != null) {
checkedMetricCount += 1
}
addMaxFailure(failures, row, label, actual, budget, unit)
}
addBudgetCheck(
'median typing latency',
parseMs(row.median, 'median', row, failures),
BUDGETS.maxMedianKeyLatencyMs,
'ms'
)
addBudgetCheck(
'worst typing latency',
parseMs(row.worst, 'worst', row, failures),
BUDGETS.maxWorstKeyLatencyMs,
'ms'
)
addBudgetCheck(
'revisit latency',
parseMs(row.revisit, 'revisit', row, failures),
BUDGETS.maxRevisitLatencyMs,
'ms'
)
addBudgetCheck(
'timer drift',
parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures),
BUDGETS.maxTimerDriftMs,
'ms'
)
addBudgetCheck(
'scroll latency',
parseMs(row.scroll, 'scroll', row, failures),
BUDGETS.maxScrollLatencyMs,
'ms'
)
addBudgetCheck(
'restore latency',
parseMs(row.restore, 'restore', row, failures),
BUDGETS.maxRestoreLatencyMs,
'ms'
)
addBudgetCheck(
'renderer queued chars',
parseCount(row.rendererQueuedChars, 'rendererQueuedChars', row, failures),
BUDGETS.maxRendererQueuedChars
)
addBudgetCheck(
'renderer peak queued chars',
parseCount(row.rendererPeakQueuedChars, 'rendererPeakQueuedChars', row, failures),
BUDGETS.maxRendererPeakQueuedChars
)
addBudgetCheck(
'renderer dropped backlogs',
parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures),
BUDGETS.maxRendererDroppedBacklogs
)
// Why: parked-memory rows carry heap/view-count metrics with no latency
// budget; recognize them so memory-only scenarios pass the gate instead of
// tripping the "no recognized budget metrics" guard.
for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) {
if (parseCount(row[fieldName], fieldName, row, failures) != null) {
checkedMetricCount += 1
}
}
if (checkedMetricCount === 0) {
failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`)
}
return failures
}
const rows = reportPaths.flatMap((path) =>
collectTerminalPerfRows(readJsonReport(path), basename(path))
)
if (rows.length === 0) {
console.error('No OpenCode terminal perf annotations found.')
process.exit(1)
}
const failures = rows.flatMap(validateRow)
if (failures.length > 0) {
console.error(`Terminal perf budget check failed with ${failures.length} violation(s):`)
for (const failure of failures) {
console.error(`- ${failure}`)
}
process.exit(1)
}
console.log(`Terminal perf budget check passed for ${rows.length} annotation row(s).`)
@@ -0,0 +1,164 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const scriptPath = 'config/scripts/check-terminal-perf-report-budgets.mjs'
const tempDirs = []
function writeReport(annotationDescription, annotationType = 'opencode-test') {
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-report-'))
tempDirs.push(dir)
const reportPath = join(dir, 'report.json')
writeFileSync(
reportPath,
JSON.stringify({
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: annotationType,
description: annotationDescription
}
]
}
]
}
]
}
]
})
)
return reportPath
}
function runChecker(reportPath) {
return spawnSync(process.execPath, [scriptPath, reportPath], {
cwd: process.cwd(),
encoding: 'utf8'
})
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('check-terminal-perf-report-budgets', () => {
it('passes reports whose OpenCode terminal perf annotations stay within budgets', () => {
const reportPath = writeReport(
[
'panes=51',
'frames=180',
'median=2.9ms',
'worst=5.9ms',
'revisit=42.0ms',
'maxTimerDrift=12.1ms',
'scroll=149.9ms',
'restore=642.0ms',
'rendererQueuedChars=0',
'rendererPeakQueuedChars=2097152',
'rendererDroppedBacklogs=0'
].join(' ')
)
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
cwd: process.cwd(),
encoding: 'utf8'
})
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
})
it('fails reports with over-budget metrics', () => {
const reportPath = writeReport(
[
'panes=101',
'frames=60',
'median=76.0ms',
'worst=301.0ms',
'revisit=301.0ms',
'maxTimerDrift=151.0ms',
'scroll=151.0ms',
'restore=1001.0ms',
'rendererQueuedChars=2097153',
'rendererPeakQueuedChars=2097153',
'rendererDroppedBacklogs=1'
].join(' ')
)
const result = runChecker(reportPath)
expect(result.status).toBe(1)
expect(result.stderr).toContain('median typing latency 76ms exceeded budget 75ms')
expect(result.stderr).toContain('worst typing latency 301ms exceeded budget 300ms')
expect(result.stderr).toContain('revisit latency 301ms exceeded budget 300ms')
expect(result.stderr).toContain('timer drift 151ms exceeded budget 150ms')
expect(result.stderr).toContain('scroll latency 151ms exceeded budget 150ms')
expect(result.stderr).toContain('restore latency 1001ms exceeded budget 1000ms')
expect(result.stderr).toContain('renderer queued chars 2097153 exceeded budget 2097152')
expect(result.stderr).toContain('renderer peak queued chars 2097153 exceeded budget 2097152')
expect(result.stderr).toContain('renderer dropped backlogs 1 exceeded budget 0')
})
it('fails malformed metric values instead of treating them as absent', () => {
const reportPath = writeReport('panes=1 median=999 worst=abcms rendererQueuedChars=wat')
const result = runChecker(reportPath)
expect(result.status).toBe(1)
expect(result.stderr).toContain('median value "999" is malformed')
expect(result.stderr).toContain('worst value "abcms" is malformed')
expect(result.stderr).toContain('rendererQueuedChars value "wat" is malformed')
expect(result.stderr).toContain('no recognized budget metrics found')
})
it('accepts revisit-only marker rows as budgeted perf evidence', () => {
const reportPath = writeReport('panes=19 revisit=25.7ms heldAckChars=2097184')
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
cwd: process.cwd(),
encoding: 'utf8'
})
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
})
it('accepts parked-memory rows that carry only heap and view-count metrics', () => {
const reportPath = writeReport(
'panes=8 parkedTabs=8 heapUsedMB=87.8 liveTerminals=1 livePaneManagers=1',
'opencode-parked-memory'
)
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
cwd: process.cwd(),
encoding: 'utf8'
})
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
})
it('fails OpenCode annotation rows that contain no budget metrics', () => {
const reportPath = writeReport('panes=1 frames=60')
const result = runChecker(reportPath)
expect(result.status).toBe(1)
expect(result.stderr).toContain('no recognized budget metrics found')
})
it('ignores non-OpenCode annotations but fails when no perf rows remain', () => {
const reportPath = writeReport('median=999.0ms', 'browser-unrelated')
const result = runChecker(reportPath)
expect(result.status).toBe(1)
expect(result.stderr).toContain('No OpenCode terminal perf annotations found.')
})
})
@@ -0,0 +1,58 @@
import fs from 'node:fs/promises'
import path from 'node:path'
const SETTINGS_DIR = path.join('src', 'renderer', 'src', 'components', 'settings')
const IMPORT_LINE = "import { translateSearchKeyword } from './settings-search-keywords'"
function transformKeywordsBlock(block) {
return block.replace(
/(?<!\.\.\.)translate\((['"`])([^'"`]+)\1,\s*(['"`])([^'"`]*)\3\)/g,
'...translateSearchKeyword($1$2$1, $3$4$3)'
)
}
function transformFile(content) {
if (!content.includes('keywords:') || content.includes('translateSearchKeyword')) {
return content
}
let next = content.replace(/keywords:\s*\[([\s\S]*?)\]/g, (match, body) => {
const transformedBody = transformKeywordsBlock(body)
return transformedBody === body ? match : `keywords: [${transformedBody}]`
})
if (next === content) {
return content
}
if (!next.includes(IMPORT_LINE)) {
const i18nImport = "import { translate } from '@/i18n/i18n'"
next = next.includes(i18nImport)
? next.replace(i18nImport, `${i18nImport}\n${IMPORT_LINE}`)
: `${IMPORT_LINE}\n${next}`
}
return next
}
async function main() {
const entries = await fs.readdir(SETTINGS_DIR)
const files = entries
.filter((name) => name.endsWith('-search.ts'))
.map((name) => path.join(SETTINGS_DIR, name))
let changed = 0
for (const filePath of files) {
const original = await fs.readFile(filePath, 'utf8')
const updated = transformFile(original)
if (updated !== original) {
await fs.writeFile(filePath, updated, 'utf8')
changed += 1
console.log(`Updated ${filePath}`)
}
}
console.log(`Codemod complete (${changed} files).`)
}
await main()
@@ -0,0 +1,394 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, dirname, isAbsolute, relative } from 'node:path'
import { pathToFileURL } from 'node:url'
import { collectTerminalPerfRows } from './terminal-perf-report-annotations.mjs'
const USAGE =
'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline <path> --candidate <path> [--title <title>] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]'
const PLAYWRIGHT_METADATA_FIELDS = new Set(['source', 'scenario', 'panes', 'frames', 'samples'])
export function parseBenchmarkComparisonArgs(argv = process.argv.slice(2)) {
const args = argv[0] === '--' ? argv.slice(1) : [...argv]
const parsed = {
higherIsBetter: new Set(),
title: 'Benchmark comparison'
}
for (let index = 0; index < args.length; index += 1) {
const flag = args[index]
if (flag === '--baseline') {
parsed.baselinePath = readRequiredValue(args, ++index, '--baseline requires a path')
continue
}
if (flag === '--candidate') {
parsed.candidatePath = readRequiredValue(args, ++index, '--candidate requires a path')
continue
}
if (flag === '--title') {
parsed.title = readRequiredValue(args, ++index, '--title requires a value')
continue
}
if (flag === '--output') {
parsed.outputPath = readRequiredValue(args, ++index, '--output requires a path')
continue
}
if (flag === '--json-output') {
parsed.jsonOutputPath = readRequiredValue(args, ++index, '--json-output requires a path')
continue
}
if (flag === '--higher-is-better') {
let consumed = 0
while (args[index + 1] != null && !args[index + 1].startsWith('--')) {
parsed.higherIsBetter.add(args[index + 1])
index += 1
consumed += 1
}
if (consumed === 0) {
throw new Error('--higher-is-better requires a metric key')
}
continue
}
throw new Error(USAGE)
}
if (!parsed.baselinePath) {
throw new Error(USAGE)
}
if (!parsed.candidatePath) {
throw new Error(USAGE)
}
return parsed
}
function readRequiredValue(args, index, message) {
const value = args[index]
if (value == null || value.startsWith('--')) {
throw new Error(message)
}
return value
}
export function readBenchmarkArtifact(path) {
return JSON.parse(readFileSync(path, 'utf8'))
}
export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifact(path)) {
if (artifact?.summaryMedianMs != null) {
return normalizeNumericObject(path, artifact, 'startup', artifact.summaryMedianMs, () => 'ms')
}
if (artifact?.summaryMedian != null) {
return normalizeNumericObject(path, artifact, 'daemon', artifact.summaryMedian, (key) =>
key.endsWith('Count') || key.endsWith('After') ? 'count' : 'ms'
)
}
if (artifact?.suites != null) {
return normalizePlaywrightArtifact(path, artifact)
}
if (artifact?.summary != null) {
return normalizeSummaryArtifact(path, artifact)
}
throw new Error(
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, Playwright suites, or top-level summary`
)
}
function artifactLabel(path, artifact) {
return typeof artifact?.label === 'string' && artifact.label.length > 0
? artifact.label
: basename(path)
}
function normalizeNumericObject(path, artifact, kind, values, unitForKey) {
return {
kind,
label: artifactLabel(path, artifact),
metrics: Object.entries(values ?? {})
.filter(([, value]) => Number.isFinite(value))
.map(([key, value]) => ({
direction: 'lower-is-better',
key,
unit: unitForKey(key),
value
}))
}
}
function normalizePlaywrightArtifact(path, artifact) {
const rows = collectTerminalPerfRows(artifact, basename(path), { typePrefix: 'opencode-' })
const groupedMetrics = new Map()
for (const row of rows) {
for (const [field, rawValue] of Object.entries(row)) {
if (PLAYWRIGHT_METADATA_FIELDS.has(field)) {
continue
}
const parsed = parseMetricValue(rawValue)
if (parsed == null) {
continue
}
const key = `${row.scenario}.${field}`
const metricGroup = groupedMetrics.get(key) ?? {
unit: parsed.unit,
values: []
}
metricGroup.values.push(parsed.value)
groupedMetrics.set(key, metricGroup)
}
}
const metrics = [...groupedMetrics.entries()].map(([key, metricGroup]) => ({
direction: 'lower-is-better',
key,
unit: metricGroup.unit,
value: mean(metricGroup.values)
}))
return {
kind: 'playwright',
label: artifactLabel(path, artifact),
metrics
}
}
function mean(values) {
return values.reduce((sum, value) => sum + value, 0) / values.length
}
function parseMetricValue(rawValue) {
if (typeof rawValue === 'string') {
const msMatch = rawValue.match(/^(-?\d+(?:\.\d+)?)ms$/)
if (msMatch) {
return { unit: 'ms', value: Number(msMatch[1]) }
}
}
const numericValue = Number(rawValue)
if (!Number.isFinite(numericValue)) {
return null
}
return { unit: 'count', value: numericValue }
}
function normalizeSummaryArtifact(path, artifact) {
const metrics = []
flattenSummary(metrics, ['summary'], artifact.summary)
return {
kind: 'summary',
label: artifactLabel(path, artifact),
metrics
}
}
function flattenSummary(metrics, pathParts, value) {
if (Number.isFinite(value)) {
const key = pathParts.join('.')
metrics.push({
direction: 'lower-is-better',
key,
unit: unitForSummaryKey(pathParts),
value
})
return
}
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
return
}
for (const [childKey, childValue] of Object.entries(value)) {
flattenSummary(metrics, [...pathParts, childKey], childValue)
}
}
function unitForSummaryKey(pathParts) {
if (pathParts.some((part) => part.endsWith('CpuPercent'))) {
return '%'
}
if (pathParts.some((part) => part.endsWith('Bytes'))) {
return 'bytes'
}
return ''
}
export function compareBenchmarkArtifacts({
baselinePath,
candidatePath,
title = 'Benchmark comparison',
higherIsBetter = new Set(),
now = () => new Date()
}) {
const baseline = normalizeBenchmarkArtifact(baselinePath)
const candidate = normalizeBenchmarkArtifact(candidatePath)
const candidateMetrics = new Map(candidate.metrics.map((metric) => [metric.key, metric]))
const baselineMetrics = new Map(baseline.metrics.map((metric) => [metric.key, metric]))
const metrics = []
const skippedMetrics = []
for (const baselineMetric of baseline.metrics) {
const candidateMetric = candidateMetrics.get(baselineMetric.key)
if (!isComparableMetric(baselineMetric)) {
skippedMetrics.push({ key: baselineMetric.key, reason: 'missing baseline metric' })
continue
}
if (!isComparableMetric(candidateMetric)) {
skippedMetrics.push({ key: baselineMetric.key, reason: 'missing candidate metric' })
continue
}
if (baselineMetric.unit !== candidateMetric.unit) {
skippedMetrics.push({
key: baselineMetric.key,
reason: `unit mismatch (${formatUnitLabel(baselineMetric.unit)} vs ${formatUnitLabel(candidateMetric.unit)})`
})
continue
}
const direction = higherIsBetter.has(baselineMetric.key)
? 'higher-is-better'
: baselineMetric.direction
metrics.push(compareMetric(baselineMetric, candidateMetric, direction))
}
for (const candidateMetric of candidate.metrics) {
if (!baselineMetrics.has(candidateMetric.key)) {
skippedMetrics.push({ key: candidateMetric.key, reason: 'missing baseline metric' })
}
}
if (metrics.length === 0) {
throw new Error('No comparable benchmark metrics found.')
}
return {
schemaVersion: 1,
createdAt: now().toISOString(),
title,
baseline: {
path: benchmarkDisplayPath(baselinePath),
label: baseline.label,
kind: baseline.kind
},
candidate: {
path: benchmarkDisplayPath(candidatePath),
label: candidate.label,
kind: candidate.kind
},
metrics,
skippedMetrics
}
}
function benchmarkDisplayPath(path, cwd = process.cwd()) {
if (!isAbsolute(path)) {
return path
}
const relativePath = relative(cwd, path)
if (relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)) {
return relativePath
}
return basename(path)
}
function isComparableMetric(metric) {
return metric != null && Number.isFinite(metric.value)
}
function compareMetric(baselineMetric, candidateMetric, direction) {
const rawDelta = candidateMetric.value - baselineMetric.value
const absoluteDelta = roundOneDecimal(rawDelta)
const percentDelta =
baselineMetric.value === 0
? null
: roundOneDecimal((rawDelta / Math.abs(baselineMetric.value)) * 100)
return {
key: baselineMetric.key,
unit: baselineMetric.unit,
direction,
baseline: baselineMetric.value,
candidate: candidateMetric.value,
absoluteDelta,
percentDelta,
status: metricStatus(absoluteDelta, direction)
}
}
function formatUnitLabel(unit) {
return unit === '' ? 'none' : unit
}
function roundOneDecimal(value) {
return Math.round(value * 10) / 10
}
function metricStatus(absoluteDelta, direction) {
if (absoluteDelta === 0) {
return 'unchanged'
}
if (direction === 'higher-is-better') {
return absoluteDelta > 0 ? 'improved' : 'regressed'
}
return absoluteDelta < 0 ? 'improved' : 'regressed'
}
export function formatBenchmarkComparisonMarkdown(comparison) {
const lines = [
`# ${markdownText(comparison.title)}`,
'',
`Baseline: ${markdownText(comparison.baseline.label)} (${markdownText(comparison.baseline.path)})`,
`Candidate: ${markdownText(comparison.candidate.label)} (${markdownText(comparison.candidate.path)})`,
`Generated: ${comparison.createdAt}`,
'',
'| Metric | Baseline | Candidate | Delta | Delta % | Result |',
'|---|---:|---:|---:|---:|---|'
]
for (const metric of comparison.metrics) {
lines.push(
`| ${markdownTableCell(metric.key)} | ${formatMetricValue(metric.baseline, metric.unit)} | ${formatMetricValue(metric.candidate, metric.unit)} | ${formatMetricValue(metric.absoluteDelta, metric.unit)} | ${formatPercent(metric.percentDelta)} | ${metric.status} |`
)
}
if (comparison.skippedMetrics.length > 0) {
lines.push('', '## Skipped metrics')
for (const skippedMetric of comparison.skippedMetrics) {
lines.push(`- ${markdownText(skippedMetric.key)}: ${markdownText(skippedMetric.reason)}`)
}
}
return `${lines.join('\n')}\n`
}
function markdownText(value) {
return String(value ?? '')
.replace(/\s*\r?\n\s*/g, ' ')
.replace(/[\\`*_{}<>()#+.!-]|\[|\]/g, '\\$&')
}
function markdownTableCell(value) {
return markdownText(value).replaceAll('|', '\\|')
}
function formatMetricValue(value, unit) {
return `${Number(value).toFixed(1)}${unit}`
}
function formatPercent(value) {
return value == null ? '' : `${value.toFixed(1)}%`
}
export function runBenchmarkComparisonCli(args = {}) {
const argv = args.argv ?? process.argv.slice(2)
const parsed = parseBenchmarkComparisonArgs(argv)
const comparison = compareBenchmarkArtifacts(parsed)
const markdown = formatBenchmarkComparisonMarkdown(comparison)
if (parsed.outputPath) {
mkdirSync(dirname(parsed.outputPath), { recursive: true })
writeFileSync(parsed.outputPath, markdown)
}
if (parsed.jsonOutputPath) {
mkdirSync(dirname(parsed.jsonOutputPath), { recursive: true })
writeFileSync(parsed.jsonOutputPath, `${JSON.stringify(comparison, null, 2)}\n`)
}
const stdout = args.stdout ?? process.stdout
stdout.write(markdown)
return comparison
}
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
runBenchmarkComparisonCli()
} catch (error) {
console.error(error.message)
process.exit(1)
}
}
@@ -0,0 +1,276 @@
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('computer-use e2e workflow', () => {
it('runs computer-use e2e files serially because they share desktop focus', () => {
const config = readFileSync(join(projectDir, 'tests/e2e/vitest.config.ts'), 'utf8')
expect(config).toContain('fileParallelism: false')
})
it('guards e2e source against fragile fixed waits and stale element indexes', () => {
const driver = readFileSync(join(projectDir, 'tests/e2e/helpers/computer-driver.ts'), 'utf8')
const cliDriver = readFileSync(
join(projectDir, 'tests/e2e/helpers/computer-cli-driver.ts'),
'utf8'
)
const windowsStoreE2e = readFileSync(
join(projectDir, 'tests/e2e/computer-windows-store.e2e.ts'),
'utf8'
)
expect(driver).not.toContain('await delay(3500)')
expect(driver).toContain("await waitForComputerWindowTitle('gedit', fileName, 15000)")
expect(cliDriver).toContain('ORCA_DEV_USER_DATA_PATH')
expect(cliDriver).toContain('orca-computer-runtime-')
expect(cliDriver).toContain('retryMissingRuntimeMetadata')
expect(cliDriver).toContain('Could not read Orca runtime metadata')
expect(cliDriver).toContain("'serve', '--no-pairing', '--json'")
expect(windowsStoreE2e).toMatch(
/for \(const buttonName of \['One', 'Plus', 'Two', 'Equals'\]\) \{[\s\S]*findRoleIndex\(state\.result\.snapshot\.treeText, `button \$\{buttonName\}`\)[\s\S]*state = parseJsonOutput/
)
expect(windowsStoreE2e).not.toMatch(/const one = findRoleIndex/)
expect(windowsStoreE2e).not.toMatch(/for \(const index of \[one, plus, two, equals\]\)/)
})
it('triggers on computer-use shared contracts, scripts, and agent skill changes', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const triggerPaths = workflow.on.pull_request.paths
expect(triggerPaths).toEqual(
expect.arrayContaining([
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/computer-use-skill-guidance.test.mjs',
'config/scripts/computer-use-smoke.mjs',
'config/scripts/computer-use-smoke.test.mjs',
'skills/computer-use/SKILL.md',
'src/main/computer/**',
'src/main/runtime/rpc/dispatcher.ts',
'src/main/runtime/rpc/errors.ts',
'src/main/runtime/rpc/methods/computer*.ts',
'src/shared/computer-use-*.ts',
'tests/e2e/vitest.config.ts'
])
)
expect(triggerPaths).not.toContain('src/shared/runtime-types.ts')
})
it('runs focused computer-use regression tests in the PR native-smoke job', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
'config/scripts/computer-e2e-workflow.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',
'src/main/computer/computer-provider-unavailable-message.test.ts',
'src/main/computer/sidecar-client.test.ts',
'src/main/computer/macos-native-provider-client.test.ts',
'src/main/computer/macos-native-provider-socket.test.ts',
'src/main/computer/macos-computer-use-permissions.test.ts',
'src/main/computer/macos-computer-use-permission-status.test.ts',
'src/main/computer/desktop-script-provider-client.test.ts',
'src/main/computer/desktop-script-provider-cache.test.ts',
'src/main/computer/desktop-script-provider-actions.test.ts',
'src/main/computer/desktop-script-provider-cache-lifecycle.test.ts',
'src/main/computer/desktop-script-provider-errors.test.ts',
'src/main/computer/desktop-script-provider-action-errors.test.ts',
'src/shared/computer-use-error-recovery.test.ts',
'src/shared/computer-use-key-spec.test.ts',
'src/cli/format.test.ts',
'src/cli/handlers/computer.test.ts',
'src/cli/handlers/computer-action-routing.test.ts',
'src/cli/handlers/computer-action-validation.test.ts',
'src/cli/handlers/computer-state-formatting.test.ts',
'src/cli/specs/computer.test.ts',
'src/cli/index.test.ts',
'src/main/runtime/rpc/dispatcher-computer-errors.test.ts',
'src/main/runtime/rpc/errors.test.ts',
'src/main/runtime/rpc/methods/computer.test.ts',
'src/main/runtime/rpc/methods/computer-actions.test.ts',
'src/cli/runtime/envelope-schema.test.ts',
'src/shared/remote-runtime-client.test.ts'
]
expect(regressionRun).toBeTruthy()
for (const file of expectedRegressionFiles) {
expect(regressionRun).toContain(file)
}
})
it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const steps = workflow.jobs['native-smoke'].steps
const runs = steps.map((step) => step.run).filter((run) => typeof run === 'string')
const buildIndex = runs.indexOf('pnpm build:electron-vite')
const daemonSmokeIndex = runs.indexOf('node config/scripts/daemon-boot-smoke.mjs')
expect(daemonSmokeIndex, 'native-smoke must boot the built daemon').toBeGreaterThanOrEqual(0)
expect(
buildIndex,
'daemon boot smoke must run after the main bundle is built'
).toBeGreaterThanOrEqual(0)
expect(daemonSmokeIndex).toBeGreaterThan(buildIndex)
})
it('runs the Windows workspace-close daemon repro after the main build', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const steps = workflow.jobs['native-smoke'].steps
const buildIndex = steps.findIndex((step) => step.run === 'pnpm build:electron-vite')
const reproIndex = steps.findIndex(
(step) => step.run === 'node config/scripts/windows-daemon-workspace-close-repro.mjs'
)
expect(reproIndex).toBeGreaterThan(buildIndex)
expect(steps[reproIndex].if).toBe("runner.os == 'Windows'")
expect(workflow.on.pull_request.paths).toContain(
'config/scripts/windows-daemon-workspace-close-repro.mjs'
)
})
it('re-runs the native-smoke job when the daemon bundle graph changes', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const triggerPaths = workflow.on.pull_request.paths
expect(triggerPaths).toEqual(
expect.arrayContaining([
'config/scripts/daemon-boot-smoke.mjs',
'config/scripts/windows-daemon-workspace-close-repro.mjs',
'electron.vite.config.ts',
'build-plugins/**',
'src/main/daemon/**'
])
)
})
it('runs Linux computer-use e2e in the PR native-smoke job under Xvfb', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const installRun = nativeSmokeRuns.find((run) => run.includes('apt-get install'))
expect(installRun).toContain('gedit')
expect(installRun).toContain('xvfb')
expect(nativeSmokeRuns).toContain(
'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts'
)
})
it('builds Electron main output before every computer-use e2e run', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
for (const jobName of ['native-smoke', 'mac', 'linux', 'windows']) {
const runs = workflow.jobs[jobName].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const buildIndex = runs.indexOf('pnpm build:electron-vite')
const e2eIndexes = runs
.map((run, index) => (run.includes('test:e2e:computer') ? index : -1))
.filter((index) => index >= 0)
expect(
buildIndex,
`${jobName} should build out/main before computer e2e`
).toBeGreaterThanOrEqual(0)
for (const e2eIndex of e2eIndexes) {
expect(buildIndex, `${jobName} should build out/main before computer e2e`).toBeLessThan(
e2eIndex
)
}
}
})
it('runs core Windows computer-use e2e in the PR native-smoke job', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const allRuns = [
...nativeSmokeRuns,
...workflow.jobs.mac.steps.map((step) => step.run).filter((run) => typeof run === 'string'),
...workflow.jobs.linux.steps.map((step) => step.run).filter((run) => typeof run === 'string'),
...workflow.jobs.windows.steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
]
expect(nativeSmokeRuns).toContain(
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts'
)
expect(allRuns.join('\n')).not.toContain('test:e2e:computer -- --reporter')
})
it('runs macOS and Linux computer-use e2e files in scheduled jobs', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const triggerPaths = workflow.on.pull_request.paths
const macRuns = workflow.jobs.mac.steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const linuxRuns = workflow.jobs.linux.steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
expect(triggerPaths).toEqual(
expect.arrayContaining([
'tests/e2e/computer-mac.e2e.ts',
'tests/e2e/computer-mac-safari.e2e.ts',
'tests/e2e/computer-linux.e2e.ts',
'tests/e2e/helpers/computer-cli-driver.ts',
'tests/e2e/helpers/computer-driver.ts'
])
)
expect(macRuns).toContain(
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts'
)
expect(linuxRuns).toContain(
'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts'
)
})
it('runs every Windows computer-use e2e file in the scheduled Windows job', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const triggerPaths = workflow.on.pull_request.paths
const windowsRuns = workflow.jobs.windows.steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
expect(triggerPaths).toEqual(
expect.arrayContaining([
'tests/e2e/computer-windows.e2e.ts',
'tests/e2e/computer-windows-store.e2e.ts'
])
)
expect(windowsRuns).toContain(
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts'
)
})
})
@@ -0,0 +1,43 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
const skillPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
describe('computer-use skill guidance', () => {
it('keeps web-app targeting on the computer-use surface', () => {
const skill = readFileSync(skillPath, 'utf8')
expect(skill).toContain('Use this skill for desktop UI through `orca computer`')
expect(skill).toContain('operate the desktop browser app/window that contains the page')
expect(skill).not.toContain('orca goto')
expect(skill).not.toContain('orca snapshot')
expect(skill).not.toContain('orca click')
expect(skill).not.toContain('orca fill')
expect(skill).not.toContain('Routing:')
})
it('warns agents to verify browser-hosted form focus before drafting text', () => {
const skill = readFileSync(skillPath, 'utf8')
expect(skill).toContain('For browser-hosted forms such as Gmail compose')
expect(skill).toContain('verify the focused UI element after each field action')
expect(skill).toContain('Prefer `paste-text` into the verified focused field')
})
it('warns agents about occluded Linux and Windows screenshots', () => {
const skill = readFileSync(skillPath, 'utf8')
expect(skill).toContain('On Linux and Windows')
expect(skill).toContain('use `--restore-window` so another window does not cover')
expect(skill).toContain('trust the tree over potentially occluded pixels')
})
it('points JSON users to the public accessibility-tree field', () => {
const skill = readFileSync(skillPath, 'utf8')
expect(skill).toContain('`result.snapshot.treeText`')
expect(skill).not.toContain('`result.elements`')
})
})
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const cliPath =
process.env.ORCA_COMPUTER_SMOKE_CLI_PATH ?? resolve(repoRoot, 'out', 'cli', 'index.js')
const args = new Set(process.argv.slice(2))
const requestedApps = valueFlag('--apps')
const includeScreenshot = args.has('--screenshot')
const launchRuntime = args.has('--launch')
const requireTarget = args.has('--require-target')
const session = valueFlag('--session') ?? `computer-smoke-${process.pid}`
const preferredApps = (
requestedApps ??
process.env.ORCA_COMPUTER_SMOKE_APPS ??
'Finder,TextEdit,Text Editor,gedit,Notepad,Calculator,Microsoft Edge,Google Chrome,Safari,Slack,Spotify'
)
.split(',')
.map((app) => app.trim())
.filter(Boolean)
if (!existsSync(cliPath)) {
fail(`Missing built CLI at ${cliPath}. Run pnpm build:cli first.`)
}
if (launchRuntime) {
const opened = unwrapResult(runCli(['open', '--json']))
console.log(
`computer-use smoke: runtime ${opened.runtime?.state ?? 'unknown'} (${opened.runtime?.runtimeId ?? 'unknown'})`
)
}
const list = unwrapResult(runCli(['computer', 'list-apps', '--json']))
const apps = Array.isArray(list.apps) ? list.apps : []
const availableNames = new Set(apps.map((app) => String(app.name ?? '').toLowerCase()))
const availableBundles = new Set(
apps.map((app) => String(app.bundleId ?? '').toLowerCase()).filter(Boolean)
)
const targets = preferredApps.filter(
(app) => availableNames.has(app.toLowerCase()) || availableBundles.has(app.toLowerCase())
)
console.log(`computer-use smoke: ${apps.length} apps listed`)
if (targets.length === 0) {
const message = `no preferred apps are running (${preferredApps.join(', ')})`
if (requireTarget) {
fail(message)
}
console.log(`computer-use smoke: ${message}`)
process.exit(0)
}
let failures = 0
let successes = 0
for (const app of targets) {
const result = runSnapshotSmoke(app)
if (!result.ok) {
if (result.skipped) {
console.log(`computer-use smoke: ${app}: skipped (${result.reason})`)
continue
}
failures += 1
console.log(`computer-use smoke: ${app}: failed: ${result.error}`)
continue
}
successes += 1
const state = unwrapResult(result.value)
const snapshot = state.snapshot
const treeText = String(snapshot.treeText ?? '')
const lineCount = treeText.split('\n').filter(Boolean).length
const secondaryActions = (treeText.match(/Secondary Actions:/g) ?? []).length
const settable = (treeText.match(/\bsettable\b/g) ?? []).length
const screenshotState = state.screenshot
? `${state.screenshot.width}x${state.screenshot.height}`
: 'missing'
console.log(
[
`computer-use smoke: ${snapshot.app.name}`,
`${snapshot.elementCount} elements`,
`${lineCount} lines`,
`${secondaryActions} secondary-action lines`,
`${settable} settable elements`,
`screenshot=${screenshotState}`
].join(' | ')
)
}
if (failures > 0) {
fail(`${failures} app snapshot smoke check(s) failed`)
}
if (requireTarget && successes === 0) {
fail('no preferred app snapshots succeeded')
}
function runSnapshotSmoke(app) {
const baseArgs = [
'computer',
'get-app-state',
'--session',
session,
'--app',
app,
'--restore-window',
...(includeScreenshot ? [] : ['--no-screenshot']),
'--json'
]
const initial = runCli(baseArgs, { allowFailure: true })
if (initial.ok) {
return initial
}
const error = parseCliFailure(initial.error)
if (error?.code === 'window_not_found') {
return { ok: false, skipped: true, reason: error.message }
}
return initial
}
function parseCliFailure(raw) {
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw)
return parsed.error ?? null
} catch {
return { code: 'runtime_error', message: String(raw) }
}
}
function valueFlag(name) {
const index = process.argv.indexOf(name)
if (index === -1) {
return null
}
return process.argv[index + 1] ?? null
}
function runCli(cliArgs, options = {}) {
const child = spawnSync(process.execPath, [cliPath, ...cliArgs], {
cwd: repoRoot,
encoding: 'utf8',
env: {
...process.env,
ORCA_USER_DATA_PATH:
process.env.ORCA_COMPUTER_SMOKE_USER_DATA_PATH ?? defaultDevUserDataPath()
}
})
if (child.status !== 0) {
const error = (child.stderr || child.stdout || `exit ${child.status}`).trim()
if (options.allowFailure) {
return { ok: false, error }
}
fail(error)
}
try {
return options.allowFailure
? { ok: true, value: JSON.parse(child.stdout) }
: JSON.parse(child.stdout)
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
fail(`Could not parse CLI JSON for ${cliArgs.join(' ')}: ${detail}\n${child.stdout}`)
}
}
function defaultDevUserDataPath() {
if (process.platform === 'darwin') {
return resolve(homedir(), 'Library', 'Application Support', 'orca-dev')
}
if (process.platform === 'win32') {
return resolve(process.env.APPDATA ?? resolve(homedir(), 'AppData', 'Roaming'), 'orca-dev')
}
return resolve(process.env.XDG_CONFIG_HOME ?? resolve(homedir(), '.config'), 'orca-dev')
}
function unwrapResult(value) {
if (value && typeof value === 'object' && 'result' in value) {
return value.result
}
return value
}
function fail(message) {
console.error(`computer-use smoke: ${message}`)
process.exit(1)
}
+190
View File
@@ -0,0 +1,190 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = path.resolve(import.meta.dirname, '../..')
const smokeScript = path.join(projectDir, 'config', 'scripts', 'computer-use-smoke.mjs')
describe('computer-use smoke script', () => {
it('can launch a runtime before checking apps', () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-computer-smoke-test-'))
const cliPath = path.join(root, 'fake-cli.cjs')
const callsPath = path.join(root, 'calls.jsonl')
writeFileSync(
cliPath,
[
'const fs = require("node:fs");',
`fs.appendFileSync(${JSON.stringify(callsPath)}, JSON.stringify(process.argv.slice(2)) + "\\n");`,
'const args = process.argv.slice(2);',
'if (args[0] === "open") {',
' console.log(JSON.stringify({ result: { runtime: { state: "ready", runtimeId: "runtime-test" } } }));',
'} else if (args.join(" ") === "computer list-apps --json") {',
' console.log(JSON.stringify({ result: { apps: [] } }));',
'} else {',
' console.error("unexpected args: " + args.join(" "));',
' process.exit(1);',
'}'
].join('\n'),
'utf8'
)
chmodSync(cliPath, 0o755)
const output = execFileSync(process.execPath, [smokeScript, '--launch'], {
encoding: 'utf8',
env: {
...process.env,
ORCA_COMPUTER_SMOKE_CLI_PATH: cliPath,
ORCA_COMPUTER_SMOKE_USER_DATA_PATH: path.join(root, 'user-data')
}
})
expect(output).toContain('computer-use smoke: runtime ready (runtime-test)')
expect(
readFileSync(callsPath, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line))
).toEqual([
['open', '--json'],
['computer', 'list-apps', '--json']
])
})
it('fails closed when a target app is required but none are available', () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-computer-smoke-test-'))
const cliPath = writeFakeListAppsCli(root, [])
const result = spawnSync(process.execPath, [smokeScript, '--require-target'], {
encoding: 'utf8',
env: {
...process.env,
ORCA_COMPUTER_SMOKE_CLI_PATH: cliPath,
ORCA_COMPUTER_SMOKE_USER_DATA_PATH: path.join(root, 'user-data'),
ORCA_COMPUTER_SMOKE_APPS: 'TestApp'
}
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('computer-use smoke: no preferred apps are running (TestApp)')
})
it('keeps no-target smoke permissive by default for local probing', () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-computer-smoke-test-'))
const cliPath = writeFakeListAppsCli(root, [])
const result = spawnSync(process.execPath, [smokeScript], {
encoding: 'utf8',
env: {
...process.env,
ORCA_COMPUTER_SMOKE_CLI_PATH: cliPath,
ORCA_COMPUTER_SMOKE_USER_DATA_PATH: path.join(root, 'user-data'),
ORCA_COMPUTER_SMOKE_APPS: 'TestApp'
}
})
expect(result.status).toBe(0)
expect(result.stdout).toContain('computer-use smoke: no preferred apps are running (TestApp)')
})
it('skips background apps that report window_not_found instead of failing smoke', () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-computer-smoke-test-'))
const cliPath = writeFakeSnapshotCli(
root,
[
{ name: 'Edge', bundleId: 'com.microsoft.edgemac' },
{ name: 'Notepad', bundleId: null }
],
{
windowNotFoundApps: ['Edge']
}
)
const result = spawnSync(process.execPath, [smokeScript, '--require-target'], {
encoding: 'utf8',
env: {
...process.env,
ORCA_COMPUTER_SMOKE_CLI_PATH: cliPath,
ORCA_COMPUTER_SMOKE_USER_DATA_PATH: path.join(root, 'user-data'),
ORCA_COMPUTER_SMOKE_APPS: 'Edge,Notepad'
}
})
expect(result.status).toBe(0)
expect(result.stdout).toContain('computer-use smoke: Edge: skipped')
expect(result.stdout).toContain('computer-use smoke: Notepad')
})
it('uses cross-platform default app targets for smoke snapshots', () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-computer-smoke-test-'))
const cliPath = writeFakeSnapshotCli(root, [{ name: 'Notepad', bundleId: null }])
const result = spawnSync(process.execPath, [smokeScript], {
encoding: 'utf8',
env: {
...process.env,
ORCA_COMPUTER_SMOKE_CLI_PATH: cliPath,
ORCA_COMPUTER_SMOKE_USER_DATA_PATH: path.join(root, 'user-data')
}
})
expect(result.status).toBe(0)
expect(result.stdout).toContain('computer-use smoke: Notepad')
})
})
function writeFakeListAppsCli(root, apps) {
const cliPath = path.join(root, 'fake-cli.cjs')
writeFileSync(
cliPath,
[
'const args = process.argv.slice(2);',
'if (args.join(" ") === "computer list-apps --json") {',
` console.log(JSON.stringify({ result: { apps: ${JSON.stringify(apps)} } }));`,
'} else {',
' console.error("unexpected args: " + args.join(" "));',
' process.exit(1);',
'}'
].join('\n'),
'utf8'
)
chmodSync(cliPath, 0o755)
return cliPath
}
function writeFakeSnapshotCli(root, apps, options = {}) {
const cliPath = path.join(root, 'fake-cli.cjs')
writeFileSync(
cliPath,
[
'const args = process.argv.slice(2);',
`const windowNotFoundApps = new Set(${JSON.stringify(options.windowNotFoundApps ?? [])});`,
'if (args.join(" ") === "computer list-apps --json") {',
` console.log(JSON.stringify({ result: { apps: ${JSON.stringify(apps)} } }));`,
'} else if (args[0] === "computer" && args[1] === "get-app-state") {',
' const appIndex = args.indexOf("--app");',
' const app = appIndex >= 0 ? args[appIndex + 1] : "Unknown";',
' if (windowNotFoundApps.has(app)) {',
' console.log(JSON.stringify({ ok: false, error: { code: "window_not_found", message: `app \'${app}\' has no on-screen window` } }));',
' process.exit(1);',
' }',
' console.log(JSON.stringify({ result: {',
' snapshot: {',
' app: { name: app },',
' elementCount: 1,',
' treeText: "[1] text area settable",',
' window: { title: "Untitled" }',
' },',
' screenshot: null',
' } }));',
'} else {',
' console.error("unexpected args: " + args.join(" "));',
' process.exit(1);',
'}'
].join('\n'),
'utf8'
)
chmodSync(cliPath, 0o755)
return cliPath
}
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url'
const API_VERSION = '2022-11-28'
const MAX_RELEASE_BODY_LENGTH = 120_000
const TRUNCATION_NOTICE =
'\n\n---\nRelease notes were truncated because GitHub release bodies are limited to 125,000 characters.'
const DESKTOP_RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/
export function parseDesktopReleaseTag(tag) {
const match = DESKTOP_RELEASE_TAG_PATTERN.exec(tag)
if (!match) {
return null
}
return {
tag,
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
rc: match[4] === undefined ? null : Number(match[4])
}
}
function compareDesktopReleaseTags(a, b) {
const versionDiff = a.major - b.major || a.minor - b.minor || a.patch - b.patch
if (versionDiff !== 0) {
return versionDiff
}
if (a.rc === b.rc) {
return 0
}
if (a.rc === null) {
return 1
}
if (b.rc === null) {
return -1
}
return a.rc - b.rc
}
export function latestPreviousPublishedDesktopReleaseTag(releases, tag) {
const current = parseDesktopReleaseTag(tag)
if (!current) {
return ''
}
const previousReleases = releases
.filter((release) => release?.draft === false && typeof release.tag_name === 'string')
.map((release) => parseDesktopReleaseTag(release.tag_name))
.filter((candidate) => candidate && candidate.tag !== current.tag)
.filter((candidate) => compareDesktopReleaseTags(candidate, current) < 0)
// Why: public changelogs should be bounded by releases users could see;
// stable releases summarize since the prior stable, not the latest RC.
.filter((candidate) => current.rc !== null || candidate.rc === null)
.sort(compareDesktopReleaseTags)
return previousReleases.at(-1)?.tag ?? ''
}
function githubHeaders(token) {
return {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': API_VERSION
}
}
async function githubJson(fetchImpl, url, token, options = {}) {
const res = await fetchImpl(url, {
...options,
headers: {
...githubHeaders(token),
...options.headers
}
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`)
}
return res.json()
}
async function fetchRepoReleases(repo, token, fetchImpl) {
const releases = []
for (let page = 1; ; page += 1) {
const pageReleases = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`,
token
)
if (!Array.isArray(pageReleases)) {
throw new Error(`GitHub releases response page ${page} for ${repo} was not an array`)
}
releases.push(...pageReleases)
if (pageReleases.length < 100) {
break
}
}
return releases
}
export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) {
if (body.length <= maxLength) {
return body
}
const availableLength = maxLength - TRUNCATION_NOTICE.length
if (availableLength <= 0) {
throw new Error('Release truncation notice is longer than the maximum release body length')
}
return `${body.slice(0, availableLength).trimEnd()}${TRUNCATION_NOTICE}`
}
export async function createDraftRelease({
repo,
tag,
token,
fetchImpl = fetch,
log = console.log
}) {
if (!repo) {
throw new Error('repo is required')
}
if (!tag) {
throw new Error('tag is required')
}
if (!token) {
throw new Error('token is required')
}
const previousTag = latestPreviousPublishedDesktopReleaseTag(
await fetchRepoReleases(repo, token, fetchImpl),
tag
)
const generateNotesBody = {
tag_name: tag,
target_commitish: tag,
...(previousTag ? { previous_tag_name: previousTag } : {})
}
// Why: GitHub's generate-notes baseline ignores draft releases, so pass the
// previous public changelog boundary explicitly.
const releaseNotes = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases/generate-notes`,
token,
{
method: 'POST',
body: JSON.stringify(generateNotesBody)
}
)
const generatedBody = typeof releaseNotes.body === 'string' ? releaseNotes.body : ''
const body = truncateReleaseBody(generatedBody)
const name =
typeof releaseNotes.name === 'string' && releaseNotes.name.length > 0 ? releaseNotes.name : tag
const prerelease = tag.includes('-rc.')
// Why: GitHub's generated release notes can exceed the release body API
// limit, so create with a bounded body. Omit target_commitish because the
// release-cut tag already exists and GitHub rejects the tag name there.
await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases`, token, {
method: 'POST',
body: JSON.stringify({
tag_name: tag,
name,
body,
draft: true,
prerelease
})
})
if (generatedBody.length !== body.length) {
log(`Created draft release ${tag} with truncated generated notes (${body.length} chars).`)
} else {
log(`Created draft release ${tag} with generated notes (${body.length} chars).`)
}
}
async function main() {
const tag = process.argv[2]
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca'
await createDraftRelease({ repo, tag, token })
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error.message)
process.exit(1)
})
}
@@ -0,0 +1,253 @@
import { describe, expect, it, vi } from 'vitest'
import {
createDraftRelease,
latestPreviousPublishedDesktopReleaseTag,
parseDesktopReleaseTag,
truncateReleaseBody
} from './create-draft-release.mjs'
function release(tag, options = {}) {
return {
draft: false,
tag_name: tag,
...options
}
}
function jsonResponse(body, init = {}) {
return {
ok: init.ok ?? true,
status: init.status ?? 200,
statusText: init.statusText ?? 'OK',
json: vi.fn(async () => body),
text: vi.fn(async () => (typeof body === 'string' ? body : JSON.stringify(body)))
}
}
describe('truncateReleaseBody', () => {
it('leaves short release notes unchanged', () => {
expect(truncateReleaseBody('short notes', 120_000)).toBe('short notes')
})
it('caps long release notes and appends an explanation', () => {
const body = truncateReleaseBody('a'.repeat(130_000), 1_000)
expect(body).toHaveLength(1_000)
expect(body).toContain('Release notes were truncated')
})
})
describe('parseDesktopReleaseTag', () => {
it('parses stable and rc desktop release tags only', () => {
expect(parseDesktopReleaseTag('v1.4.36')).toMatchObject({
tag: 'v1.4.36',
major: 1,
minor: 4,
patch: 36,
rc: null
})
expect(parseDesktopReleaseTag('v1.4.36-rc.2')).toMatchObject({
tag: 'v1.4.36-rc.2',
major: 1,
minor: 4,
patch: 36,
rc: 2
})
expect(parseDesktopReleaseTag('mobile-v0.0.12')).toBeNull()
})
})
describe('latestPreviousPublishedDesktopReleaseTag', () => {
it('bounds stable notes to the previous stable release when rcs exist', () => {
expect(
latestPreviousPublishedDesktopReleaseTag(
[release('v1.4.35'), release('v1.4.36-rc.0'), release('v1.4.36')],
'v1.4.36'
)
).toBe('v1.4.35')
})
it('does not collapse a stable changelog to its rc-to-stable version bump', () => {
expect(
latestPreviousPublishedDesktopReleaseTag(
[
release('v1.4.120'),
release('v1.4.121-rc.0'),
release('v1.4.121-rc.6'),
release('v1.4.121')
],
'v1.4.121'
)
).toBe('v1.4.120')
})
it('bounds the first rc notes to the previous stable release', () => {
expect(
latestPreviousPublishedDesktopReleaseTag(
[release('v1.4.35'), release('v1.4.36-rc.0'), release('mobile-v0.0.12')],
'v1.4.36-rc.0'
)
).toBe('v1.4.35')
})
it('bounds later rc notes to the prior rc', () => {
expect(
latestPreviousPublishedDesktopReleaseTag(
[release('v1.4.36-rc.0'), release('v1.4.36-rc.1')],
'v1.4.36-rc.1'
)
).toBe('v1.4.36-rc.0')
})
it('ignores draft releases as public changelog boundaries', () => {
expect(
latestPreviousPublishedDesktopReleaseTag(
[release('v1.4.35'), release('v1.4.36-rc.0', { draft: true }), release('v1.4.36-rc.1')],
'v1.4.36-rc.1'
)
).toBe('v1.4.35')
})
it('returns empty string for the first desktop release when no earlier tag exists', () => {
expect(
latestPreviousPublishedDesktopReleaseTag(
[release('v1.4.36'), release('mobile-v0.0.12')],
'v1.4.36'
)
).toBe('')
expect(latestPreviousPublishedDesktopReleaseTag([], 'v1.4.36')).toBe('')
})
it('returns empty string when the current tag is not a desktop release tag', () => {
expect(
latestPreviousPublishedDesktopReleaseTag(
[release('v1.4.35'), release('v1.4.36')],
'mobile-v0.0.12'
)
).toBe('')
})
})
describe('createDraftRelease', () => {
it('creates a draft release with bounded generated notes', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([release('v1.4.35'), release('v1.4.36')]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
await createDraftRelease({
repo: 'stablyai/orca',
tag: 'v1.4.36',
token: 'token',
fetchImpl,
log: vi.fn()
})
expect(fetchImpl).toHaveBeenNthCalledWith(
1,
'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1',
expect.any(Object)
)
expect(fetchImpl).toHaveBeenNthCalledWith(
2,
'https://api.github.com/repos/stablyai/orca/releases/generate-notes',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
tag_name: 'v1.4.36',
target_commitish: 'v1.4.36',
previous_tag_name: 'v1.4.35'
})
})
)
expect(fetchImpl).toHaveBeenNthCalledWith(
3,
'https://api.github.com/repos/stablyai/orca/releases',
expect.objectContaining({
method: 'POST',
body: expect.any(String)
})
)
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
expect(createBody).toMatchObject({
tag_name: 'v1.4.36',
name: 'v1.4.36',
draft: true,
prerelease: false
})
expect(createBody.body).toHaveLength(120_000)
expect(createBody.body).toContain('Release notes were truncated')
})
it('marks rc tags as prereleases', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('v1.4.36-rc.1')]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true }))
await createDraftRelease({
repo: 'stablyai/orca',
tag: 'v1.4.36-rc.1',
token: 'token',
fetchImpl,
log: vi.fn()
})
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
expect(createBody.prerelease).toBe(true)
})
it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('mobile-v0.0.12')]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
await createDraftRelease({
repo: 'stablyai/orca',
tag: 'v1.4.36',
token: 'token',
fetchImpl,
log: vi.fn()
})
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
expect(generateNotesBody).toEqual({ tag_name: 'v1.4.36', target_commitish: 'v1.4.36' })
expect(generateNotesBody).not.toHaveProperty('previous_tag_name')
})
it('paginates through every release page before choosing the previous release', async () => {
const firstPage = Array.from({ length: 100 }, (_, index) => release(`mobile-v0.0.${index}`))
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse(firstPage))
.mockResolvedValueOnce(jsonResponse([release('v1.4.35')]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
await createDraftRelease({
repo: 'stablyai/orca',
tag: 'v1.4.36',
token: 'token',
fetchImpl,
log: vi.fn()
})
expect(fetchImpl).toHaveBeenNthCalledWith(
1,
'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1',
expect.any(Object)
)
expect(fetchImpl).toHaveBeenNthCalledWith(
2,
'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=2',
expect.any(Object)
)
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
expect(generateNotesBody.previous_tag_name).toBe('v1.4.35')
})
})
+217
View File
@@ -0,0 +1,217 @@
/**
* Boots the BUILT terminal daemon (out/main/daemon-entry.js) under plain Node —
* the exact way production forks it (ELECTRON_RUN_AS_NODE = a plain-Node
* process) — and asserts it starts, serves a real PTY, and stops.
*
* Why this exists: native-smoke CI (and packaging) went green while
* v1.4.129-rc.1 shipped a daemon that exited code 1 at module load because an
* electron `require` leaked into its bundle graph. Nothing executed the built
* entry under plain Node, so the outage was invisible until an adopted old
* daemon died in the field. This runs on every PR that touches the daemon.
*
* Hard assertions (fail the job):
* - the daemon signals `{ type: 'ready' }` over IPC within the timeout, and
* - it terminates when asked (no hang / zombie).
* Best-effort (logged skip, never fails): an end-to-end `ptySpawnHealth` RPC,
* because node-pty spawn can be flaky on constrained CI runners.
*/
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import { randomUUID } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const projectDir = resolve(import.meta.dirname, '../..')
const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js')
const READY_TIMEOUT_MS = 30_000
const PTY_HEALTH_TIMEOUT_MS = 10_000
const SHUTDOWN_TIMEOUT_MS = 10_000
function log(message) {
process.stdout.write(`[daemon-boot-smoke] ${message}\n`)
}
// Why: the daemon rejects a hello whose protocol version differs, so read the
// current version from source rather than hardcoding a number that can drift.
function readProtocolVersion() {
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
if (!match) {
throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts')
}
return Number(match[1])
}
function makeSocketPath(userDataDir) {
// Why: Windows AF_UNIX-style IPC uses named pipes; POSIX uses a filesystem
// socket kept under the scratch userData dir so cleanup removes it.
if (process.platform === 'win32') {
return `\\\\.\\pipe\\orca-daemon-smoke-${process.pid}-${randomUUID()}`
}
return join(userDataDir, 'daemon.sock')
}
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
// Connects a single control socket, completes the hello handshake, and calls
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
// true on success, false on any failure — never throws.
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
return new Promise((resolveCheck) => {
let settled = false
let buffer = ''
const socket = connect(socketPath)
const finish = (ok, reason) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
socket.destroy()
if (!ok && reason) {
log(`PTY spawn health check skipped (best-effort): ${reason}`)
}
resolveCheck(ok)
}
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
socket.on('error', (err) => finish(false, err.message))
socket.on('connect', () => {
const token = readFileSync(tokenPath, 'utf8').trim()
socket.write(
`${JSON.stringify({
type: 'hello',
version: protocolVersion,
token,
clientId: randomUUID(),
role: 'control'
})}\n`
)
})
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8')
let newlineIdx = buffer.indexOf('\n')
while (newlineIdx !== -1) {
const line = buffer.slice(0, newlineIdx)
buffer = buffer.slice(newlineIdx + 1)
let msg
try {
msg = JSON.parse(line)
} catch {
finish(false, 'invalid response line')
return
}
if (msg.type === 'hello') {
if (!msg.ok) {
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
return
}
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
} else if (msg.id === 'health-1') {
finish(
msg.ok === true,
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
)
return
}
newlineIdx = buffer.indexOf('\n')
}
})
})
}
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
const socketPath = makeSocketPath(userDataDir)
const tokenPath = join(userDataDir, 'daemon.token')
const protocolVersion = readProtocolVersion()
log(`forking ${entryPath} under plain Node (${process.execPath})`)
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
// CI, and this is exactly the runtime where a leaked `require("electron")`
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
})
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk)
})
const cleanup = () => {
if (child.exitCode === null && child.signalCode === null && child.pid) {
try {
child.kill('SIGKILL')
} catch {
// already gone
}
}
rmSync(userDataDir, { recursive: true, force: true })
}
try {
await new Promise((resolveReady, rejectReady) => {
const timer = setTimeout(() => {
rejectReady(
new Error(
`daemon did not signal 'ready' within ${READY_TIMEOUT_MS}ms.\nstderr:\n${stderr}`
)
)
}, READY_TIMEOUT_MS)
child.on('message', (msg) => {
if (msg && typeof msg === 'object' && msg.type === 'ready') {
clearTimeout(timer)
resolveReady()
}
})
child.on('error', (err) => {
clearTimeout(timer)
rejectReady(new Error(`daemon fork errored: ${err.message}\nstderr:\n${stderr}`))
})
child.on('exit', (code, signal) => {
clearTimeout(timer)
rejectReady(
new Error(
`daemon exited before 'ready' (code=${code}, signal=${signal}).\nstderr:\n${stderr}`
)
)
})
})
log('daemon signaled ready')
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
if (ptyHealthy) {
log('ptySpawnHealth OK — daemon spawned a real PTY end-to-end')
}
await new Promise((resolveExit, rejectExit) => {
const timer = setTimeout(() => {
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
}, SHUTDOWN_TIMEOUT_MS)
child.on('exit', (code, signal) => {
clearTimeout(timer)
log(`daemon exited after signal (code=${code}, signal=${signal})`)
resolveExit()
})
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
// Windows has no POSIX signal delivery, so Node maps this to process
// termination. Either way the hard assertion is "it stops, no hang".
child.kill('SIGTERM')
})
log('PASS: daemon booted, served, and shut down under plain Node')
} finally {
cleanup()
}
}
main().catch((error) => {
process.stderr.write(`[daemon-boot-smoke] FAIL: ${error.message}\n`)
process.exitCode = 1
})
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Launch `pn dev` with a fresh, isolated userData profile so the app behaves
# like a first-time install (onboarding overlay paints, no persisted repos,
# no saved sessions). Your real `orca-dev` profile is left untouched.
#
# Usage:
# ./config/scripts/dev-fresh-profile.sh # ephemeral temp profile, deleted on exit
# ./config/scripts/dev-fresh-profile.sh --keep # keep the profile dir after exit
# ORCA_FRESH_PROFILE_DIR=/some/path ./config/scripts/dev-fresh-profile.sh # use a fixed dir
set -euo pipefail
KEEP=0
for arg in "$@"; do
case "$arg" in
--keep) KEEP=1 ;;
-h|--help)
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: $0 [--keep] [--help]" >&2
exit 2 ;;
esac
done
PROFILE_DIR="${ORCA_FRESH_PROFILE_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/orca-fresh-profile.XXXXXXXX")}"
mkdir -p "$PROFILE_DIR"
cleanup() {
if [[ "$KEEP" -eq 0 && -z "${ORCA_FRESH_PROFILE_DIR:-}" ]]; then
# Guard rm -rf against accidental empty/unrelated PROFILE_DIR.
[[ -n "${PROFILE_DIR:-}" && -d "$PROFILE_DIR" && "$PROFILE_DIR" == */orca-fresh-profile* ]] || return 0
rm -rf "$PROFILE_DIR"
echo "[dev-fresh-profile] removed $PROFILE_DIR"
else
echo "[dev-fresh-profile] kept $PROFILE_DIR"
fi
}
trap cleanup EXIT
echo "[dev-fresh-profile] using userData=$PROFILE_DIR"
# Don't exec — we need the EXIT trap to fire so the temp profile gets cleaned up.
ORCA_DEV_USER_DATA_PATH="$PROFILE_DIR" ORCA_DEV_SHOW_FIRST_RUN_EDUCATION=1 pnpm dev
@@ -0,0 +1,382 @@
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
const {
createPackagedRuntimeNodeModuleResources,
findAsarEntry,
prunePackagedNodePty,
prunePackagedParcelWatcher,
prunePackagedSherpaOnnx,
prunePackagedRuntimeTypeDeclarations,
prunePackagedZodSources,
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
describe('electron-builder config', () => {
it('excludes repo-only source trees from app.asar', () => {
expect(electronBuilderConfig.files).toEqual(
expect.arrayContaining([
'!src{,/**/*}',
'!config{,/**/*}',
'!docs{,/**/*}',
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
'!tests{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js'
])
)
})
it('keeps runtime resources available through extraResources', () => {
expect(electronBuilderConfig.mac.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/computer-use-macos/.build/release/Orca Computer Use.app',
to: 'Orca Computer Use.app'
})
])
)
expect(electronBuilderConfig.linux.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/computer-use-linux/runtime.py',
to: 'computer-use-linux/runtime.py'
})
])
)
expect(electronBuilderConfig.win.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/computer-use-windows/runtime.ps1',
to: 'computer-use-windows/runtime.ps1'
}),
expect.objectContaining({
from: 'native/windows-cli-launcher/.build/orca.exe',
to: 'bin/orca.exe'
})
])
)
})
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
expect(electronBuilderConfig.mac.extraFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
})
])
)
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
expect.arrayContaining([expect.objectContaining({ to: 'orca-notification-status' })])
)
})
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
)
})
// Why: without the unpacked entry the watcher client silently falls back to
// in-process @parcel/watcher, reintroducing the #7547 main-process crash.
it('unpacks the forked parcel-watcher process entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/parcel-watcher-process-entry.js'])
)
})
it('uses the multi-size icon source for Linux packages', () => {
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
})
it('matches the Linux desktop entry to Electron window class', () => {
expect(electronBuilderConfig.linux.desktop.entry.StartupWMClass).toBe('orca')
})
it('uses AppImage and deb as local Linux targets without changing existing artifact names', () => {
expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb'])
expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}')
expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}')
expect(electronBuilderConfig.rpm).toMatchObject({
packageName: 'orca-ide',
artifactName: 'orca-ide-${version}.${arch}.${ext}'
})
})
it('uses a distinct AppImage name for Linux arm64 release uploads', () => {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = process.env.ORCA_LINUX_ARM64_RELEASE
try {
delete require.cache[configPath]
process.env.ORCA_LINUX_ARM64_RELEASE = '1'
expect(require('../electron-builder.config.cjs').appImage.artifactName).toBe(
'orca-linux-arm64.${ext}'
)
} finally {
if (original === undefined) {
delete process.env.ORCA_LINUX_ARM64_RELEASE
} else {
process.env.ORCA_LINUX_ARM64_RELEASE = original
}
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)
})
it('verifies packaged main runtime deps from Windows-style asar entries', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true })
const sources = new Map([
['out\\main\\index.js', 'const z = require("zod")'],
['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")']
])
const asar = {
listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`),
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('normalizes host-specific asar entry separators', () => {
expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe(
'\\out\\main\\index.js'
)
expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
})
it('prunes non-target node-pty prebuilds from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
try {
const prebuildsDir = join(resourcesDir, 'node_modules', 'node-pty', 'prebuilds')
await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party', 'conpty'), {
recursive: true
})
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps', 'winpty'), {
recursive: true
})
prunePackagedNodePty(resourcesDir, 'darwin')
await expect(readdir(prebuildsDir).then((entries) => entries.sort())).resolves.toEqual([
'darwin-arm64',
'darwin-x64'
])
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party'))
).resolves.toEqual([])
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))
).resolves.toEqual([])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
for (const arch of ['x64', 'arm64']) {
const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
try {
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
const releaseDir = join(nodePtyDir, 'build', 'Release')
const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0')
await mkdir(releaseDir, { recursive: true })
await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8')
for (const sourceArch of ['x64', 'arm64']) {
const sourceDir = join(conptyRoot, `win10-${sourceArch}`)
await mkdir(sourceDir, { recursive: true })
await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8')
await writeFile(
join(sourceDir, 'OpenConsole.exe'),
`console payload ${sourceArch}`,
'utf8'
)
}
prunePackagedNodePty(resourcesDir, 'win32', arch)
await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
`dll payload ${arch}`
)
await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe(
`console payload ${arch}`
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
}
})
it('includes @parcel/watcher in the packaged runtime closure', () => {
// Why: the main process imports '@parcel/watcher' for filesystem change
// events; if it is absent from the packaged closure the serve host silently
// stops propagating file changes to clients (regression guard for #4851).
const packaged = createPackagedRuntimeNodeModuleResources()
const packagedTargets = packaged.map((resource) => resource.to)
expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher'))
expect(
packagedTargets.some((target) =>
target.startsWith(join('node_modules', '@parcel', 'watcher-'))
)
).toBe(true)
})
it('prunes non-target @parcel/watcher platform subpackages from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
try {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
await mkdir(join(parcelDir, 'watcher'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-x64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux')
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'watcher',
'watcher-linux-arm64-glibc',
'watcher-linux-x64-glibc'
])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('leaves unrelated @parcel/* runtime deps untouched when pruning the watcher', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-unrelated-'))
try {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
await mkdir(join(parcelDir, 'watcher'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
// A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux')
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'transformer-js',
'watcher',
'watcher-linux-x64-glibc'
])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes type declaration artifacts from packaged runtime node_modules', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'example-package')
await mkdir(join(packageDir, 'dist'), { recursive: true })
await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8')
prunePackagedRuntimeTypeDeclarations(resourcesDir)
await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs'])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64')
await mkdir(packageDir, { recursive: true })
await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8')
await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8')
await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8')
prunePackagedSherpaOnnx(resourcesDir, 'darwin')
await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([
'libonnxruntime.1.23.2.dylib',
'sherpa-onnx.node'
])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes zod TypeScript sources from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'zod')
await mkdir(join(packageDir, 'src'), { recursive: true })
await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8')
await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8')
prunePackagedZodSources(resourcesDir)
await expect(readdir(packageDir)).resolves.toEqual(['index.cjs'])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'marks packaged Unix CLI launchers executable',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
try {
const resourcesDir = join(root, 'linux-unpacked', 'resources')
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
// Why: afterPack now fails hard when the unpacked daemon entry is
// missing, so the fixture must carry one like a real package layout.
const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
await mkdir(unpackedMainDir, { recursive: true })
await writeFile(
join(unpackedMainDir, 'daemon-entry.js'),
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n',
'utf8'
)
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
appOutDir: join(root, 'linux-unpacked'),
electronPlatformName: 'linux'
})
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
})
@@ -0,0 +1,58 @@
const { execFileSync } = require('node:child_process')
const { resolve } = require('node:path')
const projectDir = resolve(__dirname, '../..')
function electronBuilderNativeRebuild(context) {
return runElectronBuilderNativeRebuild(context)
}
function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
const args = buildNativeRebuildArgs(context)
if (readPlatformName(context?.platform) === 'win32') {
runner(process.execPath, ['config/scripts/build-windows-cli-launcher.mjs'], {
cwd: projectDir,
stdio: 'inherit'
})
}
runner(process.execPath, args, {
cwd: projectDir,
stdio: 'inherit'
})
// Why: returning false tells electron-builder that native deps were handled
// externally, avoiding its all-module rebuild of optional cpu-features.
return false
}
function buildNativeRebuildArgs(context) {
const platform = readPlatformName(context?.platform)
const arch = readArchName(context?.arch)
return [
'config/scripts/rebuild-native-deps.mjs',
`--platform=${platform}`,
`--arch=${arch}`,
'--force'
]
}
function readPlatformName(platform) {
const name = typeof platform === 'string' ? platform : platform?.nodeName
if (!name) {
throw new Error('electron-builder native rebuild context is missing platform.nodeName')
}
return name
}
function readArchName(arch) {
if (!arch || typeof arch !== 'string') {
throw new Error('electron-builder native rebuild context is missing arch')
}
return arch
}
module.exports = electronBuilderNativeRebuild
module.exports.default = electronBuilderNativeRebuild
module.exports.buildNativeRebuildArgs = buildNativeRebuildArgs
module.exports.runElectronBuilderNativeRebuild = runElectronBuilderNativeRebuild
@@ -0,0 +1,74 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
buildNativeRebuildArgs,
runElectronBuilderNativeRebuild
} = require('./electron-builder-native-rebuild.cjs')
describe('electron-builder native rebuild hook', () => {
it('passes the target platform and arch to Orca native rebuild script', () => {
expect(
buildNativeRebuildArgs({
platform: { nodeName: 'darwin' },
arch: 'x64'
})
).toEqual([
'config/scripts/rebuild-native-deps.mjs',
'--platform=darwin',
'--arch=x64',
'--force'
])
})
it('returns false so electron-builder skips its optional module rebuild pass', () => {
const calls = []
const result = runElectronBuilderNativeRebuild(
{
platform: { nodeName: 'linux' },
arch: 'arm64'
},
(...args) => calls.push(args)
)
expect(result).toBe(false)
expect(calls).toEqual([
[
process.execPath,
['config/scripts/rebuild-native-deps.mjs', '--platform=linux', '--arch=arm64', '--force'],
expect.objectContaining({ stdio: 'inherit' })
]
])
})
it('builds the native CLI launcher before packaging Windows resources', () => {
const calls = []
const result = runElectronBuilderNativeRebuild(
{
platform: { nodeName: 'win32' },
arch: 'x64'
},
(...args) => calls.push(args)
)
expect(result).toBe(false)
expect(calls).toEqual([
[
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs'],
expect.objectContaining({ stdio: 'inherit' })
],
[
process.execPath,
['config/scripts/rebuild-native-deps.mjs', '--platform=win32', '--arch=x64', '--force'],
expect.objectContaining({ stdio: 'inherit' })
]
])
})
it('rejects incomplete electron-builder contexts', () => {
expect(() => buildNativeRebuildArgs({ arch: 'x64' })).toThrow(/platform/)
expect(() => buildNativeRebuildArgs({ platform: { nodeName: 'linux' } })).toThrow(/arch/)
})
})
+383
View File
@@ -0,0 +1,383 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, readFileSync } from 'node:fs'
import { release } from 'node:os'
import { basename, resolve } from 'node:path'
const require = createRequire(import.meta.url)
const scriptPath = import.meta.filename
const projectDir = resolve(import.meta.dirname, '../..')
const runtime = readRuntimeArg()
const NATIVE_MODULES = ['node-pty']
const CHILD_CHECK_FLAG = '--check-only'
if (process.argv.includes(CHILD_CHECK_FLAG)) {
const failures = collectNativeModuleFailures()
if (failures.length > 0) {
for (const failure of failures) {
console.error(`${failure.moduleName}: ${failure.message}`)
}
process.exit(1)
}
process.exit(0)
}
if (runtime === 'node') {
ensureNodeRuntime()
} else if (runtime === 'electron') {
ensureElectronRuntime()
} else {
console.error('Usage: node config/scripts/ensure-native-runtime.mjs --runtime=node|electron')
process.exit(2)
}
function readRuntimeArg() {
const inline = process.argv.find((arg) => arg.startsWith('--runtime='))
if (inline) {
return inline.slice('--runtime='.length)
}
const runtimeIndex = process.argv.indexOf('--runtime')
if (runtimeIndex >= 0) {
return process.argv[runtimeIndex + 1]
}
return null
}
function ensureNodeRuntime() {
const initial = runNodeCheck()
const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason()
if (initial.ok && !patchedNodePtyRebuildReason) {
return
}
if (patchedNodePtyRebuildReason) {
console.warn(`[native-runtime] ${patchedNodePtyRebuildReason}`)
if (!initial.ok) {
printCheckError(initial)
}
runPnpm(['rebuild', 'node-pty'])
verifyNodeRuntimeAfterRebuild()
return
}
const failedModules = initial.failures.map((failure) => failure.moduleName)
console.warn(
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
)
printCheckError(initial)
runPnpm(['rebuild', ...failedModules])
verifyNodeRuntimeAfterRebuild()
}
function verifyNodeRuntimeAfterRebuild() {
const final = runNodeCheck()
if (!final.ok) {
console.error(
`[native-runtime] Native modules still do not load for ${formatRuntimeLabel('node')}.`
)
printCheckError(final)
process.exit(1)
}
}
function ensureElectronRuntime() {
const initial = runElectronCheck()
const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason()
if (initial.ok && !patchedNodePtyRebuildReason) {
return
}
if (patchedNodePtyRebuildReason) {
console.warn(`[native-runtime] ${patchedNodePtyRebuildReason}`)
if (!initial.ok) {
printCheckError(initial)
}
} else {
console.warn(
`[native-runtime] ${formatRuntimeLabel('electron')} cannot load native modules; rebuilding native deps for Electron.`
)
printCheckError(initial)
}
runNodeScript(['config/scripts/rebuild-native-deps.mjs'])
const final = runElectronCheck()
if (!final.ok) {
console.error(
`[native-runtime] Native modules still do not load for ${formatRuntimeLabel('electron')}.`
)
printCheckError(final)
process.exit(1)
}
}
function runNodeCheck() {
// Why: a failed native addon load can poison the current process, so the
// post-rebuild verification must happen in a fresh Node process.
const result = spawnSync(process.execPath, [scriptPath, CHILD_CHECK_FLAG], {
cwd: projectDir,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
return parseChildCheckResult(result)
}
function runElectronCheck() {
const electronExecutable = resolveInstalledElectronExecutable()
if (!electronExecutable.ok) {
return { ok: false, error: electronExecutable.error }
}
const result = spawnSync(electronExecutable.path, [scriptPath, CHILD_CHECK_FLAG], {
cwd: projectDir,
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1'
},
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
return parseChildCheckResult(result)
}
function resolveInstalledElectronExecutable() {
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
try {
const electronVersion = JSON.parse(
readFileSync(resolve(electronPackageDir, 'package.json'), 'utf8')
).version
const platformPath = getElectronPlatformPath()
const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8')
.trim()
.replace(/^v/, '')
if (installedVersion !== electronVersion) {
return {
ok: false,
error: new Error(
`Electron package binary version ${installedVersion} does not match ${electronVersion}.`
)
}
}
const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8')
if (installedPlatformPath !== platformPath) {
return {
ok: false,
error: new Error(
`Electron package path.txt points at ${installedPlatformPath}, expected ${platformPath}.`
)
}
}
const electronPath = process.env.ELECTRON_OVERRIDE_DIST_PATH
? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath)
: resolve(electronPackageDir, 'dist', platformPath)
if (!existsSync(electronPath)) {
return { ok: false, error: new Error(`Electron executable is missing at ${electronPath}.`) }
}
return { ok: true, path: electronPath }
} catch (error) {
return { ok: false, error }
}
}
function getElectronPlatformPath() {
const targetPlatform =
process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || process.platform
switch (targetPlatform) {
case 'mas':
case 'darwin':
return 'Electron.app/Contents/MacOS/Electron'
case 'freebsd':
case 'openbsd':
case 'linux':
return 'electron'
case 'win32':
return 'electron.exe'
default:
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
}
}
function parseChildCheckResult(result) {
const failures = parseCheckFailures(result.stderr)
return {
ok: result.status === 0,
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
error: result.error,
failures
}
}
function parseCheckFailures(stderr) {
const failures = []
for (const line of (stderr ?? '').split(/\r?\n/)) {
const match = /^([^:]+):\s*(.*)$/.exec(line)
if (match && NATIVE_MODULES.includes(match[1])) {
failures.push({ moduleName: match[1], message: match[2] })
}
}
return failures
}
function collectNativeModuleFailures() {
const failures = []
for (const moduleName of NATIVE_MODULES) {
try {
loadNativeModule(moduleName)
} catch (cause) {
failures.push({ moduleName, message: formatError(cause), cause })
}
}
return failures
}
function loadNativeModule(moduleName) {
if (moduleName === 'node-pty') {
loadNodePtyNativeModule()
return
}
require(moduleName)
}
function loadNodePtyNativeModule() {
require('node-pty')
const { loadNativeModule } = require('node-pty/lib/utils')
const nativeName = getNodePtyNativeModuleName()
// Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
const native = loadNativeModule(nativeName)
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native.dir)) {
throw new Error(
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
)
}
}
function getNodePtyNativeModuleName() {
if (process.platform !== 'win32') {
return 'pty'
}
return getWindowsBuildNumber() >= 18309 ? 'conpty' : 'pty'
}
function getPatchedNodePtyRebuildReason() {
if (!requiresPatchedNodePtySourceBuild()) {
return null
}
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
// patch only lands in the source-built build/Release artifacts.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
return null
}
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
}
function requiresPatchedNodePtySourceBuild() {
if (process.platform === 'win32') {
return false
}
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
if (!existsSync(nodePtyPatchPath)) {
return false
}
return existsSync(resolve(projectDir, 'node_modules', 'node-pty'))
}
function isNodePtyReleaseBuildDir(nativeDir) {
return typeof nativeDir === 'string' && nativeDir.replace(/\\/g, '/').includes('build/Release/')
}
function getWindowsBuildNumber() {
const match = /(\d+)\.(\d+)\.(\d+)/g.exec(release())
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
}
function runPnpm(args) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(command, args, {
cwd: projectDir,
stdio: 'inherit',
shell: process.platform === 'win32'
})
if (result.error || result.status !== 0) {
console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
if (result.error) {
console.error(formatError(result.error))
}
process.exit(result.status ?? 1)
}
}
function runNodeScript(args) {
const result = spawnSync(process.execPath, args, {
cwd: projectDir,
stdio: 'inherit'
})
if (result.error || result.status !== 0) {
console.error(`[native-runtime] ${basename(process.execPath)} ${args.join(' ')} failed.`)
if (result.error) {
console.error(formatError(result.error))
}
process.exit(result.status ?? 1)
}
}
function printCheckError(result) {
for (const failure of result.failures ?? []) {
console.warn(`[native-runtime] ${failure.moduleName}: ${failure.message}`)
}
if (result.error) {
console.warn(`[native-runtime] ${formatError(result.error)}`)
}
if (result.stderr?.trim()) {
console.warn(result.stderr.trim())
}
if (result.stdout?.trim()) {
console.warn(result.stdout.trim())
}
if (
result.status != null &&
!result.error &&
!result.stderr?.trim() &&
!result.stdout?.trim() &&
result.status !== 0
) {
console.warn(`[native-runtime] Native check exited with status ${result.status}.`)
}
}
function formatError(error) {
return error instanceof Error ? error.message : String(error)
}
function formatRuntimeLabel(value) {
if (value === 'electron') {
return `Electron ${process.env.npm_package_devDependencies_electron ?? ''}`.trim()
}
return `Node ${process.versions.node}`
}
@@ -0,0 +1,253 @@
import { spawnSync } from 'node:child_process'
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
describe('ensure-native-runtime', () => {
it('rechecks Node native modules in fresh child processes after rebuilding', () => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
expect(log).toContain('pnpm rebuild node-pty\n')
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
])
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'rebuilds patched node-pty artifacts even when the Node load check passes',
() => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeLoadableNativeModules(projectDir)
writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain(
'Patched node-pty build artifacts are missing; rebuilding native deps.'
)
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'rebuilds when patched artifacts exist but node-pty resolves to prebuilds',
() => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeLoadableNativeModules(projectDir)
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'keeps the fast path when the platform-specific patched artifacts exist',
() => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' })
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
}
)
})
function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
return projectDir
}
function envWithPrependedPath(binDir, extraEnv) {
const pathKey =
process.platform === 'win32'
? (Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'Path')
: 'PATH'
return {
...process.env,
...extraEnv,
[pathKey]: `${binDir}${delimiter}${process.env[pathKey] ?? ''}`
}
}
function writeFakeNativeModules(projectDir) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
join(nodePtyDir, 'lib', 'utils.js'),
`
const { appendFileSync, existsSync } = require('node:fs')
exports.loadNativeModule = function loadNativeModule(nativeName) {
const markerExists = existsSync(process.env.ORCA_NATIVE_TEST_MARKER)
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`node-pty \${process.argv.includes('--check-only') ? 'child' : 'parent'} \${nativeName} marker=\${markerExists}\\n\`
)
if (!markerExists) {
throw new Error('ABI mismatch sentinel')
}
}
`
)
}
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
join(nodePtyDir, 'lib', 'utils.js'),
`
const { appendFileSync, existsSync } = require('node:fs')
exports.loadNativeModule = function loadNativeModule(nativeName) {
const rebuilt = existsSync(process.env.ORCA_NATIVE_TEST_MARKER)
const dir = ${JSON.stringify(nativeDir)} ??
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
return { dir, module: {} }
}
`
)
}
function writeNodePtyPatchFile(projectDir) {
mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true })
writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n')
}
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
}
}
function writeFakePnpm(binDir) {
mkdirSync(binDir, { recursive: true })
const shimPath = join(binDir, 'pnpm-shim.cjs')
writeFileSync(
shimPath,
`
const { appendFileSync, writeFileSync } = require('node:fs')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
`
)
const posixPnpmPath = join(binDir, 'pnpm')
writeFileSync(posixPnpmPath, `#!/usr/bin/env node\nrequire(${JSON.stringify(shimPath)})\n`)
chmodSync(posixPnpmPath, 0o755)
writeFileSync(
join(binDir, 'pnpm.cmd'),
`@echo off\r\n"${process.execPath}" "%~dp0\\pnpm-shim.cjs" %*\r\n`
)
}
@@ -0,0 +1,318 @@
#!/usr/bin/env node
import { performance } from 'node:perf_hooks'
const DEFAULT_OPTIONS = {
rootDirs: 80,
nestedDirsPerRoot: 20,
filesPerNestedDir: 70,
filesPerRoot: 20,
windowSize: 80,
passes: 80
}
function parseOptions() {
const options = { ...DEFAULT_OPTIONS }
for (const arg of process.argv.slice(2)) {
const match = arg.match(/^--([^=]+)=(\d+)$/)
if (!match) {
continue
}
const [, key, value] = match
if (key in options) {
options[key] = Number(value)
}
}
return options
}
function joinExplorerPath(parent, name) {
return parent.endsWith('/') ? `${parent}${name}` : `${parent}/${name}`
}
function relativePath(worktreePath, path) {
return path.slice(worktreePath.length + 1).replaceAll('\\', '/')
}
function addChild(dirCache, parentPath, child) {
const current = dirCache[parentPath] ?? { children: [], loading: false }
current.children.push(child)
dirCache[parentPath] = current
}
function makeTreeFixture(options) {
const worktreePath = '/repo'
const dirCache = {
[worktreePath]: { children: [], loading: false }
}
const expanded = new Set([worktreePath])
const ignored = new Set()
const canonicalPaths = []
for (let rootIndex = 0; rootIndex < options.rootDirs; rootIndex++) {
const rootName = `root-${String(rootIndex).padStart(3, '0')}`
const rootPath = joinExplorerPath(worktreePath, rootName)
expanded.add(rootPath)
canonicalPaths.push(`${rootName}/`)
addChild(dirCache, worktreePath, {
depth: 0,
isDirectory: true,
name: rootName,
path: rootPath,
relativePath: relativePath(worktreePath, rootPath)
})
dirCache[rootPath] = { children: [], loading: false }
for (let fileIndex = 0; fileIndex < options.filesPerRoot; fileIndex++) {
const fileName = `root-file-${String(fileIndex).padStart(3, '0')}.ts`
const filePath = joinExplorerPath(rootPath, fileName)
const rel = relativePath(worktreePath, filePath)
canonicalPaths.push(rel)
addChild(dirCache, rootPath, {
depth: 1,
isDirectory: false,
name: fileName,
path: filePath,
relativePath: rel
})
}
for (let nestedIndex = 0; nestedIndex < options.nestedDirsPerRoot; nestedIndex++) {
const nestedName = `nested-${String(nestedIndex).padStart(3, '0')}`
const nestedPath = joinExplorerPath(rootPath, nestedName)
const nestedRel = relativePath(worktreePath, nestedPath)
expanded.add(nestedPath)
canonicalPaths.push(`${nestedRel}/`)
addChild(dirCache, rootPath, {
depth: 1,
isDirectory: true,
name: nestedName,
path: nestedPath,
relativePath: nestedRel
})
dirCache[nestedPath] = { children: [], loading: false }
for (let fileIndex = 0; fileIndex < options.filesPerNestedDir; fileIndex++) {
const fileName = `file-${String(fileIndex).padStart(3, '0')}.tsx`
const filePath = joinExplorerPath(nestedPath, fileName)
const rel = relativePath(worktreePath, filePath)
canonicalPaths.push(rel)
if (fileIndex % 23 === 0) {
ignored.add(rel)
}
addChild(dirCache, nestedPath, {
depth: 2,
isDirectory: false,
name: fileName,
path: filePath,
relativePath: rel
})
}
}
}
return { canonicalPaths, dirCache, expanded, ignored, worktreePath }
}
function flattenCurrent(worktreePath, dirCache, expanded) {
const result = []
const addChildren = (parentPath) => {
const cached = dirCache[parentPath]
if (!cached?.children) {
return
}
for (const child of cached.children) {
result.push(child)
if (child.isDirectory && expanded.has(child.path)) {
addChildren(child.path)
}
}
}
addChildren(worktreePath)
return result
}
function getVisibleRows(flatRows, ignored, showGitIgnoredFiles) {
if (showGitIgnoredFiles) {
return flatRows
}
return flatRows.filter((row) => !ignored.has(row.relativePath))
}
function runLegacyCurrentPass(fixture, showGitIgnoredFiles) {
const flatRows = flattenCurrent(fixture.worktreePath, fixture.dirCache, fixture.expanded)
const rowsByPath = new Map(flatRows.map((row) => [row.path, row]))
const visibleRows = getVisibleRows(flatRows, fixture.ignored, showGitIgnoredFiles)
const visibleRowsByPath = new Map(visibleRows.map((row) => [row.path, row]))
const orderedPaths = visibleRows.map((row) => row.path)
return {
flatCount: flatRows.length,
orderedPathCount: orderedPaths.length,
rowsByPath,
visibleCount: visibleRows.length,
visibleRowsByPath
}
}
function collectIgnoredQueryRelativePaths(fixture) {
const relativePaths = []
const addChildren = (parentPath) => {
const cached = fixture.dirCache[parentPath]
if (!cached?.children) {
return
}
for (const row of cached.children) {
relativePaths.push(row.relativePath)
if (row.isDirectory && fixture.expanded.has(row.path)) {
addChildren(row.path)
}
}
}
addChildren(fixture.worktreePath)
return relativePaths
}
function runDirectDirCacheProjectionPass(fixture, showGitIgnoredFiles) {
const ignoredQueryRelativePaths = collectIgnoredQueryRelativePaths(fixture)
const visibleRows = []
const visibleRowsByPath = new Map()
const addChildren = (parentPath) => {
const cached = fixture.dirCache[parentPath]
if (!cached?.children) {
return
}
for (const row of cached.children) {
if (!showGitIgnoredFiles && fixture.ignored.has(row.relativePath)) {
continue
}
visibleRows.push(row)
visibleRowsByPath.set(row.path, row)
if (row.isDirectory && fixture.expanded.has(row.path)) {
addChildren(row.path)
}
}
}
addChildren(fixture.worktreePath)
const selectedPath = visibleRows.at(-1)?.path ?? null
const selectedNode = selectedPath ? visibleRowsByPath.get(selectedPath) : null
return {
ignoredQueryPathCount: ignoredQueryRelativePaths.length,
selectedPath: selectedNode?.path ?? null,
visibleCount: visibleRows.length,
visibleRowsByPath
}
}
function buildIndexedProjection(fixture, showGitIgnoredFiles) {
const rows = []
const rowsByPath = new Map()
const addChildren = (parentPath) => {
const cached = fixture.dirCache[parentPath]
if (!cached?.children) {
return
}
for (const child of cached.children) {
rowsByPath.set(child.path, child)
if (showGitIgnoredFiles || !fixture.ignored.has(child.relativePath)) {
rows.push(child)
}
if (child.isDirectory && fixture.expanded.has(child.path)) {
addChildren(child.path)
}
}
}
addChildren(fixture.worktreePath)
return {
getVisibleCount: () => rows.length,
getVisibleSlice: (start, end) => rows.slice(start, end + 1),
getRowByPath: (path) => rowsByPath.get(path) ?? null
}
}
function measure(label, passes, fn) {
const durations = []
let lastResult
for (let index = 0; index < passes; index++) {
const start = performance.now()
lastResult = fn(index)
durations.push(performance.now() - start)
}
durations.sort((a, b) => a - b)
const sum = durations.reduce((total, value) => total + value, 0)
return {
label,
maxMs: durations.at(-1),
meanMs: sum / durations.length,
minMs: durations[0],
p50Ms: durations[Math.floor(durations.length * 0.5)],
p95Ms: durations[Math.floor(durations.length * 0.95)],
passes,
sample: lastResult
}
}
function round(value) {
return Number(value.toFixed(3))
}
function printMeasurement(measurement) {
console.log(
`${measurement.label}: mean=${round(measurement.meanMs)}ms p50=${round(
measurement.p50Ms
)}ms p95=${round(measurement.p95Ms)}ms max=${round(measurement.maxMs)}ms`
)
}
function main() {
const options = parseOptions()
const fixture = makeTreeFixture(options)
const totalRows = fixture.canonicalPaths.length
console.log(
`fixture: canonicalPaths=${totalRows} roots=${options.rootDirs} nestedDirsPerRoot=${options.nestedDirsPerRoot} filesPerNestedDir=${options.filesPerNestedDir}`
)
const legacyCurrent = measure('legacy-current-full-flatten+maps', options.passes, () =>
runLegacyCurrentPass(fixture, false)
)
printMeasurement(legacyCurrent)
const directProjection = measure('direct-dir-cache-row-projection', options.passes, () =>
runDirectDirCacheProjectionPass(fixture, false)
)
printMeasurement(directProjection)
const projectionBuild = measure('indexed-projection-rebuild', options.passes, () =>
buildIndexedProjection(fixture, false)
)
printMeasurement(projectionBuild)
const projection = buildIndexedProjection(fixture, false)
const indexedSlice = measure('indexed-visible-window-slice', options.passes, (index) => {
const visibleCount = projection.getVisibleCount()
const maxStart = Math.max(0, visibleCount - options.windowSize)
const start = maxStart === 0 ? 0 : (index * 97) % maxStart
const rows = projection.getVisibleSlice(start, start + options.windowSize - 1)
const selected = projection.getRowByPath(rows.at(-1)?.path ?? '')
return { selected: selected?.path ?? null, visibleWindowCount: rows.length }
})
printMeasurement(indexedSlice)
console.log(
JSON.stringify(
{
fixture: {
canonicalPaths: totalRows,
expandedDirs: fixture.expanded.size,
ignoredPaths: fixture.ignored.size
},
indexedSlice,
legacyCurrent,
directProjection,
projectionBuild
},
null,
2
)
)
}
main()
@@ -0,0 +1,483 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import {
budgetFailures,
collectTerminalPerfRows,
compareScenarios,
escapeHtml,
formatLargeValue,
formatMs,
readJsonReport,
scenarioTitle
} from './terminal-perf-report-rows.mjs'
import { basename, dirname } from 'node:path'
const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html'
// Why: every tracked metric is lower-is-better, so delta coloring and the
// regression table share one direction rule.
const MS_METRICS = [
{ key: 'medianMs', label: 'Typing median', chart: true },
{ key: 'worstMs', label: 'Typing worst', chart: true },
{ key: 'scrollMs', label: 'Active scroll', chart: true },
{ key: 'restoreMs', label: 'Restore', chart: true },
{ key: 'revisitMs', label: 'Revisit marker', chart: true },
{ key: 'maxTimerDriftMs', label: 'Timer drift', chart: false }
]
const COUNT_METRICS = [
{ key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' },
{ key: 'mainPeakInFlightChars', label: 'Main in-flight chars' },
{ key: 'mainPeakPendingChars', label: 'Main pending chars' },
{ key: 'hiddenSkippedChars', label: 'Hidden skipped chars' },
{ key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' },
// Why: parked-memory scenarios are table-only — heap/view counts have no
// ms trend story, so they stay out of the charts.
{ key: 'heapUsedMB', label: 'Renderer JS heap (MB)' },
{ key: 'liveTerminals', label: 'Live xterm instances' },
{ key: 'livePaneManagers', label: 'Live pane managers' }
]
const SERIES_COLORS = {
medianMs: '#2563eb',
worstMs: '#dc2626',
scrollMs: '#d97706',
restoreMs: '#7c3aed',
revisitMs: '#0d9488'
}
const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/
export function parseHtmlReportArgs(argv, env = process.env) {
const args = [...argv]
if (args[0] === '--') {
args.shift()
}
const inputs = []
let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--output' || arg === '-o') {
const next = args[index + 1]
if (!next || next.startsWith('-')) {
throw new Error(`${arg} requires a path`)
}
outputPath = next
index += 1
continue
}
if (arg.startsWith('--output=')) {
outputPath = arg.slice('--output='.length)
continue
}
const labeled = arg.match(LABELED_INPUT_RE)
if (labeled) {
inputs.push({ label: labeled[1], path: labeled[2] })
} else {
inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg })
}
}
if (inputs.length === 0) {
throw new Error(
'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]<playwright-json>... --output <report.html>'
)
}
return { inputs, outputPath }
}
// ── Trend data ────────────────────────────────────────────────────────────
function buildMatrix(revisions) {
const scenarios = new Map()
for (const revision of revisions) {
for (const row of revision.rows) {
if (!scenarios.has(row.scenario)) {
scenarios.set(row.scenario, new Map())
}
scenarios.get(row.scenario).set(revision.label, row)
}
}
const orderedScenarios = [...scenarios.keys()].sort(compareScenarios)
return { scenarios, orderedScenarios }
}
function niceCeil(value) {
if (value <= 0) {
return 1
}
const magnitude = 10 ** Math.floor(Math.log10(value))
for (const step of [1, 2, 2.5, 5, 10]) {
if (value <= step * magnitude) {
return step * magnitude
}
}
return 10 * magnitude
}
// ── Rendering ─────────────────────────────────────────────────────────────
function renderTrendChart({ scenario, byRevision, revisions, title }) {
const metrics = MS_METRICS.filter(
(metric) =>
metric.chart &&
revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null)
)
if (metrics.length === 0) {
return ''
}
const width = 560
const height = 230
const pad = { left: 52, right: 14, top: 30, bottom: 38 }
const plotW = width - pad.left - pad.right
const plotH = height - pad.top - pad.bottom
const maxValue = Math.max(
1,
...metrics.flatMap((metric) =>
revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0)
)
)
const yMax = niceCeil(maxValue * 1.15)
const xFor = (index) =>
pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1))
const yFor = (value) => pad.top + plotH - (plotH * value) / yMax
const parts = []
parts.push(
`<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(title)}" class="trend-chart">`
)
parts.push(`<text x="${pad.left}" y="16" class="chart-title">${escapeHtml(title)}</text>`)
// Horizontal gridlines + y labels
const ticks = 4
for (let tick = 0; tick <= ticks; tick += 1) {
const value = (yMax * tick) / ticks
const y = yFor(value)
parts.push(
`<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" class="gridline"/>`
)
parts.push(
`<text x="${pad.left - 6}" y="${y + 3}" class="axis-label" text-anchor="end">${value % 1 === 0 ? value : value.toFixed(1)}</text>`
)
}
// X labels
revisions.forEach((revision, index) => {
parts.push(
`<text x="${xFor(index)}" y="${height - pad.bottom + 16}" class="axis-label" text-anchor="middle">${escapeHtml(revision.label)}</text>`
)
})
// Series
for (const metric of metrics) {
const color = SERIES_COLORS[metric.key] ?? '#475569'
const points = revisions
.map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] }))
.filter((point) => point.value != null)
if (points.length === 0) {
continue
}
const path = points
.map(
(point, order) =>
`${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}`
)
.join(' ')
parts.push(`<path d="${path}" fill="none" stroke="${color}" stroke-width="2"/>`)
for (const point of points) {
const x = xFor(point.index)
const y = yFor(point.value)
parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${color}"/>`)
parts.push(
`<text x="${x.toFixed(1)}" y="${(y - 7).toFixed(1)}" class="point-label" text-anchor="middle" fill="${color}">${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}</text>`
)
}
}
parts.push('</svg>')
const legend = metrics
.map((metric) => {
const color = SERIES_COLORS[metric.key] ?? '#475569'
return `<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(metric.label)}</span>`
})
.join('')
return `<figure class="chart-card" data-scenario="${escapeHtml(scenario)}">${parts.join('')}<figcaption class="legend">${legend} <span class="legend-unit">ms — lower is better</span></figcaption></figure>`
}
function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) {
if (baseline == null || latest == null) {
return '<td class="delta">—</td>'
}
const diff = latest - baseline
const pct = baseline === 0 ? null : (diff / baseline) * 100
let cls = 'neutral'
if (zeroBudget) {
cls = latest > 0 ? 'worse' : 'better'
} else if (pct != null && Math.abs(pct) >= 5) {
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
} else if (baseline === 0 && diff !== 0) {
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
}
const pctLabel =
pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%`
const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}`
return `<td class="delta ${cls}">${escapeHtml(pctLabel)} <span class="delta-abs">(${escapeHtml(diffLabel)})</span></td>`
}
function renderScenarioTable({ scenario, byRevision, revisions, title }) {
const metricRows = []
const allMetrics = [...MS_METRICS, ...COUNT_METRICS]
for (const metric of allMetrics) {
const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key])
if (values.every((value) => value == null)) {
continue
}
const isMs = MS_METRICS.includes(metric)
const format = isMs ? formatMs : formatLargeValue
const cells = values
.map((value) => `<td>${value == null ? '—' : escapeHtml(format(value))}</td>`)
.join('')
const baseline = values.find((value) => value != null)
const latest = values.toReversed().find((value) => value != null)
metricRows.push(
`<tr><th scope="row">${escapeHtml(metric.label)}</th>${cells}${deltaCell(baseline, latest, {
zeroBudget: metric.key === 'rendererDroppedBacklogs'
})}</tr>`
)
}
if (metricRows.length === 0) {
return ''
}
const headers = revisions.map((revision) => `<th>${escapeHtml(revision.label)}</th>`).join('')
return `<section class="scenario-block">
<h3>${escapeHtml(title)} <span class="scenario-id">${escapeHtml(scenario)}</span></h3>
<table class="trend-table">
<thead><tr><th>Metric</th>${headers}<th>Δ first → last</th></tr></thead>
<tbody>${metricRows.join('')}</tbody>
</table>
</section>`
}
function renderHeadline(revisions, matrix) {
if (revisions.length < 2) {
return ''
}
const first = revisions[0]
const last = revisions.at(-1)
const cards = []
for (const scenario of matrix.orderedScenarios) {
const byRevision = matrix.scenarios.get(scenario)
const baseRow = byRevision.get(first.label)
const lastRow = byRevision.get(last.label)
if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) {
continue
}
const diff = lastRow.medianMs - baseRow.medianMs
const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100
const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse'
cards.push(`<div class="card ${cls}">
<div class="card-title">${escapeHtml(scenarioTitle(scenario, lastRow))}</div>
<div class="card-value">${escapeHtml(formatMs(baseRow.medianMs))}${escapeHtml(formatMs(lastRow.medianMs))}</div>
<div class="card-sub">typing median, ${escapeHtml(first.label)}${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)</div>
</div>`)
}
if (cards.length === 0) {
return ''
}
return `<section><h2>Baseline vs latest</h2><div class="cards">${cards.join('')}</div></section>`
}
function renderBudgets(latestRevision) {
const failures = []
for (const row of latestRevision.rows) {
for (const failure of budgetFailures(row)) {
failures.push(`${row.scenario}: ${failure}`)
}
}
const status =
failures.length === 0 ? '<span class="pass">Pass</span>' : '<span class="fail">Fail</span>'
const failureList =
failures.length === 0
? ''
: `<ul>${failures.map((failure) => `<li>${escapeHtml(failure)}</li>`).join('')}</ul>`
return `<section><h2>Budget status — ${escapeHtml(latestRevision.label)}</h2>
<p>${latestRevision.rows.length} scenario rows checked: ${status}</p>${failureList}</section>`
}
function renderInputsMeta(revisions) {
const items = revisions
.map((revision) => {
const stats = revision.stats
const statsLabel = stats
? `${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky`
: ''
const failNote =
stats && stats.unexpected > 0
? ' <span class="meta-warn">(failed assertions at this revision; metrics still recorded)</span>'
: ''
return `<li><strong>${escapeHtml(revision.label)}</strong> — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}</li>`
})
.join('')
return `<ol class="inputs">${items}</ol>`
}
function renderRawDetails(revisions) {
return revisions
.map((revision) => {
const rows = revision.rows
.map(
(row) =>
`<tr><td>${escapeHtml(row.scenario)}</td><td>${row.panes ?? '—'}</td><td>${escapeHtml(formatMs(row.medianMs))}</td><td>${escapeHtml(formatMs(row.worstMs))}</td><td>${escapeHtml(formatMs(row.scrollMs))}</td><td>${escapeHtml(formatMs(row.restoreMs))}</td><td>${escapeHtml(formatMs(row.revisitMs))}</td><td>${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}</td><td>${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}</td><td>${row.rendererDroppedBacklogs ?? '—'}</td></tr>`
)
.join('')
return `<details><summary>Raw rows — ${escapeHtml(revision.label)}</summary>
<table class="trend-table">
<thead><tr><th>Scenario</th><th>Panes</th><th>Median</th><th>Worst</th><th>Scroll</th><th>Restore</th><th>Revisit</th><th>Renderer peak</th><th>Hidden skipped</th><th>Drops</th></tr></thead>
<tbody>${rows}</tbody></table></details>`
})
.join('')
}
const PAGE_CSS = `
:root { color-scheme: light; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; }
h1 { font-size: 24px; margin-bottom: 4px; }
h2 { font-size: 18px; margin: 28px 0 10px; }
h3 { font-size: 15px; margin: 18px 0 6px; }
.meta { color: #64748b; font-size: 13px; }
.inputs { font-size: 13px; color: #334155; padding-left: 20px; }
.meta-warn { color: #b45309; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; }
.card.better { border-left-color: #16a34a; }
.card.worse { border-left-color: #dc2626; }
.card.neutral { border-left-color: #94a3b8; }
.card-title { font-size: 12px; color: #64748b; }
.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; }
.card-sub { font-size: 11px; color: #94a3b8; }
.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; }
.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; }
.trend-chart { width: 100%; height: auto; }
.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; }
.gridline { stroke: #e2e8f0; stroke-width: 1; }
.axis-label { font-size: 10px; fill: #64748b; }
.point-label { font-size: 10px; font-weight: 600; }
.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.legend-unit { color: #94a3b8; margin-left: auto; }
.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; }
.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; }
table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; }
table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; }
table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; }
table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; }
td.delta.better { color: #15803d; font-weight: 600; }
td.delta.worse { color: #b91c1c; font-weight: 600; }
td.delta.neutral { color: #64748b; }
.delta-abs { font-weight: 400; color: #94a3b8; }
.pass { color: #15803d; font-weight: 700; }
.fail { color: #b91c1c; font-weight: 700; }
details { margin: 8px 0; }
summary { cursor: pointer; font-size: 13px; color: #334155; }
`
function renderHtml({ generatedAt, revisions }) {
const matrix = buildMatrix(revisions)
const charts =
revisions.length >= 2
? matrix.orderedScenarios
.map((scenario) => {
const byRevision = matrix.scenarios.get(scenario)
const anyRow = [...byRevision.values()][0]
return renderTrendChart({
scenario,
byRevision,
revisions,
title: scenarioTitle(scenario, anyRow)
})
})
.filter(Boolean)
.join('')
: ''
const tables = matrix.orderedScenarios
.map((scenario) => {
const byRevision = matrix.scenarios.get(scenario)
const anyRow = [...byRevision.values()][0]
return renderScenarioTable({
scenario,
byRevision,
revisions,
title: scenarioTitle(scenario, anyRow)
})
})
.filter(Boolean)
.join('')
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Terminal Performance Over Time</title>
<style>${PAGE_CSS}</style>
</head>
<body>
<h1>Terminal Performance Over Time</h1>
<p class="meta">Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.</p>
${renderInputsMeta(revisions)}
${renderHeadline(revisions, matrix)}
${charts ? `<section><h2>Trends across revisions</h2><div class="charts">${charts}</div></section>` : ''}
<section><h2>Metric detail by scenario</h2>${tables}</section>
${renderBudgets(revisions.at(-1))}
<section><h2>Raw data</h2>${renderRawDetails(revisions)}</section>
</body>
</html>
`
}
export function generateTerminalPerfHtmlReport({
inputs,
inputPaths,
outputPath,
now = new Date()
}) {
// Why: older callers (the scale report gate) pass bare inputPaths.
const resolvedInputs =
inputs ??
(inputPaths ?? []).map((path) => ({
label: basename(path).replace(/\.json$/i, ''),
path
}))
const revisions = resolvedInputs.map(({ label, path }) => {
const report = readJsonReport(path)
return {
label,
path,
stats: report.stats ?? null,
rows: collectTerminalPerfRows(report, label)
}
})
const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0)
if (totalRows === 0) {
throw new Error('No opencode terminal perf annotations found in the provided reports')
}
const html = renderHtml({ generatedAt: now.toISOString(), revisions })
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, html)
const latestFailures = revisions
.at(-1)
.rows.reduce((sum, row) => sum + budgetFailures(row).length, 0)
return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures }
}
const isMain = process.argv[1] && import.meta.filename === process.argv[1]
if (isMain) {
try {
const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2))
const result = generateTerminalPerfHtmlReport({ inputs, outputPath })
console.log(
`Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).`
)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
@@ -0,0 +1,272 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
generateTerminalPerfHtmlReport,
parseHtmlReportArgs
} from './generate-terminal-perf-html-report.mjs'
const tempDirs = []
function makeTempDir() {
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-html-'))
tempDirs.push(dir)
return dir
}
function writeReport(
annotationDescription,
annotationType = 'opencode-scale-same-workspace-25',
reportName = 'report.json'
) {
const dir = makeTempDir()
const reportPath = join(dir, reportName)
writeFileSync(
reportPath,
JSON.stringify({
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: annotationType,
description: annotationDescription
},
{
type: 'browser-unrelated',
description: 'median=999.0ms'
}
]
}
]
}
]
}
]
})
)
return reportPath
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('generate-terminal-perf-html-report', () => {
it('parses labeled and bare input paths plus output flags', () => {
expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({
inputs: [
{ label: 'a', path: 'a.json' },
{ label: 'b', path: 'b.json' }
],
outputPath: 'out.html'
})
expect(parseHtmlReportArgs(['main=runs/0-main.json', '#5038 final=runs/4-final.json'])).toEqual(
{
inputs: [
{ label: 'main', path: 'runs/0-main.json' },
{ label: '#5038 final', path: 'runs/4-final.json' }
],
outputPath: 'test-results/terminal-perf-impact-report.html'
}
)
expect(
parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' })
).toEqual({
inputs: [{ label: 'a', path: 'a.json' }],
outputPath: 'env.html'
})
expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path')
expect(() => parseHtmlReportArgs([])).toThrow('Usage:')
})
it('writes a single-run report with scenario tables and budget status', () => {
const reportPath = writeReport(
[
'panes=25',
'frames=60',
'median=12.4ms',
'worst=44.8ms',
'revisit=28.6ms',
'scroll=61.0ms',
'restore=320.0ms',
'maxTimerDrift=8.0ms',
'rendererPeakQueuedChars=2048',
'mainPeakInFlightChars=4096',
'heldAckChars=1024',
'hiddenSkippedChars=512',
'rendererDroppedBacklogs=0'
].join(' ')
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({
inputPaths: [reportPath],
outputPath,
now: new Date('2026-06-09T10:00:00.000Z')
})
const html = readFileSync(outputPath, 'utf8')
expect(result).toEqual({ budgetFailureCount: 0, outputPath, rowCount: 1 })
expect(html).toContain('<!doctype html>')
expect(html).toContain('Terminal Performance Over Time')
expect(html).toContain('2026-06-09T10:00:00.000Z')
expect(html).toContain('Same workspace panes — 25 panes')
expect(html).toContain('opencode-scale-same-workspace-25')
expect(html).toContain('28.6ms')
expect(html).toContain('Pass')
// Why: one run has no over-time story; the trend section must not render.
expect(html).not.toContain('Trends across revisions')
expect(html).not.toContain('browser-unrelated')
})
it('renders parked-memory heap and live view counts as table metrics', () => {
const reportPath = writeReport(
'panes=8 parkedTabs=8 heapUsedMB=142.5 liveTerminals=1 livePaneManagers=1',
'opencode-parked-memory'
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
const html = readFileSync(outputPath, 'utf8')
// Why: heapUsedMB has no budget — a memory row alone must not fail gates.
expect(result.budgetFailureCount).toBe(0)
expect(html).toContain('Parked hidden terminal memory — 8 panes')
expect(html).toContain('Renderer JS heap (MB)')
expect(html).toContain('142.5')
expect(html).toContain('Live xterm instances')
expect(html).toContain('Live pane managers')
})
it('marks over-budget rows as failures for the latest run', () => {
const reportPath = writeReport(
[
'panes=100',
'median=80.0ms',
'worst=301.0ms',
'revisit=301.0ms',
'rendererPeakQueuedChars=2097153',
'rendererDroppedBacklogs=1'
].join(' '),
'opencode-scale-cross-workspace-100'
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
const html = readFileSync(outputPath, 'utf8')
expect(result.budgetFailureCount).toBe(5)
expect(html).toContain('Fail')
expect(html).toContain('medianMs 80 &gt; 75')
expect(html).toContain('Cross-workspace hidden panes')
})
it('renders ordered revisions with trend charts and baseline deltas', () => {
const mainReport = writeReport(
'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'main.json'
)
const middleReport = writeReport(
'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'backpressure.json'
)
const finalReport = writeReport(
'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'final.json'
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({
inputs: [
{ label: 'main', path: mainReport },
{ label: 'backpressure', path: middleReport },
{ label: 'final', path: finalReport }
],
outputPath
})
const html = readFileSync(outputPath, 'utf8')
expect(result.rowCount).toBe(3)
expect(html).toContain('Baseline vs latest')
expect(html).toContain('Trends across revisions')
expect(html).toContain('trend-chart')
expect(html).toContain('>main<')
expect(html).toContain('>backpressure<')
expect(html).toContain('>final<')
// Why: median 50 -> 20 is a 60% improvement and must read as better.
expect(html).toContain('delta better')
expect(html).toContain('-60%')
expect(html).toContain('50.0ms → 20.0ms')
})
it('renders missing scenarios at older revisions as gaps, not zeros', () => {
const mainReport = writeReport(
'panes=25 median=50.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'main.json'
)
const finalReport = makeTempDir()
const finalPath = join(finalReport, 'final.json')
writeFileSync(
finalPath,
JSON.stringify({
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale-same-workspace-25',
description: 'panes=25 median=40.0ms rendererDroppedBacklogs=0'
},
{
type: 'opencode-revisit-pressure',
description: 'panes=19 median=3.0ms revisit=4.4ms rendererDroppedBacklogs=0'
}
]
}
]
}
]
}
]
})
)
const outputPath = join(makeTempDir(), 'report.html')
generateTerminalPerfHtmlReport({
inputs: [
{ label: 'main', path: mainReport },
{ label: 'final', path: finalPath }
],
outputPath
})
const html = readFileSync(outputPath, 'utf8')
expect(html).toContain('Revisit under pressure')
expect(html).toContain('<td>—</td>')
})
it('fails when reports contain no terminal perf annotations', () => {
const reportPath = writeReport('median=12.0ms', 'browser-unrelated')
expect(() =>
generateTerminalPerfHtmlReport({
inputPaths: [reportPath],
outputPath: join(makeTempDir(), 'report.html')
})
).toThrow('No opencode terminal perf annotations found')
})
})
@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
describe('Git binary compatibility PR gate', () => {
it('runs the real-binary contract at each compatibility boundary', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const step = workflow.jobs.verify.steps.find(
(candidate) => candidate.name === 'Verify Git binary compatibility matrix'
)
expect(step?.run).toContain('git-2.25.5.tar.gz')
expect(step?.run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf')
expect(step?.run).toContain('ORCA_GIT_COMPAT_BINARY="$source/git"')
expect(step?.run).toContain('alpine/git:edge-2.38.1|2.38.1')
expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1')
expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"')
expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts')
})
})
@@ -0,0 +1,109 @@
#!/usr/bin/env node
// Benchmark: renderer-heap growth of the agent-hibernation output-epoch map
// across terminal open/close cycles.
//
// recordAgentHibernationPaneOutput() adds one entry per pane (keyed by
// `tabId:leafId`, leafId a fresh UUID each open) on every PTY output chunk.
// Before the fix nothing purged those entries on permanent pane/worktree close,
// so the module-level Map grew for the renderer's whole lifetime. This script
// simulates N open→emit→close cycles and reports retained Map size with the
// purge disabled vs enabled.
import { performance } from 'node:perf_hooks'
import v8 from 'node:v8'
const CYCLES = Number.parseInt(process.env.ORCA_EPOCH_BENCH_CYCLES ?? '20000', 10)
const PANES_PER_TAB = Number.parseInt(process.env.ORCA_EPOCH_BENCH_PANES ?? '2', 10)
for (const [name, value] of [
['ORCA_EPOCH_BENCH_CYCLES', CYCLES],
['ORCA_EPOCH_BENCH_PANES', PANES_PER_TAB]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
// Mirror of the module under test (agent-hibernation-output-activity.ts): a
// module-level Map of paneKey -> epoch, plus the tab-scoped purge the fix adds.
function makeActivity() {
const outputEpochByPaneKey = new Map()
return {
map: outputEpochByPaneKey,
record(paneKey) {
outputEpochByPaneKey.set(paneKey, (outputEpochByPaneKey.get(paneKey) ?? 0) + 1)
},
forgetTab(tabId) {
const prefix = `${tabId}:`
for (const paneKey of outputEpochByPaneKey.keys()) {
if (paneKey.startsWith(prefix)) {
outputEpochByPaneKey.delete(paneKey)
}
}
}
}
}
// A v4 UUID-shaped string; varied by index so each pane open mints a fresh key
// exactly like the real leafId allocation.
function leafId(index) {
const hex = index.toString(16).padStart(12, '0').slice(-12)
return `00000000-0000-4000-8000-${hex}`
}
function runCycles({ purge }) {
const activity = makeActivity()
let peak = 0
for (let cycle = 0; cycle < CYCLES; cycle += 1) {
const tabId = `tab-${cycle}`
for (let pane = 0; pane < PANES_PER_TAB; pane += 1) {
const paneKey = `${tabId}:${leafId(cycle * PANES_PER_TAB + pane)}`
// Simulate a burst of PTY output chunks for this pane.
for (let chunk = 0; chunk < 8; chunk += 1) {
activity.record(paneKey)
}
}
peak = Math.max(peak, activity.map.size)
if (purge) {
activity.forgetTab(tabId)
}
}
return { retained: activity.map.size, peak }
}
function approxRetainedBytes(entries) {
// Rough lower bound: a paneKey string (~48 UTF-16 chars ≈ 96 B + header) plus a
// small-int value and Map node overhead. ~110 B/entry is conservative for V8.
return entries * 110
}
console.log('Hibernation output-epoch map leak benchmark')
console.log(`cycles=${CYCLES} panes/tab=${PANES_PER_TAB} (open → emit 8 chunks/pane → close)\n`)
const t0 = performance.now()
const before = runCycles({ purge: false })
const afterMs = performance.now()
const after = runCycles({ purge: true })
const doneMs = performance.now()
const fmtBytes = (n) =>
n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(2)} MiB` : `${(n / 1024).toFixed(1)} KiB`
console.log(' variant │ retained entries │ approx retained heap │ wall time')
console.log(' ────────────────┼──────────────────┼──────────────────────┼──────────')
console.log(
` before (leak) │ ${String(before.retained).padStart(16)}${fmtBytes(approxRetainedBytes(before.retained)).padStart(20)}${(afterMs - t0).toFixed(0)} ms`
)
console.log(
` after (purge) │ ${String(after.retained).padStart(16)}${fmtBytes(approxRetainedBytes(after.retained)).padStart(20)}${(doneMs - afterMs).toFixed(0)} ms`
)
console.log(
`\nWithout the purge, retained entries grow unbounded with open/close cycles` +
` (peak ${before.peak}${fmtBytes(approxRetainedBytes(before.retained))}).` +
`\nWith the purge, the map returns to ~0 after each tab closes — bounded by` +
` the live pane count, not session lifetime.`
)
// Belt-and-suspenders: confirm v8 can serialize a representative entry so the
// per-entry estimate is grounded, not invented.
const sampleBytes = v8.serialize([`tab-0:${leafId(0)}`, 8]).byteLength
console.log(`\n(v8-serialized size of one [paneKey, epoch] entry: ${sampleBytes} bytes)`)
@@ -0,0 +1,44 @@
export async function installSyntheticVisibleSpinners(page, count, animation, steps) {
if (count <= 0) {
return
}
const animationTiming =
animation === 'steps' ? `1s steps(${steps}, end) infinite` : '1s linear infinite'
await page.addStyleTag({
content: `
@keyframes orca-idle-bench-spin {
to { transform: rotate(360deg); }
}
.orca-idle-bench-spinner-host {
position: fixed;
top: 16px;
right: 16px;
z-index: 2147483647;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.orca-idle-bench-spinner {
width: 10px;
height: 10px;
border: 2px solid rgb(234 179 8);
border-top-color: transparent;
border-radius: 9999px;
animation: orca-idle-bench-spin ${animationTiming};
}
`
})
await page.evaluate((spinnerCount) => {
document.querySelector('[data-orca-idle-bench-spinners]')?.remove()
const host = document.createElement('div')
host.className = 'orca-idle-bench-spinner-host'
host.setAttribute('data-orca-idle-bench-spinners', String(spinnerCount))
for (let index = 0; index < spinnerCount; index += 1) {
const spinner = document.createElement('div')
spinner.className = 'orca-idle-bench-spinner'
host.appendChild(spinner)
}
document.body.appendChild(host)
}, count)
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// Symlinks the orca-dev wrapper into /usr/local/bin so the dev CLI is
// available globally after `pnpm run build:cli`.
import { existsSync, lstatSync, readlinkSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
const scriptDir = import.meta.dirname
const source = path.join(scriptDir, 'orca-dev.mjs')
const commandPath =
process.platform === 'darwin' || process.platform === 'linux' ? '/usr/local/bin/orca-dev' : null
if (!commandPath) {
console.log('[orca-dev] Skipping global symlink (unsupported platform).')
process.exit(0)
}
function isOwnedByUs(target) {
try {
if (!lstatSync(target).isSymbolicLink()) {
return false
}
return readlinkSync(target) === source
} catch {
return false
}
}
if (existsSync(commandPath)) {
if (isOwnedByUs(commandPath)) {
console.log(`[orca-dev] ${commandPath} already points to dev CLI.`)
process.exit(0)
}
console.error(
`[orca-dev] ${commandPath} exists but is not our symlink. Remove it manually if you want the dev CLI installed globally.`
)
process.exit(0)
}
try {
execFileSync('ln', ['-s', source, commandPath], { stdio: 'inherit' })
console.log(`[orca-dev] Symlinked ${commandPath}${source}`)
} catch {
console.log(
`[orca-dev] Could not create ${commandPath} (permission denied). Run once with:\n` +
` sudo ln -s ${source} ${commandPath}`
)
}
@@ -0,0 +1,282 @@
#!/usr/bin/env node
import {
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync
} from 'node:fs'
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { platform as osPlatform, tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const projectDir = resolve(import.meta.dirname, '../..')
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
const electronRequire = createRequire(resolve(electronPackageDir, 'package.json'))
const { version: electronVersion } = electronRequire('./package.json')
const { downloadArtifact } = electronRequire('@electron/get')
const targetPlatform = getElectronTargetPlatform()
const targetArch = getElectronTargetArch()
const platformPath = getElectronPlatformPath(targetPlatform)
try {
// Why: Electron's own install.js can exit 0 while an async extract promise is
// still unsettled, leaving a partial dist/. Top-level await makes that fail.
await main()
} catch (error) {
console.error('[electron-package] Failed to install Electron package binary.')
console.error(error)
logElectronInstallDiagnostics()
process.exit(1)
}
async function main() {
if (electronPackageIsUsable()) {
return
}
// Why: PR tests run under system Node after native modules are rebuilt for
// Node. Install only Electron's npm package binary here; do not run the full
// Electron native-module rebuild path, which would undo the Node ABI rebuild.
console.log('[electron-package] Electron package binary is missing; running Electron install.')
resetPartialElectronInstall()
await installElectronPackageBinary()
repairElectronPathFile()
if (!electronPackageIsUsable()) {
logElectronInstallDiagnostics()
console.error('[electron-package] Electron package is still unavailable after install.')
process.exit(1)
}
}
function electronPackageIsUsable() {
try {
const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8')
.trim()
.replace(/^v/, '')
const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8')
return (
installedVersion === electronVersion &&
installedPlatformPath === platformPath &&
existsSync(getElectronExecutablePath())
)
} catch {
return false
}
}
function getElectronExecutablePath() {
return process.env.ELECTRON_OVERRIDE_DIST_PATH
? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath)
: resolve(electronPackageDir, 'dist', platformPath)
}
function resetPartialElectronInstall() {
rmSync(resolve(electronPackageDir, 'dist'), { recursive: true, force: true })
rmSync(resolve(electronPackageDir, 'path.txt'), { force: true })
}
function repairElectronPathFile() {
const electronExecutable = resolve(electronPackageDir, 'dist', platformPath)
if (!existsSync(electronExecutable)) {
return
}
const pathFile = resolve(electronPackageDir, 'path.txt')
let currentPath = ''
try {
currentPath = readFileSync(pathFile, 'utf8')
} catch {
// Missing path.txt is the common CI failure this script repairs.
}
if (currentPath !== platformPath) {
writeFileSync(pathFile, platformPath)
console.log(`[electron-package] Repaired Electron path.txt -> ${platformPath}`)
}
}
async function installElectronPackageBinary() {
const electronDistDir = resolve(electronPackageDir, 'dist')
const tempDir = mkdtempSync(resolve(tmpdir(), 'orca-electron-'))
const cacheRoot = join(tempDir, 'cache')
const extractDir = join(tempDir, 'extract')
try {
const zipPath = await downloadArtifact({
version: electronVersion,
artifactName: 'electron',
platform: targetPlatform,
arch: targetArch,
cacheRoot,
force: true,
tempDirectory: tempDir,
...(shouldUseRemoteChecksums() ? {} : { checksums: electronRequire('./checksums.json') })
})
// Why: CI has observed partial extracts directly under node_modules/electron
// that leave only dist/locales. Verify in temp before replacing package dist.
extractElectronArchive(zipPath, extractDir)
const extractedExecutable = resolve(extractDir, platformPath)
if (!existsSync(extractedExecutable)) {
console.error('[electron-package] Electron archive extract did not contain executable.')
console.error(` platformPath=${platformPath}`)
console.error(` extractDir=${extractDir}`)
console.error(` extractEntries=${safeReaddir(extractDir).join(', ')}`)
process.exit(1)
}
moveExtractedElectronDist(extractDir, electronDistDir)
const srcTypeDefPath = resolve(electronDistDir, 'electron.d.ts')
if (existsSync(srcTypeDefPath)) {
renameSync(srcTypeDefPath, resolve(electronPackageDir, 'electron.d.ts'))
}
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
}
function extractElectronArchive(zipPath, extractDir) {
mkdirSync(extractDir, { recursive: true })
// Why: extract-zip/Electron install.js can leave Node 24 with an unsettled
// promise and no active handles on CI. Host unzip tools fail synchronously.
const command = getExtractorCommand(zipPath, extractDir)
const result = spawnSync(command.file, command.args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(formatExtractorFailure(command, result))
}
}
function moveExtractedElectronDist(extractDir, electronDistDir) {
rmSync(electronDistDir, { recursive: true, force: true })
try {
// Why: macOS Electron archives rely on framework symlinks. Moving the
// verified tree preserves them exactly; copying has broken them in CI.
renameSync(extractDir, electronDistDir)
} catch (/** @type {any} */ err) {
if (err?.code !== 'EXDEV') {
throw err
}
cpSync(extractDir, electronDistDir, {
recursive: true,
dereference: false,
verbatimSymlinks: true
})
}
}
function getExtractorCommand(zipPath, extractDir) {
if (process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR) {
return {
file: process.execPath,
args: [process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR, zipPath, extractDir],
label: `node ${process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR}`
}
}
if (osPlatform() === 'win32') {
return {
file: process.env.ORCA_POWERSHELL_BIN || 'powershell',
args: [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
[
"$ErrorActionPreference = 'Stop'",
`Expand-Archive -LiteralPath ${quotePowerShellLiteral(zipPath)} -DestinationPath ${quotePowerShellLiteral(extractDir)} -Force`
].join('; ')
],
label: 'powershell Expand-Archive'
}
}
return {
file: process.env.ORCA_UNZIP_BIN || 'unzip',
args: ['-q', zipPath, '-d', extractDir],
label: 'unzip'
}
}
function quotePowerShellLiteral(value) {
return `'${String(value).replaceAll("'", "''")}'`
}
function formatExtractorFailure(command, result) {
return [
`[electron-package] ${command.label} failed with status ${result.status}.`,
result.stdout ? `stdout:\n${result.stdout.trim()}` : '',
result.stderr ? `stderr:\n${result.stderr.trim()}` : ''
]
.filter(Boolean)
.join('\n')
}
function shouldUseRemoteChecksums() {
return Boolean(
process.env.electron_use_remote_checksums ||
process.env.npm_config_electron_use_remote_checksums
)
}
function logElectronInstallDiagnostics() {
const electronDistDir = resolve(electronPackageDir, 'dist')
const pathFile = resolve(electronPackageDir, 'path.txt')
console.error('[electron-package] Electron install diagnostics:')
console.error(` packageDir=${electronPackageDir} exists=${existsSync(electronPackageDir)}`)
console.error(` distDir=${electronDistDir} exists=${existsSync(electronDistDir)}`)
console.error(` pathFile=${pathFile} exists=${existsSync(pathFile)}`)
console.error(` platformPath=${platformPath}`)
if (existsSync(electronDistDir)) {
console.error(` distEntries=${safeReaddir(electronDistDir).join(', ')}`)
}
}
function safeReaddir(targetPath) {
try {
return readdirSync(targetPath).slice(0, 40)
} catch {
return []
}
}
function getElectronTargetPlatform() {
return process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || osPlatform()
}
function getElectronTargetArch() {
return process.env.ELECTRON_INSTALL_ARCH || process.env.npm_config_arch || process.arch
}
function getElectronPlatformPath(targetPlatform) {
switch (targetPlatform) {
case 'mas':
case 'darwin':
return 'Electron.app/Contents/MacOS/Electron'
case 'freebsd':
case 'openbsd':
case 'linux':
return 'electron'
case 'win32':
return 'electron.exe'
default:
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
}
}
@@ -0,0 +1,227 @@
import {
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceScriptPath = fileURLToPath(
new URL('./install-electron-package-binary.mjs', import.meta.url)
)
describe('install-electron-package-binary', () => {
it('installs Electron from an isolated cache and repairs path.txt', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir)
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toMatch(
/cacheRoot=.*orca-electron-.*cache/
)
expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe(
'electron'
)
if (process.platform !== 'win32') {
expect(
lstatSync(
join(projectDir, 'node_modules', 'electron', 'dist', 'version-link')
).isSymbolicLink()
).toBe(true)
}
expect(result.stdout).toContain('Repaired Electron path.txt -> electron')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('uses Electron 42 install env vars before npm config platform flags', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ELECTRON_INSTALL_PLATFORM: 'win32',
ELECTRON_INSTALL_ARCH: 'arm64',
npm_config_platform: 'linux',
npm_config_arch: 'x64'
})
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toContain(
'platform=win32 arch=arm64'
)
expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe(
'electron.exe'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('does not trigger Electron 42 lazy require downloads while checking install state', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir, { lazyRequireMarker: 'lazy-require.marker' })
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir)
expect(result.status, result.stderr).toBe(0)
expect(existsSync(join(projectDir, 'lazy-require.marker'))).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('fails instead of silently accepting a partial Electron extract', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: false })
mkdirSync(join(projectDir, 'node_modules', 'electron', 'dist', 'locales'), {
recursive: true
})
writeFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'stale-path')
const result = runInstallScript(projectDir)
expect(result.status).toBe(1)
expect(result.stderr).toContain('Electron archive extract did not contain executable')
expect(result.stderr).toContain('extractEntries=locales')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('does not exit successfully when Electron download never settles', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, { downloadNeverSettles: true })
writeFakeExtractor(projectDir, { createExecutable: false })
const result = runInstallScript(projectDir)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('Detected unsettled top-level await')
expect(existsSync(join(projectDir, 'node_modules', 'electron', 'path.txt'))).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
})
function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-install-electron-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
copyFileSync(
sourceScriptPath,
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
)
return projectDir
}
function runInstallScript(projectDir, extraEnv = {}) {
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
cwd: projectDir,
encoding: 'utf8',
env: {
...process.env,
npm_config_platform: 'linux',
npm_config_arch: 'x64',
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
...extraEnv
}
})
}
function writeFakeElectronPackage(projectDir, { lazyRequireMarker = null } = {}) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(electronDir, { recursive: true })
writeFileSync(
join(electronDir, 'package.json'),
JSON.stringify({ name: 'electron', version: '41.5.0' })
)
writeFileSync(join(electronDir, 'checksums.json'), '{}')
writeFileSync(
join(electronDir, 'index.js'),
`
const fs = require('node:fs')
const path = require('node:path')
${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''}
const pathFile = path.join(__dirname, 'path.txt')
if (!fs.existsSync(pathFile)) {
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
}
module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
`
)
}
function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
writeFileSync(
join(getDir, 'index.js'),
`
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
const { join } = require('node:path')
exports.downloadArtifact = async function downloadArtifact(details) {
appendFileSync(
'electron-get.log',
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + '\\n'
)
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
}
mkdirSync(details.cacheRoot, { recursive: true })
const artifactPath = join(details.cacheRoot, 'electron.zip')
writeFileSync(artifactPath, 'fake zip')
return artifactPath
}
`
)
}
function writeFakeExtractor(projectDir, { createExecutable }) {
writeFileSync(
join(projectDir, 'fake-extractor.cjs'),
`
const { mkdirSync, symlinkSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const extractDir = process.argv[3]
mkdirSync(join(extractDir, 'locales'), { recursive: true })
if (${JSON.stringify(createExecutable)}) {
writeFileSync(join(extractDir, 'electron'), '')
writeFileSync(join(extractDir, 'electron.exe'), '')
writeFileSync(join(extractDir, 'version'), 'v41.5.0')
if (process.platform !== 'win32') {
symlinkSync('version', join(extractDir, 'version-link'))
}
}
`
)
}
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url'
const API_VERSION = '2022-11-28'
const DESKTOP_STABLE_TAG_PATTERN = /^v([0-9]+)\.([0-9]+)\.([0-9]+)$/
export function parseDesktopStableTag(tag) {
const match = DESKTOP_STABLE_TAG_PATTERN.exec(tag)
if (!match) {
return null
}
return {
tag,
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3])
}
}
export function latestStableDesktopReleaseTag(releases) {
const stableTags = releases
.filter((release) => release?.draft !== true)
.map((release) => parseDesktopStableTag(release?.tag_name ?? release?.tagName ?? ''))
.filter(Boolean)
.sort((a, b) => a.major - b.major || a.minor - b.minor || a.patch - b.patch)
return stableTags.at(-1)?.tag ?? ''
}
async function githubJson(fetchImpl, url, token) {
const res = await fetchImpl(url, {
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': API_VERSION
}
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`)
}
return res.json()
}
export async function fetchReleases(repo, token, fetchImpl = fetch) {
if (!repo) {
throw new Error('repo is required')
}
if (!token) {
throw new Error('token is required')
}
const releases = []
for (let page = 1; ; page += 1) {
const pageReleases = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`,
token
)
if (!Array.isArray(pageReleases)) {
throw new Error(`GitHub releases response page ${page} for ${repo} was not an array`)
}
releases.push(...pageReleases)
if (pageReleases.length < 100) {
break
}
}
return releases
}
async function main() {
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca'
const releases = await fetchReleases(repo, token)
process.stdout.write(latestStableDesktopReleaseTag(releases))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}

Some files were not shown because too many files have changed in this diff Show More