Merge origin/main and preserve dashboard native chat routing

This commit is contained in:
Brennan Benson
2026-08-12 18:36:41 -07:00
2121 changed files with 125993 additions and 42612 deletions
+5 -7
View File
@@ -56,17 +56,15 @@ CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-type
## Pull Requests
Each pull request should:
Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular:
- explain the user-visible change
- stay focused on a single topic when possible
- include screenshots or screen recordings for new UI or behavior changes
- open with an ELI5 of the change (plain language paragraph; the PR title is the one-liner)
- explain what changed and why, and stay focused on a single topic when possible
- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual change, say `No visual change` and why
- 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.
- **Include your X (Twitter) handle** in the PR template Author section — we shout out contributors when we merge features on [@orca_build](https://x.com/orca_build).
## Release Process
+39 -17
View File
@@ -1,29 +1,51 @@
## Summary
## ELI5
Describe the user-visible change.
<!-- Simple high-level explanation -->
## Screenshots
## What Changed
- Add screenshots or a screen recording for any new or changed UI behavior.
- If there is no visual change, say `No visual change`.
<!-- Describe the change clearly and keep scope tight. -->
## Why
<!-- What problem does this solve, and why is this approach right? -->
## Linked Issue
<!-- Link the issue this PR addresses, there should ALWAYS be one -->
Fixes #
## Visual Proof
<!-- REQUIRED for UI / behavior changes. Please attach a BEFORE and AFTER that can easily tabbed/switched. Use videos for when appropriate over screenshots -->
<!-- If there is truly no visual or interaction change, write exactly: `N/A` and briefly say why. -->
<!-- For attachments NEVER add directly to the PR files (do not commit to files), use `gh image` extension or drag + drop (works for any attachment) -->
## 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
<!-- How did you verify this? Steps a reviewer can follow. Which platforms did you actually test (macOS / Linux / Windows / SSH)? -->
## AI Review Report
- [ ] I manually tested these changes locally
- [ ] Automated tests added/updated, or explained why not below
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
## AI Disclosure
<!-- For all external contributors only: Which AI model if anyone was used, please state the details -->
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.
## Review
## Notes
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
Call out any platform-specific behavior, risks, or follow-up work.
## Checklist
- [ ] This PR is small and focused
- [ ] I explained what changed and why (including ELI5)
- [ ] Before/after screenshots or videos attached for UI changes, or `N/A` with reason
- [ ] Self-reviewed for correctness, security, and performance
- [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred)
## Author
- X / Twitter: @your_handle
<!-- Optional but appreciated -->
+471
View File
@@ -0,0 +1,471 @@
name: Daily macOS Dev Build
# Why: gives developers a signed macOS build of main once a day that the in-app
# updater can install directly, without waiting for an RC cut — less noise than
# hourly.
#
# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules).
# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute
# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour.
#
# Deliberately narrow scope:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
# and release-cut remain the gates that matter.
# - Signed AND notarized, exactly like a release. The notary round trip is the
# one slow step kept: macOS anchors a notarized app's TCC grants on identifier
# + team rather than on its cdhash, so those grants survive an update. Without
# a ticket every daily reads as a new client and silently loses file access
# under Documents/Desktop/Downloads.
#
# Artifacts publish to stablyai/orca-daily, never to stablyai/orca: the main
# repo's releases atom feed exposes only its 10 newest entries, so high-volume
# dev tags would evict every stable/RC entry and break updates for real users.
# Separate from orca-hourly so someone riding main's hourlies never sees the
# sparse daily series mixed into that list.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the
# same GitHub App as hourly (installed on orca-daily with Contents: Read and
# write). Provision the App secrets with
# `bash config/scripts/setup-hourly-release-token.sh`, then grant the App access
# to orca-daily with `bash config/scripts/setup-daily-release-repo.sh`:
# HOURLY_RELEASE_APP_ID the App's numeric id (shared with hourly/adhoc)
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
#
# Installation tokens live one hour, which is why this mints three times. Install
# and build need no token at all, and notarization can hold the publish step for
# tens of minutes; minting again once the build is done starts the clock at the
# first call that actually uses it rather than burning a third of it on
# `pnpm install`. The upload step's own retry budget (2x45m) can outlive that
# second token, so a third is minted after it for verify/publish/prune/cleanup —
# without it a stuck notary run would strand a draft that cleanup gets a 401 on.
# The release stays an unpublished draft until the manifest check passes, so the
# worst case is still an invisible draft — and the build-number query counts
# drafts, so it holds its number and the next run does not reuse it.
on:
schedule:
# Once a day, early morning Pacific. Single cron — no DST twin, no clock gate.
- cron: '15 14 * * *'
workflow_dispatch:
inputs:
force:
description: Build even if main has not moved since the last daily
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: daily-mac-build
cancel-in-progress: false
env:
DAILY_REPO: stablyai/orca-daily
# Keep ~30 days so a regression can be bisected without holding a full year.
DAILY_RETAIN_COUNT: 30
jobs:
build-daily-mac:
if: github.repository == 'stablyai/orca'
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
# the job is killed mid-retry and no cleanup step runs at all. A typical run
# is far shorter — this is the notary queue's tail, not its median.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the daily repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
# build step is compromised (zizmor: artipacked).
persist-credentials: false
- name: Mint daily repo token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-daily
# Why: main is often idle. Rebuilding an unchanged commit burns a runner
# hour and adds a redundant tag to the retention window.
- name: Check whether main moved since the last daily
id: freshness
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
run: |
set -euo pipefail
head_sha="$(git rev-parse HEAD)"
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
if [[ "$FORCED" == "true" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "Forced dispatch; building $head_sha."
exit 0
fi
# The previous daily records its source commit in the release body.
# Drafts are excluded: an unpublished leftover never shipped, so treating
# it as "the last build" would skip a build that never actually happened.
last_body="$(gh release list --repo "$DAILY_REPO" --limit 20 --json tagName,isDraft \
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
if [[ -z "$last_body" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "No prior daily release found; building $head_sha."
exit 0
fi
last_sha="$(gh release view "$last_body" --repo "$DAILY_REPO" --json body \
--jq '.body | capture("commit `(?<sha>[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)"
if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then
echo "should_build=false" >>"$GITHUB_OUTPUT"
echo "main is unchanged since $last_body ($last_sha); skipping."
else
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "main moved to $head_sha (last daily built $last_sha); building."
fi
- name: Setup pnpm
if: steps.freshness.outputs.should_build == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
if: steps.freshness.outputs.should_build == 'true'
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
if: steps.freshness.outputs.should_build == 'true'
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-
- name: Install dependencies
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: signing is what makes a daily installable over an existing Orca, so
# a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
if: steps.freshness.outputs.should_build == 'true'
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 }}
- name: Compute daily version
id: daily
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Existing titles, which carry the build number this series continues
# from. The script picks the number, because it restarts per base version
# and only the script knows which base this build resolved to.
#
# Why drafts count here but not in the freshness check: that check asks
# "did this commit ship", where a draft is a no. This one asks "is the
# number free", where a stranded draft still holds one.
names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \
--jq '.[].name // empty')"
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
# package.json can sit several patches behind what users are running. A
# separate token because GH_TOKEN above is the App's, scoped to the
# daily repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \
node config/scripts/daily-build-version.mjs \
>"$RUNNER_TEMP/daily-identity.txt"
grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
# an empty name and an untitled release — silent, and only visible once
# someone opens the releases page. Fail here instead.
if ! grep -q '^name=' "$RUNNER_TEMP/daily-identity.txt"; then
echo "::error::daily-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync."
exit 1
fi
cat "$RUNNER_TEMP/daily-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
if: steps.freshness.outputs.should_build == 'true'
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: daily builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted dev artifacts.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# Why a second mint: everything from here on writes to the daily repo, and
# the notary round trip inside the publish step can be tens of minutes. The
# token minted at the top has already spent install + build of its one hour
# on steps that never touched it; restarting the clock here gives the slow
# part the full budget.
- name: Re-mint daily repo token for publish
id: app_token_publish
if: steps.freshness.outputs.should_build == 'true'
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-daily
- name: Create daily release
id: release
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: v${{ steps.daily.outputs.version }}
NAME: ${{ steps.daily.outputs.name }}
SHA: ${{ steps.freshness.outputs.head_sha }}
run: |
set -euo pipefail
# Kept at 12 even though the title shows 7: the freshness check above
# parses this back out of the body to decide whether main has moved.
short_sha="${SHA:0:12}"
# Why a file rather than an inline string: the publish step re-asserts
# this same body, and the freshness check only works if the two agree
# exactly. One source, written once, read twice.
notes_file="$RUNNER_TEMP/daily-release-notes.md"
cat >"$notes_file" <<EOF
Automated daily macOS dev build from commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
**Unvetted.** No tests ran. Signed and notarized like a release, so it
installs through Orca's in-app updater and opens from a manual download
without a Gatekeeper prompt — but nothing here has been reviewed.
EOF
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on. It is flipped live only
# after the manifest is verified.
gh release create "$TAG" \
--repo "$DAILY_REPO" \
--title "$NAME" \
--draft \
--notes-file "$notes_file"
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT"
- name: Publish daily macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
# Why 45 like the release pipeline: an attempt is pack + notarize +
# upload, and the notary queue is the unbounded part. Why 2 attempts and
# not 3: a missed daily costs a day, and a third attempt buys less than
# it costs in runner time once the notary is that stuck.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_DAILY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
env:
# Why: electron-builder's github publisher targets the repo named in the
# config; the token must therefore carry write access to orca-daily.
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.daily.outputs.commit }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Why all three: electron-builder's notarize step authenticates to the
# Apple notary service with the app-specific password, not with the
# signing cert. Omitting them fails the build rather than skipping it,
# since `notarize` is now on for this path.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why a third mint: the upload step above allows two 45-minute attempts, so
# it can outlive the one-hour token minted before it. Every remaining step
# writes to the daily repo, including the failure path that discards the
# draft — a 401 there is exactly the stranded draft nobody can clean up.
#
# Why always(): a failed or cancelled upload is the case that needs this
# most, since the cleanup step below runs only on that path.
- name: Re-mint daily repo token for verify and cleanup
id: app_token_final
if: always() && steps.freshness.outputs.should_build == 'true'
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-daily
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
# release missing that manifest is a tag the picker offers and the download
# 404s on, so fail loudly instead of leaving a broken entry.
- name: Verify update manifest published
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$DAILY_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Why exit 1 without deleting here: the release is still a draft, so it is
# already invisible to users, and the failure handler below owns cleanup.
# Deleting inline under `set -e` would also let the delete's exit code
# preempt this explicit failure.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Daily draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Daily draft $TAG has no ZIP artifact for the updater to download."
exit 1
fi
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
- name: Publish the verified release
id: publish_live
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.daily.outputs.name }}
NOTES_FILE: ${{ steps.release.outputs.notes_file }}
run: |
set -euo pipefail
# --title again: electron-builder resolves this draft by tag and may
# rewrite its title on upload. Re-asserting here means the name the
# picker reads is the one composed above, whatever it did in between.
# --notes-file for the same reason, and it matters more: the next run's
# freshness check parses the source commit back out of this body, so a
# body the publisher overwrote would rebuild an unchanged main daily.
gh release edit "$TAG" --repo "$DAILY_REPO" --draft=false --prerelease \
--title "$NAME" --notes-file "$NOTES_FILE"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.
#
# Why it is gated on publish_live not having succeeded: a later failure (the
# prune step) must not delete a release that already went live and that users
# may already be installing. A job killed by the outer timeout runs no steps
# at all — which is exactly why the release stays a draft until verified.
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
# so without it a manual cancel mid-publish would strand the draft.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
# Fall back to the publish token: if the re-mint itself is what failed,
# the older token is the only one left and may still have time on it.
GH_TOKEN: ${{ steps.app_token_final.outputs.token || steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -uo pipefail
# No --cleanup-tag: an unpublished draft never created a git tag.
echo "Run failed before publish; discarding draft $TAG"
gh release delete "$TAG" --repo "$DAILY_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune old daily releases
# Only after a live publish: $TAG is then a non-draft we must not delete,
# and failed runs should not reshuffle retention around a draft that the
# failure path is about to discard.
if: steps.publish_live.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
# Protect the tag this run just shipped; at the retain cap, a bad sort
# can otherwise mark the newest release as stale and delete it.
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
# keep showing up in tag lists with no release or assets attached.
# Drafts are excluded so retention counts shipped builds only; a stale
# draft is handled by the failure path, not by the retention window.
#
# Sort by publishedAt (not createdAt). Many non-draft dailies can share
# one createdAt (bulk import / re-create), so createdAt ranking is
# unstable. publishedAt is real recency; tagName (...YYYYMMDDHHMM) is
# the deterministic tie-break.
#
# Why force $TAG to the front before slicing: a hard retain-window seat
# for this run's release. Dropping $TAG from the list *before* the slice
# would permanently keep retain+1 releases; skipping it only in the
# delete loop would under-prune when the sort is still wrong. Partition
# keeps relative order of every other tag.
jq_filter='map(select(.isDraft | not)) | sort_by(.publishedAt // "", .tagName) | reverse'
if [[ -n "${TAG:-}" ]]; then
jq_filter+=" | (map(select(.tagName == \"${TAG//\"/\\\"}\")) + map(select(.tagName != \"${TAG//\"/\\\"}\")))"
fi
jq_filter+=" | .[${DAILY_RETAIN_COUNT}:] | .[].tagName"
stale="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName,publishedAt,isDraft \
--jq "$jq_filter")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune; at or under $DAILY_RETAIN_COUNT retained builds."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
# Belt-and-suspenders: partition above should already exclude $TAG.
if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then
echo "::warning::Prune list still included just-published $tag after protect; skipping delete."
continue
fi
echo "Pruning $tag"
gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
+50 -4
View File
@@ -30,9 +30,11 @@ jobs:
# (@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
# Archive + upload is ~30–40m when healthy; 90m leaves margin without
# sitting multi-hour on ASC processing polls (see Fastfile pilot wait).
# Archive + upload is ~30–40m when healthy; 90m leaves margin for setup.
timeout-minutes: 90
outputs:
release_version: ${{ steps.release_metadata.outputs.version }}
build_number: ${{ steps.release_metadata.outputs.build_number }}
defaults:
run:
@@ -79,6 +81,10 @@ jobs:
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: Capture release metadata
id: release_metadata
run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)'
- name: Expo prebuild
run: npx expo prebuild --platform ios --no-install
@@ -122,11 +128,10 @@ jobs:
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
run: bundle exec fastlane ios build_and_upload
- name: Upload .ipa artifact
if: always()
@@ -135,3 +140,44 @@ jobs:
name: orca-mobile-ipa
path: mobile/build/*.ipa
if-no-files-found: ignore
ios-distribute:
name: Distribute to external TestFlight testers
needs: ios-build
runs-on: ubuntu-latest
# Fastlane's processing wait fails after 20m; this outer bound leaves setup
# margin without allowing App Store Connect polling to hang for hours.
timeout-minutes: 30
defaults:
run:
working-directory: mobile
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Ruby and fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: mobile
- name: Wait for processing and distribute to peeps
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
TESTFLIGHT_CHANGELOG: ${{ github.event.inputs.testflight_changelog }}
MOBILE_IOS_RELEASE_VERSION: ${{ needs.ios-build.outputs.release_version }}
MOBILE_IOS_BUILD_NUMBER: ${{ needs.ios-build.outputs.build_number }}
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: |
if ! bundle exec fastlane ios distribute_testflight \
version:"$MOBILE_IOS_RELEASE_VERSION" \
build_number:"$MOBILE_IOS_BUILD_NUMBER"; then
echo "::error::TestFlight $MOBILE_IOS_RELEASE_VERSION ($MOBILE_IOS_BUILD_NUMBER) was uploaded but not distributed to peeps. If App Store Connect is still processing it, rerun only this failed job after the build becomes Ready; otherwise inspect the Fastlane error above."
exit 1
fi
+53 -2
View File
@@ -176,6 +176,12 @@ jobs:
shell_contracts:
name: shell contracts
runs-on: ubuntu-latest
env:
# Why: the suites below gate their live fish tests on the binary, which is
# right on a developer machine and wrong here — this job is a required check
# and its fish lane is the only end-to-end guard for #9993, so a skip would
# report green with nothing exercised. Turns those skips into failures.
ORCA_REQUIRE_FISH: '1'
steps:
- name: Checkout
@@ -186,8 +192,46 @@ jobs:
# Why fish: shell-ready.test.ts gates its live fish test on the binary being
# present, so without this the fish barrier is only covered by config-shape
# assertions and never actually exercised.
# Why release-4: DECSET 2031 arming lives in the fish 4.0 Rust tty_handoff, and
# fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships
# 3.7, so the PPA is what makes that lane real.
- name: Install zsh and fish
run: sudo apt-get update && sudo apt-get install -y zsh fish
run: |
# Why the update/PPA/fish steps are tolerant: a repo the runner image already
# ships can lack a Release file for this suite, and a failed add-apt-repository
# still leaves its list entry behind — either makes `apt-get update` exit
# non-zero and would red this required check over something unrelated to the
# PR. Every fish outcome is judged by the version gate below instead, so only
# the zsh install (which has no such gate) stays fatal here.
sudo apt-get update || true
# Why retry only here: adding the PPA is the network-flaky step, and the
# version gate below is fatal, so a transient Launchpad blip would
# otherwise red a required check on PRs unrelated to shells.
for attempt in 1 2 3; do
sudo add-apt-repository -y ppa:fish-shell/release-4 && break
echo "add-apt-repository attempt ${attempt} failed; retrying" >&2
sudo add-apt-repository -y -r ppa:fish-shell/release-4 || true
sleep 5
done
sudo apt-get update || true
# Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the
# first install command in this step to prove the lane really installs them.
sudo apt-get install -y zsh fish
# Separate from the install so the failure names the contract, not an apt error.
# ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds
# instead of after a full dependency install.
- name: Require fish 4+
run: |
version="$(fish --version 2>/dev/null || true)"
major="${version##*version }"
major="${major%%.*}"
case "$major" in '' | *[!0-9]*) major=0 ;; esac
echo "${version:-<fish not installed>}"
if [ "$major" -lt 4 ]; then
echo "::error::shell contracts needs fish 4+ (DECSET 2031 arming, #9993) but got '${version:-none}'. Fix the ppa:fish-shell/release-4 install rather than letting the fish lane skip." >&2
exit 1
fi
- uses: ./.github/actions/install-node-dependencies
with:
@@ -195,12 +239,14 @@ jobs:
- name: Test real shell contracts
run: |
pnpm exec vitest run --config config/vitest.config.ts \
pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \
src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
src/main/daemon/shell-ready.test.ts \
src/main/daemon/node-pty-fd-leak.test.ts \
src/main/providers/local-pty-shell-ready.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/posix-command-path-lookup.test.ts
test:
@@ -230,11 +276,13 @@ jobs:
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
@@ -378,6 +426,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test Windows PowerShell CLI boundary
run: pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts
- name: Build package inputs
run: pnpm run build:release
+98
View File
@@ -0,0 +1,98 @@
name: Release Policy
on:
release:
types:
- published
- edited
permissions:
contents: write
concurrency:
group: release-policy
cancel-in-progress: false
jobs:
enforce:
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Enforce release policy
uses: actions/github-script@v8
with:
script: |
const release = context.payload.release;
const tag = release.tag_name;
const author = release.author?.login;
const number = "(?:0|[1-9][0-9]*)";
const version = `${number}\\.${number}\\.${number}`;
const stableTag = new RegExp(`^v${version}$`);
const prereleaseTag = new RegExp(
`^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`,
);
const expectedPrerelease = prereleaseTag.test(tag);
const allowed = author === "github-actions[bot]" &&
(stableTag.test(tag) || expectedPrerelease);
const { owner, repo } = context.repo;
async function restoreLatestStable() {
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const stable = releases
.filter((candidate) =>
!candidate.draft &&
!candidate.prerelease &&
candidate.author?.login === "github-actions[bot]" &&
stableTag.test(candidate.tag_name),
)
.sort((left, right) => {
const a = left.tag_name.slice(1).split(".").map(Number);
const b = right.tag_name.slice(1).split(".").map(Number);
return a.reduce((result, part, index) => result || part - b[index], 0);
})
.at(-1);
if (stable) {
await github.rest.repos.updateRelease({
owner,
repo,
release_id: stable.id,
make_latest: "true",
});
}
}
if (allowed) {
if (release.prerelease !== expectedPrerelease) {
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
prerelease: expectedPrerelease,
make_latest: expectedPrerelease ? "false" : "legacy",
});
}
await restoreLatestStable();
return;
}
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
draft: true,
prerelease: true,
make_latest: "false",
});
await restoreLatestStable();
await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id });
try {
await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` });
} catch (error) {
if (error.status !== 404) throw error;
}
core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`);
@@ -77,7 +77,6 @@ jobs:
'!config/reliability-gates.jsonc',
'!config/max-lines-baseline.txt',
'!config/vitest.config.ts',
'config/build-plugins/**',
'native/**',
'resources/**',
'electron.vite.config.ts',
+2
View File
@@ -94,6 +94,7 @@ docs/**
!docs/readme/
!docs/readme/**
!docs/STYLEGUIDE.md
!docs/ai-vault-process-isolation-plan.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
!docs/reference/git-compatibility.md
@@ -101,6 +102,7 @@ docs/**
!docs/reference/linux-glibc-compatibility.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/renderer-agent-status-performance.md
!docs/reference/windows-setup-shell.md
# Stably CLI (only docs/ are tracked)
+10
View File
@@ -56,6 +56,7 @@
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"typescript/array-type": "error",
"typescript/consistent-generic-constructors": "error",
"typescript/consistent-indexed-object-style": "error",
"typescript/consistent-type-assertions": "error",
"typescript/consistent-type-definitions": ["error", "type"],
@@ -68,12 +69,16 @@
"typescript/prefer-function-type": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-optional-chain": "error",
"typescript/prefer-ts-expect-error": "error",
"typescript/switch-exhaustiveness-check": [
"error",
{ "allowDefaultCaseForExhaustiveSwitch": false }
],
"curly": "error",
"no-unneeded-ternary": "error",
"no-else-return": "error",
"prefer-exponentiation-operator": "error",
"prefer-object-has-own": "error",
"no-restricted-imports": [
"error",
{
@@ -90,11 +95,16 @@
"no-useless-return": "error",
"prefer-template": "error",
"unicorn/consistent-empty-array-spread": "error",
"unicorn/consistent-existence-index-check": "error",
"unicorn/empty-brace-spaces": "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-typeof-undefined": "error",
"unicorn/no-unnecessary-array-splice-count": "error",
"unicorn/no-useless-promise-resolve-reject": "error",
"unicorn/no-zero-fractions": "error",
"unicorn/prefer-array-find": "error",
"unicorn/prefer-array-flat-map": "warn",
"unicorn/prefer-array-index-of": "error",
+4
View File
@@ -2,6 +2,10 @@
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.
## Electron UI Validation
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
# Style
## Concise/Brief Non-obviosu comments ONLY
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
- **Android:** [Download APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
---
+124 -14
View File
@@ -1,7 +1,8 @@
import { spawnSync } from 'node:child_process'
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
type NormalizedInputOptions = Rollup.NormalizedInputOptions
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
type OutputBundle = Rollup.OutputBundle
type OutputChunk = Rollup.OutputChunk
@@ -42,9 +43,34 @@ const WORKER_THREAD_ENTRY_NAMES = [
'port-scan-command-worker-entry'
] as const
export const GUARDED_ENTRY_NAMES = [
...PLAIN_NODE_ENTRY_NAMES,
...WORKER_THREAD_ENTRY_NAMES
] as const
type EntryRuntime = 'plain-Node process' | 'worker thread'
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
// Subpaths (electron/main) are as unloadable as the bare module under plain Node.
const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/
// Why: writeBundle skips any name missing from the bundle, so a renamed or
// removed rollup input would silently drop that entry from the guard and let the
// regression back in. Pin the lists to the input keys at build start instead.
function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void {
if (typeof input === 'string' || Array.isArray(input)) {
return
}
const inputNames = new Set(Object.keys(input))
const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name))
if (missing.length > 0) {
throw new Error(
`[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` +
`${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` +
`Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` +
`the current entry names — a stale name silently stops guarding that entry.`
)
}
}
function collectReachableChunks(
entry: OutputChunk,
@@ -89,41 +115,125 @@ function assertNoElectronRequire(
}
}
// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync.
const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry'
export type SmokeTimings = {
timeoutMs: number
// daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline
// needs an uncatchable follow-up to stay a deadline.
killGraceMs: number
}
const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 }
// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio
// can hold the pipes open long after the child is gone.
const SMOKE_STDERR_DRAIN_MS = 250
type SmokeResult = {
status: number | null
signal: NodeJS.Signals | null
stderr: string
error?: Error
timedOut: boolean
}
// Why not spawnSync({ timeout }): its timeout only sends killSignal and then
// keeps blocking until the child exits, so a child that traps SIGTERM hangs the
// build forever. Escalate to SIGKILL instead.
function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise<SmokeResult> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] })
let stderr = ''
let timedOut = false
let settled = false
let forceKillTimer: NodeJS.Timeout | undefined
let drainTimer: NodeJS.Timeout | undefined
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => {
stderr += chunk
})
const deadlineTimer = setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs)
}, timings.timeoutMs)
const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(deadlineTimer)
clearTimeout(forceKillTimer)
clearTimeout(drainTimer)
resolve({ status, signal, stderr, error, timedOut })
}
child.on('error', (error: Error) => finish(null, null, error))
// 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe
// cannot outlast the process itself.
child.on('close', (status, signal) => finish(status, signal))
child.on('exit', (status, signal) => {
drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS)
})
})
}
// 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 {
async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise<void> {
const entryPath = join(outputDir, 'daemon-entry.js')
const result = spawnSync(process.execPath, [entryPath], {
encoding: 'utf8',
timeout: 15_000
})
const result = await runDaemonEntry(entryPath, timings)
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 ?? ''
// Almost always means the daemon stopped rejecting an empty argv and started
// listening instead.
if (result.timedOut) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` +
`empty argv under plain Node, so the smoke killed it.`
)
}
if (result.signal) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.`
)
}
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')) {
if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) {
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}`
`[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` +
`(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` +
`${result.status}). stderr:\n${stderr}`
)
}
}
export function createPlainNodeEntryGuardPlugin(): Plugin {
export function createPlainNodeEntryGuardPlugin(
smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS
): Plugin {
let daemonOutputDir: string | undefined
return {
name: 'orca-plain-node-entry-guard',
buildStart(options: NormalizedInputOptions) {
assertEntryNamesAreRollupInputs(options.input)
},
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.
@@ -159,11 +269,11 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
daemonOutputDir = options.dir
}
},
closeBundle() {
async closeBundle() {
if (daemonOutputDir) {
const outputDir = daemonOutputDir
daemonOutputDir = undefined
smokeLoadDaemonEntry(outputDir)
await smokeLoadDaemonEntry(outputDir, smokeTimings)
}
}
}
+26 -7
View File
@@ -20,21 +20,32 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.
// Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to
// swap them over an installed Orca and macOS treats each build as a new app.
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
const isMacDaily = process.env.ORCA_MAC_DAILY === '1'
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc
const isMacRelease =
process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const devChannelBuildVersion = isMacHourly
? process.env.ORCA_HOURLY_BUILD_VERSION
: isMacAdhoc
? process.env.ORCA_ADHOC_BUILD_VERSION
: undefined
: isMacDaily
? process.env.ORCA_DAILY_BUILD_VERSION
: isMacAdhoc
? process.env.ORCA_ADHOC_BUILD_VERSION
: undefined
// Why each dev channel gets its own repo rather than tagging into the main one:
// the releases atom feed exposes only the 10 newest entries, so 24 hourly tags a
// day would evict every stable/RC entry and strand users on a feed with nothing
// to install. Keeping adhoc separate from hourly too means a branch build cannot
// be picked up by someone who only meant to ride main.
const devChannelRepo = isMacHourly ? 'orca-hourly' : isMacAdhoc ? 'orca-adhoc' : null
// to install. Keeping adhoc/daily separate from hourly too means a branch build
// or a once-a-day cut cannot be picked up by someone who only meant to ride
// main's hourlies.
const devChannelRepo = isMacHourly
? 'orca-hourly'
: isMacDaily
? 'orca-daily'
: isMacAdhoc
? 'orca-adhoc'
: null
const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
@@ -155,6 +166,12 @@ module.exports = {
// 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.
// Why: the OpenCode SQLite worker entry is also spawned by the scanner
// service, which runs under ELECTRON_RUN_AS_NODE and so cannot see into
// app.asar. Left packed, that spawn fails closed and every OpenCode session
// disappears from Agent Session History in packaged builds only. Worker
// entries reached solely from the Electron main process stay packed, since
// asar redirects their app.asar paths.
asarUnpack: [
'out/package.json',
'out/cli/**',
@@ -171,6 +188,8 @@ module.exports = {
'out/main/grok/**',
'out/main/hermes/**',
'out/main/daemon-entry.js',
'out/main/session-scanner-service-entry.js',
'out/main/session-scanner-opencode-sqlite-worker-entry.js',
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
+1 -1
View File
@@ -8,7 +8,7 @@ const configByTarget = {
renderer: { renderer: electronViteConfig.renderer }
}
if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) {
if (!target || !Object.hasOwn(configByTarget, target)) {
throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? '<unset>'}`)
}
+1 -8
View File
@@ -1,13 +1,6 @@
[
{
"filePath": "src/renderer/src/lib/agent-background-session-test-state.ts",
"kind": "object-property:title",
"text": "Terminal 1",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/sidebar/WorktreeCard.tsx",
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
"kind": "jsx-attribute:label",
"text": "sidebar",
"dynamic": false,
-5
View File
@@ -232,10 +232,8 @@ 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
@@ -246,7 +244,6 @@ 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
@@ -270,7 +267,6 @@ 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
@@ -342,7 +338,6 @@ 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
@@ -16,6 +16,7 @@
"import/namespace": "warn",
"import/no-cycle": ["warn", { "maxDepth": 3 }],
"import/no-duplicates": "warn",
"import/no-empty-named-blocks": "warn",
"import/no-self-import": "warn"
},
"overrides": [
@@ -12,6 +12,7 @@
},
"rules": {
"typescript/await-thenable": "warn",
"typescript/no-redundant-type-constituents": "warn",
"typescript/restrict-plus-operands": "warn",
"typescript/restrict-template-expressions": "warn",
"typescript/switch-exhaustiveness-check": [
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,255 @@
#!/usr/bin/env node
// Measures the no-URL PTY path that every terminal chunk enters.
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import nodeModule from 'node:module'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
if (!process.execArgv.includes('--experimental-transform-types')) {
const result = spawnSync(
process.execPath,
['--experimental-transform-types', '--no-warnings', import.meta.filename],
{ stdio: 'inherit' }
)
process.exit(result.status ?? 1)
}
nodeModule.registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
const candidate = new URL(`${specifier}.ts`, context.parentURL)
if (existsSync(fileURLToPath(candidate))) {
return { url: candidate.href, shortCircuit: true }
}
}
return nextResolve(specifier, context)
}
})
const source = readFileSync(
new URL('../../src/main/ports/advertised-url-watcher.ts', import.meta.url),
'utf8'
)
for (const marker of [
"return mayContainHttpUrl(finalized) ? stripTerminalControls(finalized) : ''",
'if (chunk.length >= PER_PTY_BUFFER_LIMIT)',
'this.raw.length + chunk.length > PER_PTY_BUFFER_LIMIT'
]) {
if (!source.includes(marker)) {
throw new Error(`advertised-url-watcher.ts no longer contains \`${marker}\``)
}
}
const { AdvertisedUrlWatcher, extractUrlCandidates, stripTerminalControls } = await import(
new URL('../../src/main/ports/advertised-url-watcher.ts', import.meta.url).href
)
const BUFFER_LIMIT = 4096
const ITERATIONS = Number(process.env.ORCA_ADVERTISED_URL_BENCH_ITERATIONS ?? '10000')
const ROUNDS = Number(process.env.ORCA_ADVERTISED_URL_BENCH_ROUNDS ?? '12')
const WARMUP = Number(process.env.ORCA_ADVERTISED_URL_BENCH_WARMUP ?? '1000')
for (const [name, value] of [
['ORCA_ADVERTISED_URL_BENCH_ITERATIONS', ITERATIONS],
['ORCA_ADVERTISED_URL_BENCH_ROUNDS', ROUNDS],
['ORCA_ADVERTISED_URL_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
if (ROUNDS % 2 !== 0) {
throw new Error('ORCA_ADVERTISED_URL_BENCH_ROUNDS must be even')
}
class BeforePtyBuffer {
raw = ''
ingest(chunk) {
const chunkHasLineBreak = chunk.includes('\n') || chunk.includes('\r')
this.raw += chunk
if (this.raw.length > BUFFER_LIMIT) {
this.raw = this.raw.slice(-BUFFER_LIMIT)
}
if (!chunkHasLineBreak) {
return ''
}
const lastNewline = lastLineBreak(this.raw)
if (lastNewline === -1) {
return ''
}
const finalized = this.raw.slice(0, lastNewline + 1)
this.raw = this.raw.slice(lastNewline + 1)
return stripTerminalControls(finalized)
}
}
class BeforeWatcher {
buffers = new Map()
ptyToWorktree = new Map()
bindPty(ptyId, worktreeId) {
this.ptyToWorktree.set(ptyId, worktreeId)
}
ingest(ptyId, chunk) {
if (!this.ptyToWorktree.has(ptyId)) {
return 0
}
let buffer = this.buffers.get(ptyId)
if (!buffer) {
buffer = new BeforePtyBuffer()
this.buffers.set(ptyId, buffer)
}
const finalized = buffer.ingest(chunk)
return finalized ? extractUrlCandidates(finalized).length : 0
}
}
function lastLineBreak(text) {
for (let index = text.length - 1; index >= 0; index -= 1) {
const code = text.charCodeAt(index)
if (code === 0x0a || code === 0x0d) {
return index
}
}
return -1
}
function repeatToLine(text, length, sample) {
const unit = `${text}${sample}\n`
return `${unit.repeat(Math.ceil(length / unit.length)).slice(0, length - 1)}\n`
}
function repeatWithoutLineBreak(text, length, sample) {
const unit = `${text}${sample}`
return unit.repeat(Math.ceil(length / unit.length)).slice(0, length)
}
const fixtures = [
{
label: 'plain 32B line',
chunks: Array.from({ length: 32 }, (_, index) => `compiled module ${index} in 183ms\n`)
},
{
label: 'ANSI 32B line',
chunks: Array.from({ length: 32 }, (_, index) => `\x1b[2K\x1b[1GThinking ${index}...\r\n`)
},
{
label: 'guard fallthrough line',
chunks: Array.from(
{ length: 32 },
(_, index) => `path/to/http_server: compiled module ${index}\n`
)
},
{
label: 'plain 4KiB line',
chunks: Array.from({ length: 32 }, (_, index) =>
repeatToLine('INFO build completed module=renderer duration=12ms ', BUFFER_LIMIT, index)
)
},
{
label: 'ANSI 4KiB line',
chunks: Array.from({ length: 32 }, (_, index) =>
repeatToLine(
'\x1b[2K\x1b[1Gthinking about compiler output and writing patches ',
BUFFER_LIMIT,
index
)
)
},
{
label: 'guard fallthrough 4KiB',
chunks: Array.from({ length: 32 }, (_, index) =>
repeatToLine(
'path/to/http_server: compiled without an advertised endpoint ',
BUFFER_LIMIT,
index
)
)
},
{
label: '64KiB partial chunk',
chunks: Array.from({ length: 32 }, (_, index) =>
repeatWithoutLineBreak('compiler output without a line break ', 64 * 1024, index)
)
},
{
label: '256KiB partial chunk',
chunks: Array.from({ length: 32 }, (_, index) =>
repeatWithoutLineBreak('compiler output without a line break ', 256 * 1024, index)
)
}
]
let checksum = 0
function runBefore(chunks, iterations) {
const watcher = new BeforeWatcher()
watcher.bindPty('pty', 'repo::/bench')
let found = 0
for (let index = 0; index < iterations; index += 1) {
found += watcher.ingest('pty', chunks[index % chunks.length])
}
checksum = Math.imul(checksum ^ found ^ watcher.buffers.get('pty').raw.length, 16777619) >>> 0
}
function runAfter(chunks, iterations) {
const watcher = new AdvertisedUrlWatcher()
watcher.bindPty('pty', 'repo::/bench')
for (let index = 0; index < iterations; index += 1) {
watcher.ingest('pty', chunks[index % chunks.length], index)
}
const buffer = watcher.buffers.get('pty')
checksum = Math.imul(checksum ^ (buffer?.raw.length ?? 0), 16777619) >>> 0
}
function median(values) {
const sorted = [...values].sort((left, right) => left - right)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
function measure(fixture) {
runBefore(fixture.chunks, WARMUP)
runAfter(fixture.chunks, WARMUP)
const before = []
const after = []
for (let round = 0; round < ROUNDS; round += 1) {
const measureBefore = () => {
const startedAt = performance.now()
runBefore(fixture.chunks, ITERATIONS)
before.push(((performance.now() - startedAt) * 1000) / ITERATIONS)
}
const measureAfter = () => {
const startedAt = performance.now()
runAfter(fixture.chunks, ITERATIONS)
after.push(((performance.now() - startedAt) * 1000) / ITERATIONS)
}
if (round % 2 === 0) {
measureBefore()
measureAfter()
} else {
measureAfter()
measureBefore()
}
}
return { before: median(before), after: median(after) }
}
console.log('Advertised URL watcher no-match cost in microseconds/chunk. Lower is better.')
console.log(`iterations=${ITERATIONS}, rounds=${ROUNDS}, warmup=${WARMUP}`)
console.log('fixture before after speedup')
for (const fixture of fixtures) {
const result = measure(fixture)
console.log(
`${fixture.label.padEnd(26)} ${result.before.toFixed(3).padStart(8)} ${result.after
.toFixed(3)
.padStart(10)} ${(result.before / result.after).toFixed(2).padStart(9)}x`
)
}
console.log(`checksum=${checksum}`)
console.log(
'Limitations: synthetic in-process scan; explicit timestamps omit the production clock read and surrounding PTY dispatch.'
)
@@ -7,6 +7,9 @@ import process from 'node:process'
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-fixtures.ts` here, not under `__tests__/`.
const TEST_SUPPORT_FILE_PATTERN =
/[.-](?:test-harness|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/
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([
@@ -73,13 +76,14 @@ function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
export function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
if (
relative.endsWith('.d.ts') ||
relative.includes('.test.') ||
relative.includes('.spec.') ||
relative.includes('/__tests__/')
relative.includes('/__tests__/') ||
TEST_SUPPORT_FILE_PATTERN.test(relative)
) {
return true
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { collectLocalizationCandidates } from './audit-localization-coverage.mjs'
import { collectLocalizationCandidates, isSkippedFile } from './audit-localization-coverage.mjs'
const ROOT = process.cwd()
@@ -42,3 +42,23 @@ describe('localization coverage candidates', () => {
expect(reports).toEqual([])
})
})
describe('localization coverage file skipping', () => {
function skipped(relativePath) {
return isSkippedFile(ROOT, `${ROOT}/${relativePath}`)
}
it('skips test-only modules that sit beside their spec', () => {
expect(skipped('src/renderer/src/components/browser-pane/stream-test-harness.ts')).toBe(true)
expect(skipped('src/renderer/src/hooks/ipc-events-test-fixtures.ts')).toBe(true)
expect(skipped('src/renderer/src/lib/session-test-state.ts')).toBe(true)
expect(skipped('src/renderer/src/store/slices/routing-fixture.ts')).toBe(true)
expect(skipped('src/renderer/src/components/tab-bar/icon-stub.fixture.tsx')).toBe(true)
})
it('still scans shipped modules whose names merely mention a fixture concept', () => {
expect(skipped('src/renderer/src/components/browser-pane/fixture-picker.tsx')).toBe(false)
expect(skipped('src/renderer/src/components/settings/fixtures-panel.tsx')).toBe(false)
expect(skipped('src/renderer/src/components/browser-pane/BrowserPane.tsx')).toBe(false)
})
})
@@ -79,7 +79,7 @@ try {
function readArg(name) {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
return index !== -1 ? args[index + 1] : undefined
}
function embeddedInfoPlist(identifier) {
+18
View File
@@ -18,6 +18,7 @@ const __dirname = import.meta.dirname
const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
const MANAGED_HOOK_RUNTIME_ENTRY = join(
ROOT,
'src',
@@ -88,6 +89,21 @@ for (const platform of PLATFORMS) {
}
})
await build({
entryPoints: [AI_VAULT_SERVICE_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'relay-ai-vault-service.js'),
external: ['electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
bundle: true,
@@ -110,10 +126,12 @@ for (const platform of PLATFORMS) {
// so a companion-only change always deploys beside the matching relay host.
const relayContent = readFileSync(join(outDir, 'relay.js'))
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js'))
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
const hash = createHash('sha256')
.update(relayContent)
.update(watcherContent)
.update(aiVaultServiceContent)
.update(managedHookRuntimeContent)
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
if (platform.startsWith('win32-')) {
@@ -56,5 +56,5 @@ function findFrameworkCompiler(env) {
function readArg(name) {
const index = process.argv.indexOf(name)
return index >= 0 ? process.argv[index + 1] : undefined
return index !== -1 ? process.argv[index + 1] : undefined
}
@@ -11,7 +11,7 @@ function source(path) {
function sourceBetween(contents, startMarker, endMarker) {
const start = contents.indexOf(startMarker)
const end = contents.indexOf(endMarker, start + startMarker.length)
if (start < 0 || end < 0) {
if (start === -1 || end === -1) {
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
}
return contents.slice(start, end)
+109
View File
@@ -0,0 +1,109 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** `1.4.160-daily.202607281300` — UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
export function createDailyBuildVersion(baseVersion, date) {
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion)
if (!match) {
throw new Error(`Package version is not valid semver: ${baseVersion}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Daily build timestamp is invalid.')
}
const pad = (value, width = 2) => String(value).padStart(width, '0')
const stamp = [
pad(date.getUTCFullYear(), 4),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes())
].join('')
// Why: drop any -rc.N tail. Keeping it makes every daily semver-NEWER than the
// RC it was cut from (1.4.160-rc.3-daily.X > 1.4.160-rc.3), which would let an
// ordinary RC-channel check offer untested daily builds to RC users. Stripping
// to the base parks dailies below both rc.N and stable ('daily' < 'rc'
// alphabetically), reachable only by an explicit pinned jump.
return `${match[1]}-daily.${stamp}`
}
/**
* The next build number for `baseVersion`, counting from the titles of existing
* daily releases.
*
* Why the series restarts at 01 on every base version: the number answers "which
* build of 1.4.163 is this", so a counter shared across versions makes it
* meaningless — 1.4.164 would open at 38 for no reason a reader can see.
*
* Why the maximum rather than a count: the prune step trims to
* DAILY_RETAIN_COUNT, so a count would roll backwards and reissue a number
* already in use. Titles that predate this naming simply do not match, which is
* how the first build of a version lands on 01.
*/
export function nextDailyBuildNumber(baseVersion, releaseNames = []) {
const prefix = `${baseVersion} • `
const highest = releaseNames.reduce((max, entry) => {
const name = String(entry ?? '')
if (!name.startsWith(prefix)) {
return max
}
const match = /^(\d+) • /.exec(name.slice(prefix.length))
return match ? Math.max(max, Number(match[1])) : max
}, 0)
return highest + 1
}
/**
* `1.4.163 • 01 • Aug 9, 6:15AM • e698241` — the human-facing release title,
* shown verbatim in both the GitHub releases list and the in-app build picker.
*/
export function formatDailyReleaseName(version, buildNumber, commit, date) {
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
throw new Error(`Daily build number must be a positive integer: ${buildNumber}`)
}
return [
version.split('-')[0],
String(buildNumber).padStart(2, '0'),
formatReleaseTitleTimestamp(date),
commit.slice(0, 7)
].join(' • ')
}
// Why the number is derived here rather than passed in: it counts builds of the
// base version, and the base is only known once the published tags have been
// resolved just above. Computing it outside meant numbering against whatever
// version the caller guessed.
export function getDailyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? [])
const version = createDailyBuildVersion(base, now)
const buildNumber = nextDailyBuildNumber(base, releaseNames ?? [])
return {
commit,
version,
buildNumber,
name: formatDailyReleaseName(version, buildNumber, commit, now)
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getDailyBuildIdentity(new Date(), {
publishedVersions: readPublishedVersionsFromEnv(),
// Titles are newline separated and contain spaces, so this cannot reuse the
// whitespace split the version list gets.
releaseNames: (process.env.ORCA_DAILY_RELEASE_NAMES ?? '').split('\n').filter(Boolean)
})
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n`
)
}
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
createDailyBuildVersion,
formatDailyReleaseName,
nextDailyBuildNumber
} from './daily-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createDailyBuildVersion', () => {
it('stamps the version with a zero-padded UTC timestamp', () => {
expect(createDailyBuildVersion('1.4.160', new Date('2026-07-28T13:00:00Z'))).toBe(
'1.4.160-daily.202607281300'
)
})
// Why: main's package.json carries the in-flight RC tail. Keeping it would make
// every daily semver-NEWER than the RC it was cut from (1.4.160-rc.3-daily.X >
// 1.4.160-rc.3), so an ordinary RC-channel check would offer untested daily
// builds to RC users. Dropping it parks dailies below both rc.N and stable,
// reachable only by an explicit pinned jump.
it('drops an in-flight rc tail so dailies never outrank the rc series', () => {
const version = createDailyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T13:00:00Z'))
expect(version).toBe('1.4.160-daily.202607281300')
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
expect(compareAppVersions('1.4.160-rc.3-daily.202607281300', '1.4.160-rc.3')).toBeGreaterThan(0)
})
// Why: 'daily' sorts before 'hourly' alphabetically, so a daily of the same
// base never outranks an hourly — both stay below rc/stable and only the
// channel picker offers them.
it('sorts below the hourly build of the same base version', () => {
expect(
compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400')
).toBeLessThan(0)
})
it('rejects invalid input', () => {
expect(() => createDailyBuildVersion('nope', new Date())).toThrow(/valid semver/)
expect(() => createDailyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
describe('formatDailyReleaseName', () => {
const name = (iso, buildNumber = 1, commit = 'e698241abcde') =>
formatDailyReleaseName('1.4.163-daily.x', buildNumber, commit, new Date(iso))
it('renders version, number, Pacific timestamp, and short sha', () => {
// 13:15 UTC is 6:15AM PDT in July (the daily cut time).
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
expect(name('2026-01-15T14:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 6:15AM • e698241')
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
})
it('pads to two digits and grows past them', () => {
expect(name('2026-07-28T13:15:00Z', 9)).toContain(' • 09 • ')
expect(name('2026-07-28T13:15:00Z', 42)).toContain(' • 42 • ')
})
it('rejects a build number that is not a positive integer', () => {
expect(() => name('2026-07-28T13:15:00Z', 0)).toThrow(/positive integer/)
expect(() => name('2026-07-28T13:15:00Z', -1)).toThrow(/positive integer/)
expect(() => name('2026-07-28T13:15:00Z', 1.5)).toThrow(/positive integer/)
})
it('rejects an invalid timestamp', () => {
expect(() => formatDailyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow(
/invalid/
)
})
})
describe('nextDailyBuildNumber', () => {
const titles = [
'1.4.163 • 01 • Jul 28, 6:00AM • e698241',
'1.4.163 • 02 • Jul 29, 6:00AM • aaaaaaa',
'1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'
]
it('continues the series for the version being built', () => {
expect(nextDailyBuildNumber('1.4.163', titles)).toBe(10)
})
it('restarts at 1 when the base version moves', () => {
expect(nextDailyBuildNumber('1.4.164', titles)).toBe(1)
expect(
nextDailyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 6:00AM • ccccccc'])
).toBe(2)
})
// Why max and not count: pruning trims to DAILY_RETAIN_COUNT, so counting
// would roll backwards and reissue a number already used.
it('takes the highest number, not the count', () => {
expect(nextDailyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 6:00AM • e698241'])).toBe(10)
})
it('starts at 1 with no history at all', () => {
expect(nextDailyBuildNumber('1.4.163')).toBe(1)
expect(nextDailyBuildNumber('1.4.163', [])).toBe(1)
})
it('ignores titles that are not this version', () => {
expect(nextDailyBuildNumber('1.4.16', ['1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'])).toBe(1)
expect(nextDailyBuildNumber('1.4.163', ['v1.4.163-daily.202607311300', null, ''])).toBe(1)
})
})
+26 -119
View File
@@ -4,6 +4,9 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const REPO_ROOT = join(import.meta.dirname, '..', '..')
const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main')
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
@@ -19,42 +22,6 @@ const {
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_ADHOC',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_ADHOC_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
expect(electronBuilderConfig.appId).toBe(
@@ -207,6 +174,29 @@ describe('electron-builder config', () => {
)
})
// Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is
// invisible to it and a packed worker entry fails closed — dropping every
// OpenCode session in packaged builds while dev stays green. Three legs must
// agree on the filename, so all three are read rather than hardcoded.
it('unpacks the OpenCode SQLite worker entry the scanner service forks', async () => {
const spawnSource = await readFile(
join(SRC_MAIN_DIR, 'ai-vault', 'session-scanner-opencode-sqlite-worker-spawn.ts'),
'utf8'
)
const entryFilename = spawnSource.match(/WORKER_ENTRY_FILENAME = '([^']+)'/)?.[1]
expect(entryFilename).toBeDefined()
expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`)
// Why: the emitted path comes from the rollup input key under
// entryFileNames '[name].js', not from the source filename — renaming the
// key alone would leave the other two legs agreeing on a file that no
// longer exists.
const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8')
expect(viteConfig).toContain("entryFileNames: '[name].js'")
expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`))
})
it('keeps the worker-thread hang watchdog inside app.asar', () => {
expect(electronBuilderConfig.asarUnpack).not.toContain(
'out/main/main-thread-hang-watchdog-entry.js'
@@ -303,89 +293,6 @@ describe('electron-builder config', () => {
}
})
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
// same bundle id and a valid Developer ID signature. A hourly built on the local
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
// Orca — the whole point of the channel.
it('builds hourly artifacts with the release signing identity', () => {
withHourlyEnv((config) => {
expect(config.mac.appId).toBeUndefined()
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.forceCodeSigning).toBe(true)
})
})
// Why hourly must notarize despite the round trip: TCC anchors a notarized
// Developer ID app's grants on identifier + team, not on its cdhash, so they
// survive an update. An unnotarized hourly reads as a new client every build
// and loses file access under Documents/Desktop/Downloads with no re-prompt.
it('notarizes hourly builds like releases, and neither locally', () => {
withHourlyEnv((config) => {
expect(config.mac.notarize).toBe(true)
})
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
expect(config.mac.notarize).toBe(true)
})
expect(electronBuilderConfig.mac.notarize).toBe(false)
})
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
// break update checks for every real user.
it('publishes hourly builds to the separate hourly repo', () => {
withHourlyEnv((config) => {
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
})
expect(electronBuilderConfig.publish).toMatchObject({
repo: 'orca',
releaseType: 'release'
})
})
it('stamps hourly packages with the hourly version', () => {
withEnv(
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
}
)
})
// Why adhoc carries the identical mac identity to hourly: it installs over a
// real Orca through the same updater path, so the same signing and the same TCC
// argument apply. Only the destination repo differs.
it('builds adhoc artifacts with the release identity and its own repo', () => {
withAdhocEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' })
})
})
it('stamps adhoc packages with the adhoc version', () => {
withEnv(
{ ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' })
}
)
})
// Why: the two dev channels share every packaging decision except where they
// publish, so a future edit that collapses them must not also collapse the
// repos — a branch build landing in orca-hourly would be offered to everyone
// riding main.
it('keeps the two dev channels on separate repos', () => {
withHourlyEnv((hourly) => {
withAdhocEnv((adhoc) => {
expect(hourly.publish.repo).not.toBe(adhoc.publish.repo)
})
})
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)
@@ -0,0 +1,152 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_DAILY',
'ORCA_MAC_ADHOC',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_DAILY_BUILD_VERSION',
'ORCA_ADHOC_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
const withDailyEnv = (assert) => withEnv({ ORCA_MAC_DAILY: '1' }, assert)
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
describe('electron-builder mac channel config', () => {
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
// same bundle id and a valid Developer ID signature. A hourly built on the local
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
// Orca — the whole point of the channel.
it('builds hourly artifacts with the release signing identity', () => {
withHourlyEnv((config) => {
expect(config.mac.appId).toBeUndefined()
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.forceCodeSigning).toBe(true)
})
})
// Why hourly must notarize despite the round trip: TCC anchors a notarized
// Developer ID app's grants on identifier + team, not on its cdhash, so they
// survive an update. An unnotarized hourly reads as a new client every build
// and loses file access under Documents/Desktop/Downloads with no re-prompt.
it('notarizes hourly builds like releases, and neither locally', () => {
withHourlyEnv((config) => {
expect(config.mac.notarize).toBe(true)
})
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
expect(config.mac.notarize).toBe(true)
})
expect(electronBuilderConfig.mac.notarize).toBe(false)
})
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
// break update checks for every real user.
it('publishes hourly builds to the separate hourly repo', () => {
withHourlyEnv((config) => {
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
})
expect(electronBuilderConfig.publish).toMatchObject({
repo: 'orca',
releaseType: 'release'
})
})
it('stamps hourly packages with the hourly version', () => {
withEnv(
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
}
)
})
// Why adhoc carries the identical mac identity to hourly: it installs over a
// real Orca through the same updater path, so the same signing and the same TCC
// argument apply. Only the destination repo differs.
it('builds adhoc artifacts with the release identity and its own repo', () => {
withAdhocEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' })
})
})
it('stamps adhoc packages with the adhoc version', () => {
withEnv(
{ ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' })
}
)
})
it('builds daily artifacts with the release identity and its own repo', () => {
withDailyEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-daily', releaseType: 'prerelease' })
})
})
it('stamps daily packages with the daily version', () => {
withEnv(
{ ORCA_MAC_DAILY: '1', ORCA_DAILY_BUILD_VERSION: '1.4.160-daily.202607281300' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-daily.202607281300' })
}
)
})
// Why: the dev channels share every packaging decision except where they
// publish, so a future edit that collapses them must not also collapse the
// repos — a branch or daily build landing in orca-hourly would be offered to
// everyone riding main's hourlies.
it('keeps the dev channels on separate repos', () => {
withHourlyEnv((hourly) => {
withDailyEnv((daily) => {
withAdhocEnv((adhoc) => {
expect(new Set([hourly.publish.repo, daily.publish.repo, adhoc.publish.repo]).size).toBe(
3
)
})
})
})
})
})
@@ -196,7 +196,8 @@ describe('Electron Vite output contract', () => {
})
it('rejects prototype properties as build targets', () => {
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
// Own-property check only: an inherited key like `constructor` must not select a build target.
expect(targetConfig).toContain('Object.hasOwn(configByTarget, target)')
})
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
+1 -1
View File
@@ -45,7 +45,7 @@ function readRuntimeArg() {
}
const runtimeIndex = process.argv.indexOf('--runtime')
if (runtimeIndex >= 0) {
if (runtimeIndex !== -1) {
return process.argv[runtimeIndex + 1]
}
@@ -227,7 +227,7 @@ function readGitBlobs(objectShas) {
let offset = 0
for (const requestedSha of uniqueShas) {
const headerEnd = output.indexOf(10, offset)
if (headerEnd < 0) {
if (headerEnd === -1) {
throw new Error(`Missing git cat-file header for ${requestedSha}`)
}
const header = output.subarray(offset, headerEnd).toString('utf8')
@@ -648,8 +648,8 @@ async function main() {
const argv = process.argv.slice(2)
const rebuildFromTags = argv.includes('--rebuild-from-tags')
const releaseIndex = argv.indexOf('--release')
const releaseVersion = releaseIndex >= 0 ? argv[releaseIndex + 1] : null
if (releaseIndex >= 0 && !releaseVersion) {
const releaseVersion = releaseIndex !== -1 ? argv[releaseIndex + 1] : null
if (releaseIndex !== -1 && !releaseVersion) {
throw new Error('--release requires a version argument, e.g. --release 1.4.160')
}
@@ -0,0 +1,323 @@
import { execFileSync, spawnSync } from 'node:child_process'
function parseCpuTimeSeconds(value) {
const trimmed = String(value || '').trim()
if (!trimmed) {
return null
}
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
const days = dayOrTime === null ? 0 : Number(dayOrTime)
const parts = maybeTime.split(':').map(Number)
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
return null
}
if (parts.length === 3) {
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
}
if (parts.length === 2) {
return days * 86400 + parts[0] * 60 + parts[1]
}
if (parts.length === 1) {
return days * 86400 + parts[0]
}
return null
}
function parseUnixProcesses(stdout) {
const rows = []
for (const raw of stdout.split('\n')) {
const line = raw.trim()
if (!line) {
continue
}
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
percentCpu: Number(match[3]),
rssBytes: Number(match[4]) * 1024,
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
command: match[6]
})
}
return rows
}
function readUnixProcesses() {
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
encoding: 'utf8',
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
maxBuffer: 20 * 1024 * 1024
})
return parseUnixProcesses(stdout)
}
function readWindowsProcesses() {
const script =
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress'
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
if (result.status !== 0) {
throw new Error(result.stderr || 'PowerShell process enumeration failed')
}
const parsed = JSON.parse(result.stdout || '[]')
const entries = Array.isArray(parsed) ? parsed : [parsed]
return entries.map((entry) => ({
pid: Number(entry.ProcessId),
ppid: Number(entry.ParentProcessId),
percentCpu: 0,
cpuTimeSeconds: null,
rssBytes: Number(entry.WorkingSetSize) || 0,
command: String(entry.CommandLine || '')
}))
}
export function readProcessRows() {
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
}
export function descendantsOf(rows, rootPid) {
const children = new Map()
for (const row of rows) {
const list = children.get(row.ppid) ?? []
list.push(row)
children.set(row.ppid, list)
}
const result = []
const stack = [rootPid]
const seen = new Set()
while (stack.length > 0) {
const pid = stack.pop()
if (seen.has(pid)) {
continue
}
seen.add(pid)
const row = rows.find((candidate) => candidate.pid === pid)
if (row) {
result.push(row)
}
for (const child of children.get(pid) ?? []) {
stack.push(child.pid)
}
}
return result
}
export function classify(row, rootPid) {
const command = row.command.toLowerCase()
if (row.pid === rootPid) {
return 'main'
}
if (command.includes('daemon-entry')) {
return 'daemon'
}
if (command.includes('--type=gpu-process')) {
return 'gpu'
}
if (command.includes('--type=renderer')) {
return 'renderer'
}
if (command.includes('--type=utility')) {
return 'utility'
}
if (command.includes('--type=')) {
return 'electron-other'
}
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
return 'agent-or-node'
}
return 'other-descendant'
}
const DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS = 120_000
export async function sampleProcessTreeUntilWorkloadsComplete({
rootPid,
requestedDurationMs,
intervalMs,
workloadPromise,
maxWorkloadOverrunMs = DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS,
readRows = readProcessRows,
now = Date.now,
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
}) {
const samplingStartedAt = now()
const requestedDeadline = samplingStartedAt + requestedDurationMs
const hardDeadline = requestedDeadline + maxWorkloadOverrunMs
let workloadSettled = false
let workloadResult
let workloadError
let workloadSettledAt = null
void workloadPromise.then(
(result) => {
workloadResult = result
workloadSettled = true
workloadSettledAt = now()
},
(error) => {
workloadError = error
workloadSettled = true
workloadSettledAt = now()
}
)
const samples = []
let previousSnapshot = null
const needsFinalWorkloadSample = () =>
workloadSettledAt !== null && (previousSnapshot?.at ?? -Infinity) < workloadSettledAt
while (
now() <= requestedDeadline ||
samples.length === 0 ||
!workloadSettled ||
needsFinalWorkloadSample()
) {
const sampledAt = now()
if (workloadError) {
throw workloadError
}
if (
(!workloadSettled && sampledAt >= hardDeadline) ||
(workloadSettledAt !== null && workloadSettledAt > hardDeadline)
) {
throw new Error(
`Benchmark workload exceeded the ${maxWorkloadOverrunMs}ms sampling overrun limit`
)
}
const processRows = descendantsOf(readRows(), rootPid)
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
if (previousSnapshot) {
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
const processes = rawProcesses.map((row) => {
const previous = previousByPid.get(row.pid)
const canComputeDelta =
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
const cpu = canComputeDelta
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
: row.percentCpu
return { ...row, cpu }
})
samples.push({
at: sampledAt,
elapsedMs: sampledAt - previousSnapshot.at,
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
processes
})
}
previousSnapshot = { at: sampledAt, processes: rawProcesses }
await wait(intervalMs)
}
if (workloadError) {
throw workloadError
}
const measuredDurationMs = Math.max(
0,
(previousSnapshot?.at ?? samplingStartedAt) - samplingStartedAt
)
return {
samples,
workloadResult,
samplingWindow: {
requestedDurationMs,
measuredDurationMs,
maxWorkloadOverrunMs,
extendedForWorkload: workloadSettledAt !== null && workloadSettledAt > requestedDeadline,
workloadSettledElapsedMs:
workloadSettledAt === null ? null : Math.max(0, workloadSettledAt - samplingStartedAt),
workloadSettledBeforeStop: workloadSettled
}
}
}
function mean(values) {
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
}
function percentile(sorted, fraction) {
if (sorted.length === 0) {
return 0
}
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
return sorted[index]
}
export function summarizeSamples(samples) {
const byKind = new Map()
for (const sample of samples) {
for (const proc of sample.processes) {
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
bucket.cpuValues.push(proc.cpu)
bucket.rssValues.push(proc.rssBytes)
byKind.set(proc.kind, bucket)
}
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
}
for (const [kind, count] of counts) {
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
}
}
const summary = {}
for (const [kind, values] of byKind) {
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
const rssSumBySample = samples.map((sample) =>
sample.processes
.filter((proc) => proc.kind === kind)
.reduce((sum, proc) => sum + proc.rssBytes, 0)
)
summary[kind] = {
meanCpuPercent: mean(values.cpuValues),
p95CpuPercent: percentile(cpuSorted, 0.95),
maxCpuPercent: Math.max(0, ...values.cpuValues),
meanRssBytes: mean(rssSumBySample),
maxProcessCount: values.maxProcessCount
}
}
summary.total = {
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
p95CpuPercent: percentile(
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
0.95
),
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
}
return summary
}
export function summarizeProcessInventory(samples) {
const inventory = {}
for (const sample of samples) {
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
const entry = inventory[proc.kind] ?? {
maxProcessCount: 0,
maxCpuPercent: 0,
commandSamples: []
}
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
entry.commandSamples.push(proc.command)
}
inventory[proc.kind] = entry
}
for (const [kind, count] of counts) {
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
}
}
return inventory
}
export function terminateProcesses(processes) {
for (const proc of processes) {
try {
process.kill(proc.pid)
} catch {}
}
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { sampleProcessTreeUntilWorkloadsComplete } from './idle-cpu-process-sampling.mjs'
function createClock(workloadCompletesAt) {
let currentMs = 0
let resolveWorkload
const workloadPromise = new Promise((resolve) => {
resolveWorkload = resolve
})
const wait = async (durationMs) => {
currentMs += durationMs
if (currentMs >= workloadCompletesAt) {
resolveWorkload('complete')
}
await Promise.resolve()
}
const readRows = () => [
{
pid: 10,
ppid: 0,
percentCpu: 0,
rssBytes: 1_024,
cpuTimeSeconds: currentMs / 2_000,
command: 'electron'
}
]
return { now: () => currentMs, readRows, wait, workloadPromise }
}
describe('idle CPU process sampling window', () => {
it('extends through a slow workload and captures a final CPU delta', async () => {
const clock = createClock(40)
const result = await sampleProcessTreeUntilWorkloadsComplete({
rootPid: 10,
requestedDurationMs: 20,
intervalMs: 10,
maxWorkloadOverrunMs: 100,
...clock
})
expect(result.workloadResult).toBe('complete')
expect(result.samples.map((sample) => sample.at)).toEqual([10, 20, 30, 40])
expect(result.samplingWindow).toEqual({
requestedDurationMs: 20,
measuredDurationMs: 40,
maxWorkloadOverrunMs: 100,
extendedForWorkload: true,
workloadSettledElapsedMs: 40,
workloadSettledBeforeStop: true
})
})
it('invalidates a run that reaches the workload overrun guard', async () => {
const clock = createClock(Infinity)
await expect(
sampleProcessTreeUntilWorkloadsComplete({
rootPid: 10,
requestedDurationMs: 20,
intervalMs: 10,
maxWorkloadOverrunMs: 20,
...clock
})
).rejects.toThrow('exceeded the 20ms sampling overrun limit')
})
})
@@ -0,0 +1,202 @@
export async function configureRendererScaleFixture(page, options, repoPath) {
return page.evaluate(
({ agentsPerWorktree, lineageDepth, repoPath }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const normalizePath = (value) =>
String(value ?? '')
.replaceAll('\\', '/')
.toLowerCase()
const primaryPath = normalizePath(repoPath)
const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0)
const state = store.getState()
const worktrees = Object.values(state.worktreesByRepo)
.flat()
.filter((worktree) => !worktree.isArchived)
.sort((left, right) => {
const primaryOrder =
Number(normalizePath(right.path) === primaryPath) -
Number(normalizePath(left.path) === primaryPath)
return (
primaryOrder ||
compare(normalizePath(left.path), normalizePath(right.path)) ||
compare(left.id, right.id)
)
})
const appliedLineageDepth = Math.min(lineageDepth, Math.max(0, worktrees.length - 1))
if (lineageDepth === 0 && agentsPerWorktree === 0) {
return {
applied: false,
requestedLineageDepth: lineageDepth,
appliedLineageDepth,
agentsPerWorktree,
seededAgentRows: 0
}
}
state.setActiveView('terminal')
state.setSidebarOpen(true)
state.setGroupBy('none')
state.setSortBy('recent')
state.setShowActiveOnly(false)
state.setShowSleepingWorkspaces(true)
state.setHideDefaultBranchWorkspace(false)
state.setFilterRepoIds([])
const lineageById = { ...store.getState().worktreeLineageById }
const lineageParentIds = new Set()
if (appliedLineageDepth > 0) {
for (const worktree of worktrees) {
delete lineageById[worktree.id]
if (!worktree.instanceId) {
throw new Error(`Worktree ${worktree.id} has no instanceId for lineage seeding`)
}
}
for (let index = 1; index < worktrees.length; index += 1) {
const child = worktrees[index]
const parent = worktrees[Math.min(index - 1, appliedLineageDepth - 1)]
lineageParentIds.add(parent.id)
lineageById[child.id] = {
worktreeId: child.id,
worktreeInstanceId: child.instanceId,
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId,
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 1_700_000_000_000 + index
}
}
}
const collapsedGroups = new Set(store.getState().collapsedGroups)
for (const parentId of lineageParentIds) {
collapsedGroups.delete(`lineage:${parentId}`)
}
store.setState({ worktreeLineageById: lineageById, collapsedGroups })
let seededAgentRows = 0
if (agentsPerWorktree > 0) {
store.getState().setWorktreeCardMode('Default')
store.getState().setAgentActivityDisplayMode('full')
const fixtureNow = Date.now()
worktrees.forEach((worktree, worktreeIndex) => {
const next = store.getState()
const tab =
next.tabsByWorktree[worktree.id]?.[0] ??
next.createTab(worktree.id, undefined, undefined, {
activate: false,
id: `idle-cpu-tab-${worktreeIndex}`
})
for (let agentIndex = 0; agentIndex < agentsPerWorktree; agentIndex += 1) {
const agentType = agentIndex % 2 === 0 ? 'codex' : 'claude'
const leafSequence =
BigInt(worktreeIndex) * BigInt(agentsPerWorktree) + BigInt(agentIndex + 1)
const leafId = `00000000-0000-4000-8000-${leafSequence.toString(16).padStart(12, '0')}`
store.getState().setAgentStatus(
`${tab.id}:${leafId}`,
{
state: 'working',
prompt: `Idle CPU agent ${worktreeIndex + 1}.${agentIndex + 1}`,
agentType
},
agentType,
{ updatedAt: fixtureNow, stateStartedAt: fixtureNow },
{ tabId: tab.id, worktreeId: worktree.id }
)
seededAgentRows += 1
}
})
}
return {
applied: true,
requestedLineageDepth: lineageDepth,
appliedLineageDepth,
lineageEdges: appliedLineageDepth > 0 ? Math.max(0, worktrees.length - 1) : 0,
expandedLineageGroups: lineageParentIds.size,
agentsPerWorktree,
seededAgentRows,
orderedWorktreeIds: worktrees.map((worktree) => worktree.id)
}
},
{ agentsPerWorktree: options.agentsPerWorktree, lineageDepth: options.lineageDepth, repoPath }
)
}
export async function collectRendererCensus(page, configuredLineageDepth) {
return page.evaluate((configuredDepth) => {
const state = window.__store?.getState()
if (!state) {
throw new Error('window.__store is not available')
}
const worktrees = Object.values(state.worktreesByRepo).flat()
const worktreeIds = new Set(worktrees.map((worktree) => worktree.id))
const lineageDepthById = new Map()
const getDepth = (worktreeId, trail = new Set()) => {
if (lineageDepthById.has(worktreeId)) {
return lineageDepthById.get(worktreeId)
}
const lineage = state.worktreeLineageById[worktreeId]
if (!lineage || !worktreeIds.has(lineage.parentWorktreeId) || trail.has(worktreeId)) {
return 0
}
const nextTrail = new Set(trail)
nextTrail.add(worktreeId)
const depth = 1 + getDepth(lineage.parentWorktreeId, nextTrail)
lineageDepthById.set(worktreeId, depth)
return depth
}
const logicalDepths = worktrees.map((worktree) => getDepth(worktree.id))
const lineageParentIds = new Set(
Object.values(state.worktreeLineageById)
.filter((lineage) => worktreeIds.has(lineage.worktreeId))
.map((lineage) => lineage.parentWorktreeId)
)
const sidebar = document.querySelector('[data-worktree-sidebar]')
const mountedWorktreeIds = [
...new Set(
[...(sidebar?.querySelectorAll('[data-worktree-id]') ?? [])]
.map((element) => element.getAttribute('data-worktree-id'))
.filter(Boolean)
)
]
const mountedAgentRows = [...(sidebar?.querySelectorAll('*') ?? [])].filter(
(element) =>
element.classList.contains('group/agent-row') ||
element.classList.contains('compact-agent-row')
).length
let diagnosticCensus = null
try {
diagnosticCensus = window.__orcaTypingDiagnostic?.report().census ?? null
} catch {}
const collapsedLineageGroups = [...lineageParentIds].filter((parentId) =>
state.collapsedGroups.has(`lineage:${parentId}`)
).length
return {
capturedAt: new Date().toISOString(),
worktrees: {
store: worktrees.length,
mountedCards: sidebar?.querySelectorAll('[data-worktree-card-surface]').length ?? 0,
mountedUnique: mountedWorktreeIds.length,
mountedIds: mountedWorktreeIds.slice(0, 100),
mountedIdsTruncated: Math.max(0, mountedWorktreeIds.length - 100)
},
agentRows: {
storeLive: Object.keys(state.agentStatusByPaneKey ?? {}).length,
storeRetained: Object.keys(state.retainedAgentsByPaneKey ?? {}).length,
mounted: mountedAgentRows,
diagnosticMounted: diagnosticCensus?.agentRows.mountedDom ?? null
},
storeListeners: diagnosticCensus?.storeListeners ?? null,
lineage: {
configuredDepth,
measuredMaxDepth: Math.max(0, ...logicalDepths),
edges: logicalDepths.filter((depth) => depth > 0).length,
groups: lineageParentIds.size,
expandedGroups: lineageParentIds.size - collapsedLineageGroups,
collapsedGroups: collapsedLineageGroups
},
diagnostic: diagnosticCensus
}
}, configuredLineageDepth)
}
@@ -0,0 +1,191 @@
const RENDERER_TIMER_INTERVAL_MS = 100
export async function startRendererTimingProbe(page) {
await page.evaluate((timerIntervalMs) => {
const maxSamples = 5_000
const maxEntries = 80
let phaseStartedAt = performance.now()
let phaseStartedAtIso = new Date().toISOString()
let timerId = null
let driftCount = 0
let driftSamples = []
let longTaskEntries = []
let observer = null
let longTaskSupported = false
const round = (value) => Math.round(value * 100) / 100
const summarize = (values, totalCount = values.length) => {
const sorted = [...values].sort((left, right) => left - right)
const percentile = (fraction) =>
sorted.length === 0
? null
: sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]
return {
count: totalCount,
retainedCount: sorted.length,
mean:
sorted.length === 0
? null
: round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length),
p50: sorted.length === 0 ? null : round(percentile(0.5)),
p95: sorted.length === 0 ? null : round(percentile(0.95)),
max: sorted.length === 0 ? null : round(sorted.at(-1))
}
}
const recordLongTasks = (entries) => {
for (const entry of entries) {
longTaskEntries.push({
startTime: entry.startTime,
duration: entry.duration,
name: entry.name
})
if (longTaskEntries.length > maxSamples) {
longTaskEntries.shift()
}
}
}
try {
longTaskSupported = PerformanceObserver.supportedEntryTypes?.includes('longtask') === true
if (longTaskSupported) {
observer = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
observer.observe({ type: 'longtask', buffered: true })
}
} catch {
observer = null
longTaskSupported = false
}
const scheduleTimer = () => {
const expectedAt = performance.now() + timerIntervalMs
timerId = setTimeout(() => {
driftCount += 1
driftSamples.push(Math.max(0, performance.now() - expectedAt))
if (driftSamples.length > maxSamples) {
driftSamples.shift()
}
scheduleTimer()
}, timerIntervalMs)
}
const snapshot = (reset) => {
if (observer) {
recordLongTasks(observer.takeRecords())
}
const capturedAt = performance.now()
const phaseLongTasks = longTaskEntries.filter((entry) => entry.startTime >= phaseStartedAt)
const durations = phaseLongTasks.map((entry) => entry.duration)
const result = {
startedAt: phaseStartedAtIso,
capturedAt: new Date().toISOString(),
durationMs: round(capturedAt - phaseStartedAt),
timerIntervalMs,
timerDriftMs: summarize(driftSamples, driftCount),
longTasks: {
supported: longTaskSupported,
...summarize(durations),
totalDurationMs: round(durations.reduce((sum, value) => sum + value, 0)),
entries: phaseLongTasks.slice(-maxEntries).map((entry) => ({
startMs: round(entry.startTime - phaseStartedAt),
durationMs: round(entry.duration),
name: entry.name
})),
entriesTruncated: Math.max(0, phaseLongTasks.length - maxEntries)
}
}
if (reset) {
phaseStartedAt = capturedAt
phaseStartedAtIso = new Date().toISOString()
driftCount = 0
driftSamples = []
longTaskEntries = []
}
return result
}
scheduleTimer()
window.__orcaIdleCpuTimingProbe = {
snapshot: () => snapshot(true),
stop: () => {
if (timerId !== null) {
clearTimeout(timerId)
}
const result = snapshot(false)
observer?.disconnect()
return result
}
}
}, RENDERER_TIMER_INTERVAL_MS)
}
export async function snapshotRendererTimingProbe(page) {
return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.snapshot() ?? null)
}
export async function stopRendererTimingProbe(page) {
return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.stop() ?? null)
}
export async function runZustandPublications(page, count, intervalMs) {
return page.evaluate(
({ count, intervalMs }) =>
new Promise((resolve, reject) => {
const store = window.__store
if (!store) {
reject(new Error('window.__store is not available'))
return
}
const maxSamples = 5_000
const startedAt = performance.now()
const startedAtIso = new Date().toISOString()
const schedulingDriftMs = []
let completed = 0
const finish = () => {
const sorted = [...schedulingDriftMs].sort((left, right) => left - right)
const percentile = (fraction) =>
sorted.length === 0
? null
: sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]
const round = (value) => Math.round(value * 100) / 100
resolve({
requested: count,
completed,
intervalMs,
startedAt: startedAtIso,
completedAt: new Date().toISOString(),
durationMs: round(performance.now() - startedAt),
schedulingDriftMs: {
retainedCount: sorted.length,
p50: sorted.length === 0 ? null : round(percentile(0.5)),
p95: sorted.length === 0 ? null : round(percentile(0.95)),
max: sorted.length === 0 ? null : round(sorted.at(-1))
}
})
}
const publish = () => {
const publishedAt = performance.now()
const scheduledAt = startedAt + completed * intervalMs
schedulingDriftMs.push(Math.max(0, publishedAt - scheduledAt))
if (schedulingDriftMs.length > maxSamples) {
schedulingDriftMs.shift()
}
try {
// Why: an empty partial notifies every subscriber without changing domain state.
store.setState({})
} catch (error) {
reject(error)
return
}
completed += 1
if (completed >= count) {
finish()
return
}
const nextAt = startedAt + completed * intervalMs
setTimeout(publish, Math.max(0, nextAt - performance.now()))
}
if (count === 0) {
finish()
} else {
setTimeout(publish, 0)
}
}),
{ count, intervalMs }
)
}
@@ -28,6 +28,8 @@ const transientDownloadErrorCodes = new Set([
'EAI_AGAIN',
'ECONNREFUSED',
'ECONNRESET',
// GitHub release CDN can refuse HTTP/2 streams under load; retry like socket resets.
'ERR_HTTP2_STREAM_ERROR',
'ENETDOWN',
'ENETRESET',
'ENETUNREACH',
@@ -185,7 +187,9 @@ async function downloadElectronArtifactWithRetry(downloadOptions) {
function getDownloadRetryDelays() {
const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS
if (!configured) {
return [1_000, 3_000]
// Why: GitHub release CDN returns intermittent 503 / HTTP2 stream refusals
// under CI fan-out; a few short attempts still exhaust during outages.
return [1_000, 3_000, 5_000, 10_000]
}
const delays = configured.split(',').map(Number)
@@ -200,7 +204,7 @@ function isTransientDownloadError(error) {
if (transientDownloadErrorCodes.has(candidate?.code)) {
return true
}
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
const statusCode = getDownloadErrorStatusCode(candidate)
if (
statusCode === 408 ||
statusCode === 425 ||
@@ -213,6 +217,13 @@ function isTransientDownloadError(error) {
return false
}
function getDownloadErrorStatusCode(error) {
// @electron/get FetchDownloader uses Fetch Response.status; older got uses statusCode.
return (
error?.statusCode ?? error?.status ?? error?.response?.statusCode ?? error?.response?.status
)
}
function getErrorChain(error) {
const errors = []
let candidate = error
@@ -225,7 +236,7 @@ function getErrorChain(error) {
function formatDownloadError(error) {
for (const candidate of getErrorChain(error)) {
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
const statusCode = getDownloadErrorStatusCode(candidate)
if (statusCode) {
return `HTTP ${statusCode}`
}
@@ -118,6 +118,58 @@ describe('install-electron-package-binary', () => {
}
})
it('retries HTTP/2 stream refusal Electron download failures', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, {
downloadFailures: 1,
downloadErrorCode: 'ERR_HTTP2_STREAM_ERROR'
})
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
})
expect(result.status, result.stderr).toBe(0)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(2)
expect(result.stderr).toContain(
'Transient Electron download failure (ERR_HTTP2_STREAM_ERROR)'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('retries Electron download HTTP 503 failures from Fetch Response.status', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, {
downloadFailures: 1,
downloadHttpStatus: 503
})
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
})
expect(result.status, result.stderr).toBe(0)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(2)
expect(result.stderr).toContain('Transient Electron download failure (HTTP 503)')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('fails after exhausting transient Electron download retries', () => {
const projectDir = mkTempProject()
@@ -279,7 +331,12 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
function writeFakeElectronGet(
projectDir,
{ downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {}
{
downloadNeverSettles = false,
downloadFailures = 0,
downloadErrorCode = 'ECONNRESET',
downloadHttpStatus = null
} = {}
) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
@@ -299,6 +356,11 @@ exports.downloadArtifact = async function downloadArtifact(details) {
return new Promise(() => {})
}
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
if (${JSON.stringify(downloadHttpStatus)} != null) {
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
throw error
}
const cause = Object.assign(new Error('download failed'), {
code: ${JSON.stringify(downloadErrorCode)}
})
@@ -0,0 +1,141 @@
#!/usr/bin/env node
// Run: node config/scripts/legacy-worker-recovery-persistence-benchmark.mjs
import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtemp, open, readFile, rename } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
const runtimePath = join(repoRoot, 'src/main/runtime/orca-runtime.ts')
const args = new Map(
process.argv.slice(2).map((value, index, values) => [value, values[index + 1]])
)
const fixtureMiB = Number(args.get('--fixture-mib') ?? 24)
const trials = Number(args.get('--trials') ?? 3)
const jsonOutput = process.argv.includes('--json')
if (!Number.isInteger(fixtureMiB) || fixtureMiB < 1 || !Number.isInteger(trials) || trials < 1) {
throw new Error('fixture-mib and trials must be positive integers')
}
const runtimeSource = await readFile(runtimePath, 'utf8')
const recoveryStart = runtimeSource.indexOf(
'private async persistLegacyWorkerTerminalRecoveryBatch'
)
const recoveryEnd = runtimeSource.indexOf(
'private reconcileMissingLegacyWorkerTerminal',
recoveryStart
)
const recoverySource = runtimeSource.slice(recoveryStart, recoveryEnd)
if (
recoveryStart === -1 ||
recoveryEnd === -1 ||
!recoverySource.includes('await this.flushWorkspaceSessionOrThrowAsync()') ||
recoverySource.includes('flushOrThrow()')
) {
throw new Error('recovery persistence implementation changed; update this benchmark')
}
const root = await mkdtemp(join(tmpdir(), 'orca-legacy-recovery-benchmark-'))
const filler = 'x'.repeat(fixtureMiB * 1024 * 1024)
function payload(state) {
return JSON.stringify({ state, filler })
}
function writeDurableSync(path, body) {
const tempPath = `${path}.sync.tmp`
writeFileSync(tempPath, body)
const fd = openSync(tempPath, 'r')
try {
fsyncSync(fd)
} finally {
closeSync(fd)
}
renameSync(tempPath, path)
}
async function writeDurableAsync(path, body) {
const tempPath = `${path}.async.tmp`
const handle = await open(tempPath, 'w')
try {
await handle.writeFile(body)
await handle.sync()
} finally {
await handle.close()
}
await rename(tempPath, path)
}
async function measure(run) {
let maxEventLoopDelayMs = 0
let expected = performance.now() + 1
const timer = setInterval(() => {
const now = performance.now()
maxEventLoopDelayMs = Math.max(maxEventLoopDelayMs, now - expected)
expected = now + 1
}, 1)
await new Promise((resolve) => setTimeout(resolve, 5))
const startedAt = performance.now()
await run()
const durationMs = performance.now() - startedAt
await new Promise((resolve) => setTimeout(resolve, 5))
clearInterval(timer)
return { durationMs, maxEventLoopDelayMs }
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
async function runLegacy(path) {
const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true }
state.fenced = true
writeDurableSync(path, payload(state))
state.surfacePresent = false
writeDurableSync(path, payload(state))
state.recoveryRecordPresent = false
writeDurableSync(path, payload(state))
}
async function runBatched(path) {
const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true }
state.fenced = true
state.surfacePresent = false
state.recoveryRecordPresent = false
await writeDurableAsync(path, payload(state))
}
try {
const legacy = []
const batched = []
for (let index = 0; index < trials; index += 1) {
legacy.push(await measure(() => runLegacy(join(root, `legacy-${index}.json`))))
batched.push(await measure(() => runBatched(join(root, `batched-${index}.json`))))
}
const result = {
benchmark: 'legacy-worker-recovery-persistence',
fixtureMiB,
trials,
legacy: {
durableWrites: 3,
medianDurationMs: median(legacy.map((sample) => sample.durationMs)),
medianMaxEventLoopDelayMs: median(legacy.map((sample) => sample.maxEventLoopDelayMs))
},
batchedAsync: {
durableWrites: 1,
medianDurationMs: median(batched.map((sample) => sample.durationMs)),
medianMaxEventLoopDelayMs: median(batched.map((sample) => sample.maxEventLoopDelayMs))
}
}
if (jsonOutput) {
console.log(JSON.stringify(result))
} else {
console.log(JSON.stringify(result, null, 2))
}
} finally {
rmSync(root, { recursive: true, force: true })
}
@@ -0,0 +1,147 @@
#!/usr/bin/env node
// Benchmarks optioned localeCompare calls used by renderer list comparators.
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
import { createJiti } from 'jiti'
const ROUND_COUNT = 5
const MIN_ROUND_MS = 120
const jiti = createJiti(import.meta.url, {
alias: { '@': fileURLToPath(new URL('../../src/renderer/src', import.meta.url)) }
})
const { compareBaseSensitivityLocaleText } = await jiti.import(
'../../src/renderer/src/lib/locale-text-collators.ts'
)
const { sortJiraIssues } = await jiti.import(
'../../src/renderer/src/components/jira-issue-sorter.ts'
)
let randomState = 0x9e3779b9
function random() {
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
return randomState / 0x100000000
}
function shuffle(values) {
for (let index = values.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1))
;[values[index], values[swapIndex]] = [values[swapIndex], values[index]]
}
return values
}
const labels = ['alpha', 'Álpha', 'beta', 'BÉTA', 'café', 'Cafe', 'zeta', 'Ångström']
function makeJiraIssues(count) {
return shuffle(
Array.from({ length: count }, (_, index) => ({
key: `TASK-${(index * 37) % (count + 17)}`,
title: labels[index % labels.length],
updatedAt: '2026-01-01T00:00:00.000Z'
}))
)
}
function makeBaseSensitivityValues(count) {
return shuffle(
Array.from(
{ length: count },
(_, index) =>
`${labels[(index * 5) % labels.length]}-${String((index * 41) % (count + 23)).padStart(4, '0')}`
)
)
}
function calibrate(run) {
for (let index = 0; index < 5; index += 1) {
run()
}
let iterations = 1
while (true) {
const startedAt = performance.now()
for (let index = 0; index < iterations; index += 1) {
run()
}
if (performance.now() - startedAt >= MIN_ROUND_MS) {
break
}
iterations *= 2
}
return iterations
}
function measureRound(run, iterations) {
const startedAt = performance.now()
for (let index = 0; index < iterations; index += 1) {
run()
}
return (performance.now() - startedAt) / iterations
}
function measurePair(before, after) {
const beforeIterations = calibrate(before)
const afterIterations = calibrate(after)
const beforeSamples = []
const afterSamples = []
for (let round = 0; round < ROUND_COUNT; round += 1) {
if (round % 2 === 0) {
beforeSamples.push(measureRound(before, beforeIterations))
afterSamples.push(measureRound(after, afterIterations))
} else {
afterSamples.push(measureRound(after, afterIterations))
beforeSamples.push(measureRound(before, beforeIterations))
}
}
const middle = Math.floor(ROUND_COUNT / 2)
return {
beforeMs: beforeSamples.sort((a, b) => a - b)[middle],
afterMs: afterSamples.sort((a, b) => a - b)[middle]
}
}
function assertSameOrder(before, after, label) {
const expected = before()
const actual = after()
if (
expected.length !== actual.length ||
expected.some((value, index) => value !== actual[index])
) {
throw new Error(`${label} sort order changed`)
}
}
const pad = (value, width) => String(value).padStart(width)
console.log('Renderer locale sort, ms per sort (median of 5 rounds). Lower is better.')
console.log(
`${pad('mode', 9)} ${pad('items', 7)} ${pad('per-call', 11)} ${pad('reused', 11)} ${pad('speedup', 9)}`
)
for (const count of [36, 50, 250]) {
const issues = makeJiraIssues(count)
const before = () =>
[...issues]
.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
.map((issue) => issue.key)
const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key)
assertSameOrder(before, after, `numeric ${count}`)
const { beforeMs, afterMs } = measurePair(before, after)
console.log(
`${pad('numeric', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
)
}
for (const count of [10, 50, 250]) {
const values = makeBaseSensitivityValues(count)
const before = () =>
[...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
const after = () => [...values].sort(compareBaseSensitivityLocaleText)
assertSameOrder(before, after, `base ${count}`)
const { beforeMs, afterMs } = measurePair(before, after)
console.log(
`${pad('base', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
)
}
console.log(
'\n36 rows matches the Linear page size, 50 matches the picker/Jira scale, and\n250 is a stress case. Both arms assert identical output before timing.'
)
@@ -1058,36 +1058,6 @@
"auto.components.feature.wall.EditorAnimatedVisual.e16479c1c5": {
"ko": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }"
},
"auto.components.feature.wall.FeatureTourPreview.04d54d50ec": {
"ko": "orca · zsh"
},
"auto.components.feature.wall.FeatureTourPreview.304ad0dfc1": {
"ko": "생각 중..."
},
"auto.components.feature.wall.FeatureTourPreview.3822d8d14b": {
"ko": "fix/worktree-picker-truncates"
},
"auto.components.feature.wall.FeatureTourPreview.47f16ecf34": {
"ko": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다."
},
"auto.components.feature.wall.FeatureTourPreview.5d6ee181b6": {
"ko": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요."
},
"auto.components.feature.wall.FeatureTourPreview.6218a9014d": {
"ko": "pnpm playwright test"
},
"auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0": {
"ko": "dashboard.spec.ts"
},
"auto.components.feature.wall.FeatureTourPreview.70aa182266": {
"ko": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다."
},
"auto.components.feature.wall.FeatureTourPreview.f10c14dd9d": {
"ko": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요."
},
"auto.components.feature.wall.FeatureTourWorkspaceCard.9220794f6a": {
"ko": "작업 중"
},
"auto.components.feature.wall.FeatureWallSetupChecklist.713cc529a5": {
"ko": "마일스톤"
},
@@ -1502,12 +1472,6 @@
"auto.components.new.workspace.ProjectCombobox.search": {
"ko": "프로젝트 검색..."
},
"auto.components.new.workspace.ProjectHostSetupCombobox.empty": {
"ko": "이 프로젝트에 준비된 호스트가 없습니다."
},
"auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": {
"ko": "호스트 선택"
},
"auto.components.notification.sound.options.0acd3d384e": {
"ko": "클랙"
},
@@ -1520,9 +1484,6 @@
"auto.components.notification.sound.options.86af8d938c": {
"ko": "봉 소리"
},
"auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": {
"ko": "기능 활성화"
},
"auto.components.onboarding.AgentStep.1eee1c7bd8": {
"ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요."
},
@@ -1538,12 +1499,6 @@
"auto.components.onboarding.AgentStep.yoloPermissionsTooltip": {
"ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다."
},
"auto.components.onboarding.FeatureSetupChecklist.01426f3a23": {
"ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다."
},
"auto.components.onboarding.FeatureSetupChecklist.c5292c409d": {
"ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다."
},
"auto.components.onboarding.IntegrationsStep.0b4a7d23ab": {
"ko": "로그인 중"
},
@@ -1556,18 +1511,6 @@
"auto.components.onboarding.OnboardingFlow.windowsTerminalSubtitle": {
"ko": "새 창에서 사용할 기본 셸과 terminal에서 오른쪽 클릭이 동작하는 방식을 선택하세요."
},
"auto.components.onboarding.RepoStep.466108ab89": {
"ko": "선택한 호스트에 존재하는 경로를 입력하세요."
},
"auto.components.onboarding.RepoStep.6558d50c69": {
"ko": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요."
},
"auto.components.onboarding.RepoStep.c33b190ca3": {
"ko": "호스트 경로만"
},
"auto.components.onboarding.RepoStep.cf23006ba7": {
"ko": "선택한 호스트"
},
"auto.components.onboarding.ThemeStep.7ee9234e54": {
"ko": "Ghostty 설정을 찾았습니다."
},
@@ -4976,9 +4919,6 @@
"auto.components.ui.color.picker.faa855a582": {
"ko": "Hex"
},
"auto.components.ui.team.multi.combobox.18ec58881e": {
"ko": "팀 검색..."
},
"auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": {
"ko": "리뷰 필요"
},
@@ -210,7 +210,7 @@ function connectInvalidPeer(socketPath) {
const onData = (chunk) => {
buffer += chunk
const newline = buffer.indexOf('\n')
if (newline < 0) {
if (newline === -1) {
return
}
let response
@@ -54,7 +54,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
const frames = []
while (cursor.length > 0) {
const frameStart = cursor.indexOf(JPEG_START)
if (frameStart < 0) {
if (frameStart === -1) {
const keepLastByte = cursor.at(-1) === 0xff
return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) }
}
@@ -62,7 +62,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
cursor = cursor.subarray(frameStart)
}
const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length)
if (frameEnd < 0) {
if (frameEnd === -1) {
const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes)
return { frames, pending: trackedCopy(tail) }
}
@@ -79,7 +79,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
const frames = []
while (cursor.length > 0) {
const frameStart = cursor.indexOf(JPEG_START)
if (frameStart < 0) {
if (frameStart === -1) {
const keepLastByte = cursor.at(-1) === 0xff
return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) }
}
@@ -87,7 +87,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
cursor = cursor.subarray(frameStart)
}
const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length)
if (frameEnd < 0) {
if (frameEnd === -1) {
const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes)
return { frames, pending: trackedCopy(tail) }
}
@@ -0,0 +1,335 @@
/**
* Synthetic benchmark for native chat's post-incremental renderer hot paths.
* Run: pnpm exec tsx config/scripts/native-chat-live-session-benchmark.ts
*
* This excludes transcript parsing, IPC/remote latency, React, and DOM work. The
* 2 KB/message fixtures intentionally stress fallback-key normalization and are
* not estimates of average production message size or end-to-end frame latency.
*/
import { deepStrictEqual, strictEqual } from 'node:assert'
import { performance } from 'node:perf_hooks'
import type { NativeChatMessage, NativeChatSession } from '../../src/shared/native-chat-types'
import { getVerifiedNativeChatCommands } from '../../src/shared/native-chat-agent-profiles'
import { surfaceSkillInvocationUserTurns } from '../../src/shared/native-chat-command-envelope'
import { prepareNativeChatLiveMessages } from '../../src/renderer/src/components/native-chat/native-chat-live-message-preparation'
import { mergeNativeChatLiveSession } from '../../src/renderer/src/components/native-chat/native-chat-live-status'
import {
matchingNativeChatUserTexts,
selectPendingIndicesRepresentedByUserTexts
} from '../../src/renderer/src/components/native-chat/native-chat-pending-occurrence'
import { pendingSendsAsMessages } from '../../src/renderer/src/components/native-chat/native-chat-pending'
import { assembleNativeChatSession } from '../../src/renderer/src/components/native-chat/native-chat-session-assembler'
type Operation = (index: number) => number
type ExpectedChecksum = (iterations: number) => number
type Calibration = { iterations: number; elapsedMs: number; capped: boolean }
const TARGET_SAMPLE_MS = 50
const MAX_ITERATIONS = 16_777_216
const ROUNDS = 10
let checksum = 0
let expectedChecksum = 0
let validatedCases = 0
let cappedCalibrations = 0
let sessionSink: NativeChatSession | null = null
let messageArraySink: NativeChatMessage[] | null = null
let contentSink = ''
let pendingMatchSink: Set<number> | null = null
const benchmarkStartedAt = performance.now()
function proseFixture(count: number, bytes: number, withTurnId: boolean): NativeChatMessage[] {
const payload = 'Ab Cd '.repeat(Math.ceil(bytes / 7)).slice(0, bytes)
return Array.from({ length: count }, (_, index) => ({
id: `message-${index}`,
role: index % 2 === 0 ? ('user' as const) : ('assistant' as const),
blocks: [{ type: 'text' as const, text: `${payload}-${index}` }],
timestamp: index,
source: 'transcript' as const,
...(withTurnId ? { turnId: `turn-${index}` } : {})
}))
}
function toolFixture(count: number): NativeChatMessage[] {
return Array.from({ length: count }, (_, index) => ({
id: `tool-${index}`,
role: index % 2 === 0 ? ('assistant' as const) : ('tool' as const),
blocks:
index % 2 === 0
? [
{
type: 'tool-call' as const,
name: 'read',
input: { path: `${index}.txt`, context: 'x'.repeat(512) }
}
]
: [{ type: 'tool-result' as const, output: `result-${index}-${'x'.repeat(512)}` }],
timestamp: index,
source: 'transcript' as const
}))
}
function legacySession(messages: NativeChatMessage[]): NativeChatSession {
return assembleNativeChatSession({
sources: { transcript: messages },
sessionId: 'benchmark',
agent: 'claude'
})
}
function legacyMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSession {
const commandNames = new Set(
getVerifiedNativeChatCommands('claude').map((command) => command.name)
)
return legacySession(surfaceSkillInvocationUserTurns(messages, commandNames))
}
function directMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSession {
return mergeNativeChatLiveSession({
messages: prepareNativeChatLiveMessages(messages, 'claude'),
sessionId: 'benchmark',
agent: 'claude',
hookState: null
})
}
function oldEmptyPending(messages: NativeChatMessage[]): NativeChatMessage[] {
pendingMatchSink = selectPendingIndicesRepresentedByUserTexts(
[],
matchingNativeChatUserTexts(messages)
)
return []
}
function blockContent(message: NativeChatMessage): string {
const block = message.blocks[0]
if (!block) {
return ''
}
if (block.type === 'text') {
return block.text
}
if (block.type === 'tool-call') {
return block.name
}
if (block.type === 'tool-result') {
return block.output
}
return block.path ?? block.url ?? block.alt ?? ''
}
function messageWeight(message: NativeChatMessage, content: string): number {
const idTail = message.id.length > 0 ? message.id.charCodeAt(message.id.length - 1) : 0
const contentTail = content.length > 0 ? content.charCodeAt(content.length - 1) : 0
return message.id.length + idTail + message.role.charCodeAt(0) + content.length + contentTail
}
function consumeSession(session: NativeChatSession, index: number): number {
sessionSink = session
messageArraySink = session.messages
const message = session.messages[index % session.messages.length]
if (!message) {
contentSink = ''
return session.status.charCodeAt(0)
}
contentSink = blockContent(message)
return session.status.charCodeAt(0) + messageWeight(message, contentSink)
}
function consumePendingOutput(messages: NativeChatMessage[], index: number): number {
messageArraySink = messages
return (index % 7) + 1
}
function cyclicChecksum(values: readonly number[], iterations: number): number {
if (values.length === 0) {
return 0
}
const cycle = values.reduce((sum, value) => sum + value, 0)
const fullCycles = Math.floor(iterations / values.length)
let total = cycle * fullCycles
for (let index = 0; index < iterations % values.length; index += 1) {
total += values[index]!
}
return total
}
function sessionExpectedChecksum(session: NativeChatSession): ExpectedChecksum {
const statusWeight = session.status.charCodeAt(0)
const weights = session.messages.map((message) => {
const content = blockContent(message)
return messageWeight(message, content)
})
return (iterations) => statusWeight * iterations + cyclicChecksum(weights, iterations)
}
function pendingExpectedChecksum(iterations: number): number {
return cyclicChecksum([1, 2, 3, 4, 5, 6, 7], iterations)
}
function runSample(operation: Operation, expected: ExpectedChecksum, iterations: number): number {
let sampleChecksum = 0
const startedAt = performance.now()
for (let index = 0; index < iterations; index += 1) {
sampleChecksum += operation(index)
}
const elapsedMs = performance.now() - startedAt
checksum += sampleChecksum
expectedChecksum += expected(iterations)
return elapsedMs
}
function median(samples: number[]): number {
return [...samples].sort((left, right) => left - right)[Math.floor(samples.length / 2)]!
}
function calibrate(operation: Operation, expected: ExpectedChecksum): Calibration {
let iterations = 1
let reachedTarget = false
while (true) {
const elapsedMs = runSample(operation, expected, iterations)
if (elapsedMs >= TARGET_SAMPLE_MS) {
if (reachedTarget) {
return { iterations, elapsedMs, capped: false }
}
reachedTarget = true
continue
}
if (iterations >= MAX_ITERATIONS) {
return { iterations, elapsedMs, capped: true }
}
reachedTarget = false
iterations = Math.min(iterations * 2, MAX_ITERATIONS)
}
}
function benchmark(
name: string,
baseline: Operation,
optimized: Operation,
expected: ExpectedChecksum
): void {
const baselineValue = baseline(0)
const optimizedValue = optimized(0)
strictEqual(optimizedValue, baselineValue, `${name}: timed arms returned different checksums`)
validatedCases += 1
const baselineCalibration = calibrate(baseline, expected)
const optimizedCalibration = calibrate(optimized, expected)
cappedCalibrations += Number(baselineCalibration.capped) + Number(optimizedCalibration.capped)
const baselineSamples: number[] = []
const optimizedSamples: number[] = []
for (let round = 0; round < ROUNDS; round += 1) {
if (round % 2 === 0) {
baselineSamples.push(
runSample(baseline, expected, baselineCalibration.iterations) /
baselineCalibration.iterations
)
optimizedSamples.push(
runSample(optimized, expected, optimizedCalibration.iterations) /
optimizedCalibration.iterations
)
} else {
optimizedSamples.push(
runSample(optimized, expected, optimizedCalibration.iterations) /
optimizedCalibration.iterations
)
baselineSamples.push(
runSample(baseline, expected, baselineCalibration.iterations) /
baselineCalibration.iterations
)
}
}
const baselineMs = median(baselineSamples)
const optimizedMs = median(optimizedSamples)
const speedup = baselineMs / Math.max(optimizedMs, Number.EPSILON)
console.log(
`${name}\t${baselineCalibration.iterations}\t${optimizedCalibration.iterations}\t${baselineCalibration.elapsedMs.toFixed(1)}\t${optimizedCalibration.elapsedMs.toFixed(1)}\t${baselineMs.toFixed(6)}\t${optimizedMs.toFixed(6)}\t${speedup.toFixed(1)}x`
)
}
function benchmarkSessionArms(
name: string,
baselineSession: () => NativeChatSession,
optimizedSession: () => NativeChatSession
): void {
const expectedSession = baselineSession()
deepStrictEqual(optimizedSession(), expectedSession, `${name}: session mismatch`)
const expected = sessionExpectedChecksum(expectedSession)
benchmark(
name,
(index) => consumeSession(baselineSession(), index),
(index) => consumeSession(optimizedSession(), index),
expected
)
}
function benchmarkMessageUpdate(name: string, messages: NativeChatMessage[]): void {
benchmarkSessionArms(
name,
() => legacyMessageUpdateSession(messages),
() => directMessageUpdateSession(messages)
)
}
const prose300 = proseFixture(300, 2_048, false)
const fixtures = [
['300 x 2KB prose, no turnId', prose300],
['300 x 2KB prose, with turnId', proseFixture(300, 2_048, true)],
['300 tool-heavy, no turnId', toolFixture(300)],
['100 x 2KB prose, no turnId', proseFixture(100, 2_048, false)],
['500 x 2KB prose, no turnId', proseFixture(500, 2_048, false)]
] as const
console.log(
`Node ${process.version}; ${ROUNDS} alternating interleaved median rounds; ${TARGET_SAMPLE_MS} ms calibration target; ${MAX_ITERATIONS} iteration cap`
)
console.log(
'case\tbaseline iters\toptimized iters\tbaseline cal ms\toptimized cal ms\tbaseline ms/op\toptimized ms/op\tspeedup'
)
for (const [name, messages] of fixtures) {
benchmarkMessageUpdate(name, messages)
}
benchmarkSessionArms(
'300 x 2KB status-only frame',
() => legacySession(prose300),
() =>
mergeNativeChatLiveSession({
messages: prose300,
sessionId: 'benchmark',
agent: 'claude',
hookState: null
})
)
deepStrictEqual(pendingSendsAsMessages([], prose300), oldEmptyPending(prose300))
benchmark(
'300 x 2KB empty pending',
(index) => consumePendingOutput(oldEmptyPending(prose300), index),
(index) => consumePendingOutput(pendingSendsAsMessages([], prose300), index),
pendingExpectedChecksum
)
const combinedSessionExpected = sessionExpectedChecksum(legacyMessageUpdateSession(prose300))
benchmark(
'300 x 2KB combined',
(index) =>
consumeSession(legacyMessageUpdateSession(prose300), index) +
consumePendingOutput(oldEmptyPending(prose300), index),
(index) =>
consumeSession(directMessageUpdateSession(prose300), index) +
consumePendingOutput(pendingSendsAsMessages([], prose300), index),
(iterations) => combinedSessionExpected(iterations) + pendingExpectedChecksum(iterations)
)
strictEqual(checksum, expectedChecksum, 'benchmark checksum accounting drifted')
strictEqual(sessionSink?.sessionId, 'benchmark', 'session outputs did not escape')
strictEqual(Array.isArray(messageArraySink), true, 'message arrays did not escape')
strictEqual(contentSink.length > 0, true, 'message content did not escape')
strictEqual(pendingMatchSink instanceof Set, true, 'pending baseline scan did not escape')
console.log(
`validated=${validatedCases} cases, checksum=${checksum}, capped calibrations=${cappedCalibrations}, runtime=${(
performance.now() - benchmarkStartedAt
).toFixed(0)} ms`
)
@@ -281,7 +281,7 @@ describe('orchestration skill guidance', () => {
expect(workerLoop).toContain('opaque provider model id with `--model`')
expect(workerLoop).toContain('`--effort` requires `--model`')
expect(workerLoop).toContain('neither option can combine with `--terminal`')
expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json')
expect(workerLoop).toContain('--agent claude --model opus --effort high --json')
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
})
@@ -197,8 +197,13 @@ describe('Electron runtime package contract', () => {
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))")
expect(builderConfig).toContain("from: 'out/relay'")
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
expect(remoteCommands).toContain(
"joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')"
)
const assertRelayGate = (steps, publishStepName) => {
const names = steps.map((step) => step.name)
+74 -7
View File
@@ -3,7 +3,10 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
import { afterEach, describe, expect, it } from 'vitest'
import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard'
import {
createPlainNodeEntryGuardPlugin,
GUARDED_ENTRY_NAMES
} from '../build-plugins/plain-node-entry-guard'
let outputDir: string | undefined
@@ -45,16 +48,16 @@ function runWriteBundle(plugin: Plugin, dir: string, code = ''): void {
)
}
function runCloseBundle(plugin: Plugin): void {
async function runCloseBundle(plugin: Plugin): Promise<void> {
const hook = plugin.closeBundle
if (typeof hook !== 'function') {
throw new Error('Expected closeBundle hook')
}
hook.call({} as never)
await hook.call({} as never)
}
describe('plain Node entry guard', () => {
it('smoke-loads the daemon after output files are written', () => {
it('smoke-loads the daemon after output files are written', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
@@ -64,17 +67,17 @@ describe('plain Node entry guard', () => {
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n'
)
expect(() => runCloseBundle(plugin)).not.toThrow()
await expect(runCloseBundle(plugin)).resolves.toBeUndefined()
})
it('runs the deferred smoke from closeBundle', () => {
it('runs the deferred smoke from closeBundle', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
runWriteBundle(plugin, dir)
writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n")
expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node')
await expect(runCloseBundle(plugin)).rejects.toThrow('failed to load under plain Node')
})
it('rejects Electron imports during the static bundle scan', () => {
@@ -84,6 +87,70 @@ describe('plain Node entry guard', () => {
'requires electron'
)
})
it('rejects Electron subpath requires', () => {
const plugin = createPlainNodeEntryGuardPlugin()
expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron/main")')).toThrow(
'requires electron'
)
})
it('fails the smoke when the daemon exits zero on an empty argv', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
runWriteBundle(plugin, dir)
writeFileSync(join(dir, 'daemon-entry.js'), 'console.error("Usage: daemon-entry <socket>")\n')
await expect(runCloseBundle(plugin)).rejects.toThrow('did not reject an empty argv')
})
// daemon-entry installs a SIGTERM handler, so the smoke deadline only holds if
// it escalates to SIGKILL — spawnSync's own timeout would block here forever.
it('kills a daemon that traps SIGTERM and never exits', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin({ timeoutMs: 250, killGraceMs: 250 })
runWriteBundle(plugin, dir)
writeFileSync(
join(dir, 'daemon-entry.js'),
"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\n"
)
await expect(runCloseBundle(plugin)).rejects.toThrow('did not exit within 250ms')
}, 10_000)
})
// Why: writeBundle skips names absent from the bundle, so a renamed rollup input
// would silently stop guarding that entry instead of failing the build.
describe('guarded entry names', () => {
function runBuildStart(plugin: Plugin, input: unknown): void {
const hook = plugin.buildStart
if (typeof hook !== 'function') {
throw new Error('Expected buildStart hook')
}
hook.call({} as never, { input } as Rollup.NormalizedInputOptions)
}
it('rejects a guarded name that is no longer a rollup input', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const input = Object.fromEntries(
GUARDED_ENTRY_NAMES.filter((name) => name !== 'stt-worker').map((name) => [
name,
`${name}.ts`
])
)
expect(() => runBuildStart(plugin, input)).toThrow('"stt-worker"')
})
it('passes when every guarded name is a rollup input', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const input = Object.fromEntries(GUARDED_ENTRY_NAMES.map((name) => [name, `${name}.ts`]))
expect(() => runBuildStart(plugin, input)).not.toThrow()
})
})
// Why (#11161): Electron's module is not registered on worker threads either —
@@ -8,6 +8,7 @@ const dependencyAction = parse(
)
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
const shellContractFiles = [
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
'src/main/daemon/shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
@@ -75,6 +76,7 @@ describe('PR workflow parallelism', () => {
expect(shellStep).toBeDefined()
expect(shellInstall).toBeDefined()
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
// Why the whole workflow, not just the general shards: any other lane installing
// these shells would silently start running the real-shell tests twice.
expect(jobsInstallingPackages).toEqual(['shell_contracts'])
@@ -0,0 +1,166 @@
import { performance } from 'node:perf_hooks'
import { platform, release } from 'node:os'
import {
HEADER_LENGTH,
MAX_MESSAGE_SIZE,
MessageType,
encodeFrame,
encodePreparedJsonRpcFrame,
prepareJsonRpcPayload,
type JsonRpcNotification
} from '../../src/relay/protocol'
type BenchmarkCase = {
name: string
message: JsonRpcNotification
targets: number
iterations: number
}
type BenchmarkResult = {
name: string
targets: number
payloadBytes: number
legacyMicros: number
preparedMicros: number
reductionPercent: number
speedup: number
}
const SAMPLES = 17
let checksum = 0
function encodeLegacyJsonRpcFrame(message: JsonRpcNotification, id: number, ack: number): Buffer {
const payload = Buffer.from(JSON.stringify(message), 'utf8')
if (payload.length > MAX_MESSAGE_SIZE) {
throw new Error(`Message too large: ${payload.length} bytes`)
}
return encodeFrame(MessageType.Regular, id, ack, payload)
}
function runLegacy(message: JsonRpcNotification, targets: number, iteration: number): void {
const estimated = encodeLegacyJsonRpcFrame(message, 0, 0).length
checksum = (checksum + estimated) >>> 0
for (let target = 0; target < targets; target++) {
consume(encodeLegacyJsonRpcFrame(message, iteration + target + 1, iteration))
}
}
function runPrepared(message: JsonRpcNotification, targets: number, iteration: number): void {
const payload = prepareJsonRpcPayload(message)
checksum = (checksum + HEADER_LENGTH + payload.byteLength) >>> 0
for (let target = 0; target < targets; target++) {
consume(encodePreparedJsonRpcFrame(payload, iteration + target + 1, iteration))
}
}
function consume(frame: Buffer): void {
checksum = (checksum + frame.length + frame[0] + frame.at(-1)!) >>> 0
}
function measure(operation: (iteration: number) => void, iterations: number): number {
const startedAt = performance.now()
for (let iteration = 0; iteration < iterations; iteration++) {
operation(iteration)
}
return ((performance.now() - startedAt) * 1000) / iterations
}
function median(values: readonly number[]): number {
const sorted = [...values].sort((left, right) => left - right)
return sorted[Math.floor(sorted.length / 2)]
}
function benchmarkCase(entry: BenchmarkCase): BenchmarkResult {
const legacy = (iteration: number): void => runLegacy(entry.message, entry.targets, iteration)
const prepared = (iteration: number): void => runPrepared(entry.message, entry.targets, iteration)
for (let warmup = 0; warmup < 3; warmup++) {
measure(legacy, Math.min(20, entry.iterations))
measure(prepared, Math.min(20, entry.iterations))
}
const legacySamples: number[] = []
const preparedSamples: number[] = []
for (let sample = 0; sample < SAMPLES; sample++) {
if (sample % 2 === 0) {
legacySamples.push(measure(legacy, entry.iterations))
preparedSamples.push(measure(prepared, entry.iterations))
} else {
preparedSamples.push(measure(prepared, entry.iterations))
legacySamples.push(measure(legacy, entry.iterations))
}
}
const legacyMicros = median(legacySamples)
const preparedMicros = median(preparedSamples)
return {
name: entry.name,
targets: entry.targets,
payloadBytes: Buffer.byteLength(JSON.stringify(entry.message)),
legacyMicros,
preparedMicros,
reductionPercent: ((legacyMicros - preparedMicros) / legacyMicros) * 100,
speedup: legacyMicros / preparedMicros
}
}
function terminalData(bytes: number): string {
const row = '\u001b[38;5;45mcompile src/renderer/pane.ts\u001b[0m\r\n'
return row.repeat(Math.ceil(bytes / row.length)).slice(0, bytes)
}
function watcherEvents(count: number): {
kind: string
absolutePath: string
isDirectory: boolean
}[] {
return Array.from({ length: count }, (_, index) => ({
kind: index % 7 === 0 ? 'create' : 'update',
absolutePath: `/repo/src/features/terminal/generated-${String(index).padStart(4, '0')}.ts`,
isDirectory: false
}))
}
const ptyMessage: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'pty.data',
params: { id: 'pty-42', data: terminalData(16 * 1024), seq: 18_432, rawLength: 16 * 1024 }
}
const watcherMessage: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'fs.changed',
params: { events: watcherEvents(256) }
}
const fileMessage: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'fs.streamChunk',
params: {
streamId: 37,
seq: 11,
data: Buffer.alloc(256 * 1024, 0x61).toString('base64')
}
}
const cases: BenchmarkCase[] = [
{ name: 'PTY 16KiB', message: ptyMessage, targets: 1, iterations: 800 },
{ name: 'PTY 16KiB', message: ptyMessage, targets: 2, iterations: 600 },
{ name: 'watcher 256 events', message: watcherMessage, targets: 1, iterations: 400 },
{ name: 'watcher 256 events', message: watcherMessage, targets: 2, iterations: 300 },
{ name: 'file 256KiB raw', message: fileMessage, targets: 1, iterations: 50 },
{ name: 'file 256KiB raw', message: fileMessage, targets: 2, iterations: 35 }
]
const results = cases.map(benchmarkCase)
const lines = [
`relay JSON payload benchmark: Node ${process.version}, ${platform()} ${release()}, ${SAMPLES} interleaved samples`,
'case | targets | JSON bytes | legacy median us/op | prepared median us/op | reduction | speedup',
...results.map(
(result) =>
`${result.name} | ${result.targets} | ${result.payloadBytes} | ` +
`${result.legacyMicros.toFixed(2)} | ${result.preparedMicros.toFixed(2)} | ` +
`${result.reductionPercent.toFixed(1)}% | ${result.speedup.toFixed(2)}x`
),
`checksum=${checksum}`,
'Limitations: in-process framing only; excludes dispatcher scheduling, sink/network I/O, and tail-GC.',
'Legacy is the exact pre-change framing composition retained in this script, not a separate checkout.',
'The two-target rows model dispatcher fan-out; watcher batches and file streams are commonly single-target.'
]
process.stdout.write(`${lines.join('\n')}\n`)
@@ -94,7 +94,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) {
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
const index = stdoutBuffer.indexOf(sentinel)
if (index < 0) {
if (index === -1) {
return
}
settled = true
@@ -148,7 +148,7 @@ function createRelayClient(entryPath, args, env, protocol) {
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
const index = stdoutBuffer.indexOf(sentinel)
if (index < 0) {
if (index === -1) {
return
}
ready = true
@@ -5,7 +5,8 @@ import {
resolveEnvironment,
resolveEnvironmentPairingOffer
} from '../../src/shared/runtime-environment-store'
import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types'
import type { MemorySnapshot } from '../../src/shared/types'
import type { RuntimeStatus } from '../../src/shared/runtime-types'
async function main(): Promise<void> {
const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME
@@ -0,0 +1,50 @@
import { spawn } from 'node:child_process'
const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'
const knobByFlag = {
'--iterations': 'ORCA_AI_VAULT_BENCH_ITERATIONS',
'--sessions': 'ORCA_AI_VAULT_BENCH_SESSIONS',
'--payload-kib': 'ORCA_AI_VAULT_BENCH_PAYLOAD_KIB',
'--keys': 'ORCA_AI_VAULT_BENCH_KEYS',
'--cadence-ms': 'ORCA_AI_VAULT_BENCH_CADENCE_MS',
'--label': 'ORCA_AI_VAULT_BENCH_LABEL'
}
const env = { ...process.env, ORCA_AI_VAULT_TYPING_BENCH: '1' }
const passthroughArgs = []
const argv = process.argv.slice(2)
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--') {
continue
}
const knob = knobByFlag[argv[index]]
if (knob) {
env[knob] = argv[++index]
} else {
passthroughArgs.push(argv[index])
}
}
const child = spawn(
npxCommand,
[
'playwright',
'test',
'tests/e2e/terminal-ai-vault-typing-latency.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...passthroughArgs
],
{ stdio: 'inherit', env }
)
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})
+152 -262
View File
@@ -1,15 +1,34 @@
#!/usr/bin/env node
import { _electron as electron } from '@stablyai/playwright-test'
import { execFileSync, spawnSync } from 'node:child_process'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
descendantsOf,
readProcessRows,
sampleProcessTreeUntilWorkloadsComplete,
summarizeProcessInventory,
summarizeSamples,
terminateProcesses
} from './idle-cpu-process-sampling.mjs'
import {
collectRendererCensus,
configureRendererScaleFixture
} from './idle-cpu-renderer-scale-fixture.mjs'
import {
runZustandPublications,
snapshotRendererTimingProbe,
startRendererTimingProbe,
stopRendererTimingProbe
} from './idle-cpu-renderer-timing-probe.mjs'
import { installSyntheticVisibleSpinners } from './idle-cpu-synthetic-spinners.mjs'
const DEFAULT_WARMUP_MS = 15_000
const DEFAULT_SAMPLE_MS = 30_000
const DEFAULT_INTERVAL_MS = 1_000
const DEFAULT_WORKTREE_COUNT = 1
const DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS = 100
const ONBOARDING_FINAL_STEP = 3
const ONBOARDING_FLOW_VERSION = 2
@@ -19,6 +38,10 @@ function parseArgs(argv) {
sampleMs: DEFAULT_SAMPLE_MS,
intervalMs: DEFAULT_INTERVAL_MS,
worktrees: DEFAULT_WORKTREE_COUNT,
lineageDepth: 0,
agentsPerWorktree: 0,
zustandPublications: 0,
zustandPublicationIntervalMs: DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS,
skipBuild: false,
headful: false,
output: null,
@@ -47,6 +70,14 @@ function parseArgs(argv) {
options.intervalMs = Number(readValue())
} else if (arg === '--worktrees') {
options.worktrees = Number(readValue())
} else if (arg === '--lineage-depth') {
options.lineageDepth = Number(readValue())
} else if (arg === '--agents-per-worktree') {
options.agentsPerWorktree = Number(readValue())
} else if (arg === '--zustand-publications') {
options.zustandPublications = Number(readValue())
} else if (arg === '--zustand-publication-interval-ms') {
options.zustandPublicationIntervalMs = Number(readValue())
} else if (arg === '--output') {
options.output = readValue()
} else if (arg === '--skip-build') {
@@ -73,6 +104,10 @@ function parseArgs(argv) {
'sampleMs',
'intervalMs',
'worktrees',
'lineageDepth',
'agentsPerWorktree',
'zustandPublications',
'zustandPublicationIntervalMs',
'syntheticVisibleSpinners',
'syntheticSpinnerSteps'
]) {
@@ -82,16 +117,33 @@ function parseArgs(argv) {
}
options.worktrees = Math.max(1, Math.floor(options.worktrees))
options.intervalMs = Math.max(250, Math.floor(options.intervalMs))
options.lineageDepth = Math.floor(options.lineageDepth)
options.agentsPerWorktree = Math.floor(options.agentsPerWorktree)
options.zustandPublications = Math.floor(options.zustandPublications)
options.zustandPublicationIntervalMs = Math.max(
1,
Math.floor(options.zustandPublicationIntervalMs)
)
options.syntheticVisibleSpinners = Math.max(0, Math.floor(options.syntheticVisibleSpinners))
options.syntheticSpinnerSteps = Math.max(1, Math.floor(options.syntheticSpinnerSteps))
if (!['smooth', 'steps'].includes(options.syntheticSpinnerAnimation)) {
throw new Error(`Invalid --synthetic-spinner-animation: ${options.syntheticSpinnerAnimation}`)
}
if (options.lineageDepth > 0 && options.worktrees < 2) {
throw new Error('--lineage-depth requires at least two --worktrees')
}
const publicationSpanMs =
Math.max(0, options.zustandPublications - 1) * options.zustandPublicationIntervalMs
if (publicationSpanMs > options.sampleMs) {
throw new Error(
`Zustand publication span ${publicationSpanMs}ms exceeds --sample-ms ${options.sampleMs}`
)
}
return options
}
function printUsage() {
console.log(
`Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> Step count for --synthetic-spinner-animation steps (default 12)\n`
`Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --lineage-depth <n> Nest all worktrees under one expanded lineage, up to this depth\n --agents-per-worktree <n> Seed this many visible inline agent rows per worktree\n --zustand-publications <n> Publish exactly this many store updates during sampling\n --zustand-publication-interval-ms <n> Publication cadence (default ${DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> Step count for --synthetic-spinner-animation steps (default 12)\n`
)
}
function run(command, args, options = {}) {
@@ -188,140 +240,6 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function parseCpuTimeSeconds(value) {
const trimmed = String(value || '').trim()
if (!trimmed) {
return null
}
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
const days = dayOrTime === null ? 0 : Number(dayOrTime)
const parts = maybeTime.split(':').map(Number)
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
return null
}
if (parts.length === 3) {
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
}
if (parts.length === 2) {
return days * 86400 + parts[0] * 60 + parts[1]
}
if (parts.length === 1) {
return days * 86400 + parts[0]
}
return null
}
function parseUnixProcesses(stdout) {
const rows = []
for (const raw of stdout.split('\n')) {
const line = raw.trim()
if (!line) {
continue
}
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
percentCpu: Number(match[3]),
rssBytes: Number(match[4]) * 1024,
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
command: match[6]
})
}
return rows
}
function readUnixProcesses() {
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
encoding: 'utf8',
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
maxBuffer: 20 * 1024 * 1024
})
return parseUnixProcesses(stdout)
}
function readWindowsProcesses() {
const script =
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress'
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
if (result.status !== 0) {
throw new Error(result.stderr || 'PowerShell process enumeration failed')
}
const parsed = JSON.parse(result.stdout || '[]')
const entries = Array.isArray(parsed) ? parsed : [parsed]
return entries.map((entry) => ({
pid: Number(entry.ProcessId),
ppid: Number(entry.ParentProcessId),
percentCpu: 0,
cpuTimeSeconds: null,
rssBytes: Number(entry.WorkingSetSize) || 0,
command: String(entry.CommandLine || '')
}))
}
function readProcessRows() {
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
}
function descendantsOf(rows, rootPid) {
const children = new Map()
for (const row of rows) {
const list = children.get(row.ppid) ?? []
list.push(row)
children.set(row.ppid, list)
}
const result = []
const stack = [rootPid]
const seen = new Set()
while (stack.length > 0) {
const pid = stack.pop()
if (seen.has(pid)) {
continue
}
seen.add(pid)
const row = rows.find((candidate) => candidate.pid === pid)
if (row) {
result.push(row)
}
for (const child of children.get(pid) ?? []) {
stack.push(child.pid)
}
}
return result
}
function classify(row, rootPid) {
const command = row.command.toLowerCase()
if (row.pid === rootPid) {
return 'main'
}
if (command.includes('daemon-entry')) {
return 'daemon'
}
if (command.includes('--type=gpu-process')) {
return 'gpu'
}
if (command.includes('--type=renderer')) {
return 'renderer'
}
if (command.includes('--type=utility')) {
return 'utility'
}
if (command.includes('--type=')) {
return 'electron-other'
}
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
return 'agent-or-node'
}
return 'other-descendant'
}
async function collectRendererIdleState(page) {
return page.evaluate(() => {
const describeElement = (element) => {
@@ -364,93 +282,6 @@ async function collectRendererIdleState(page) {
})
}
function summarizeSamples(samples) {
const byKind = new Map()
for (const sample of samples) {
for (const proc of sample.processes) {
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
bucket.cpuValues.push(proc.cpu)
bucket.rssValues.push(proc.rssBytes)
byKind.set(proc.kind, bucket)
}
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
}
for (const [kind, count] of counts) {
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
}
}
const summary = {}
for (const [kind, values] of byKind) {
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
const rssSumBySample = samples.map((sample) =>
sample.processes
.filter((proc) => proc.kind === kind)
.reduce((sum, proc) => sum + proc.rssBytes, 0)
)
summary[kind] = {
meanCpuPercent: mean(values.cpuValues),
p95CpuPercent: percentile(cpuSorted, 0.95),
maxCpuPercent: Math.max(0, ...values.cpuValues),
meanRssBytes: mean(rssSumBySample),
maxProcessCount: values.maxProcessCount
}
}
summary.total = {
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
p95CpuPercent: percentile(
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
0.95
),
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
}
return summary
}
function summarizeProcessInventory(samples) {
const inventory = {}
for (const sample of samples) {
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
const entry = inventory[proc.kind] ?? {
maxProcessCount: 0,
maxCpuPercent: 0,
commandSamples: []
}
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
entry.commandSamples.push(proc.command)
}
inventory[proc.kind] = entry
}
for (const [kind, count] of counts) {
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
}
}
return inventory
}
function mean(values) {
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
}
function percentile(sorted, fraction) {
if (sorted.length === 0) {
return 0
}
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
return sorted[index]
}
function terminateProcesses(processes) {
for (const proc of processes) {
try {
process.kill(proc.pid)
} catch {}
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const root = path.resolve(import.meta.dirname, '..', '..')
@@ -491,6 +322,11 @@ async function main() {
const page = await app.firstWindow({ timeout: 120_000 })
await page.waitForLoadState('domcontentloaded')
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
await page.waitForFunction(
() => window.__store?.getState().workspaceSessionReady === true,
null,
{ timeout: 60_000 }
)
const measurementCss = []
if (options.disableRendererAnimations) {
measurementCss.push(
@@ -506,63 +342,111 @@ async function main() {
options.syntheticSpinnerAnimation,
options.syntheticSpinnerSteps
)
await page.evaluate(async (repoPath) => {
await window.api.repos.add({ path: repoPath })
const fixtureState = await page.evaluate(async (repoPath) => {
const added = await window.api.repos.add({ path: repoPath })
if ('error' in added) {
return { error: added.error }
}
const store = window.__store
await store?.getState().fetchRepos()
const repo = store?.getState().repos.find((candidate) => candidate.path === repoPath)
const repo = store?.getState().repos.find((candidate) => candidate.id === added.repo.id)
if (repo) {
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
await store.getState().fetchWorktrees(repo.id)
const detected = await store
.getState()
.fetchWorktrees(repo.id, { requireAuthoritative: true })
const importedWorktreePaths = (
store.getState().detectedWorktreesByRepo[repo.id]?.worktrees ?? []
)
.filter((worktree) => !worktree.selectedCheckout)
.map((worktree) => worktree.path)
const updated = await store.getState().updateRepo(repo.id, {
externalWorktreeVisibility: 'show',
importedExternalWorktreePaths: importedWorktreePaths,
externalWorktreeInboxBaselinePaths: importedWorktreePaths
})
const refreshed = await store
.getState()
.fetchWorktrees(repo.id, { requireAuthoritative: true })
return {
detected,
updated,
refreshed,
detectedCount: store.getState().detectedWorktreesByRepo[repo.id]?.worktrees.length ?? 0,
importedCount: importedWorktreePaths.length,
visibleCount: store.getState().worktreesByRepo[repo.id]?.length ?? 0
}
}
return { error: 'repo-not-found' }
}, repoDir)
console.log(`[idle-cpu] fixture ${JSON.stringify(fixtureState)}`)
await page.waitForFunction(
() => window.__store?.getState().workspaceSessionReady === true,
(expectedWorktrees) => {
const state = window.__store?.getState()
return (
state?.workspaceSessionReady === true &&
Object.values(state.worktreesByRepo).flat().length === expectedWorktrees
)
},
options.worktrees,
{ timeout: 180_000 }
)
const scaleFixtureState = await configureRendererScaleFixture(page, options, repoDir)
if (scaleFixtureState.applied) {
console.log(`[idle-cpu] scale fixture ${JSON.stringify(scaleFixtureState)}`)
}
await page.waitForFunction(
(expectedAgentRows) => {
const state = window.__store?.getState()
return (
state !== undefined &&
Object.keys(state.agentStatusByPaneKey ?? {}).length >= expectedAgentRows
)
},
scaleFixtureState.seededAgentRows,
{ timeout: 30_000 }
)
await page.waitForFunction(
() => Boolean(document.querySelector('[data-worktree-sidebar] [data-worktree-id]')),
null,
{ timeout: 60_000 }
{ timeout: 30_000 }
)
console.log(
`[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees}`
`[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees} lineage-depth=${options.lineageDepth} agents/worktree=${options.agentsPerWorktree} publications=${options.zustandPublications}`
)
await startRendererTimingProbe(page)
await sleep(options.warmupMs)
const rendererIdleState = await collectRendererIdleState(page)
const deadline = Date.now() + options.sampleMs
const samples = []
let previousSnapshot = null
while (Date.now() <= deadline || samples.length === 0) {
const sampledAt = Date.now()
const processRows = descendantsOf(readProcessRows(), rootPid)
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
if (previousSnapshot) {
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
const processes = rawProcesses.map((row) => {
const previous = previousByPid.get(row.pid)
const canComputeDelta =
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
const cpu = canComputeDelta
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
: row.percentCpu
return { ...row, cpu }
})
samples.push({
at: sampledAt,
elapsedMs: sampledAt - previousSnapshot.at,
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
processes
})
}
previousSnapshot = { at: sampledAt, processes: rawProcesses }
await sleep(options.intervalMs)
}
const rendererCensusBefore = await collectRendererCensus(page, options.lineageDepth)
const rendererTimingBefore = await snapshotRendererTimingProbe(page)
const publicationPromise = runZustandPublications(
page,
options.zustandPublications,
options.zustandPublicationIntervalMs
)
const sampled = await sampleProcessTreeUntilWorkloadsComplete({
rootPid,
requestedDurationMs: options.sampleMs,
intervalMs: options.intervalMs,
workloadPromise: publicationPromise
})
const samples = sampled.samples
const zustandPublications = sampled.workloadResult
const rendererTimingAfter = await stopRendererTimingProbe(page)
const rendererCensusAfter = await collectRendererCensus(page, options.lineageDepth)
const report = {
benchmark: 'orca-idle-cpu',
createdAt: new Date().toISOString(),
options,
rootPid,
platform: { platform: process.platform, arch: process.arch, cpus: os.cpus().length },
fixtureState,
scaleFixtureState,
rendererIdleState,
rendererCensusBefore,
rendererCensusAfter,
rendererTiming: { before: rendererTimingBefore, after: rendererTimingAfter },
zustandPublications,
samplingWindow: sampled.samplingWindow,
sampleCount: samples.length,
summary: summarizeSamples(samples),
processInventory: summarizeProcessInventory(samples),
@@ -578,7 +462,13 @@ async function main() {
{
summary: report.summary,
processInventory: report.processInventory,
sampleCount: report.sampleCount
sampleCount: report.sampleCount,
scaleFixtureState: report.scaleFixtureState,
rendererCensusBefore: report.rendererCensusBefore,
rendererCensusAfter: report.rendererCensusAfter,
rendererTiming: report.rendererTiming,
zustandPublications: report.zustandPublications,
samplingWindow: report.samplingWindow
},
null,
2
@@ -17,8 +17,8 @@ const defaultFiles = [
const cliArguments = process.argv.slice(2)
const powerShellFlag = cliArguments.indexOf('--powershell')
const configuredPowerShell =
powerShellFlag >= 0 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE
if (powerShellFlag >= 0 && !configuredPowerShell) {
powerShellFlag !== -1 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE
if (powerShellFlag !== -1 && !configuredPowerShell) {
console.error('--powershell requires an executable path')
process.exit(2)
}
+1 -1
View File
@@ -86,7 +86,7 @@ Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in
1. Open: https://github.com/organizations/$ORG/settings/installations
2. Configure -> $APP_SLUG
3. Repository access -> Only select repositories -> add $ADHOC_REPO
(keep orca-hourly selected; both dev channels use this one App)
(keep orca-hourly/orca-daily selected; all dev channels use this one App)
4. Save.
EOF
fi
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
#
# Creates stablyai/orca-daily and grants the existing release App write access to
# it, so daily-mac-build.yml can publish there.
#
# Why a separate repo rather than reusing orca-hourly: the daily channel is a
# once-a-day cut that people ride deliberately. Sharing hourly's list would mix a
# sparse daily series into the 72-entry hourly retention window and make both
# pickers harder to read.
#
# Why no secrets are set here: the daily workflow reuses the same GitHub App as
# hourly/adhoc — one App id, one private key, one thing to rotate. This script
# only has to widen that App's installation to cover the new repo.
#
# Run once, after config/scripts/setup-hourly-release-token.sh:
# bash config/scripts/setup-daily-release-repo.sh
#
set -euo pipefail
ORG="stablyai"
DAILY_REPO="$ORG/orca-daily"
MAIN_REPO="$ORG/orca"
APP_SLUG="orca-hourly-release"
fail() {
echo "error: $*" >&2
exit 1
}
command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com"
gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login"
if gh api "repos/$DAILY_REPO" --jq '.full_name' >/dev/null 2>&1; then
echo "$DAILY_REPO already exists."
else
echo "Creating $DAILY_REPO..."
# Why public: the in-app updater fetches release assets unauthenticated, exactly
# as it does for orca-hourly. A private repo would 404 for every client.
#
# Why the features are off: this repo holds releases and nothing else. Leaving
# issues open invites bug reports against an unvetted daily in a repo nobody
# watches, where they are simply lost.
#
# Why --add-readme in a repo with no source: publishing a release creates a tag,
# and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in.
gh repo create "$DAILY_REPO" \
--public \
--description "Daily macOS dev builds of Orca, cut from main each morning. Not a source repo." \
--add-readme \
--disable-issues \
--disable-wiki ||
fail "Could not create $DAILY_REPO."
fi
# Also checked outside the create branch: a repo made before --add-readme is here.
if ! gh api "repos/$DAILY_REPO/commits" --jq 'length' >/dev/null 2>&1; then
fail "$DAILY_REPO has no commits — releases cannot be tagged. Add any file to it first."
fi
echo
echo "Granting $APP_SLUG access to $DAILY_REPO..."
# Why attempt the API before printing instructions: an org owner can do this in
# one call. Everyone else gets a 403 and the manual path below — GitHub does not
# let a mere admin widen an App's repository selection.
INSTALL_ID="$(gh api "orgs/$ORG/installations" --paginate \
--jq ".installations[] | select(.app_slug == \"$APP_SLUG\") | .id" 2>/dev/null || true)"
REPO_ID="$(gh api "repos/$DAILY_REPO" --jq '.id' 2>/dev/null || true)"
GRANTED=false
if [[ -n "$INSTALL_ID" && -n "$REPO_ID" ]]; then
if gh api -X PUT "user/installations/$INSTALL_ID/repositories/$REPO_ID" >/dev/null 2>&1; then
GRANTED=true
echo "Done — $APP_SLUG can now write to $DAILY_REPO."
fi
fi
if [[ "$GRANTED" != "true" ]]; then
# Why no automated check afterwards: the endpoints that report an App's
# repository access (repos/*/installation, user/installations/*/repositories)
# both reject an ordinary `gh auth login` token, so any "verified" this script
# printed would be guesswork. The smoke test below is the real check.
cat <<EOF
Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in the browser:
1. Open: https://github.com/organizations/$ORG/settings/installations
2. Configure -> $APP_SLUG
3. Repository access -> Only select repositories -> add $DAILY_REPO
(keep orca-hourly and orca-adhoc selected; all dev channels use this one App)
4. Save.
EOF
fi
echo
echo "Smoke-test the pipeline (after this merges):"
echo " gh workflow run daily-mac-build.yml --repo $MAIN_REPO -f force=true"
echo " gh run watch --repo $MAIN_REPO"
+4 -3
View File
@@ -8,9 +8,10 @@
# — no yearly rotation — and it belongs to the org rather than to the person who
# created it, so it survives that person leaving.
#
# The same App also serves adhoc-mac-build.yml, which reads these same two
# secrets: one credential, one rotation, both dev channels. Widening it to cover
# stablyai/orca-adhoc is config/scripts/setup-adhoc-release-repo.sh's job.
# The same App also serves adhoc-mac-build.yml and daily-mac-build.yml, which
# read these same two secrets: one credential, one rotation, all dev channels.
# Widening it to cover stablyai/orca-adhoc / orca-daily is
# setup-adhoc-release-repo.sh / setup-daily-release-repo.sh's job.
#
# The key is read from a file and piped straight into `gh secret set`. It is never
# echoed, never passed as a command-line argument (argv is world-readable via
+2 -2
View File
@@ -222,8 +222,8 @@ if (process.argv[1]?.endsWith('terminal-e2e-helpers.mjs')) {
const args = process.argv.slice(2)
const portIdx = args.indexOf('--port')
const cmdIdx = args.indexOf('--command')
const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 9444
const command = cmdIdx >= 0 ? args[cmdIdx + 1] : null
const port = portIdx !== -1 ? Number(args[portIdx + 1]) : 9444
const command = cmdIdx !== -1 ? args[cmdIdx + 1] : null
const term = new OrcaTerminal(port)
console.log('Connecting to Orca on CDP port', port, '...')
@@ -5,6 +5,7 @@ import {
accessSync,
chmodSync,
constants as fsConstants,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
@@ -141,7 +142,7 @@ function readGeneratedScripts(home, minMtime) {
const body = readFileSync(path, 'utf8')
const captureIndex = body.indexOf('payload=$(cat)')
const firstExitIndex = body.indexOf('exit 0')
if (captureIndex < 0 || firstExitIndex <= captureIndex) {
if (captureIndex === -1 || firstExitIndex <= captureIndex) {
throw new Error([fileName, ' can exit before capturing stdin'].join(''))
}
return { body, fileName, path, source }
@@ -311,43 +312,37 @@ async function verifyForwarding(scripts, home, payload) {
}
}
// Why: rewrite from the path embedded in the installed command, not a
// reconstructed join(home, ...). That way missing/failing-script cases cannot
// silently re-run the real script if the install layout changes.
function rewriteLauncherScriptPath(command, nextPath) {
const match = /if \[ -f '([^']+)'/.exec(command)
if (!match) {
throw new Error('Installed launcher command did not reference a quoted script path')
}
return command.replaceAll(match[1], nextPath)
}
async function verifyInstalledLauncher(home, payload) {
const settingsPath = join(home, '.claude', 'settings.json')
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
const command = findStrings(settings).find(
(value) => value.includes('claude-hook.sh') && value.includes('if [ -f ')
)
if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) {
if (
!command ||
!command.includes('"$HOME/.orca/agent-hooks/claude-hook.sh"') ||
!command.includes('] && [ -r ') ||
!command.includes('else { command -p cat')
) {
throw new Error('Electron did not install the guarded Claude launcher')
}
const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-'))
try {
const missingPath = join(scratch, 'missing-hook.sh')
const missingResult = await runShell(
rewriteLauncherScriptPath(command, missingPath),
command,
payload,
withoutOrcaEnvironment({ HOME: home })
withoutOrcaEnvironment({ HOME: scratch })
)
assertSuccessfulWrite(missingResult, 'installed missing-script launcher')
const failingPath = join(scratch, 'failing-hook.sh')
const failingPath = join(scratch, '.orca', 'agent-hooks', 'claude-hook.sh')
mkdirSync(join(scratch, '.orca', 'agent-hooks'), { recursive: true })
writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8')
chmodSync(failingPath, 0o755)
const failingResult = await runShell(
rewriteLauncherScriptPath(command, failingPath),
command,
payload,
withoutOrcaEnvironment({ HOME: home })
withoutOrcaEnvironment({ HOME: scratch })
)
if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) {
throw new Error('Installed launcher did not preserve a running script failure')
+23 -4
View File
@@ -27,6 +27,12 @@ const checks = [
],
enabled: true
},
{
name: 'Linux snapshot renderer tests',
command: 'python3',
args: ['native/computer-use-linux/runtime_render_test.py'],
enabled: true
},
{
name: 'native provider argument guardrails',
run: verifyNativeArgumentGuardrails,
@@ -69,6 +75,19 @@ const checks = [
run: verifyWindowsProviderHandshake,
enabled: process.platform === 'win32'
},
{
name: 'Windows snapshot renderer tests',
command: 'powershell.exe',
args: [
'-NoLogo',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
'native/computer-use-windows/runtime-render.test.ps1'
],
enabled: process.platform === 'win32'
},
{
name: 'macOS helper app bundle and signature',
run: verifyMacOSHelperApp,
@@ -470,11 +489,11 @@ function verifyNativeArgumentGuardrails() {
function powerShellFunctionBody(source, name) {
const start = source.indexOf(`function ${name}`)
if (start < 0) {
if (start === -1) {
return null
}
const bodyStart = source.indexOf('{', start)
if (bodyStart < 0) {
if (bodyStart === -1) {
return null
}
let depth = 0
@@ -494,11 +513,11 @@ function powerShellFunctionBody(source, name) {
function swiftFunctionBody(source, name) {
const start = source.indexOf(`func ${name}`)
if (start < 0) {
if (start === -1) {
return null
}
const bodyStart = source.indexOf('{', start)
if (bodyStart < 0) {
if (bodyStart === -1) {
return null
}
let depth = 0
@@ -153,6 +153,7 @@ function prepareRelayTree(runDir, nodePtyDir) {
for (const filename of [
'relay.js',
'relay-watcher.js',
'relay-ai-vault-service.js',
'managed-hook-runtime.js',
NODE_PTY_PATCH_FILENAME,
'.version'
@@ -0,0 +1,117 @@
#!/usr/bin/env node
// Run with: node config/scripts/worktree-base-pending-marker-benchmark.mjs
import fs from 'node:fs'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { syncBuiltinESMExports } from 'node:module'
import os from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { createJiti } from 'jiti'
const CANDIDATE_COUNT = Number(process.env.ORCA_PENDING_MARKER_BENCH_CANDIDATES ?? '64')
const TOTAL_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_TICKS ?? '900')
const STEADY_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_STEADY_TICKS ?? '300')
for (const [name, value] of [
['ORCA_PENDING_MARKER_BENCH_CANDIDATES', CANDIDATE_COUNT],
['ORCA_PENDING_MARKER_BENCH_TICKS', TOTAL_TICKS],
['ORCA_PENDING_MARKER_BENCH_STEADY_TICKS', STEADY_TICKS]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
if (STEADY_TICKS >= TOTAL_TICKS) {
throw new Error('ORCA_PENDING_MARKER_BENCH_STEADY_TICKS must be smaller than total ticks')
}
const root = await mkdtemp(join(os.tmpdir(), 'orca-pending-marker-bench-'))
const markerPaths = new Set()
for (let index = 0; index < CANDIDATE_COUNT; index += 1) {
const candidate = join(root, `ordinary-folder-${index}`)
await mkdir(candidate)
markerPaths.add(join(candidate, '.git'))
}
const originalStat = fs.promises.stat
let visibleTick = 0
const markerStatTicks = []
fs.promises.stat = async (path, ...args) => {
if (markerPaths.has(String(path))) {
markerStatTicks.push(visibleTick)
}
return originalStat(path, ...args)
}
syncBuiltinESMExports()
let poller
let timeout
try {
const jiti = createJiti(import.meta.url)
const { startWorktreeBaseDirectoryPoller } = await jiti.import(
'../../src/main/ipc/worktree-base-directory-poller.ts'
)
const repo = { repoId: 'repo-1', repoName: 'repo', nestWorkspaces: false }
const target = {
key: `base:local:${root}`,
kind: 'base',
path: root,
repos: new Map([[repo.repoId, repo]])
}
let finish
let fail
const completed = new Promise((resolve, reject) => {
finish = resolve
fail = reject
})
timeout = setTimeout(() => fail(new Error('poller benchmark timed out')), 30_000)
const startedAt = performance.now()
poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
() => {},
{
pollIntervalMs: 0,
visibility: {
isWindowVisible: () => {
visibleTick += 1
if (visibleTick > TOTAL_TICKS) {
finish()
return false
}
return true
},
onWindowBecameVisible: () => () => {}
}
}
)
await completed
clearTimeout(timeout)
await poller.unsubscribe()
poller = undefined
const steadyStartTick = TOTAL_TICKS - STEADY_TICKS
const steadyMarkerStats = markerStatTicks.filter((tick) => tick > steadyStartTick).length
const totalMarkerStats = markerStatTicks.length
const statsPerCandidateTick = steadyMarkerStats / CANDIDATE_COUNT / STEADY_TICKS
const elapsedMs = performance.now() - startedAt
console.log('Worktree-base marker cooldown benchmark. Lower is better.')
console.log(
JSON.stringify({
candidates: CANDIDATE_COUNT,
totalTicks: TOTAL_TICKS,
steadyTicks: STEADY_TICKS,
totalMarkerStats,
steadyMarkerStats,
statsPerCandidateTick: Number(statsPerCandidateTick.toFixed(4)),
elapsedMs: Number(elapsedMs.toFixed(1))
})
)
} finally {
clearTimeout(timeout)
await poller?.unsubscribe()
fs.promises.stat = originalStat
syncBuiltinESMExports()
await rm(root, { recursive: true, force: true })
}
@@ -0,0 +1,143 @@
#!/usr/bin/env node
import { performance } from 'node:perf_hooks'
import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree-sort-order-update.ts'
import { reuseEqualCatalogRows } from '../../src/renderer/src/store/slices/worktree-catalog-reconciliation.ts'
const WORKTREE_COUNT = 655
const TAB_COUNT = 1_895
const REFRESH_COUNT = 10
const ROUNDS = 15
const BATCHES_PER_ROUND = 25
function makeFixture() {
const worktrees = Array.from({ length: WORKTREE_COUNT }, (_, index) => ({
id: `repo-${index % 15}::/workspace/${index}`,
sortOrder: WORKTREE_COUNT - index,
lastActivityAt: index % 19,
isArchived: false
}))
const tabs = Array.from({ length: TAB_COUNT }, (_, index) => ({
worktreeId: worktrees[index % worktrees.length].id,
live: index % 3 === 0
}))
return { worktrees, tabs }
}
function runSidebarProjection(worktrees, tabs) {
const liveTabsByWorktree = new Map()
for (const tab of tabs) {
if (tab.live) {
liveTabsByWorktree.set(tab.worktreeId, (liveTabsByWorktree.get(tab.worktreeId) ?? 0) + 1)
}
}
const sorted = [...worktrees].sort(
(left, right) =>
(liveTabsByWorktree.get(right.id) ?? 0) - (liveTabsByWorktree.get(left.id) ?? 0) ||
right.lastActivityAt - left.lastActivityAt ||
right.sortOrder - left.sortOrder
)
return sorted.map((worktree) => worktree.id)
}
function runBaseline() {
const fixture = makeFixture()
const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree]))
const requestedIds = fixture.worktrees.map((worktree) => worktree.id)
let catalog = fixture.worktrees
let publications = 0
let persistenceWrites = 0
for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) {
const now = 1_000_000 + refresh
requestedIds.forEach((worktreeId, index) => {
metaById.set(worktreeId, {
...metaById.get(worktreeId),
sortOrder: now - index * 1000
})
persistenceWrites += 1
})
const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId)))
const reconciled = reuseEqualCatalogRows(catalog, incoming)
if (reconciled !== catalog) {
catalog = reconciled
publications += 1
runSidebarProjection(catalog, fixture.tabs)
}
}
return { publications, persistenceWrites }
}
function runCandidate() {
const fixture = makeFixture()
const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree]))
const requestedIds = fixture.worktrees.map((worktree) => worktree.id)
let catalog = fixture.worktrees
let publications = 0
let persistenceWrites = 0
for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) {
const updates = planWorktreeSortOrderUpdates(
requestedIds,
(worktreeId) => metaById.get(worktreeId),
1_000_000 + refresh
)
for (const update of updates) {
metaById.set(update.worktreeId, {
...metaById.get(update.worktreeId),
sortOrder: update.sortOrder
})
}
persistenceWrites += updates.length
const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId)))
const reconciled = reuseEqualCatalogRows(catalog, incoming)
if (reconciled !== catalog) {
catalog = reconciled
publications += 1
runSidebarProjection(catalog, fixture.tabs)
}
}
return { publications, persistenceWrites }
}
function measureBatch(run) {
let counters
const startedAt = performance.now()
for (let batch = 0; batch < BATCHES_PER_ROUND; batch += 1) {
counters = run()
}
return { elapsedMs: (performance.now() - startedAt) / BATCHES_PER_ROUND, counters }
}
function measureComparison() {
runBaseline()
runCandidate()
const samples = Array.from({ length: ROUNDS }, (_, round) => {
let baseline
let candidate
if (round % 2 === 0) {
baseline = measureBatch(runBaseline)
candidate = measureBatch(runCandidate)
} else {
candidate = measureBatch(runCandidate)
baseline = measureBatch(runBaseline)
}
return { baseline, candidate, ratio: candidate.elapsedMs / baseline.elapsedMs }
}).sort((left, right) => left.ratio - right.ratio)
return samples[Math.floor(samples.length / 2)]
}
const { baseline, candidate } = measureComparison()
if (baseline.counters.publications !== REFRESH_COUNT || candidate.counters.publications !== 0) {
throw new Error('Benchmark fixture no longer reproduces the refresh feedback loop')
}
console.log(
`Worktree refresh churn: ${WORKTREE_COUNT} worktrees, ${TAB_COUNT} tabs, ${REFRESH_COUNT} refreshes`
)
console.log(
`Before: ${baseline.elapsedMs.toFixed(3)} ms, ${baseline.counters.publications} catalog publications, ${baseline.counters.persistenceWrites} rank writes`
)
console.log(
`After: ${candidate.elapsedMs.toFixed(3)} ms, ${candidate.counters.publications} catalog publications, ${candidate.counters.persistenceWrites} rank writes`
)
console.log(
`Reduction: ${((1 - candidate.elapsedMs / baseline.elapsedMs) * 100).toFixed(1)}% CPU, 100% feedback publications`
)
+1 -1
View File
@@ -175,7 +175,7 @@ async function main() {
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)])
)
const markerOffset = result.stdout.indexOf(outputMarker)
if (markerOffset < 0) {
if (markerOffset === -1) {
throw new Error('Login shell did not emit the Git output marker')
}
result.stdout = result.stdout.subarray(markerOffset + Buffer.byteLength(outputMarker))
+3
View File
@@ -12,6 +12,8 @@
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
"../src/main/agent-hooks/runtime-home-hook-command.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
@@ -74,6 +76,7 @@
"../src/main/openclaude/hook-service.ts",
"../src/main/rolling-file-backup.ts",
"../src/main/startup/hydrate-shell-path.ts",
"../src/main/startup/windows-shell-path-ownership.ts",
// Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this
// project's serve spec; the module has no imports, so listing it pulls in nothing else.
"../src/main/startup/serve-mode-argv.ts",
+2 -1
View File
@@ -16,7 +16,8 @@ export default defineConfig({
test: {
environment: 'node',
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
execArgv: ['--no-experimental-webstorage'],
// Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds.
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
include: [
+4 -4
View File
@@ -67,9 +67,9 @@ A common point of drift. Use these conventions for any list-style row (worktrees
- **Idle:** transparent background.
- **Hover:** `bg-accent` (in the worktree sidebar, `bg-sidebar-accent`).
- **Keyboard-selected (cmdk highlight):** `data-[selected=true]:bg-accent` plus a `border-border` outline so the active row stays visible while the user types. The `data-selected` attribute is set by `cmdk` automatically.
- **Keyboard-selected (cmdk highlight):** do **not** rely on flat `bg-accent` alone on light popover/dialog surfaces — `--accent` (#f5f5f5) is nearly identical to `--background` (#fff), so the cursor vanishes. Use the jump-palette recipe in `main.css` (`.jump-palette-item[data-selected='true']`): `color-mix` foreground into background (~12%) plus an inset ring. Expose the mix as `--jump-palette-selection-surface` when nested cutouts (status pips) must match. The `data-selected` attribute is set by `cmdk` automatically.
- **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, _plus_ a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight.
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. The accent token already adapts to light/dark and matches the rest of the app.
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. Mix from existing tokens (`foreground`/`background`/`accent`) so light/dark stay aligned.
### Color mixing
@@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you'
Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern:
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner` and `repo-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
- Use `cn()` for class merging. Pass user `className` last so callers can override.
- Use `class-variance-authority` (CVA) for variants when there are multiple.
@@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork:
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
| Single choice from a known list | `Select` | Custom listbox |
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one |
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
+596
View File
@@ -0,0 +1,596 @@
# AI Vault process isolation architecture and implementation plan
Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete.
Last updated: 2026-08-09.
## Decision summary
AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary.
The target has three rules:
1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process.
2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data.
3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer.
This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified.
The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal.
## Goals
- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host.
- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active.
- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time.
- The service can be restarted without restarting the app, runtime, relay, or terminals.
- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible.
- Mixed-version remote peers continue to work.
- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services.
## Non-goals
- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity.
- Rewriting the scanner or changing session discovery semantics during the process migration.
- Moving xterm into another renderer.
- Adding a process per window, worktree, repository, or SSH request.
- Removing the old-relay compatibility fallback in the same change.
- Making pagination a public wire dependency before mixed-version fallback behavior exists.
## Current architecture and coupling
| Path | Current execution | Remaining coupling |
| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main |
| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host |
| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process |
| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay |
| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm |
The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API.
The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign.
## Feature-parity contract
The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics.
| Capability | Required behavior after migration | Owner |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters |
| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today |
| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection |
| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router |
| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them |
| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation |
| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing |
| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate |
| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate |
| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result |
| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration |
| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success |
| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling |
| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports |
Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow.
No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list.
## Target topology
```text
Desktop / runtime host
Integrated renderer
├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon
└─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process
│
└─ host merge + compatibility routing
SSH execution host
Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar
└────────────────── PTY handler/event loop
```
The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar.
## Process ownership
- Desktop: one Vault service per Electron main process, shared by every window and workspace.
- Orca runtime: one Vault service per runtime host process, shared by all connected clients.
- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests.
- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration.
- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop.
## Service responsibilities
The service owns:
- Source discovery and filesystem metadata reads.
- Transcript streaming and parsing for all supported agents.
- OpenCode SQLite reads and its nested worker lifecycle.
- In-memory and persisted session parse caches.
- The bounded title index and transcript title resolution.
- On-demand Claude/OMP subagent listing and full first-user-prompt extraction.
- Scan parse concurrency and cooperative cancellation.
- Result construction, deduplication, sorting, and internal paging.
- Per-request metrics that contain counts and timings, not session content.
The router owns:
- Electron IPC, runtime RPC, or relay method registration.
- Authentication and connection ownership already enforced by that transport.
- Public argument validation and execution-host selection.
- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration.
- Multi-host result merging.
- Request coalescing and mapping caller cancellation to the service request.
- Validation of service responses before publishing them across a public boundary.
- Compatibility fallback selection.
- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation.
The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication.
## Internal process protocol
Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol.
Parent to service:
```ts
type ServiceRequest =
| { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions }
| { type: 'request'; id: number; operation: 'scan'; options: ScanOptions }
| { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] }
| { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest }
| { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest }
| { type: 'invalidate'; paths: string[]; generation: number }
| { type: 'cancel'; id: number }
| { type: 'shutdown' }
```
Service to parent:
```ts
type ServiceResponse =
| { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities }
| { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics }
| { type: 'result'; id: number; operation: 'titles'; value: TitlesResult }
| { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult }
| { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult }
| { type: 'invalidated'; generation: number }
| { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean }
| { type: 'fatal'; code: ServiceErrorCode; message: string }
```
Protocol rules:
- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch.
- Use monotonically increasing safe-integer request IDs scoped to the child lifetime.
- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth.
- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes.
- Coalesce equivalent scans. Replace an older queued background scan with the newest one.
- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does.
- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy.
- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck.
- Continue returning the last good renderer result when a refresh fails.
- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state.
- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented.
The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers.
## Lifecycle and supervision
### Startup
- Start lazily on the first scan or title request.
- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader.
- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows.
- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready.
- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child.
### Steady state
- Reuse the process and caches across calls.
- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency.
- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal.
- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive.
- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper.
### Faults and restart
- Reject the active request on exit, disconnect, malformed protocol, or timeout.
- Retry queued work in a new process only when it is safe and has not been cancelled.
- Back off at 250 ms, 1 second, then 5 seconds.
- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt.
- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely.
## Resource and quality-of-service policy
The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness.
- CPU: below-normal process priority, scan concurrency eight, one scan at a time.
- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry.
- Queue: 16 calls, coalescing, background replacement, explicit priority.
- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior.
- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve.
- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending.
- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap.
Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation.
## Renderer publication
`ai-vault-session-refresh.ts` should stop applying a completed result immediately.
Add `ai-vault-session-publication-gate.ts` with this behavior:
1. Cache the validated result immediately so another caller can reuse it.
2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`.
3. Otherwise retain only the newest pending result and wait for quiet.
4. Publish after 1 second even if input continues, using a transition and one bounded page.
5. Cancel a pending publication when the scope, host, or component request token changes.
Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel.
A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization.
## Desktop and runtime implementation
Create these concrete modules:
- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown.
- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy.
- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions.
- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution.
- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes.
- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy.
Reuse the scanner and cache modules without moving their business logic. Replace imports in:
- `src/main/ai-vault/cached-session-list.ts`
- `src/main/ai-vault/session-title-resolver.ts`
- `src/main/ipc/ai-vault-subagent-list.ts`
- `src/main/ai-vault/session-first-user-prompt-read.ts`
- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete
- `src/main/ipc/ai-vault.ts`
- `src/main/runtime/rpc/methods/ai-vault.ts`
Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period.
Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration.
## SSH relay sidecar implementation
Create:
- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader.
- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter.
- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports.
Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process.
`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness.
If the sidecar cannot start or crashes:
- Return a normal `AiVaultListResult` containing a host issue and no new sessions.
- Do not scan inline in the relay.
- Keep all PTYs, reconnect state, and other relay methods available.
- Allow the next manual refresh to cross the restart circuit breaker once.
Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals.
## Remote compatibility
The first implementation adds no public method, stream opcode, required field, or changed result meaning.
- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar.
- New desktop with current relay: calls the same methods and receives the same result shape.
- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback.
- Old desktop with new relay: unknown internal sidecar details never cross the wire.
Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability.
The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision.
## Folder workspaces, WSL, and paths
- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata.
- Continue using `path.join` and the existing host-platform path adapters.
- Keep Windows batch and shell selection unrelated to terminal-shell preference.
- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows.
- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths.
- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript.
- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected.
## Security boundary
The service is a performance and reliability boundary, not a privilege boundary.
- It has the same OS user and must access the same agent stores.
- Do not expose its IPC channel to renderer code or the network.
- The parent passes validated options; the service validates again before filesystem access.
- Do not log prompts, titles, transcript paths, environment values, or raw service messages.
- Preserve symlink and allowed-root checks for title reads.
- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further.
## Packaging
Desktop packaging changes:
- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`.
- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`.
- Reuse the existing unpacked entry-path pattern used by other forked processes.
- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down.
Relay packaging changes:
- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture.
- Include it in `.version` content hashing and deploy completeness assertions.
- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together.
- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier.
## Observability
Add spans and counters for:
- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit.
- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls.
- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count.
- Parent/child serialization duration and serialized bytes.
- Child RSS and heap-used snapshots at request completion.
- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count.
- Relay sidecar failures separately from PTY and relay-loop health.
Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs.
## Verification matrix
The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable.
| Layer | Required coverage |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts |
| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ |
| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush |
| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown |
| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP |
| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior |
| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions |
| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state |
| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency |
| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata |
The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals.
## Failure behavior
| Failure | User-visible Vault behavior | Terminal behavior |
| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- |
| Service fails to start | Keep last result and show a retryable host issue | Unchanged |
| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged |
| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged |
| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged |
| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged |
| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available |
| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged |
| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains |
## Benchmark methodology
The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home.
Each iteration:
1. Keeps the Vault panel mounted in both arms.
2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home.
3. Alternates arm order to reduce warm-up bias.
4. Runs paced raw-mode terminal echo typing as the control.
5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment.
6. Verifies the newest seeded title is visible, proving the full scan and UI path completed.
The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key.
Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory.
## Current measurements
Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work.
Pre-implementation parity baseline:
- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts.
- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests.
- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes.
### Representative pass
Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data.
| Metric | Control | Refresh active |
| ------------------------------ | -------------------: | ------------------: |
| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms |
| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms |
| Worst timer drift | 14.2 ms | 24.5 ms |
| Worst frame gap | 9.4 ms | 24.9 ms |
| Renderer long tasks over 50 ms | 0 | 0 |
| Missing echoes | 0 / 300 | 0 / 300 |
| Refresh duration p50 / max | — | 103.5 / 204.2 ms |
There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration.
### Stress pass
Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data.
| Metric | Control | Refresh active |
| ------------------------------ | ------------------: | -------------------: |
| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms |
| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms |
| Worst timer drift | 9.3 ms | 43.6 ms |
| Worst frame gap | 9.4 ms | 50.0 ms |
| Renderer long tasks over 50 ms | 0 | 0 |
| Missing echoes | 0 / 240 | 0 / 240 |
| Refresh duration p50 / max | — | 276.3 / 453.5 ms |
The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer.
### Measurement limits
- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior.
- The benchmark covers the local service path, not the currently unisolated SSH relay path.
- Raw shell echo is a stable latency probe, not a full-screen TUI workload.
- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost.
## Post-implementation A/B
Run the same build with the rollout switch off and on, alternating launch order:
```bash
ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control
ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment
```
Run at least five representative and five stress passes on:
- macOS Apple Silicon.
- Windows x64 with a folder workspace and a WSL-backed source present.
- Ubuntu 20.04-compatible x64 packaging/runtime.
- A 4-core or smaller machine or constrained CI runner.
Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy.
## Implemented result
The implementation now matches the target topology:
- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback.
- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry.
- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue.
- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes.
- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second.
- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker.
Local validation on 2026-08-09:
- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully.
- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane.
- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone.
- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled.
Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence.
| Metric | Worker control | Worker refresh | Process control | Process refresh |
| --------------------- | -------------: | -------------: | --------------: | --------------: |
| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms |
| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms |
| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms |
| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms |
| Long tasks over 50 ms | 0 | 0 | 0 | 0 |
| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 |
| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms |
The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness.
Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable.
## Acceptance criteria
Correctness:
- Zero missing terminal echoes.
- The latest seeded Vault session is visibly published in every treatment arm.
- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner.
- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics.
Performance:
- Treatment parse p95 is no more than 10 ms above its paired control.
- Treatment render p95 is no more than 15 ms above its paired control.
- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms.
- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run.
- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes.
Isolation:
- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon.
- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods.
- The service respects queue, timeout, idle, and heap bounds.
- Packaged app and relay artifacts contain and launch the correct entry for every supported platform.
## Phased implementation plan
### Phase 0 — measurement harness (completed in this change)
- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes.
- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`.
- Capture representative and stress baselines.
Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes.
### Phase 1 — desktop and runtime service behind a switch
- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor.
- Reuse current scanner/cache implementations.
- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled.
- Keep the worker path as the disabled-switch fallback.
- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution.
- Add E2E fault injection that kills the service during active terminal typing.
Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets.
### Phase 2 — make the desktop/runtime process the default
- Enable the process by default in development, E2E, and canary builds.
- Collect process RSS, restart, timeout, and latency telemetry without content.
- Validate Windows, Linux, WSL, and constrained-runner results.
- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle.
Exit: no unexplained crash or timeout increase and all acceptance gates green.
### Phase 3 — SSH relay sidecar
- Add the relay sidecar entry and supervisor.
- Remove scanner/parser/cache imports from the relay handler.
- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test.
- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests.
- Make inline relay scanning impossible; sidecar failure returns a host issue.
Exit: remote typing and fault gates green; current public relay methods unchanged.
### Phase 4 — renderer quiet publication
- Add the shared quiet-publication gate and React transition.
- Retain only the newest pending result per scope/host.
- Add supersession, starvation timeout, unmount, and rapid-host-switch tests.
- Re-run the Electron benchmark with large result counts and active filtering/grouping.
Exit: no Vault-attributable renderer long task over 50 ms in the representative gate.
### Phase 5 — hardening and cleanup
- Remove the worker implementation after one stable release if the kill switch was unused.
- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry.
- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate.
- Document operational recovery and service diagnostics.
Exit: kill switch and dead worker code removed only after evidence supports it.
## Rollback
- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data.
- Renderer: disable quiet publication and apply the validated complete result directly.
- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails.
- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion.
## Confidence and remaining questions
Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation.
The remaining implementation questions are deliberately narrow:
1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on.
2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry.
3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets.
4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 17m">
<title>downloads: 17m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 19m">
<title>downloads: 19m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">17m</text>
<text x="90" y="14">17m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">19m</text>
<text x="90" y="14">19m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
+2 -2
View File
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android :** [Télécharger l'APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android :** [Télécharger l'APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
+18 -3
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -179,12 +179,14 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
<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;
@@ -211,6 +213,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
- **[onOrca.dev에서 다운로드](https://onorca.dev/download)**
- 또는 빌드를 직접 받기: [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) · [전체 빌드](https://github.com/stablyai/orca/releases/latest)
- headless Linux 서버에서 `orca serve`를 실행하시나요? [Headless Linux 서버 가이드](../reference/headless-linux-server.md)를 확인하세요.
_또는 패키지 매니저로 설치:_
@@ -226,8 +229,8 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [APK 0.0.42 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
---
@@ -235,6 +238,10 @@ yay -S stably-orca-bin
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 7에 참여하세요.
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 커뮤니티 WeChat 그룹 7 QR 코드" width="160" />
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요.
@@ -249,6 +256,14 @@ yay -S stably-orca-bin
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca 기여자" />
</a>
<p align="center">
<img src="../assets/star-history.png" alt="stablyai/orca GitHub Star 히스토리 차트" width="880" />
</p>
## 서명된 빌드
Windows 코드 서명은 [SignPath.io](https://signpath.io)가 후원·제공하며, 인증서는 [SignPath Foundation](https://signpath.org)에서 제공합니다.
## 라이선스
Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다.
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [Baixar APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
@@ -0,0 +1,328 @@
# Renderer agent-status performance
## Status
This design is adopted for the renderer's high-frequency agent-status path. It
keeps the existing event semantics while bounding the amount of synchronous
store fanout performed for one IPC burst.
It lands in slices. This document and the `bench:idle-cpu` harness land first so
the store changes can be reviewed against a baseline someone else measured. Until
the store slice lands, the `setAgentStatuses` / `transactAgentStatuses` actions
and the agent-status write workload described below are not yet on `main`; the
harness measures scale, listener census, and raw publication fanout only.
## Context
Orca can display a large expanded worktree lineage inside one virtualized list
row. Virtualizing the root row does not virtualize its descendants, so a
100-worktree lineage can mount 100 `WorktreeCard` instances at once.
Agent-status IPC events are bursty. The renderer already groups live events into
a 33 ms window, but the original flush applied every queued event with a
separate Zustand write. Zustand synchronously visits every listener for every
publication. The resulting work therefore grew with both the number of status
events and the number of mounted subscriptions:
```text
burst work ~= status events x store listeners x selector work
```
A production trace captured the renderer repeatedly entering
`flushLiveAgentStatusBurst -> applyAgentStatus -> setAgentStatus -> setState`
through `Set.forEach`. A deterministic 100-worktree fixture reproduces the
structural multiplier; see "Baseline on `main`" below for the currently measured
listener count and publication cost.
The production app later recovered substantially when all configured remote
hosts were removed. Host removal can stop relay/reconnect traffic, remove
mounted remote worktrees, or both, depending on host type and removal options.
That observation identifies remote presence as the production trigger but does
not by itself distinguish traffic volume from mounted-listener fanout.
A read-only reconnect audit ruled out systematic double status emission from a
full PTY replay: replay bytes bypass OSC status parsing. Reconnect still causes
a full terminal-buffer repaint for every attached remote pane, which is a
separate source of renderer work and remains a follow-up investigation.
## Goals
- Keep a large expanded lineage responsive during dense agent-status traffic.
- Preserve every ordered status transition, including repeated updates for one
pane inside the same burst.
- Publish agent-status state once for a deferred live burst.
- Preserve selector identity and child render isolation.
- Make the regression reproducible without relying on a user's production data.
## Non-goals
- Changing the client/server status payload or remote protocol.
- Deduplicating status events by pane.
- Changing agent freshness, retention, history, title, completion, or provider
session behavior.
- Changing remote reconnect, PTY replay, or terminal repaint behavior.
- Redesigning lineage presentation or collapsing worktrees automatically.
## Design
### Bound mounted subscription fanout
Sidebar components select cohesive state bundles with shallow equality instead
of registering one listener per field. Derived arrays and maps retain their
existing shallow identity behavior so unrelated store writes do not rerender a
card. Full agent-list mode keeps its child-level subscription boundary; compact
mode passes the already selected rows to avoid selecting the same inputs twice.
The deterministic 100-worktree fixture pins the resulting listener budget:
| Surface | Listener budget |
| ------------------------------ | --------------: |
| Worktree card state and caches | 2 |
| Agent-row inputs | 1 |
| Worktree activity status | 1 |
| Closed context menu | 1 |
Unmount tests require the listener count to return to its prior baseline. In the
bundled prototype, the fixture without seeded agents fell from 8,518 listeners
to 1,218; with 100 visible agent rows the candidate mounted 1,618. Compare
against the census in "Baseline on `main`", which the harness reports directly.
### Share working-spinner phase without per-element animation queries
Working rows keep the existing compositor-driven CSS animation and shared
visual phase. Each mount derives one negative animation delay from the document
timeline instead of querying `getAnimations()` and mutating the animation start
time. This removes per-row Web Animations setup from dense status transitions
without adding a JavaScript animation clock.
### Fold a burst in event order
The store exposes the single-update action and two batch forms:
- `setAgentStatus(paneKey, payload, ...)` retains the positional single-update
API for the immediate live path.
- `setAgentStatuses(updates)` applies a prebuilt ordered list, while
`transactAgentStatuses(operation)` lets IPC derive each update against the
exact staged state before the single commit.
Both entry points reuse the same single-update state transition. The batch
reducer passes each resulting state into the next update, so a sequence such as
`working -> waiting -> done` retains the same history and timestamps as three
sequential calls. Updates are never keyed or deduplicated before the fold.
The live IPC queue is spliced before it is processed. This preserves the
existing reentrancy guarantee: a synchronous subscriber can enqueue another
event without causing the current queue to be drained recursively. The first
event outside an active burst remains immediate; events accumulated within the
33 ms window are applied as one ordered transaction. Startup snapshots and
bounded pending-hydration retries use the same transaction path instead of
publishing once per restored pane.
Each transaction builds pane-routing ownership once with the same first-match
semantics as the standalone resolver. Split-layout leaf membership is indexed
once per layout root, so a large snapshot performs linear tab and leaf work
instead of rescanning every mounted worktree for every pane.
### Run effects after the transaction
Generated-title work that requires committed state is deferred until after the
transaction. Accepted updates also request freshness scheduling; the outer batch
coalesces those requests and schedules the shared freshness timer once after its
single commit. Generated-title requests are folded in event order and published
together, including first-write and forced-replacement semantics. Resolved tab
titles are projected while the transaction folds, then final title changes are
published together. Completion-triggered review refreshes remain deferred
microtasks.
Bulk title application preserves event order and duplicate-tab behavior while
indexing owners once, cloning each changed owner array once, and replacing each
top-level map once. This keeps the post-commit title phase linear in mounted
tabs plus changed titles.
This separation is important: invoking store actions from inside a Zustand
updater would re-enter the store, while running an effect before the commit
would let it observe stale state.
## Semantic invariants
Sequential and batched application must agree on:
- live and retained agent maps;
- state history, `updatedAt`, and `stateStartedAt`;
- agent identity, model, prompt, tools, assistant messages, and subagents;
- orchestration and provider-session continuity;
- sleeping-session and launch-config recovery records;
- retired/closed-pane rejection and inherited-status suppression;
- retention cleanup and live-map eviction;
- `agentStatusEpoch` and `sortEpoch`;
- automation completion observation across intermediate transitions;
- generated-title inputs, freshness scheduling, and completion refreshes.
Equivalence tests use fixed timestamps and include repeated same-pane
transitions. A publication-count test subscribes to the real store and requires
one notification for a non-empty batch and none for an empty batch.
## Benchmark contract
The benchmark launches an E2E-mode Electron build with the store exposed only
for instrumentation. It creates 100 worktrees in one expanded lineage, verifies
100 mounted cards, captures the store listener census, and then applies seeded
ordered agent-status traffic through the real store action.
The benchmark measures the synchronous store action, not the live IPC leading
edge or post-commit notification path. A real-store snapshot test covers the
end-to-end budget for 100 panes with auto-generated titles enabled: one status,
one bulk generated-title, and one bulk resolved-title publication. Disabling
generated titles removes that middle publication, independent of pane count.
The artifact records only fixed diagnostic fields needed for comparison:
- requested and completed batches and updates;
- store action calls and observed publications;
- elapsed time, throughput, and scheduling drift;
- final-state verification;
- renderer mean, p95, and maximum CPU;
- renderer timer drift and long tasks;
- mounted-card and listener counts.
Raw process inventories, temporary paths, pane identifiers, and DOM text are
diagnostic-only and must not be embedded in the shareable report.
Run baseline and candidate on the same machine and OS with the same Electron
build mode. CPU samples from macOS and Linux are comparable within that
constraint; Windows process CPU collection currently cannot support this
comparison.
## Harness
`pnpm run bench:idle-cpu` drives `config/scripts/run-idle-cpu-benchmark.mjs`,
which composes four modules:
| Module | Responsibility |
| ------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `idle-cpu-renderer-scale-fixture.mjs` | Seeds the lineage, agent rows, and sidebar view state; takes the mounted-card and listener census |
| `idle-cpu-renderer-timing-probe.mjs` | In-page timer drift and long-task probe; runs the no-op publication workload |
| `idle-cpu-process-sampling.mjs` | Classifies the Electron process tree and samples per-role CPU/RSS |
| `idle-cpu-synthetic-spinners.mjs` | Measurement-only visible spinners |
The sampling window extends past `--sample-ms` until the workload settles, and
fails the run rather than reporting a truncated window if the workload overruns
the guard. That is why a 2,000-publication run reports a measured window longer
than the requested one.
`--zustand-publications` publishes an empty partial through the real store, so
each publication costs exactly one full subscriber visit and nothing else. It
isolates the `listeners x selector work` half of the burst-cost model from
agent-status payload work, and it is store-API independent — it measures the
same thing before and after the batching slice.
The agent-status write workload (`--agent-status-batches`,
`--agent-status-write-mode`) is not in this harness yet. It depends on
`setAgentStatuses`, so it lands with the store slice.
## Baseline on `main`
Measured on `main` at `077f5a11cd4` (macOS, arm64, 16 CPUs), Electron built with
`electron-vite --mode e2e`, headless, 100 worktrees at lineage depth 99 with 100
seeded agent rows, 10 s warmup and a 30 s sampling window.
Fixture scale is confirmed by the census rather than assumed: 100 store
worktrees, 100 mounted cards, 100 mounted agent rows, and **9,279 store
listeners**. That listener count is the multiplier the design targets.
2,000 no-op store publications at a 1 ms cadence, three repetitions. The
listener census was 9,279 in every run.
| Measure | Median | Runs |
| ------------------------ | ----------: | ------------------------------ |
| Wall time to complete | 12,325.7 ms | 13,148.6 / 12,325.7 / 11,870.5 |
| p50 scheduling drift | 5,150.3 ms | 5,432.3 / 5,150.3 / 4,945.2 |
| p95 scheduling drift | 9,802.9 ms | 10,570.4 / 9,802.9 / 9,390.0 |
| Renderer mean CPU | 18.25% | 20.25 / 18.25 / 15.88 |
| Renderer p95 CPU | 32.59% | 40.07 / 32.59 / 31.28 |
| Renderer timer drift p95 | 7.0 ms | 7.3 / 5.3 / 7.0 |
2,000 publications requested over 2 s take about 12 s, so the renderer sustains
roughly 160 publications per second at this scale. Each publication is
individually short - the long-task observer recorded zero entries in all three
runs - so the cost surfaces as scheduling drift and sustained CPU rather than as
discrete long tasks. Compare drift and CPU here, not long-task counts.
The idle control at the same scale with `--zustand-publications 0` reports 6.63%
renderer mean CPU, 17.11% p95, and 1.6 ms p95 timer drift. Roughly 11.6 points
of mean renderer CPU are therefore attributable to publication fanout rather than
to the mounted fixture itself. 200 spinner animations run in both cases, so the
control also bounds the animation cost out of the comparison.
Reproduce with:
```bash
pnpm run bench:idle-cpu -- --worktrees 100 --lineage-depth 99 \
--agents-per-worktree 1 --warmup-ms 10000 --sample-ms 30000 \
--zustand-publications 2000 --zustand-publication-interval-ms 1 \
--output /tmp/idle-cpu-baseline.json
```
## Results
Three repetitions used 100 mounted worktrees, lineage depth 99, 100 seeded
agent rows, and verified final state. Medians from the regenerated evidence set
are:
| Single 2,000-update burst | Sequential | Batched |
| ------------------------- | ---------: | ---------: |
| Status-state publications | 2,000 | 1 |
| Store action time | 3,692.0 ms | 188.7 ms |
| Update throughput | 541.7/s | 10,598.8/s |
| Renderer mean CPU | 36.2% | 2.9% |
| Renderer p95 CPU | 107.3% | 8.2% |
| p95 long task | 4,653 ms | 216 ms |
The direct store transaction performs 99.95% fewer status-state publications,
spends 94.9% less time in the store action, and processes updates 19.6 times
faster. Renderer mean CPU falls 92.0%, renderer p95 CPU falls 92.4%, and the p95
long task falls 95.4%.
The 60-burst × 32-update case at 33 ms is a sustained saturation stress, not a
real-time production SLO. Publications fall from 1,920 to 60 and median store
action time falls from 2,791.9 ms to 323.3 ms. Median completion time falls from
5,298.2 ms to 2,710.6 ms, p95 scheduling drift falls from 3,073.0 ms to 664.9
ms, and long-task count falls from 57 to 1. Renderer p95 CPU remains saturated
and noisy in this cadence, so it is not used as the discriminating measure.
The 20-pane artificial OpenCode regression passes with 12.4 ms median key echo,
25.2 ms worst key echo, 19.4 ms maximum timer drift, and zero dropped renderer
backlogs.
These figures come from the bundled prototype and are restated here as the
target. They are re-measured with the harness when the store slice lands.
## Acceptance criteria
- The 100-worktree fixture stays at or below the pinned listener budgets.
- A deferred transaction performs one status-state publication while preserving
ordered final state, including live-map eviction at the 500-row cap.
- A 100-pane startup snapshot performs one status and one bulk resolved-title
publication with generated titles disabled; enabling generated titles adds at
most one ordered bulk publication while preserving final statuses and titles.
- Sequential-versus-batch equivalence tests pass across same-pane transitions
and side-effect-bearing updates.
- Renderer CPU tails and scheduling drift improve in repeated candidate runs.
- The 20-pane artificial terminal test reports no dropped output backlog and no
material typing-latency regression.
- Web typecheck, focused unit tests, lint, max-lines ratchet, and E2E build pass.
## Compatibility
This is renderer-local. It adds no RPC field, stream opcode, persisted data, Git
command, or provider-specific contract. Native, WSL, SSH, relay, folder
workspace, and git-worktree status events enter the same renderer action. Mixed
client/server versions therefore need no capability negotiation.
## Failure containment
The first live event remains immediate. Startup replay and bounded pending
retries fold synchronously without waiting for the 33 ms live-burst window, but
publish their accepted updates together. Empty batches are no-ops. If an update
is stale or targets retired authority, the reducer skips only that update and
continues folding later events in order.
+3
View File
@@ -220,6 +220,9 @@ export const electronViteConfig: UserConfig = {
'session-scanner-worker-entry': resolve(
'src/main/ai-vault/session-scanner-worker-entry.ts'
),
'session-scanner-service-entry': resolve(
'src/main/ai-vault/session-scanner-service-entry.ts'
),
// Why: libuv spawns processes inline on the calling loop, so the port
// scan's probe commands run on a worker thread instead of the UI one.
'port-scan-command-worker-entry': resolve(
-6
View File
@@ -93,12 +93,6 @@
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/terminal-settings.tsx"],
"rules": {
"max-lines": ["error", { "max": 514, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/mock-server.ts"],
"rules": {
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.42",
"version": "0.0.43",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 11
"versionCode": 12
},
"plugins": [
"expo-router",
+4 -131
View File
@@ -106,7 +106,7 @@ import type {
TerminalModes,
TerminalWebViewHandle
} from '../../../../src/terminal/terminal-webview-contract'
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
import { isTerminalOscLinkRanges } from '../../../../../src/shared/terminal-osc-link-ranges'
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import {
@@ -154,7 +154,7 @@ import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
import { TextInputModal } from '../../../../src/components/TextInputModal'
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor'
import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader'
import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments'
import {
CustomKeyModal,
@@ -267,7 +267,6 @@ import {
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} from '../../../../src/session/mobile-session-route-helpers'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
import {
@@ -275,7 +274,7 @@ import {
dismissMobileSessionCreateWarningState,
reconcileMobileSessionCreateWarningState
} from '../../../../src/session/mobile-session-create-warning-state'
import { colors, spacing } from '../../../../src/theme/mobile-theme'
import { colors } from '../../../../src/theme/mobile-theme'
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
import { styles } from '../../../../src/session/mobile-session-styles'
import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types'
@@ -302,132 +301,6 @@ import type {
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function MarkdownReader({
documentId,
doc,
onRefresh,
onChange,
onSave,
onCopy,
onDiscard,
keyboardLift
}: {
documentId: string
doc: MarkdownDocState | undefined
onRefresh: () => void
onChange: (content: string) => void
onSave: () => void
onCopy: () => void
onDiscard: () => void
keyboardLift: number
}) {
// Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
<RefreshCw size={14} color={colors.textPrimary} />
<Text style={styles.markdownRefreshText}>Retry</Text>
</Pressable>
</View>
)
}
const statusText = doc.saveError
? doc.saveError
: doc.readOnlyReason
? 'Read only'
: doc.stale
? 'Changed on desktop'
: null
const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable
const showCopy = doc.saveError || !doc.editable
const showSave = doc.isDirty || doc.saving
const showFloatingActions = statusText || showRefresh || showCopy || showSave
return (
<View style={styles.markdownEditor}>
<MobileRichMarkdownEditor
key={documentId}
content={doc.localContent}
editable={doc.editable && !doc.saving}
onChange={onChange}
onKeyboardInsetChange={setWebviewKeyboardInset}
/>
{showFloatingActions ? (
<View
pointerEvents="box-none"
style={[
styles.markdownFloatingBar,
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
{
bottom: resolveMarkdownFloatingActionsBottom({
keyboardLift: effectiveKeyboardLift,
restingBottom: spacing.lg,
liftedClearance: spacing.md
})
}
]}
>
{statusText ? (
<Text
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
numberOfLines={2}
>
{statusText}
</Text>
) : null}
<View style={styles.markdownFloatingActions}>
{showCopy ? (
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
</Pressable>
) : null}
{showRefresh ? (
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
<RefreshCw size={13} color={colors.textPrimary} />
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
</Pressable>
) : null}
{doc.isDirty ? (
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
</Pressable>
) : null}
{showSave ? (
<Pressable
style={[
styles.markdownFloatingButton,
styles.markdownSaveButton,
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
]}
disabled={!doc.editable || !doc.isDirty || doc.saving}
onPress={onSave}
>
{doc.saving ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Text style={styles.markdownFloatingButtonText}>Save</Text>
)}
</Pressable>
) : null}
</View>
</View>
) : null}
</View>
)
}
function DiffLineRow({
line,
title,
@@ -4636,7 +4509,7 @@ export default function SessionScreen() {
</View>
) : activeMarkdownTab ? (
<View style={styles.markdownFrame}>
<MarkdownReader
<MobileMarkdownReader
documentId={activeMarkdownTab.id}
doc={markdownDocs.get(activeMarkdownTab.id)}
onRefresh={() => void readMarkdownTab(activeMarkdownTab)}
+5 -3
View File
@@ -115,13 +115,15 @@ import {
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { triggerMediumImpact } from '../../../src/platform/haptics'
import {
type GitHubProjectSortDirection,
type GitHubProjectTable as SharedGitHubProjectTable,
groupRows,
isIterationCurrent,
sortRows,
type ProjectGroup
} from '../../../src/tasks/mobile-github-project-group-sort'
} from '../../../../src/shared/github-project-group-sort'
import type {
GitHubProjectSortDirection,
GitHubProjectTable as SharedGitHubProjectTable
} from '../../../../src/shared/github-project-types'
import {
CROSS_REPO_DISPLAY_LIMIT,
isGitHubWorkItemsSshRemoteRequiredError,
+3 -91
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { View, Text, StyleSheet, Pressable, Switch } from 'react-native'
import { View, Text, Pressable, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
@@ -9,7 +9,7 @@ import Animated, {
} from 'react-native-reanimated'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { colors, spacing } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
@@ -17,6 +17,7 @@ import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles'
import {
loadTerminalAutocompleteEnabled,
loadTerminalTextScale,
@@ -380,92 +381,3 @@ export default function TerminalSettingsScreen() {
</GestureHandlerRootView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.lg,
paddingTop: 0
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.sm,
marginBottom: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
scrollContent: {
paddingBottom: spacing.xl
},
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
inputGroupGap: {
marginTop: spacing.xl
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
padding: spacing.md
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
}
})
+63 -8
View File
@@ -2,8 +2,9 @@
#
# Builds the prebuilt iOS workspace, signs it with the distribution identity
# imported into the CI keychain plus an explicit App Store provisioning profile
# fetched via the App Store Connect API key, then uploads the .ipa to
# TestFlight. All Apple credentials come from CI env vars
# fetched via the App Store Connect API key, uploads the .ipa, then distributes
# the processed build to external TestFlight testers. Credentials come from CI
# env vars
# (see .github/workflows/mobile-ios-release.yml) so nothing secret lives in the
# repo.
#
@@ -28,6 +29,9 @@ WORKSPACE = File.join(MOBILE_ROOT, "ios", "Orca.xcworkspace")
BUILD_OUTPUT_DIRECTORY = File.join(MOBILE_ROOT, "build")
SCHEME = "Orca"
BUNDLE_ID = "com.stably.orca.mobile"
TESTFLIGHT_GROUPS = ["peeps"].freeze
DEFAULT_TESTFLIGHT_CHANGELOG = "Latest Orca Mobile updates and fixes.".freeze
TESTFLIGHT_PROCESSING_TIMEOUT_SECONDS = 20 * 60
# App Store version states in which the version "train" is terminally closed to
# new TestFlight build uploads (altool rejects with 90186 "train ... is
@@ -72,6 +76,11 @@ def current_mobile_version(config)
config.fetch("expo").fetch("version")
end
def testflight_changelog
changelog = ENV.fetch("TESTFLIGHT_CHANGELOG", "").strip
changelog.empty? ? DEFAULT_TESTFLIGHT_CHANGELOG : changelog
end
# True when either state field reports a terminally closed train. `respond_to?`
# guards the newer field, which older spaceship versions do not define.
def closed_state?(app_store_version)
@@ -172,7 +181,7 @@ platform :ios do
end
desc "Build, sign, and upload Orca Mobile to App Store Connect / TestFlight"
lane :release do
lane :build_and_upload do
api_key = app_store_connect_api_key_from_env
team_id = ENV.fetch("APPLE_TEAM_ID")
@@ -214,13 +223,59 @@ platform :ios do
upload_to_testflight(
api_key: api_key,
# Why: ASC processing wait hung for hours (0.0.42 builds 1–2) with no Ready
# build and no email. Exit after upload acceptance. Do not pass changelog —
# Pilot only fully skips waiting when changelog is nil (otherwise it still
# polls until the build appears). External distribute (e.g. peeps) from ASC
# or a follow-up lane once Ready.
# Distribution runs separately so a processing timeout can be retried
# against this exact build without uploading another binary.
skip_waiting_for_build_processing: true,
distribute_external: false,
)
end
desc "Wait for and distribute an uploaded build to external TestFlight testers"
lane :distribute_testflight do |options|
version = options[:version].to_s.strip
build_number = options[:build_number].to_s.strip
UI.user_error!("version is required") if version.empty?
UI.user_error!("build_number is required") if build_number.empty?
api_key = app_store_connect_api_key_from_env
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
UI.user_error!("Could not find App Store Connect app #{BUNDLE_ID}") unless app
available_groups = app.get_beta_groups.map(&:name)
missing_groups = TESTFLIGHT_GROUPS - available_groups
unless missing_groups.empty?
UI.user_error!(
"Missing TestFlight group(s): #{missing_groups.join(', ')}. " \
"Create them in App Store Connect or update TESTFLIGHT_GROUPS.",
)
end
upload_to_testflight(
api_key: api_key,
app_identifier: BUNDLE_ID,
# distribute_only means there is no local .ipa to sniff, so fastlane would
# otherwise fall through to an interactive platform prompt and crash in CI.
app_platform: "ios",
app_version: version,
build_number: build_number,
distribute_only: true,
skip_waiting_for_build_processing: false,
wait_processing_timeout_duration: TESTFLIGHT_PROCESSING_TIMEOUT_SECONDS,
distribute_external: true,
groups: TESTFLIGHT_GROUPS,
notify_external_testers: true,
changelog: testflight_changelog,
)
end
desc "Build, upload, and distribute Orca Mobile to external TestFlight testers"
lane :release do
build_and_upload
config = load_mobile_app_config
distribute_testflight(
version: current_mobile_version(config),
build_number: config.fetch("expo").fetch("ios").fetch("buildNumber"),
)
end
end
+4 -21
View File
@@ -157,28 +157,12 @@ const RESET_SNAPSHOT = {
}
} as const
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => spy.mockRestore()
}
async function renderAccountsRoute(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(AccountsScreen))
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
await act(async () => {
renderer = create(createElement(AccountsScreen))
await Promise.resolve()
})
if (!renderer) {
throw new Error('Accounts route did not render')
}
@@ -229,7 +213,6 @@ describe('accounts route Codex reset credit', () => {
let storedValues: Map<string, string>
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
resetCodexResetAttemptJournalForTests()
storedValues = new Map()
dependencies.alert.mockReset()
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { CodexResetCreditAction } from './CodexResetCreditAction'
vi.mock('react-native', () => ({
@@ -19,35 +19,19 @@ const summary = {
expiryLabel: 'Expires in 5d'
}
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
return () => spy.mockRestore()
}
function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer {
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
act(() => {
renderer = create(
createElement(CodexResetCreditAction, {
summary,
scopeLabel: 'dev@example.com on the host',
busy,
disabled,
onPress: vi.fn()
})
)
})
} finally {
restore()
}
act(() => {
renderer = create(
createElement(CodexResetCreditAction, {
summary,
scopeLabel: 'dev@example.com on the host',
busy,
disabled,
onPress: vi.fn()
})
)
})
if (!renderer) {
throw new Error('Reset action did not render')
}
@@ -55,10 +39,6 @@ function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer {
}
describe('CodexResetCreditAction', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('exposes a 44pt touch target and enabled accessibility state', () => {
const renderer = renderAction(false, false)
const button = renderer.root.findByType('Pressable')
@@ -78,7 +78,6 @@ describe('HostProtocolGate', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
nativeTestState.openUrl.mockClear()
nativeTestState.platform.OS = 'ios'
probeMounts.count = 0
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HostProfile } from '../transport/types'
import { MobileHomeQuickActions } from './MobileHomeQuickActions'
@@ -37,10 +37,6 @@ function host(id: string, name: string, endpoint: string): HostProfile {
describe('MobileHomeQuickActions', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
+1 -5
View File
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileHostCard } from './MobileHostCard'
vi.mock('react-native', () => ({
@@ -30,10 +30,6 @@ function suppressRendererDeprecation() {
describe('MobileHostCard', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionVerdict } from '../transport/connection-health'
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types'
@@ -39,10 +39,6 @@ const loaded: HostWorktreeInfo = {
describe('MobileHostCard', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
@@ -38,7 +38,6 @@ describe('MobileMarkdown file links', () => {
const onOpenFile = vi.fn()
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
onOpenFile.mockClear()
openURL.mockClear()
})
@@ -1,170 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { Check, ChevronDown } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { RpcClient } from '../transport/rpc-client'
import { searchBaseRefs } from '../source-control/mobile-base-ref-search'
type Props = {
client: RpcClient | null
worktreeId: string
value: string
onChange: (ref: string) => void
editable?: boolean
}
// Base-branch field for the create-PR composer: a free-text input that also searches
// repo refs (debounced) and offers matches to tap — the RN analogue of desktop's
// base-ref combobox. Free text stays valid so an SSH-only / unmatched ref can still
// be entered.
export function MobilePrBasePicker({
client,
worktreeId,
value,
onChange,
editable = true
}: Props) {
const [results, setResults] = useState<string[]>([])
const [focused, setFocused] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Guards: drop results after unmount, and ignore an earlier search whose response
// arrives after a later one (out-of-order network) so stale matches can't clobber.
const mounted = useRef(true)
const seq = useRef(0)
useEffect(() => {
return () => {
mounted.current = false
if (timer.current) {
clearTimeout(timer.current)
}
}
}, [])
const queryRefs = useCallback(
(query: string) => {
if (timer.current) {
clearTimeout(timer.current)
}
if (!client || query.trim().length === 0) {
// Advance the generation so an earlier in-flight search can't land and
// repopulate results after the input was cleared.
seq.current += 1
setResults([])
return
}
timer.current = setTimeout(() => {
const requestSeq = ++seq.current
void searchBaseRefs(client, worktreeId, query.trim())
.then((refs) => {
if (!mounted.current || requestSeq !== seq.current) {
return
}
setResults(refs.filter((r) => r !== query).slice(0, 6))
})
// Why: a rejected ref search must not escape as an unhandled rejection;
// drop to an empty result set (free text stays valid to submit).
.catch(() => {
if (mounted.current && requestSeq === seq.current) {
setResults([])
}
})
}, 200)
},
[client, worktreeId]
)
return (
<View>
<View style={[styles.inputShell, !editable && styles.inputShellDisabled]}>
<TextInput
style={styles.input}
value={value}
onChangeText={(text) => {
onChange(text)
queryRefs(text)
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="main"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
editable={editable}
/>
<ChevronDown size={14} color={colors.textSecondary} strokeWidth={2.2} />
</View>
{focused && results.length > 0 ? (
<View style={styles.results}>
{results.map((ref) => (
<Pressable
key={ref}
style={({ pressed }) => [styles.resultRow, pressed && styles.resultRowPressed]}
onPress={() => {
onChange(ref)
setResults([])
}}
>
<Text style={styles.resultText} numberOfLines={1}>
{ref}
</Text>
{ref === value ? (
<Check size={14} color={colors.textPrimary} strokeWidth={2.2} />
) : null}
</Pressable>
))}
</View>
) : null}
</View>
)
}
const styles = StyleSheet.create({
inputShell: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs
},
inputShellDisabled: {
opacity: 0.6
},
input: {
flex: 1,
minWidth: 0,
padding: 0,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontFamily: typography.monoFamily
},
results: {
marginTop: spacing.xs,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
backgroundColor: colors.bgPanel,
overflow: 'hidden'
},
resultRow: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
paddingHorizontal: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
resultRowPressed: { backgroundColor: colors.bgRaised },
resultText: {
flex: 1,
minWidth: 0,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontFamily: typography.monoFamily
}
})
@@ -1,52 +0,0 @@
import { Linking } from 'react-native'
import { BottomDrawer } from './BottomDrawer'
import { MobilePrComposeForm, type PrComposePrefill } from './pr-sidebar/MobilePrComposeForm'
import type { RpcClient } from '../transport/rpc-client'
type Props = {
visible: boolean
client: RpcClient | null
worktreeId: string
prefill: PrComposePrefill
// Head branch — enables the base≠head guard and the "from <branch>" hint.
head?: string | null
onClose: () => void
onCreated: (url: string, warning?: string) => void
}
// BottomDrawer wrapper around the inline compose form, for full-screen roots
// (source-control modals). The PR sidebar empty-state renders MobilePrComposeForm
// inline instead, since a BottomDrawer overlay nested in a ScrollView clips it.
export function MobilePrComposeSheet({
visible,
client,
worktreeId,
prefill,
head,
onClose,
onCreated
}: Props) {
return (
<BottomDrawer visible={visible} onClose={onClose}>
{/* Why: key on visible + prefill fields so reopening (or a new prefill)
remounts the form with fresh initial values; the previous sheet reset
its fields via an effect on the same signals. */}
<MobilePrComposeForm
key={`${visible}:${prefill.title}:${prefill.base}:${prefill.body}`}
client={client}
worktreeId={worktreeId}
prefill={prefill}
head={head}
onCancel={onClose}
onCreated={onCreated}
/>
</BottomDrawer>
)
}
export function openMobilePrUrl(url: string): void {
// Why: Linking.openURL rejects when iOS/Android can't open the URL (no app,
// bad scheme, etc.). Without a catch that surfaces as LogBox "Uncaught
// (in promise) Error: Unable to open URL…" over the PR screen.
void Linking.openURL(url).catch(() => {})
}
@@ -0,0 +1,84 @@
import { createElement, createRef, forwardRef, useImperativeHandle } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
MobileRichMarkdownEditor,
type MobileRichMarkdownEditorHandle
} from './MobileRichMarkdownEditor'
const mocks = vi.hoisted(() => ({
dismissKeyboard: vi.fn(),
injectJavaScript: vi.fn()
}))
vi.mock('react-native', async () => {
const React = await import('react')
return {
Keyboard: { dismiss: mocks.dismissKeyboard },
Linking: { openURL: vi.fn() },
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
View: 'View'
}
})
vi.mock('react-native-webview', () => {
const WebView = forwardRef((props: Record<string, unknown>, ref) => {
useImperativeHandle(ref, () => ({ injectJavaScript: mocks.injectJavaScript }))
return createElement('WebView', props)
})
return { WebView, default: WebView }
})
vi.mock('lucide-react-native', () => ({
Bold: 'Bold',
ChevronDown: 'ChevronDown',
Code2: 'Code2',
FileCode2: 'FileCode2',
Heading1: 'Heading1',
Heading2: 'Heading2',
Heading3: 'Heading3',
ImageIcon: 'ImageIcon',
Italic: 'Italic',
Keyboard: 'Keyboard',
Link: 'Link',
List: 'List',
ListOrdered: 'ListOrdered',
ListTodo: 'ListTodo',
Pilcrow: 'Pilcrow',
Quote: 'Quote',
Strikethrough: 'Strikethrough'
}))
describe('MobileRichMarkdownEditor', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.clearAllMocks()
})
it('exposes WebView keyboard dismissal to its native parent', () => {
const editorRef = createRef<MobileRichMarkdownEditorHandle>()
act(() => {
renderer = create(
createElement(MobileRichMarkdownEditor, {
ref: editorRef,
content: '',
editable: true,
onChange: vi.fn()
})
)
})
act(() => editorRef.current?.dismissKeyboard())
expect(mocks.injectJavaScript).toHaveBeenCalledWith(
expect.stringContaining('window.__orcaRichMarkdown.dismissKeyboard()')
)
expect(mocks.dismissKeyboard).toHaveBeenCalledOnce()
})
})
@@ -1,5 +1,15 @@
import { memo, useCallback, useEffect, useMemo, useRef, type ComponentType } from 'react'
import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
type ComponentType,
type ForwardedRef
} from 'react'
import { Keyboard, Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
import {
Bold,
Code2,
@@ -77,6 +87,10 @@ type Props = {
onKeyboardInsetChange?: (bottom: number) => void
}
export type MobileRichMarkdownEditorHandle = {
dismissKeyboard: () => void
}
type EditorWebViewMessage =
| { type: 'ready' }
| { type: 'change'; markdown: string; generation: number }
@@ -107,12 +121,10 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
]
function MobileRichMarkdownEditorInner({
content,
editable,
onChange,
onKeyboardInsetChange
}: Props) {
function MobileRichMarkdownEditorInner(
{ content, editable, onChange, onKeyboardInsetChange }: Props,
ref: ForwardedRef<MobileRichMarkdownEditorHandle>
) {
const webViewRef = useRef<WebView>(null)
const readyRef = useRef(false)
const documentGenerationRef = useRef(0)
@@ -227,6 +239,15 @@ function MobileRichMarkdownEditorInner({
[inject]
)
const dismissKeyboard = useCallback(() => {
// Why: the caret lives in the WebView, so the injected blur is what closes the keyboard;
// Keyboard.dismiss only clears a native TextInput that stole focus first.
inject('window.__orcaRichMarkdown && window.__orcaRichMarkdown.dismissKeyboard();')
Keyboard.dismiss()
}, [inject])
useImperativeHandle(ref, () => ({ dismissKeyboard }), [dismissKeyboard])
return (
<View style={styles.container}>
<View style={styles.toolbar}>
@@ -278,7 +299,7 @@ function MobileRichMarkdownEditorInner({
)
}
export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner)
export const MobileRichMarkdownEditor = memo(forwardRef(MobileRichMarkdownEditorInner))
const styles = StyleSheet.create({
container: {
+1 -2
View File
@@ -149,8 +149,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
// Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious
// control, matching TextInputModal / MobilePrBasePicker input shells.
// Keep search visually distinct from surrounding panels.
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
@@ -58,7 +58,6 @@ describe('NewWorktreeModal repo list', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
setCachedRepos('host-1', repos)
})
@@ -24,7 +24,6 @@ describe('PickerModal accessibility', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
throw new Error(String(args[0]))

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