mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Merge origin/main into sta-3367-codex-resume-command
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
<!-- DO NOT FILL IN IF !INTERNAL! CONTRIBUTOR: 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 -->
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
function readRootEntries(sha) {
|
||||
// Why: a git pathname is arbitrary bytes, and 'utf8' folds every invalid
|
||||
// sequence to U+FFFD — that mangles the reported name and makes two different
|
||||
// entries compare equal, so a genuinely new one can slip past the Set below.
|
||||
// latin1 maps each byte to one code unit, so the bytes survive the round trip.
|
||||
const stdout = execFileSync('git', ['ls-tree', '-z', '--name-only', sha], {
|
||||
encoding: 'latin1',
|
||||
stdio: ['ignore', 'pipe', 'inherit']
|
||||
})
|
||||
return stdout.split('\0').filter(Boolean)
|
||||
}
|
||||
|
||||
function checkRootDirectoryEntries(argv) {
|
||||
if (argv.length !== 2) {
|
||||
console.error(`Usage: ${process.argv[1]} <base-sha> <head-sha>`)
|
||||
return 2
|
||||
}
|
||||
|
||||
const [baseSha, headSha] = argv
|
||||
const baseEntries = new Set(readRootEntries(baseSha))
|
||||
const blockedEntries = readRootEntries(headSha).filter((entry) => !baseEntries.has(entry))
|
||||
|
||||
if (blockedEntries.length === 0) {
|
||||
console.log('Root directory guard passed: no new root-level files or folders.')
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(
|
||||
'::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page.'
|
||||
)
|
||||
console.log('Root directory guard failed.')
|
||||
console.log(
|
||||
'New root-level files or folders are not allowed because they bloat the GitHub landing page.'
|
||||
)
|
||||
console.log('Move each new entry under an existing top-level directory.')
|
||||
console.log('Blocked entries:')
|
||||
// Why: an entry name is attacker-controlled and may start with '::' (the runner
|
||||
// trims leading spaces before matching) or embed a newline, so printing it bare
|
||||
// lets a PR forge annotations. Fence the untrusted list with an unguessable
|
||||
// stop-commands token, and write the raw bytes rather than a re-encoded string.
|
||||
const resumeToken = randomUUID()
|
||||
console.log(`::stop-commands::${resumeToken}`)
|
||||
for (const entry of blockedEntries) {
|
||||
process.stdout.write(Buffer.from(` ${entry}\n`, 'latin1'))
|
||||
}
|
||||
console.log(`::${resumeToken}::`)
|
||||
return 1
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: process.exit truncates a piped write part-way through on macOS, so set
|
||||
// exitCode and let node flush the blocked-entry list before it exits.
|
||||
process.exitCode = checkRootDirectoryEntries(process.argv.slice(2))
|
||||
} catch (error) {
|
||||
// Why: git already reported the failure on the inherited stderr, so surface its
|
||||
// status rather than a node stack trace. Anything else is a real bug — rethrow.
|
||||
if (typeof error.status !== 'number') {
|
||||
throw error
|
||||
}
|
||||
process.exitCode = error.status
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "Usage: $0 <base-sha> <head-sha>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
base_sha=$1
|
||||
head_sha=$2
|
||||
git rev-parse --verify "${base_sha}^{tree}" >/dev/null
|
||||
git rev-parse --verify "${head_sha}^{tree}" >/dev/null
|
||||
|
||||
declare -A base_entries=()
|
||||
while IFS= read -r -d '' entry; do
|
||||
base_entries["$entry"]=1
|
||||
done < <(git ls-tree -z --name-only "$base_sha")
|
||||
|
||||
blocked_entries=()
|
||||
while IFS= read -r -d '' entry; do
|
||||
if [[ -z "${base_entries[$entry]+present}" ]]; then
|
||||
blocked_entries+=("$entry")
|
||||
fi
|
||||
done < <(git ls-tree -z --name-only "$head_sha")
|
||||
|
||||
if (( ${#blocked_entries[@]} == 0 )); then
|
||||
echo "Root directory guard passed: no new root-level files or folders."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page."
|
||||
echo "Root directory guard failed."
|
||||
echo "New root-level files or folders are not allowed because they bloat the GitHub landing page."
|
||||
echo "Move each new entry under an existing top-level directory."
|
||||
printf 'Blocked entries:\n'
|
||||
printf ' %s\n' "${blocked_entries[@]}"
|
||||
exit 1
|
||||
@@ -20,6 +20,7 @@ on:
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
- 'config/scripts/computer-use-smoke.test.mjs'
|
||||
- 'config/scripts/daemon-boot-smoke.mjs'
|
||||
- 'config/scripts/daemon-endpoint-handover-smoke.mjs'
|
||||
- 'config/scripts/windows-daemon-workspace-close-repro.mjs'
|
||||
- 'config/scripts/verify-computer-native.mjs'
|
||||
# Why: the native-smoke job boots the built terminal daemon under plain
|
||||
@@ -135,6 +136,11 @@ jobs:
|
||||
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
|
||||
- name: Daemon boot smoke
|
||||
run: node config/scripts/daemon-boot-smoke.mjs
|
||||
# Why: a daemon that lost its endpoint name used to delete the replacement's
|
||||
# socket on exit, stranding a live daemon that hosts PTYs nothing can reach.
|
||||
# Only two real processes racing the same endpoint reproduce it.
|
||||
- name: Daemon endpoint handover smoke
|
||||
run: node config/scripts/daemon-endpoint-handover-smoke.mjs
|
||||
# Why: workspace removal overlaps graceful renderer teardown with the
|
||||
# forced main-process sweep; exercise that exact pair against real ConPTY.
|
||||
- name: Windows daemon workspace-close repro
|
||||
|
||||
@@ -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"
|
||||
@@ -380,24 +380,52 @@ jobs:
|
||||
echo "::warning::Could not discard draft $TAG; remove it manually."
|
||||
|
||||
- name: Prune old hourly releases
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
# 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_publish.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.
|
||||
stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,createdAt,isDraft \
|
||||
--jq "map(select(.isDraft | not)) | sort_by(.createdAt) | reverse | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName")"
|
||||
#
|
||||
# Sort by publishedAt (not createdAt). Many non-draft hourlies share one
|
||||
# createdAt (bulk import / re-create), so createdAt ranking is unstable:
|
||||
# reverse of a stable sort then drops the just-published tag past the
|
||||
# retain window. 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+=" | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName"
|
||||
stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,publishedAt,isDraft \
|
||||
--jq "$jq_filter")"
|
||||
if [[ -z "$stale" ]]; then
|
||||
echo "Nothing to prune; at or under $HOURLY_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 "$HOURLY_REPO" --yes --cleanup-tag || \
|
||||
echo "::warning::Could not prune $tag"
|
||||
|
||||
@@ -30,6 +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 for setup.
|
||||
timeout-minutes: 90
|
||||
outputs:
|
||||
release_version: ${{ steps.release_metadata.outputs.version }}
|
||||
build_number: ${{ steps.release_metadata.outputs.build_number }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -76,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
|
||||
|
||||
@@ -119,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()
|
||||
@@ -132,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
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: bash .github/scripts/check-root-directory-entries.sh "$BASE_SHA" "$HEAD_SHA"
|
||||
run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA"
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -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
|
||||
@@ -183,8 +189,49 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update && sudo apt-get install -y zsh
|
||||
# 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: |
|
||||
# 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:
|
||||
@@ -192,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:
|
||||
@@ -227,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 }}
|
||||
@@ -259,6 +310,29 @@ jobs:
|
||||
- name: Old/new client and server terminal journey
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Build relay companions
|
||||
run: pnpm run build:relay
|
||||
|
||||
- name: Setup Node 18 runtime
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Smoke managed-hook companions
|
||||
run: node config/scripts/smoke-managed-hook-runtime-node18.mjs
|
||||
|
||||
package:
|
||||
name: package
|
||||
runs-on: ubuntu-latest
|
||||
@@ -352,6 +426,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
src/main/cli/wsl-cli-powershell-boundary.test.ts
|
||||
src/main/orca-profiles/profile-index-store.test.ts
|
||||
src/shared/secure-file-fsync-flags.test.ts
|
||||
|
||||
- name: Build package inputs
|
||||
run: pnpm run build:release
|
||||
|
||||
@@ -423,6 +504,7 @@ jobs:
|
||||
- git_compatibility
|
||||
- shell_contracts
|
||||
- test
|
||||
- managed_hook_node18
|
||||
- package
|
||||
- package_windows
|
||||
runs-on: ubuntu-latest
|
||||
@@ -444,6 +526,7 @@ jobs:
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
|
||||
run: |
|
||||
@@ -454,6 +537,7 @@ jobs:
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -37,13 +38,39 @@ const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'stt-worker',
|
||||
'warp-theme-parser-worker',
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'session-scanner-worker-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'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,
|
||||
@@ -88,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.
|
||||
@@ -158,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>'}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"src/main/speech/stt-worker.ts",
|
||||
"src/main/warp-themes/warp-theme-parser-worker.ts",
|
||||
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
|
||||
"src/main/ai-vault/session-scanner-worker-entry.ts",
|
||||
"src/main/ports/port-scan-command-worker-entry.ts",
|
||||
"src/main/ipc/parcel-watcher-process-entry.ts",
|
||||
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[
|
||||
{
|
||||
"filePath": "src/renderer/src/lib/agent-background-session-test-state.ts",
|
||||
"kind": "object-property:title",
|
||||
"text": "Terminal 1",
|
||||
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
|
||||
"kind": "jsx-attribute:label",
|
||||
"text": "sidebar",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ inline src/main/agent-hooks/server.test.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/amp/hook-service.ts
|
||||
inline src/main/antigravity/hook-service.ts
|
||||
inline src/main/attribution/terminal-attribution.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
inline src/main/browser/agent-browser-bridge.test.ts
|
||||
@@ -232,10 +231,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 +243,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 +266,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 +337,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 one or more lines are too long
File diff suppressed because one or more lines are too long
+1683
-112
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([
|
||||
@@ -60,18 +63,27 @@ const USER_VISIBLE_OBJECT_METHODS = new Set([
|
||||
'warning'
|
||||
])
|
||||
const USER_VISIBLE_OBJECT_NAMES = new Set(['toast'])
|
||||
// Why: only comparison operands are code, not copy. Bailing on every non-`+`
|
||||
// operator hid whole subtrees behind `cond && <JSX/>` guards and `?? 'fallback'`.
|
||||
const COPY_PRESERVING_BINARY_OPERATORS = new Set([
|
||||
ts.SyntaxKind.PlusToken,
|
||||
ts.SyntaxKind.QuestionQuestionToken,
|
||||
ts.SyntaxKind.BarBarToken,
|
||||
ts.SyntaxKind.AmpersandAmpersandToken
|
||||
])
|
||||
|
||||
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
|
||||
}
|
||||
@@ -226,7 +238,7 @@ function isRenderedJsxExpression(node) {
|
||||
continue
|
||||
}
|
||||
if (ts.isBinaryExpression(current)) {
|
||||
if (current.operatorToken.kind !== ts.SyntaxKind.PlusToken) {
|
||||
if (!COPY_PRESERVING_BINARY_OPERATORS.has(current.operatorToken.kind)) {
|
||||
return false
|
||||
}
|
||||
current = current.parent
|
||||
@@ -318,7 +330,8 @@ function classifyStringNode(node) {
|
||||
findAncestor(
|
||||
node,
|
||||
(ancestor) =>
|
||||
ts.isBinaryExpression(ancestor) && ancestor.operatorToken.kind !== ts.SyntaxKind.PlusToken
|
||||
ts.isBinaryExpression(ancestor) &&
|
||||
!COPY_PRESERVING_BINARY_OPERATORS.has(ancestor.operatorToken.kind)
|
||||
)
|
||||
) {
|
||||
return undefined
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { collectLocalizationCandidates, isSkippedFile } from './audit-localization-coverage.mjs'
|
||||
|
||||
const ROOT = process.cwd()
|
||||
|
||||
function candidates(fileName, source) {
|
||||
return collectLocalizationCandidates(`${ROOT}/src/renderer/src/${fileName}`, source, ROOT)
|
||||
}
|
||||
|
||||
describe('localization coverage candidates', () => {
|
||||
it('sees copy guarded by a nullish or logical fallback', () => {
|
||||
const reports = candidates(
|
||||
'Sample.tsx',
|
||||
`export function Sample({ label, connecting }) {
|
||||
return <span>{label ?? (connecting ? 'Connecting…' : 'Idle')}</span>
|
||||
}`
|
||||
)
|
||||
|
||||
expect(reports.map((report) => report.text)).toEqual(['Connecting…', 'Idle'])
|
||||
})
|
||||
|
||||
it('sees copy nested inside a conditional JSX guard', () => {
|
||||
const reports = candidates(
|
||||
'Sample.tsx',
|
||||
`export function Sample({ show }) {
|
||||
return <div>{show && <button aria-label="Retry the sync" />}</div>
|
||||
}`
|
||||
)
|
||||
|
||||
expect(reports.map((report) => report.text)).toEqual(['Retry the sync'])
|
||||
})
|
||||
|
||||
it('ignores literals used as comparison operands', () => {
|
||||
const reports = candidates(
|
||||
'Sample.tsx',
|
||||
`export function Sample({ phase }) {
|
||||
return <span>{phase === 'workspace conflict' ? phase : null}</span>
|
||||
}`
|
||||
)
|
||||
|
||||
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,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
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const guardScript = join(projectDir, '.github/scripts/check-root-directory-entries.sh')
|
||||
const guardScript = join(projectDir, '.github/scripts/check-root-directory-entries.mjs')
|
||||
const tempDirs = []
|
||||
|
||||
function git(cwd, args) {
|
||||
@@ -37,13 +37,45 @@ function commitFiles(root, files) {
|
||||
return git(root, ['rev-parse', 'HEAD'])
|
||||
}
|
||||
|
||||
// Why: a root entry name can be bytes no filesystem here accepts (APFS rejects
|
||||
// invalid UTF-8), so build the tree in the object database instead of on disk.
|
||||
// git ls-tree -z emits exactly the record format git mktree -z reads back.
|
||||
function commitRawEntries(root, parent, entries) {
|
||||
const parentTree = execFileSync('git', ['ls-tree', '-z', parent], { cwd: root })
|
||||
const records = entries.map((name) => {
|
||||
const blob = execFileSync('git', ['hash-object', '-w', '--stdin'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
input: 'too prominent\n'
|
||||
}).trim()
|
||||
return Buffer.concat([Buffer.from(`100644 blob ${blob}\t`), name, Buffer.from([0])])
|
||||
})
|
||||
const tree = execFileSync('git', ['mktree', '-z'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
input: Buffer.concat([parentTree, ...records])
|
||||
}).trim()
|
||||
return execFileSync('git', ['commit-tree', tree, '-p', parent, '-m', 'head'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function runGuard({ root, base, head }) {
|
||||
return spawnSync('bash', [guardScript, base, head], {
|
||||
return runGuardArgs(root, [base, head])
|
||||
}
|
||||
|
||||
function runGuardArgs(root, args) {
|
||||
return spawnSync(process.execPath, [guardScript, ...args], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
}
|
||||
|
||||
function runGuardBytes({ root, base, head }) {
|
||||
return spawnSync(process.execPath, [guardScript, base, head], { cwd: root })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
@@ -84,6 +116,100 @@ describe('root directory guard', () => {
|
||||
expect(output).toContain('new-folder')
|
||||
})
|
||||
|
||||
// Why: git escapes odd paths unless it is read NUL-delimited, so dropping -z
|
||||
// (or decoding the bytes wrong) reports a mangled name nobody can act on.
|
||||
it.skipIf(process.platform === 'win32')('reports a blocked entry byte-for-byte', () => {
|
||||
const awkwardName = '日本 root file\nwith newline.txt'
|
||||
const fixture = makeFixture()
|
||||
const head = commitFiles(fixture.root, [[awkwardName, 'too prominent\n']])
|
||||
|
||||
const result = runGuard({ ...fixture, head })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout).toContain(awkwardName)
|
||||
})
|
||||
|
||||
// Why: decoding git's output as UTF-8 rewrites every invalid byte to U+FFFD, so
|
||||
// the name the guard prints is not the name anyone has to rename.
|
||||
it('reports an entry whose name is not valid UTF-8 byte-for-byte', () => {
|
||||
const rawName = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('-raw.txt')])
|
||||
const fixture = makeFixture()
|
||||
const head = commitRawEntries(fixture.root, fixture.base, [rawName])
|
||||
|
||||
const result = runGuardBytes({ ...fixture, head })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout.includes(rawName)).toBe(true)
|
||||
})
|
||||
|
||||
// Why: U+FFFD is not injective, so two different invalid names decode to the
|
||||
// same string and a new root entry gets waved through as pre-existing.
|
||||
it('does not confuse two different invalid UTF-8 names for the same entry', () => {
|
||||
const fixture = makeFixture()
|
||||
const base = commitRawEntries(fixture.root, fixture.base, [
|
||||
Buffer.concat([Buffer.from([0xc0, 0x80]), Buffer.from('.txt')])
|
||||
])
|
||||
const head = commitRawEntries(fixture.root, fixture.base, [
|
||||
Buffer.concat([Buffer.from([0xc0, 0x81]), Buffer.from('.txt')])
|
||||
])
|
||||
|
||||
const result = runGuardBytes({ root: fixture.root, base, head })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout.toString('latin1')).not.toContain('guard passed')
|
||||
})
|
||||
|
||||
// Why: the runner trims leading spaces before matching '::', so an indented
|
||||
// entry name still reaches the workflow-command parser and can forge output.
|
||||
it('prints blocked entries with workflow-command parsing disabled', () => {
|
||||
const fixture = makeFixture()
|
||||
const forgedName = '::error title=forged::injected\n::warning::second line.txt'
|
||||
const head = commitRawEntries(fixture.root, fixture.base, [Buffer.from(forgedName)])
|
||||
|
||||
const result = runGuard({ ...fixture, head })
|
||||
const lines = result.stdout.split('\n')
|
||||
const stopIndex = lines.findIndex((line) => line.startsWith('::stop-commands::'))
|
||||
const resumeToken = lines[stopIndex]?.slice('::stop-commands::'.length)
|
||||
const resumeIndex = lines.indexOf(`::${resumeToken}::`)
|
||||
const escaped = lines.filter(
|
||||
(line, index) =>
|
||||
(index < stopIndex || index > resumeIndex) && line.trimStart().startsWith('::')
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(resumeToken).toMatch(/^[\da-f-]{36}$/)
|
||||
expect(stopIndex).toBeLessThan(resumeIndex)
|
||||
// Why: the guard's own annotation is the only line the runner may act on.
|
||||
expect(escaped).toHaveLength(1)
|
||||
expect(escaped[0]).toContain('Root-level additions blocked')
|
||||
expect(lines.slice(stopIndex, resumeIndex).join('\n')).toContain(forgedName)
|
||||
})
|
||||
|
||||
it('exits 2 with usage when the two shas are not both supplied', () => {
|
||||
const fixture = makeFixture()
|
||||
|
||||
const result = runGuardArgs(fixture.root, [fixture.base])
|
||||
|
||||
expect(result.status).toBe(2)
|
||||
expect(result.stderr).toContain('<base-sha> <head-sha>')
|
||||
})
|
||||
|
||||
it('fails loudly instead of passing when a sha does not resolve', () => {
|
||||
const fixture = makeFixture()
|
||||
|
||||
const result = runGuardArgs(fixture.root, [
|
||||
fixture.base,
|
||||
'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
|
||||
])
|
||||
|
||||
// Why: git's own exit status, not node's. An unhandled throw is also non-zero,
|
||||
// so assert the status and the absent stack trace or the guard's error
|
||||
// handling can be deleted without a test noticing.
|
||||
expect(result.status).toBe(128)
|
||||
expect(result.stderr).not.toContain('node:internal')
|
||||
expect(result.stdout).not.toContain('guard passed')
|
||||
})
|
||||
|
||||
it('is wired into the PR verify gate', () => {
|
||||
const workflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const guardJob = workflow.jobs.root_directory_guard
|
||||
@@ -93,7 +219,7 @@ describe('root directory guard', () => {
|
||||
|
||||
expect(guardJob.name).toBe('root directory guard')
|
||||
expect(guardJob.steps[0].with['fetch-depth']).toBe(0)
|
||||
expect(guardStep.run).toContain('.github/scripts/check-root-directory-entries.sh')
|
||||
expect(guardStep.run).toContain('node .github/scripts/check-root-directory-entries.mjs')
|
||||
expect(workflow.jobs.verify.needs).toContain('root_directory_guard')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
@@ -28,7 +28,11 @@ describe('computer-use modifier safety', () => {
|
||||
)
|
||||
|
||||
expect(mouseInput).toContain('event.flags = flags')
|
||||
expect(clickInput.match(/flags: flags/gu)).toHaveLength(3)
|
||||
// Every click event flows through the shared delivery plan and carries
|
||||
// the modifier flags on the mouse event itself.
|
||||
expect(clickInput).toContain('SyntheticMouseClickDelivery.deliver(')
|
||||
expect(clickInput).toContain('currentSyntheticClickRecipient(')
|
||||
expect(clickInput).toContain('event.flags = flags')
|
||||
expect(clickInput).not.toContain('down: true')
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { fork } from 'node:child_process'
|
||||
import { connect } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
@@ -54,30 +54,40 @@ function makeSocketPath(userDataDir) {
|
||||
return join(userDataDir, 'daemon.sock')
|
||||
}
|
||||
|
||||
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
|
||||
// Connects a single control socket, completes the hello handshake, and calls
|
||||
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
|
||||
// true on success, false on any failure — never throws.
|
||||
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
return new Promise((resolveCheck) => {
|
||||
// 'connected' only when something actually answers; a dead entry left on the name is not it.
|
||||
function probeEndpoint(socketPath) {
|
||||
return new Promise((resolveProbe) => {
|
||||
const socket = connect(socketPath)
|
||||
const settle = (result) => {
|
||||
socket.destroy()
|
||||
resolveProbe(result)
|
||||
}
|
||||
socket.on('connect', () => settle('connected'))
|
||||
socket.on('error', () => settle('unreachable'))
|
||||
})
|
||||
}
|
||||
|
||||
function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) {
|
||||
return new Promise((resolveRpc, rejectRpc) => {
|
||||
let settled = false
|
||||
let buffer = ''
|
||||
const socket = connect(socketPath)
|
||||
const finish = (ok, reason) => {
|
||||
const finish = (error, response) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
if (!ok && reason) {
|
||||
log(`PTY spawn health check skipped (best-effort): ${reason}`)
|
||||
if (error) {
|
||||
rejectRpc(error)
|
||||
} else {
|
||||
resolveRpc(response)
|
||||
}
|
||||
resolveCheck(ok)
|
||||
}
|
||||
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
|
||||
const timer = setTimeout(() => finish(new Error(`${request.type} timed out`)), timeoutMs)
|
||||
|
||||
socket.on('error', (err) => finish(false, err.message))
|
||||
socket.on('error', (error) => finish(error))
|
||||
socket.on('connect', () => {
|
||||
const token = readFileSync(tokenPath, 'utf8').trim()
|
||||
socket.write(
|
||||
@@ -100,19 +110,19 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
try {
|
||||
msg = JSON.parse(line)
|
||||
} catch {
|
||||
finish(false, 'invalid response line')
|
||||
finish(new Error('invalid response line'))
|
||||
return
|
||||
}
|
||||
if (msg.type === 'hello') {
|
||||
if (!msg.ok) {
|
||||
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
|
||||
finish(new Error(`hello rejected: ${msg.error ?? 'unknown'}`))
|
||||
return
|
||||
}
|
||||
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
|
||||
} else if (msg.id === 'health-1') {
|
||||
socket.write(`${JSON.stringify(request)}\n`)
|
||||
} else if (msg.id === request.id) {
|
||||
finish(
|
||||
msg.ok === true,
|
||||
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
|
||||
msg.ok === true ? undefined : new Error(msg.error ?? `${request.type} failed`),
|
||||
msg
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -122,28 +132,61 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
})
|
||||
}
|
||||
|
||||
// Best-effort: constrained CI runners can make node-pty spawn flaky.
|
||||
async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
try {
|
||||
await runDaemonRpc(
|
||||
socketPath,
|
||||
tokenPath,
|
||||
protocolVersion,
|
||||
{ id: 'health-1', type: 'ptySpawnHealth' },
|
||||
PTY_HEALTH_TIMEOUT_MS
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
log(`PTY spawn health check skipped (best-effort): ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
|
||||
const socketPath = makeSocketPath(userDataDir)
|
||||
const tokenPath = join(userDataDir, 'daemon.token')
|
||||
const pidPath = join(userDataDir, 'daemon.pid')
|
||||
const launchNonce = randomUUID()
|
||||
const protocolVersion = readProtocolVersion()
|
||||
|
||||
log(`forking ${entryPath} under plain Node (${process.execPath})`)
|
||||
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
|
||||
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
|
||||
// CI, and this is exactly the runtime where a leaked `require("electron")`
|
||||
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
|
||||
})
|
||||
const child = fork(
|
||||
entryPath,
|
||||
[
|
||||
'--socket',
|
||||
socketPath,
|
||||
'--token',
|
||||
tokenPath,
|
||||
'--pid-record',
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
launchNonce,
|
||||
'--entry-path',
|
||||
entryPath,
|
||||
'--app-version',
|
||||
'daemon-boot-smoke'
|
||||
],
|
||||
{
|
||||
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
|
||||
// CI, and this is exactly the runtime where a leaked `require("electron")`
|
||||
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
|
||||
}
|
||||
)
|
||||
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
process.stdout.write(chunk)
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
||||
@@ -185,6 +228,25 @@ async function main() {
|
||||
})
|
||||
})
|
||||
log('daemon signaled ready')
|
||||
const pidRecord = JSON.parse(readFileSync(pidPath, 'utf8'))
|
||||
if (
|
||||
pidRecord.pid !== child.pid ||
|
||||
pidRecord.launchNonce !== launchNonce ||
|
||||
pidRecord.entryPath !== entryPath ||
|
||||
pidRecord.appVersion !== 'daemon-boot-smoke'
|
||||
) {
|
||||
throw new Error('daemon readiness did not publish the expected PID ownership record')
|
||||
}
|
||||
log('PID ownership record matches the ready daemon')
|
||||
if (!existsSync(socketPath)) {
|
||||
throw new Error('daemon did not publish its endpoint at the canonical socket path')
|
||||
}
|
||||
log('endpoint published at the canonical socket path')
|
||||
// Production releases startup-only handles after ready; they can pin the child on Windows.
|
||||
// Diagnostics past this point therefore carry the tail captured up to readiness only.
|
||||
child.stderr?.destroy()
|
||||
stderr += '[boot-smoke] stderr released at readiness, mirroring production\n'
|
||||
child.disconnect()
|
||||
|
||||
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
|
||||
if (ptyHealthy) {
|
||||
@@ -193,18 +255,44 @@ async function main() {
|
||||
|
||||
await new Promise((resolveExit, rejectExit) => {
|
||||
const timer = setTimeout(() => {
|
||||
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
|
||||
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of shutdown RPC`))
|
||||
}, SHUTDOWN_TIMEOUT_MS)
|
||||
child.on('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
log(`daemon exited after signal (code=${code}, signal=${signal})`)
|
||||
log(`daemon exited after shutdown RPC (code=${code}, signal=${signal})`)
|
||||
resolveExit()
|
||||
})
|
||||
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
|
||||
// Windows has no POSIX signal delivery, so Node maps this to process
|
||||
// termination. Either way the hard assertion is "it stops, no hang".
|
||||
child.kill('SIGTERM')
|
||||
void runDaemonRpc(
|
||||
socketPath,
|
||||
tokenPath,
|
||||
protocolVersion,
|
||||
{
|
||||
id: 'shutdown-1',
|
||||
type: 'shutdown',
|
||||
payload: { killSessions: false }
|
||||
},
|
||||
SHUTDOWN_TIMEOUT_MS
|
||||
).catch((error) => {
|
||||
clearTimeout(timer)
|
||||
rejectExit(error)
|
||||
})
|
||||
})
|
||||
if (existsSync(pidPath)) {
|
||||
throw new Error('daemon left its PID ownership record behind after shutdown')
|
||||
}
|
||||
// Why not "the entry is gone": a departing daemon deliberately leaves its endpoint behind
|
||||
// for the next publisher to replace in one rename. What must be true is that nothing
|
||||
// answers there any more.
|
||||
if (process.platform !== 'win32' && (await probeEndpoint(socketPath)) === 'connected') {
|
||||
throw new Error('daemon still answers its endpoint after shutdown')
|
||||
}
|
||||
// The private bind name is consumed by the publish, and nothing sweeps the runtime dir any
|
||||
// more, so a leak here is permanent. Match what the code actually generates rather than a
|
||||
// literal prefix: this check silently matched nothing after the namespace moved from .b.
|
||||
const leaked = readdirSync(userDataDir).filter((entry) => /^\.[a-z][0-9a-f]{10}$/.test(entry))
|
||||
if (leaked.length > 0) {
|
||||
throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`)
|
||||
}
|
||||
|
||||
log('PASS: daemon booted, served, and shut down under plain Node')
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Endpoint handover smoke — guards the split-brain failure with real daemon processes.
|
||||
*
|
||||
* Two starting daemons race to replace one dead endpoint entry. The loser may exit after the
|
||||
* winner has published, but its close must not remove the winner's canonical socket. The survivor
|
||||
* must remain reachable through that path with its own token.
|
||||
*
|
||||
* Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur.
|
||||
*
|
||||
* Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs
|
||||
*/
|
||||
import { fork } from 'node:child_process'
|
||||
import { connect } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..', '..')
|
||||
const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js')
|
||||
|
||||
// Why read it from source: the launcher keys adoption of a live incumbent on this exact code,
|
||||
// and hardcoding it here would let the two drift silently — which is the failure the assertion
|
||||
// below exists to catch.
|
||||
const DAEMON_EXIT_ENDPOINT_OCCUPIED = Number(
|
||||
readFileSync(join(repoRoot, 'src/main/daemon/daemon-endpoint-ownership.ts'), 'utf8').match(
|
||||
/DAEMON_EXIT_ENDPOINT_OCCUPIED = (\d+)/
|
||||
)?.[1]
|
||||
)
|
||||
const READY_TIMEOUT_MS = 20_000
|
||||
const EXIT_TIMEOUT_MS = 15_000
|
||||
const REACHABILITY_TIMEOUT_MS = 2_000
|
||||
|
||||
const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`)
|
||||
|
||||
function readProtocolVersion() {
|
||||
const source = readFileSync(join(repoRoot, 'src/main/daemon/daemon-protocol-version.ts'), 'utf8')
|
||||
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
|
||||
if (!match) {
|
||||
throw new Error('could not read daemon protocol version')
|
||||
}
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
function bootDaemon(tag, dir, socketPath) {
|
||||
const tokenPath = join(dir, `${tag}.token`)
|
||||
const pidPath = join(dir, `${tag}.pid`)
|
||||
const child = fork(
|
||||
entryPath,
|
||||
[
|
||||
'--socket',
|
||||
socketPath,
|
||||
'--token',
|
||||
tokenPath,
|
||||
'--pid-record',
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
randomUUID(),
|
||||
'--entry-path',
|
||||
entryPath,
|
||||
'--app-version',
|
||||
'endpoint-handover-smoke'
|
||||
],
|
||||
{
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: dir }
|
||||
}
|
||||
)
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
const ready = new Promise((resolveReady, rejectReady) => {
|
||||
const timer = setTimeout(
|
||||
() => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)),
|
||||
READY_TIMEOUT_MS
|
||||
)
|
||||
child.on('message', (msg) => {
|
||||
if (msg && typeof msg === 'object' && msg.type === 'ready') {
|
||||
clearTimeout(timer)
|
||||
resolveReady()
|
||||
}
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`))
|
||||
})
|
||||
})
|
||||
return { child, tokenPath, pidPath, ready }
|
||||
}
|
||||
|
||||
function isReachable(socketPath) {
|
||||
return new Promise((resolveReachable) => {
|
||||
const socket = connect({ path: socketPath })
|
||||
socket.once('connect', () => {
|
||||
socket.destroy()
|
||||
resolveReachable(true)
|
||||
})
|
||||
socket.once('error', () => {
|
||||
socket.destroy()
|
||||
resolveReachable(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isDaemonReachable(socketPath, tokenPath, protocolVersion) {
|
||||
if (!existsSync(tokenPath)) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
const token = readFileSync(tokenPath, 'utf8').trim()
|
||||
return new Promise((resolveReachable) => {
|
||||
let buffer = ''
|
||||
let settled = false
|
||||
const socket = connect({ path: socketPath })
|
||||
const finish = (reachable) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
resolveReachable(reachable)
|
||||
}
|
||||
const timer = setTimeout(() => finish(false), REACHABILITY_TIMEOUT_MS)
|
||||
socket.once('error', () => finish(false))
|
||||
socket.once('connect', () => {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
type: 'hello',
|
||||
version: protocolVersion,
|
||||
token,
|
||||
clientId: randomUUID(),
|
||||
role: 'control'
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8')
|
||||
const newlineIndex = buffer.indexOf('\n')
|
||||
if (newlineIndex === -1) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const message = JSON.parse(buffer.slice(0, newlineIndex))
|
||||
finish(message.type === 'hello' && message.ok === true)
|
||||
} catch {
|
||||
finish(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolves with the child's exit code, so callers can assert on it. */
|
||||
function killAndWait(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return Promise.resolve(child.exitCode)
|
||||
}
|
||||
return new Promise((resolveExit, rejectExit) => {
|
||||
const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS)
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
resolveExit(code)
|
||||
})
|
||||
child.kill('SIGTERM')
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform === 'win32') {
|
||||
log('SKIP: named pipes are not filesystem entries, so endpoint handover cannot occur')
|
||||
return
|
||||
}
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`missing ${entryPath} — run \`pnpm build\` first`)
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-'))
|
||||
const socketPath = join(dir, 'daemon.sock')
|
||||
const protocolVersion = readProtocolVersion()
|
||||
const daemons = []
|
||||
try {
|
||||
const departed = bootDaemon('departed', dir, socketPath)
|
||||
daemons.push(departed)
|
||||
await departed.ready
|
||||
const deadInode = statSync(socketPath).ino
|
||||
await killAndWait(departed.child)
|
||||
if (!existsSync(socketPath) || statSync(socketPath).ino !== deadInode) {
|
||||
throw new Error('departing daemon did not leave its dead endpoint entry in place')
|
||||
}
|
||||
if (await isReachable(socketPath)) {
|
||||
throw new Error('departed daemon remains reachable')
|
||||
}
|
||||
log('daemon A departed and left a dead endpoint entry')
|
||||
|
||||
const racers = [bootDaemon('racer-b', dir, socketPath), bootDaemon('racer-c', dir, socketPath)]
|
||||
daemons.push(...racers)
|
||||
const readiness = await Promise.allSettled(racers.map((daemon) => daemon.ready))
|
||||
if (readiness.every((result) => result.status === 'rejected')) {
|
||||
throw new Error(
|
||||
`neither racing daemon published the endpoint:\n${readiness
|
||||
.map((result) => (result.status === 'rejected' ? result.reason.message : ''))
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
|
||||
const owners = []
|
||||
for (const daemon of racers) {
|
||||
if (await isDaemonReachable(socketPath, daemon.tokenPath, protocolVersion)) {
|
||||
owners.push(daemon)
|
||||
}
|
||||
}
|
||||
if (owners.length !== 1) {
|
||||
throw new Error(`expected one reachable racing daemon, found ${owners.length}`)
|
||||
}
|
||||
const survivor = owners[0]
|
||||
const loser = racers.find((daemon) => daemon !== survivor)
|
||||
const survivorInode = statSync(socketPath).ino
|
||||
if (survivorInode === deadInode) {
|
||||
throw new Error('survivor did not replace the dead endpoint entry')
|
||||
}
|
||||
log('racing daemon published over the dead entry and is reachable')
|
||||
|
||||
await killAndWait(loser.child)
|
||||
if (!existsSync(socketPath) || statSync(socketPath).ino !== survivorInode) {
|
||||
throw new Error("losing racer's exit removed the survivor's endpoint")
|
||||
}
|
||||
if (!(await isDaemonReachable(socketPath, survivor.tokenPath, protocolVersion))) {
|
||||
throw new Error("survivor became unreachable after the losing racer's exit")
|
||||
}
|
||||
if (!existsSync(survivor.pidPath)) {
|
||||
throw new Error('survivor lost its ownership record')
|
||||
}
|
||||
if (existsSync(loser.pidPath)) {
|
||||
throw new Error('losing racer left its ownership record behind')
|
||||
}
|
||||
|
||||
// Why a second, non-racing phase: above, both racers are awaited to ready-or-exit before a
|
||||
// winner is identified, so the loser has usually already gone and killing it proves little.
|
||||
// With a known-live incumbent the interleaving is forced rather than hoped for: the newcomer
|
||||
// must find the endpoint occupied, refuse to take it, and damage nothing on its way out.
|
||||
const survivorInodeBeforeBlocked = statSync(socketPath).ino
|
||||
const blocked = bootDaemon('blocked', dir, socketPath)
|
||||
daemons.push(blocked)
|
||||
await blocked.ready.then(
|
||||
() => {
|
||||
throw new Error('a daemon published onto an endpoint a live daemon already owned')
|
||||
},
|
||||
() => {
|
||||
// Expected: it cannot publish onto a live owner's name, so it exits instead.
|
||||
}
|
||||
)
|
||||
// Why pin the code: the launcher keys adoption of a live incumbent on exactly this exit
|
||||
// code, so a silent change to it would strand a concurrently starting app on local
|
||||
// non-persistent terminals with nothing failing.
|
||||
const blockedExit = await killAndWait(blocked.child)
|
||||
if (blockedExit !== DAEMON_EXIT_ENDPOINT_OCCUPIED) {
|
||||
throw new Error(
|
||||
`a daemon that lost the endpoint exited ${blockedExit}, not ${DAEMON_EXIT_ENDPOINT_OCCUPIED}`
|
||||
)
|
||||
}
|
||||
if (!existsSync(socketPath) || statSync(socketPath).ino !== survivorInodeBeforeBlocked) {
|
||||
throw new Error("a daemon that could not publish removed the live owner's endpoint")
|
||||
}
|
||||
if (!(await isDaemonReachable(socketPath, survivor.tokenPath, protocolVersion))) {
|
||||
throw new Error('live owner became unreachable after a newcomer failed to publish')
|
||||
}
|
||||
if (existsSync(blocked.pidPath)) {
|
||||
throw new Error('a daemon that could not publish left an ownership record behind')
|
||||
}
|
||||
log('a newcomer refused the live owner’s endpoint and left it intact')
|
||||
|
||||
log('PASS: the racing survivor remains reachable through the canonical endpoint')
|
||||
} finally {
|
||||
for (const daemon of daemons) {
|
||||
if (daemon.child.exitCode === null && daemon.child.signalCode === null) {
|
||||
try {
|
||||
daemon.child.kill('SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[endpoint-handover-smoke] FAIL: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
Executable
+109
@@ -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`
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,167 @@
|
||||
// Per-locale renderings that are machine-translation errors for a brand or product name, and get
|
||||
// reverted to the Latin original. Ordinary vocabulary that merely looks like a brand lives in
|
||||
// locale-generic-ui-terms.mjs and stays translated.
|
||||
export const BRAND_MISTRANSLATIONS = {
|
||||
ko: {
|
||||
Codex: ['사본', '코덱스'],
|
||||
Gemini: ['쌍둥이자리'],
|
||||
Claude: ['클로드'],
|
||||
Grok: ['그록'],
|
||||
Orca: ['오르카', '범고래'],
|
||||
Cursor: ['커서'],
|
||||
OpenCode: ['오픈코드'],
|
||||
OpenClaw: ['오픈클로'],
|
||||
OpenClaude: ['오픈클로드'],
|
||||
Antigravity: ['반중력'],
|
||||
Continue: ['계속하다'],
|
||||
Charm: ['매력'],
|
||||
Goose: ['거위'],
|
||||
Pi: ['파이'],
|
||||
'GitHub Copilot': ['GitHub 코파일럿', '코파일럿'],
|
||||
Git: ['힘내'],
|
||||
Discord: ['디스코드'],
|
||||
Linear: ['선형'],
|
||||
Agent: ['에이전트'],
|
||||
Agents: ['에이전트'],
|
||||
agent: ['에이전트'],
|
||||
agents: ['에이전트'],
|
||||
Commit: ['커밋'],
|
||||
Commits: ['커밋'],
|
||||
commit: ['커밋'],
|
||||
commits: ['커밋'],
|
||||
Markdown: ['마크다운', '가격 인하'],
|
||||
markdown: ['마크다운', '가격 인하'],
|
||||
Repo: ['저장소', '레포'],
|
||||
Repos: ['저장소', '레포'],
|
||||
repo: ['저장소', '레포'],
|
||||
repos: ['저장소', '레포'],
|
||||
Terminal: ['터미널'],
|
||||
Terminals: ['터미널'],
|
||||
terminal: ['터미널'],
|
||||
terminals: ['터미널']
|
||||
},
|
||||
zh: {
|
||||
Codex: ['法典'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['克洛德', '克劳德'],
|
||||
Grok: ['格罗克'],
|
||||
Orca: ['虎鲸', '逆戟鲸'],
|
||||
Cursor: ['光标'],
|
||||
OpenCode: ['开放代码'],
|
||||
OpenClaw: ['开爪'],
|
||||
OpenClaude: ['开放克劳德'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['继续'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['鹅'],
|
||||
Pi: ['圆周率'],
|
||||
Droid: ['机器人'],
|
||||
Kimi: ['基米'],
|
||||
// Why: zh-only — 登月计划 is the literal "moon landing programme", not Kimi's vendor. ja and
|
||||
// ko render this keyword their own way, so it must not go in the cross-locale preserve set.
|
||||
moonshot: ['登月计划'],
|
||||
'GitHub Copilot': ['GitHub 副驾驶', '副驾驶'],
|
||||
Bitbucket: ['位桶'],
|
||||
Linear: ['线性', '线形'],
|
||||
Jira: ['吉拉'],
|
||||
Tailscale: ['尾鳞', '尾鱗'],
|
||||
Agent: ['代理', '智能体'],
|
||||
Agents: ['代理', '智能体'],
|
||||
agent: ['代理', '智能体'],
|
||||
agents: ['代理', '智能体'],
|
||||
Commit: ['提交'],
|
||||
Commits: ['提交'],
|
||||
commit: ['提交'],
|
||||
commits: ['提交'],
|
||||
Markdown: ['降价'],
|
||||
markdown: ['降价'],
|
||||
Repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Terminal: ['终端', '端子'],
|
||||
Terminals: ['终端', '端子'],
|
||||
terminal: ['终端', '端子'],
|
||||
terminals: ['终端', '端子'],
|
||||
Bash: ['重击'],
|
||||
PowerShell: ['电源外壳'],
|
||||
REST: ['休息'],
|
||||
HEAD: ['头'],
|
||||
Swift: ['迅速'],
|
||||
Rust: ['锈'],
|
||||
'Claude Code': ['Claude·科德'],
|
||||
'Git AI Author': ['Git AI 作者']
|
||||
},
|
||||
ja: {
|
||||
Codex: ['法典', 'コーデックス'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['クロード'],
|
||||
Grok: ['グロック'],
|
||||
Orca: ['シャチ', '逆戟鲸', 'オルカ'],
|
||||
Cursor: ['カーソル'],
|
||||
OpenCode: ['オープンコード', 'オープン・コード'],
|
||||
OpenClaw: ['オープンクロー'],
|
||||
OpenClaude: ['オープンクロード'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['続ける', '続行'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['ガチョウ', '雁'],
|
||||
Pi: ['円周率'],
|
||||
Droid: ['ロボット', 'ドロイド'],
|
||||
'GitHub Copilot': ['GitHub コパイロット', 'コパイロット'],
|
||||
Discord: ['不和'],
|
||||
Linear: ['線形'],
|
||||
Agent: ['エージェント'],
|
||||
Agents: ['エージェント'],
|
||||
agent: ['エージェント'],
|
||||
agents: ['エージェント'],
|
||||
Commit: ['コミット'],
|
||||
Commits: ['コミット'],
|
||||
commit: ['コミット'],
|
||||
commits: ['コミット'],
|
||||
Markdown: ['マークダウン'],
|
||||
markdown: ['マークダウン'],
|
||||
Hermes: ['エルメス', 'ヘルメス'],
|
||||
PowerShell: ['パワーシェル'],
|
||||
Mermaid: ['マーメイド'],
|
||||
'Claude Code': ['Claude コード'],
|
||||
'VS Code': ['VS コード'],
|
||||
Repo: ['リポジトリ', 'リポ'],
|
||||
Repos: ['リポジトリ', 'リポ'],
|
||||
repo: ['リポジトリ', 'リポ'],
|
||||
repos: ['リポジトリ', 'リポ'],
|
||||
// 端子 is the zh mistranslation and never appears in ja; the ja one is 端末, which the phrase
|
||||
// fixes rewrite to ターミナル rather than back to Latin.
|
||||
Terminal: ['ターミナル'],
|
||||
Terminals: ['ターミナル'],
|
||||
terminal: ['ターミナル'],
|
||||
terminals: ['ターミナル']
|
||||
},
|
||||
es: {
|
||||
Codex: ['códice', 'Códice'],
|
||||
Gemini: ['Géminis'],
|
||||
Claude: ['claudia', 'Claudia'],
|
||||
Orca: ['orca', 'Orcas', 'orcas'],
|
||||
OpenCode: ['código abierto', 'Código abierto'],
|
||||
OpenClaude: ['Openclaude'],
|
||||
Antigravity: ['antigravedad', 'Antigravedad'],
|
||||
'GitHub Copilot': ['Copiloto de GitHub'],
|
||||
Discord: ['discordia'],
|
||||
Linear: ['lineal', 'Lineal'],
|
||||
Jira: ['jira'],
|
||||
Agent: ['Agente', 'agente'],
|
||||
Agents: ['Agentes', 'agentes'],
|
||||
agent: ['agente'],
|
||||
agents: ['agentes'],
|
||||
Commit: ['Confirmación', 'confirmación', 'Confirmar', 'Comprometerse'],
|
||||
Commits: ['Confirmaciones', 'confirmaciones', 'Compromisos', 'compromisos', 'Se compromete'],
|
||||
commit: ['confirmación', 'confirmar', 'comprometerse', 'compromiso'],
|
||||
commits: ['confirmaciones', 'compromisos'],
|
||||
Markdown: ['Reducción', 'reducción', 'Rebaja', 'rebaja', 'rebajas'],
|
||||
markdown: ['reducción', 'rebaja', 'rebajas'],
|
||||
Repo: ['Repositorio', 'repositorio'],
|
||||
Repos: ['Repositorios', 'repositorios'],
|
||||
repo: ['repositorio'],
|
||||
repos: ['repositorios']
|
||||
}
|
||||
}
|
||||
@@ -46,5 +46,16 @@ export const CJK_LATIN_SPACED_TERMS = [
|
||||
'PowerShell',
|
||||
'Git AI Author',
|
||||
'Token',
|
||||
'token'
|
||||
'token',
|
||||
'fast-forward',
|
||||
'Cookie',
|
||||
'Cookies',
|
||||
'upstream',
|
||||
'origin',
|
||||
'Hermes',
|
||||
'DuckDuckGo',
|
||||
'Mermaid',
|
||||
'VS Code',
|
||||
'IDE',
|
||||
'Git'
|
||||
]
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
// Japanese overrides for English values whose translation depends on the call site.
|
||||
export const JA_KEY_OVERRIDES = {
|
||||
// "on" is the toggle state here, but the preposition in the delete confirmation below.
|
||||
'auto.components.github.PRFilterSections.1e9b5244f2': { ja: 'オン' },
|
||||
'auto.components.settings.TerminalPane.29154326bb': { ja: 'オン' },
|
||||
'auto.components.automations.AutomationsPage.1b586f0e2b': { ja: 'の' },
|
||||
// Bare "Open" is the PR state elsewhere; these two are the action, as ko/zh/es render it.
|
||||
'auto.components.browser.pane.BrowserPane.756bfc25c9': { ja: '開く' },
|
||||
'auto.components.right.sidebar.checks.panel.content.7c1f0a2b11': { ja: '開く' }
|
||||
}
|
||||
@@ -1,9 +1,46 @@
|
||||
// Japanese phrase fixes from high-visibility UI audit rounds 1–4.
|
||||
// Why: keep locale-phrase-fixes.mjs under max-lines while preserving repair coverage.
|
||||
|
||||
const JA_SENTENCE_CHAR = '[-ヿ一-龯、。・「」()]'
|
||||
|
||||
// Two-word commands (`git commit`, `orca terminal`) must keep their second word in Latin.
|
||||
const COMMAND_HEAD = '(?<!\\b(?:git|gh|glab|orca|npm|pnpm|npx|yarn|docker|kubectl) )'
|
||||
|
||||
// #12113 stopped new damage; ~700 values still read "terminal を閉じる" and are healed here.
|
||||
// Anchored on adjacent Japanese, so `--agent`, agents.md and "Agent SDK" keep their spelling.
|
||||
const RELOCALIZED_GENERIC_TERMS = [
|
||||
['[Tt]erminals?', 'ターミナル', 'terminal'],
|
||||
['[Cc]ommits?', 'コミット', 'commit'],
|
||||
['[Rr]epos?', 'リポジトリ', 'repo'],
|
||||
// Agent stays Latin in ja; this only normalizes the case.
|
||||
['[Aa]gents?', 'Agent', 'agent'],
|
||||
['[Ww]orktrees?', 'ワークツリー', 'worktree']
|
||||
].flatMap(([latin, katakana, whenEnIncludes]) => [
|
||||
{
|
||||
// The trailing space only goes when Japanese follows, so 和欧間スペース survives before {{…}}.
|
||||
pattern: new RegExp(
|
||||
`(?<=${JA_SENTENCE_CHAR}) ?${COMMAND_HEAD}${latin}(?![-\\w.])(?! [A-Za-z])(?: (?=${JA_SENTENCE_CHAR}))?`,
|
||||
'g'
|
||||
),
|
||||
replacement: katakana,
|
||||
whenEnIncludes
|
||||
},
|
||||
{
|
||||
pattern: new RegExp(
|
||||
`(?<![-\\w])${COMMAND_HEAD}${latin}(?![-\\w.]) ?(?=${JA_SENTENCE_CHAR})`,
|
||||
'g'
|
||||
),
|
||||
replacement: katakana,
|
||||
whenEnIncludes
|
||||
}
|
||||
])
|
||||
|
||||
export const JA_PHRASE_FIXES = [
|
||||
...RELOCALIZED_GENERIC_TERMS,
|
||||
{ pattern: /エージェント/g, replacement: 'Agent', whenEnIncludes: 'agent' },
|
||||
{ pattern: /解雇/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
|
||||
{ pattern: /却下/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
|
||||
{ pattern: /代理人/g, replacement: 'エージェント', whenEnIncludes: 'agent' },
|
||||
{ pattern: /代理人/g, replacement: 'Agent', whenEnIncludes: 'agent' },
|
||||
{ pattern: /支店/g, replacement: 'ブランチ', whenEnIncludes: 'ranch' },
|
||||
{ pattern: /港(?!口)/g, replacement: 'ポート', whenEnIncludes: 'ort' },
|
||||
{ pattern: /会議/g, replacement: 'セッション', whenEnIncludes: 'session' },
|
||||
@@ -198,7 +235,7 @@ export const JA_PHRASE_FIXES = [
|
||||
{ pattern: /電話サイズ/g, replacement: 'スマートフォンサイズ', whenEnIncludes: 'phone' },
|
||||
{
|
||||
pattern: /コンピュータと電話/g,
|
||||
replacement: 'コンピューターとスマートフォン',
|
||||
replacement: 'コンピュータとスマートフォン',
|
||||
whenEnIncludes: 'phone'
|
||||
},
|
||||
{ pattern: /前の問題/g, replacement: 'フロントマター', whenEnIncludes: 'Front Matter' },
|
||||
@@ -235,11 +272,40 @@ export const JA_PHRASE_FIXES = [
|
||||
whenEnIncludes: 'More PR actions'
|
||||
},
|
||||
{ pattern: /アクション/g, replacement: '操作', whenEnIncludes: 'action' },
|
||||
{ pattern: /を選択してください/g, replacement: 'を選択', whenEnIncludes: 'Select' },
|
||||
{ pattern: /を選択してください/g, replacement: 'を選択', whenEnIncludes: 'Choose' },
|
||||
{ pattern: /入力してください/g, replacement: '入力', whenEnIncludes: 'Enter' },
|
||||
{ pattern: /追加してください/g, replacement: '追加', whenEnIncludes: 'Add' },
|
||||
{ pattern: /試してください/g, replacement: '試す', whenEnIncludes: 'Try' },
|
||||
// Orca's host covers SSH hosts and this computer; skipped where the English also says server.
|
||||
{
|
||||
pattern: /(ランタイム)?サーバー/g,
|
||||
replacement: 'ホスト',
|
||||
whenEnMatches: /^(?![\s\S]*\bservers?\b)[\s\S]*\bhosts?\b/i
|
||||
},
|
||||
{ pattern: /上流/g, replacement: 'upstream', whenEnIncludes: 'upstream' },
|
||||
{ pattern: /起源/g, replacement: 'origin', whenEnIncludes: 'origin' },
|
||||
{ pattern: /段階的な変更/g, replacement: 'ステージ済みの変更', whenEnIncludes: 'staged' },
|
||||
{ pattern: /紛争/g, replacement: '競合', whenEnIncludes: 'conflict' },
|
||||
{ pattern: /資格情報/g, replacement: '認証情報', whenEnIncludes: 'credential' },
|
||||
{ pattern: /未知/g, replacement: '不明', whenEnIncludes: 'unknown' },
|
||||
{ pattern: /クッキー/g, replacement: 'Cookie', whenEnIncludes: 'cookie' },
|
||||
{ pattern: /プロフィール/g, replacement: 'プロファイル', whenEnIncludes: 'profile' },
|
||||
{ pattern: /(?<![ンプリ])ロード中/g, replacement: '読み込み中', whenEnIncludes: 'load' },
|
||||
{
|
||||
pattern: /(?<![ンプリ])ロードしています/g,
|
||||
replacement: '読み込んでいます',
|
||||
whenEnIncludes: 'load'
|
||||
},
|
||||
{ pattern: /構成されて/g, replacement: '設定されて', whenEnIncludes: 'configur' },
|
||||
{ pattern: /第一方/g, replacement: 'ファーストパーティ', whenEnIncludes: 'first-party' },
|
||||
{ pattern: /早送り/g, replacement: 'fast-forward', whenEnIncludes: 'fast-forward' },
|
||||
{ pattern: /ファストフォワード/g, replacement: 'fast-forward', whenEnIncludes: 'fast-forward' },
|
||||
{ pattern: /([-ヿ一-龯])\.\.\./g, replacement: '$1…', whenEnMatches: /./ },
|
||||
{ pattern: /([゠-ヿ]) (?=[゠-ヿ])/g, replacement: '$1', whenEnMatches: /./ },
|
||||
{ pattern: /サーバ(?!ー)/g, replacement: 'サーバー', whenEnMatches: /./ },
|
||||
{ pattern: /フォルダ(?!ー)/g, replacement: 'フォルダー', whenEnMatches: /./ },
|
||||
{ pattern: /エディタ(?!ー)/g, replacement: 'エディター', whenEnMatches: /./ },
|
||||
{ pattern: /レンダラ(?!ー)/g, replacement: 'レンダラー', whenEnMatches: /./ },
|
||||
{ pattern: /ブラウザー/g, replacement: 'ブラウザ', whenEnMatches: /./ },
|
||||
{ pattern: /エミュレーター/g, replacement: 'エミュレータ', whenEnMatches: /./ },
|
||||
{ pattern: /コンピューター/g, replacement: 'コンピュータ', whenEnMatches: /./ },
|
||||
{ pattern: /インターフェイス/g, replacement: 'インターフェース', whenEnMatches: /./ },
|
||||
// Why: JP engineers use "Issue" in Latin, not katakana. Runs last so all *→イシュー fixes above normalize to Issue.
|
||||
{ pattern: /イシュー/g, replacement: 'Issue', whenEnIncludes: 'issue' }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// One rendering per English string. 215 sources carried two or more Japanese forms; picks follow
|
||||
// the catalog majority — action labels drop する, completion notices use 〜しました, status labels
|
||||
// are 体言, and デフォルト / フィルター / スコープ / ディストリビューション / 並べ替え win their pairs.
|
||||
export const JA_UNIFIED_VALUES = {
|
||||
"Can't reach {{value0}}": '{{value0}} に接続できません',
|
||||
'Copy Address': 'アドレスをコピー',
|
||||
'Open Externally': '外部で開く',
|
||||
'Show Orca Mobile Button': 'Orca Mobile ボタンを表示',
|
||||
Paste: '貼り付け',
|
||||
'WSL default': 'WSL のデフォルト',
|
||||
'Unable to open the workspace attached to this issue.':
|
||||
'この Issue にリンクされたワークスペースを開けませんでした。',
|
||||
'Failed to connect to project.': 'プロジェクトへの接続に失敗しました。',
|
||||
'Dev-only tools for exercising UI states.': 'UI の状態を確認するための開発者専用ツール。',
|
||||
'Start new workspace': '新規ワークスペースを開始',
|
||||
'Open workspace attached to issue': 'Issue にリンクされたワークスペースを開く',
|
||||
'No one assigned': '担当者なし',
|
||||
'Edit assignees': '担当者を編集',
|
||||
'Edit labels on GitHub': 'GitHub でラベルを編集',
|
||||
'check #': 'チェック #',
|
||||
Started: '開始済み',
|
||||
'Status:': 'ステータス:',
|
||||
'Loading check details…': 'チェックの詳細を読み込んでいます…',
|
||||
'No description provided.': '説明はありません。',
|
||||
'Review comment added.': 'レビューコメントを追加しました。',
|
||||
Copied: 'コピーしました',
|
||||
'No activity yet.': 'まだアクティビティはありません。',
|
||||
Merge: 'マージ',
|
||||
'Create workspace': 'ワークスペースを作成',
|
||||
'Loading labels': 'ラベルを読み込んでいます',
|
||||
Hide: '非表示',
|
||||
Resolve: '解決',
|
||||
'Start workspace': 'ワークスペースを開始',
|
||||
Unassigned: '未割り当て',
|
||||
'Copy prompt': 'プロンプトをコピー',
|
||||
'Failed to copy {{value0}}': '{{value0}} のコピーに失敗しました',
|
||||
'{{value0}} copied': '{{value0}} をコピーしました',
|
||||
'Add project': 'プロジェクトを追加',
|
||||
'Cloning...': 'クローン作成中…',
|
||||
'Add SSH host': 'SSH ホストを追加',
|
||||
Later: '後で',
|
||||
"Don't ask again": '今後表示しない',
|
||||
'Atlassian account settings': 'Atlassian のアカウント設定',
|
||||
'Create a token in': 'でトークンを作成します',
|
||||
'Atlassian API token': 'Atlassian API トークン',
|
||||
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.':
|
||||
'Jira Cloud のサイト URL、Atlassian のメールアドレス、API トークンを使って Issue を閲覧します。',
|
||||
'Connect Jira site': 'Jira サイトに接続',
|
||||
Updated: '更新済み',
|
||||
Owner: 'オーナー',
|
||||
'Connect Jira': 'Jira に接続',
|
||||
'Change the query or clear it.': '検索条件を変更するか、クリアしてください。',
|
||||
'No matching GitHub work': '一致する GitHub の作業はありません',
|
||||
'No GitLab work matches this filter.': 'このフィルターに一致する GitLab の作業はありません。',
|
||||
'No GitLab MRs match this filter.': 'このフィルターに一致する GitLab の MR はありません。',
|
||||
'No GitLab issues match this filter.': 'このフィルターに一致する GitLab の Issue はありません。',
|
||||
Resume: '再開',
|
||||
github: 'GitHub',
|
||||
'Could not build launch command for {{value0}}.':
|
||||
'{{value0}} の起動コマンドを作成できませんでした。',
|
||||
Update: '更新',
|
||||
'Re-check': '再確認',
|
||||
'Checks passed': 'チェックに合格しました',
|
||||
'Copy command': 'コマンドをコピー',
|
||||
Pinned: 'ピン留め済み',
|
||||
'Remove {{value0}} filter': '{{value0}} フィルターを解除',
|
||||
selected: '選択中',
|
||||
'Remove {{value0}}': '{{value0}} を削除',
|
||||
'Last active': '最終アクティブ',
|
||||
'Unpushed commits': '未プッシュのコミット',
|
||||
Ignored: '無視済み',
|
||||
Sort: '並べ替え',
|
||||
Context: 'コンテキスト',
|
||||
'Delete {{value0}}': '{{value0}} を削除',
|
||||
Commits: 'コミット',
|
||||
'Expand details': '詳細を展開',
|
||||
'Uncommitted changes': '未コミットの変更',
|
||||
'No projects match your search.': '検索条件に一致するプロジェクトはありません。',
|
||||
Scope: 'スコープ',
|
||||
'Context copied': 'コンテキストをコピーしました',
|
||||
'Worktree not ready — try again in a moment.':
|
||||
'ワークツリーが準備できていません — しばらくしてから再試行してください。',
|
||||
'Open a workspace before resuming a session.':
|
||||
'セッションを再開する前に、ワークスペースを開いてください。',
|
||||
Run: '実行',
|
||||
'ios simulator': 'iOS シミュレータ',
|
||||
'Kill session {{value0}}': 'セッション {{value0}} を強制終了',
|
||||
Disconnected: '切断済み',
|
||||
'OpenCode Go Usage': 'OpenCode Go の使用量',
|
||||
'Switch to': '切り替え先',
|
||||
'Update failed': '更新に失敗しました',
|
||||
cancelling: 'キャンセル中',
|
||||
Unavailable: '利用不可',
|
||||
'Usage unavailable': '使用量を取得できません',
|
||||
Usage: '使用量',
|
||||
Bundled: '同梱',
|
||||
'Update available': '更新があります',
|
||||
Skipped: 'スキップ済み',
|
||||
'1 update available': '1 件の更新があります',
|
||||
'{{value0}} updates available': '{{value0}} 件の更新があります',
|
||||
Stop: '停止',
|
||||
'Up to date': '最新',
|
||||
'Host path': 'ホストパス',
|
||||
'Open project on SSH host': 'SSH ホスト上のプロジェクトを開く',
|
||||
'Clone from URL': 'URL からクローン',
|
||||
more: '件以上',
|
||||
'Keep hidden': '非表示のままにする',
|
||||
Manual: '手動',
|
||||
Automation: 'オートメーション',
|
||||
Notes: 'ノート',
|
||||
'Board settings': 'ボード設定',
|
||||
'Project on SSH host': 'SSH ホスト上のプロジェクト',
|
||||
'Created by automation': 'オートメーションによって作成',
|
||||
'Open in Orca': 'Orca で開く',
|
||||
'Failed to copy link': 'リンクのコピーに失敗しました',
|
||||
'Delete Worktree': 'ワークツリーを削除',
|
||||
rename: '名前変更',
|
||||
'Create workspace for {{value0}}': '{{value0}} のワークスペースを作成',
|
||||
'Browse folder': 'フォルダーを参照',
|
||||
'{{value0}} failed': '{{value0}} 件失敗',
|
||||
Neutral: '中立',
|
||||
'Set up': 'セットアップ',
|
||||
'Add a persistent machine you can log into over SSH.':
|
||||
'SSH でログインできる永続的なマシンを追加します。',
|
||||
'on the server and paste the printed pairing URL.':
|
||||
'をサーバーで実行し、出力されたペアリング URL を貼り付けます。',
|
||||
'Keep Local Main Up to Date': 'ローカル main を最新の状態に保つ',
|
||||
Saved: '保存済み',
|
||||
Replace: '置換',
|
||||
'Account scope': 'アカウントスコープ',
|
||||
'Agent Permissions': 'Agent の権限',
|
||||
'Show the Orca Mobile button at the top of the left sidebar.':
|
||||
'左サイドバー上部に Orca Mobile ボタンを表示します。',
|
||||
'Enable Orca CLI': 'Orca CLI を有効化',
|
||||
'Registered the Orca CLI in PATH.': 'Orca CLI を PATH に登録しました。',
|
||||
'Failed to load CLI status.': 'CLI ステータスの読み込みに失敗しました。',
|
||||
'Fix PATH': 'PATH を修正',
|
||||
installed: 'インストール済み',
|
||||
cli: 'CLI',
|
||||
'Opened macOS Privacy & Security': 'macOS のプライバシーとセキュリティを開きました',
|
||||
'Could not request permission': '権限をリクエストできませんでした',
|
||||
Shown: '表示中',
|
||||
'Clear all': 'すべてクリア',
|
||||
'Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.':
|
||||
'接続された各 Linear ワークスペースには、アクティブなランタイムが保存したキーが 1 つあります。フルアクセスキーはキー所有者がアクセスできるすべてのチームを対象にできます。制限付きキーはいつでも置き換えられます。',
|
||||
'Add workspace access': 'ワークスペースへのアクセスを追加',
|
||||
'Learn more': '詳しく見る',
|
||||
'Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.':
|
||||
'Gitea の認証情報は設定されていますが、認証できませんでした。トークン、API ベース URL、リポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
|
||||
'Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.':
|
||||
'Azure DevOps の認証情報は設定されていますが、認証できませんでした。トークン、API ベース URL、リポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
|
||||
'Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.':
|
||||
'Bitbucket の認証情報は設定されていますが、認証できませんでした。トークンとリポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
|
||||
'The GitLab CLI is installed but not authenticated. Run this command in a terminal:':
|
||||
'GitLab CLI はインストールされていますが、認証されていません。ターミナルで次のコマンドを実行してください:',
|
||||
'Install GitLab CLI': 'GitLab CLI をインストール',
|
||||
'CLI.': 'CLI。',
|
||||
'The GitHub CLI is installed but not authenticated. Run this command in a terminal:':
|
||||
'GitHub CLI はインストールされていますが、認証されていません。ターミナルで次のコマンドを実行してください:',
|
||||
'Install GitHub CLI': 'GitHub CLI をインストール',
|
||||
'Disconnect {{value0}}': '{{value0}} の接続を解除',
|
||||
'Add Linear access to browse and link issues.':
|
||||
'Linear アクセスを追加して、Issue を閲覧およびリンクします。',
|
||||
'Pull requests and build statuses via Azure DevOps REST API tokens.':
|
||||
'Azure DevOps REST API トークン経由の PR とビルドステータス。',
|
||||
'Pull requests and build statuses via Bitbucket Cloud API tokens.':
|
||||
'Bitbucket Cloud API トークン経由の PR とビルドステータス。',
|
||||
'Reload from Disk': 'ディスクから再読み込み',
|
||||
'Refresh network interfaces': 'ネットワークインターフェースを更新',
|
||||
'CLI arguments': 'CLI 引数',
|
||||
Created: '作成日時',
|
||||
Switch: '切り替え',
|
||||
'Status unavailable': 'ステータスを取得できません',
|
||||
'Checking…': '確認中…',
|
||||
'Updating servers…': 'サーバーを更新しています…',
|
||||
'{{value0}} shortcut': '{{value0}} のショートカット',
|
||||
'Connect {{provider}}': '{{provider}} に接続',
|
||||
'Try again': '再試行',
|
||||
'Windows default': 'Windows のデフォルト',
|
||||
'Loading distributions': 'ディストリビューションを読み込んでいます',
|
||||
'WSL Distribution': 'WSL ディストリビューション',
|
||||
'Default Shell': 'デフォルトのシェル',
|
||||
'Right-click pastes the clipboard. Ctrl+right-click opens the context menu.':
|
||||
'右クリックでクリップボードを貼り付けます。Ctrl+右クリックでコンテキストメニューを開きます。',
|
||||
credentials: '認証情報',
|
||||
display: 'ディスプレイ',
|
||||
setup: 'セットアップ',
|
||||
note: 'メモ',
|
||||
update: '更新',
|
||||
'default branch': 'デフォルトブランチ',
|
||||
target: '対象',
|
||||
'Client default': 'クライアントのデフォルト',
|
||||
'Select distro': 'ディストリビューションを選択',
|
||||
'Sign out': 'サインアウト',
|
||||
'Available on': 'で利用可能',
|
||||
'Update this server manually once to enable remote updates.':
|
||||
'リモート更新を有効にするには、このサーバーを一度手動で更新してください。',
|
||||
Official: '公式',
|
||||
'Open on {{value0}}': '{{value0}} で開く',
|
||||
'Create {{value0}}': '{{value0}} を作成',
|
||||
'{{value0}} #{{value1}} is already open': '{{value0}} #{{value1}} はすでに開いています',
|
||||
'Copy Details': '詳細をコピー',
|
||||
'Clear Notes': 'ノートをクリア',
|
||||
'Fix with AI': 'AI で修正',
|
||||
'AI Fix': 'AI 修正',
|
||||
'Committed on Branch': 'コミット先のブランチ',
|
||||
Changes: '変更点',
|
||||
'Filter files by name': 'ファイル名でフィルター',
|
||||
'Choose the agent and edit the full command input before launch.':
|
||||
'起動前に Agent を選択し、コマンド入力全体を編集してください。',
|
||||
'Unable to resolve the workspace connection.': 'ワークスペースの接続を解決できません。',
|
||||
'Branch has unpushed commits': 'ブランチに未プッシュのコミットがあります',
|
||||
'No enabled AI agents. Configure agents in Settings.':
|
||||
'有効な AI Agent がありません。[設定] で Agent を構成してください。',
|
||||
'Saved AI agent is unavailable. Use Customize launch to choose another agent.':
|
||||
'保存された AI Agent は使用できません。[起動のカスタマイズ] から別の Agent を選択してください。',
|
||||
'Commit failure prompt is empty. Update Source Control AI settings.':
|
||||
'コミット失敗プロンプトが空です。ソース管理の AI 設定を更新してください。',
|
||||
'Create PR': 'PR を作成',
|
||||
'{{value0}} msgs': '{{value0}} 件のメッセージ',
|
||||
'No projects/folders match your search.': '検索に一致するプロジェクト/フォルダーはありません。',
|
||||
Keep: '保持',
|
||||
'Skill command copied and inserted below for review.':
|
||||
'スキルコマンドをコピーし、確認用に以下へ挿入しました。',
|
||||
'Try it out': '試してみる',
|
||||
'1 pending': '保留中 1 件',
|
||||
'Zoom in': 'ズームイン',
|
||||
'Zoom out': 'ズームアウト',
|
||||
Undo: '元に戻す',
|
||||
'{{value0}} failing': '{{value0}} 件失敗',
|
||||
'{{value0}} pending': '{{value0}} 件保留中',
|
||||
'Draft review': '下書きレビュー',
|
||||
'Switch profile': 'プロファイルを切り替え',
|
||||
'Draw on the page, then copy the markup to paste into your agent.':
|
||||
'ページに描き込み、マークアップをコピーして Agent に貼り付けます。',
|
||||
// Grab mode picks a page element and hands it to the AI; 掴む / グラブモード were literal.
|
||||
Grab: '取得',
|
||||
'Grab mode is unavailable in the web client.':
|
||||
'要素の取得モードは Web クライアントでは使用できません。',
|
||||
agent: 'Agent',
|
||||
'{{value0}} workspace{{value1}} connected':
|
||||
'{{value0}} ワークスペース{{value1}} が接続されました',
|
||||
Plugins: 'プラグイン',
|
||||
'Orca will continue to work, but commands such as orca status, orca terminal, and orchestration are unavailable for this session.\n\n{{guidance}}\n\nCause: {{cause}}':
|
||||
'Orca は引き続き使用できますが、このセッションでは orca status や orca terminal などのコマンドとオーケストレーションを利用できません。\n\n{{guidance}}\n\n原因: {{cause}}'
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
import { JA_UNIFIED_VALUES } from './locale-ja-unified-values.mjs'
|
||||
|
||||
// Japanese value overrides from high-visibility UI audit rounds 1–4.
|
||||
// Why: keep locale-value-overrides.mjs under max-lines while preserving exact-match repairs.
|
||||
export const JA_VALUE_OVERRIDES = {
|
||||
const JA_AUDIT_OVERRIDES = {
|
||||
'Retry loading presets': 'プリセットの読み込みを再試行',
|
||||
'Retry Download': 'ダウンロードを再試行',
|
||||
'Enter a reviewer': 'レビュアーを入力',
|
||||
'Choose folder': 'フォルダーを選択',
|
||||
'Choose pet': 'ペットを選択',
|
||||
'Choose a sound': 'サウンドを選択',
|
||||
'Pick your default agent': 'デフォルトのエージェントを選択',
|
||||
'Pick your default agent': 'デフォルトの Agent を選択',
|
||||
'Complete {{artifact_url}}': '{{artifact_url}} を完了',
|
||||
'Control terminals and agents from your phone.':
|
||||
'スマートフォンからターミナルとエージェントを操作',
|
||||
'スマートフォンからターミナルと Agent を操作します。',
|
||||
'Add reviewer': 'レビュアーを追加',
|
||||
Reviewer: 'レビュアー',
|
||||
Reviewers: 'レビュアー',
|
||||
'Fix broken checks': '失敗したチェックを修正',
|
||||
'No broken checks to fix.': '修正が必要な失敗したチェックはありません',
|
||||
'No broken checks to fix.': '修正が必要なチェックはありません。',
|
||||
'Started an AI agent for the broken checks.':
|
||||
'失敗したチェックに対して AI エージェントを開始しました。',
|
||||
'失敗したチェックに対して AI Agent を開始しました。',
|
||||
'Failed to start an AI agent for the broken checks: {{value0}}':
|
||||
'失敗したチェックのため AI エージェントを開始できませんでした: {{value0}}',
|
||||
'失敗したチェックのため AI Agent を開始できませんでした: {{value0}}',
|
||||
'Checks unavailable': 'チェックは利用できません',
|
||||
'Checks pending': '保留中のチェック',
|
||||
checks: 'チェック',
|
||||
@@ -92,7 +94,7 @@ export const JA_VALUE_OVERRIDES = {
|
||||
Launch: '起動',
|
||||
'Launch:': '起動:',
|
||||
'Launch plan': '起動プラン',
|
||||
'Launch agent': 'エージェントを起動',
|
||||
'Launch agent': 'Agent を起動',
|
||||
'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動',
|
||||
Play: '再生',
|
||||
Action: '操作',
|
||||
@@ -148,8 +150,8 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Select a workspace to browse files': 'ファイルを参照するワークスペースを選択',
|
||||
'Select a workspace to search': '検索するワークスペースを選択',
|
||||
'Select a workspace to view changes': '変更を表示するワークスペースを選択',
|
||||
'Choose agent to fix commit failure': 'コミット失敗を修正するエージェントを選択',
|
||||
'Choose an agent for this commit failure': 'このコミット失敗に対するエージェントを選択',
|
||||
'Choose agent to fix commit failure': 'コミット失敗を修正する Agent を選択',
|
||||
'Choose an agent for this commit failure': 'このコミット失敗に対する Agent を選択',
|
||||
'Enter a commit message to commit': 'コミットメッセージを入力してコミット',
|
||||
'Choose parent folder...': '親フォルダーを選択...',
|
||||
'Check for stuck work, stale generated files, failing validation, and anything that needs human attention. Report only actionable issues.':
|
||||
@@ -234,5 +236,313 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Cursor Opacity': 'カーソルの不透明度',
|
||||
'Cursor Shape': 'カーソル形状',
|
||||
'Blinking Cursor': 'カーソルの点滅',
|
||||
'Terminal Cursor': 'Terminal カーソル'
|
||||
'Terminal Cursor': 'ターミナルのカーソル',
|
||||
'Local folders and Git repositories': 'ローカルフォルダーと Git リポジトリ',
|
||||
'Started an AI agent for the conflicts.': '競合の解決のために AI Agent を開始しました。',
|
||||
'Previous conflict': '前の競合',
|
||||
'1 commit ahead of {{value0}}': '{{value0}} より 1 コミット進んでいます',
|
||||
'1 commit behind {{value0}}': '{{value0}} より 1 コミット遅れています',
|
||||
'3 commits ahead': '3 コミット進んでいます',
|
||||
'Fast-forwarded {{branch}} by 1 commit.': '{{branch}} を 1 コミット分 fast-forward しました。',
|
||||
'e.g. cloudflared access ssh --hostname %h': '例: cloudflared access ssh --hostname %h',
|
||||
'Git is not installed': 'Git がインストールされていません',
|
||||
'CLI not installed': 'CLI がインストールされていません',
|
||||
'Locate SDK folder': 'SDK フォルダーを選択',
|
||||
'Not found. Install Android Studio, then create a Virtual Device.':
|
||||
'見つかりません。Android Studio をインストールしてから、仮想デバイスを作成してください。',
|
||||
'ANSI blue color': 'ANSI青色',
|
||||
'MR !{{value0}}': 'MR !{{value0}}',
|
||||
mrs: 'MR',
|
||||
'No human comments.': 'ユーザーによるコメントはありません。',
|
||||
'Any state': 'すべての状態',
|
||||
State: '状態',
|
||||
Health: '健全性',
|
||||
Ordering: '並べ替え',
|
||||
Start: '開始',
|
||||
'Local {{value0}} is behind {{value1}}': 'ローカル {{value0}} は {{value1}} より遅れています',
|
||||
'behind main': 'main より遅れています',
|
||||
'behind (base commit:': '遅れ (ベースコミット:',
|
||||
'No pending todos. You’re all caught up!': '保留中の ToDo はありません。すべて完了しています!',
|
||||
High: '高',
|
||||
Medium: '中',
|
||||
Low: '低',
|
||||
'Split Terminal Down': 'ターミナルを下に分割',
|
||||
'Split Terminal Right': 'ターミナルを右に分割',
|
||||
'Split Up': '上に分割',
|
||||
'Split Down': '下に分割',
|
||||
"Install the Browser Use skill so agents can operate Orca's browser.":
|
||||
'Browser Use スキルをインストールすると、 Agent が Orca のブラウザを操作できるようになります。',
|
||||
'bold ·': '太字 ·',
|
||||
'Duplicate of another issue in this repository': 'このリポジトリ内の別の Issue と重複',
|
||||
'Choose a different issue.': '別の Issue を選択してください。',
|
||||
'No matching issues loaded.': '一致する Issue がありません。',
|
||||
'Duplicate of another issue': '別の Issue と重複',
|
||||
'mentioned this': 'がこの Issue をメンションしました',
|
||||
'closed this': 'がこの Issue をクローズしました',
|
||||
'reopened this': 'がこの Issue を再オープンしました',
|
||||
'moved this': 'がこの Issue を移動しました',
|
||||
'Failed to save setup startup behavior.': 'セットアップ時の起動設定を保存できませんでした。',
|
||||
'Failed to resolve MR base.': 'MR のベースブランチを特定できませんでした。',
|
||||
'Failed to copy context.': 'コンテキストのコピーに失敗しました。',
|
||||
// 3-way split: [this][query]["]. The closing side is already 」, so end the sentence here.
|
||||
'No settings found for "': '検索条件に一致する設定が見つかりませんでした:「',
|
||||
"Doesn't apply to agents where you've overridden launch arguments.":
|
||||
'起動引数を上書きしている Agent には適用されません。',
|
||||
'Check for Server Updates': 'サーバーの更新を確認',
|
||||
'All servers are up to date.': 'すべてのサーバーが最新です。',
|
||||
'Open worktree': 'ワークツリーを開く',
|
||||
'Update this server': 'このサーバーを更新',
|
||||
'Git repository': 'Git リポジトリ',
|
||||
'{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.':
|
||||
'{{host}} は保存済みの SSH ホストではなくなったため、このワークスペースは稼働中のホストに接続されていません。Orca から削除することのみ可能です。リモート上のファイルとブランチはそのまま残ります。',
|
||||
'The app shell could not finish rendering. Retry to remount it, or relaunch Orca if the error persists.':
|
||||
'アプリシェルのレンダリングを完了できませんでした。再マウントを試すか、エラーが解決しない場合は Orca を再起動してください。',
|
||||
'Workspaces are unavailable on a mobile-scope pairing. Reconnect using the browser access link from Settings → Runtime Environments → Share this Orca server.':
|
||||
'モバイル向けペアリングではワークスペースを使用できません。[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] から、ブラウザ用リンクで再接続してください。',
|
||||
'Change this later in Settings → Browser.': '後で [設定] → [ブラウザ] から変更できます。',
|
||||
'Orca Cloud sign-in is not configured': 'Orca Cloud へのサインインが設定されていません',
|
||||
'{{provider}} could not authenticate the credentials available in this environment. Check the {{provider}} login or environment token, then retry.':
|
||||
'{{provider}} はこの環境で利用できる認証情報で認証できませんでした。{{provider}} のログインまたは環境トークンを確認して、再試行してください。',
|
||||
'e.g. ollama run llama3.1 {prompt}': '例: ollama run llama3.1 {prompt}',
|
||||
'e.g. ollama run llama3.1 {{value0}}': '例: ollama run llama3.1 {{value0}}',
|
||||
'Connecting terminal...': 'ターミナルに接続中…',
|
||||
'Starring…': 'スターを付けています…',
|
||||
'self-hosted': 'セルフホスト',
|
||||
'not-authenticated': '未認証',
|
||||
'side-by-side': '左右に並べて',
|
||||
'Side-by-side': '左右に並べて',
|
||||
'Pull requests and commit statuses via the Gitea REST API.':
|
||||
'Gitea REST API 経由の PR とコミットステータス。',
|
||||
Key: 'キー',
|
||||
slug: 'スラッグ',
|
||||
// Rendered as [fragment][var name][fragment] by two cards that share these fragments.
|
||||
'Public repositories are detected from their git remote. Set':
|
||||
'パブリックリポジトリは git リモートから検出されます。設定:',
|
||||
'for private repositories, and set': '(プライベートリポジトリ用)。また、',
|
||||
'only when Orca cannot derive the API base URL from the git remote.':
|
||||
'は、Orca が git リモートから API のベース URL を取得できない場合のみ設定します。',
|
||||
'only when Orca cannot derive the API URL from the remote.':
|
||||
'は、Orca がリモートから API URL を取得できない場合のみ設定します。',
|
||||
Set: '設定:',
|
||||
'. Set': '。設定:',
|
||||
', or set': '、または',
|
||||
and: 'と',
|
||||
// Concatenated: the leading space is part of the string, not padding.
|
||||
' and ': ' と ',
|
||||
' in {{value0}}': '({{value0}})',
|
||||
' • Last scan error: {{value0}}': ' • 最終スキャンエラー: {{value0}}',
|
||||
' vs {{value0}}': ' vs {{value0}}',
|
||||
'When you create a workspace, Orca refreshes the remote base and safely fast-forwards your matching local branch, such as':
|
||||
'ワークスペースを作成すると、Orca はリモートベースを更新し、一致するローカルブランチを安全に fast-forward します。対象は',
|
||||
'. This keeps commands like': '。これにより、',
|
||||
'from comparing against stale history. Orca skips the update if that branch has uncommitted changes or local-only commits.':
|
||||
'などのコマンドが古い履歴と比較されるのを防ぎます。対象のブランチにコミットしていない変更やローカルのみのコミットがある場合、Orca は更新をスキップします。',
|
||||
'Supports skills, file paths, and built-in commands like':
|
||||
'スキル、ファイルパス、および次のような組み込みコマンドに対応しています:',
|
||||
' · {{value0}} external': ' · 外部 {{value0}} 件',
|
||||
'Use your current': '現在のアカウントを使用',
|
||||
Running: '実行中',
|
||||
'Back to runs': '実行一覧に戻る',
|
||||
Failed: '失敗',
|
||||
Unknown: '不明',
|
||||
'Identity file': '秘密鍵ファイル',
|
||||
'Identity File': '秘密鍵ファイル',
|
||||
key: 'キー',
|
||||
failing: '失敗',
|
||||
'Selected host': '選択したホスト',
|
||||
'Copied cleanup payload.': 'クリーンアップ用ペイロードをコピーしました。',
|
||||
'Jump to...': '移動先…',
|
||||
'Start work': '作業を開始',
|
||||
'Remove {{value0}} contained {{value1}} from Orca':
|
||||
'含まれている {{value1}} {{value0}} 件を Orca から削除',
|
||||
'People in {{orgName}} who can collaborate on Orca.':
|
||||
'{{orgName}} で Orca を共同利用できるメンバーです。',
|
||||
'People in your organization who can collaborate on Orca.':
|
||||
'組織内で Orca を共同利用できるメンバーです。',
|
||||
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.':
|
||||
'ソース管理のコミット、PR、ブランチ名、修正操作に使うアクションレシピを追加します。',
|
||||
'Repo fallback for text actions that select Custom command.':
|
||||
'「カスタムコマンド」を選択したテキスト操作に使う、リポジトリ単位のフォールバック。',
|
||||
'Recipe-created runtimes are workspace-owned. Clean up stale entries after crashes, failed creates, or manual recovery.':
|
||||
'レシピで作成されたランタイムはワークスペースに紐づいています。クラッシュ、作成失敗、手動リカバリの後に残った古いエントリをクリーンアップします。',
|
||||
'Failed to register the recipe-created project root on the runtime.':
|
||||
'レシピによって作成されたプロジェクトルートをランタイムに登録できませんでした。',
|
||||
'Update all {{value0}} servers': '{{value0}} 台のサーバーをすべて更新',
|
||||
'This QR code grants limited (mobile) access. To use the full web app, open the browser access link from Settings → Runtime Environments → Share this Orca server → New Link.':
|
||||
'この QR コードでは、モバイル向けの制限付きアクセスのみ利用できます。完全版の Web アプリを使用するには、[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] → [新規リンク] から、ブラウザ用アクセスリンクを開いてください。',
|
||||
'Loading skills...': 'スキルを読み込み中…',
|
||||
'Start agents on your tasks without leaving Orca': 'Orca から離れずに、タスクから Agent を開始',
|
||||
'Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}':
|
||||
'ワークスペースは {{value0}} から作成されましたが、{{value2}} のため Orca はローカル {{value1}} を fast-forward できませんでした',
|
||||
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.':
|
||||
'このワークスペースでは、ターミナル、ブラウザ、またはエディターのレンダリングに失敗しました。再マウントを試してください。',
|
||||
'The dashboard could not finish rendering. Retry to remount it, or reopen it.':
|
||||
'ダッシュボードのレンダリングを完了できませんでした。再マウントを試すか、開き直してください。',
|
||||
|
||||
'Review the prompt before starting an agent.':
|
||||
'Agent を開始する前に、プロンプトを確認してください。',
|
||||
'Use Add Project to enter a path on the selected host.':
|
||||
'[プロジェクトの追加] から、選択したホスト上のパスを入力します。',
|
||||
'Use repo-relative paths like packages/web or apps/api.':
|
||||
'packages/web や apps/api などのリポジトリ相対パスを使用します。',
|
||||
'orca.yaml first, then your local commands.': 'まず orca.yaml、次にローカルのコマンドの順です。',
|
||||
'Rename Orca-created branches from the initial agent task.':
|
||||
'Orca が作成したブランチの名前を、最初の Agent タスクに基づいて変更します。',
|
||||
'Dim files matched by .gitignore in the file explorer.':
|
||||
'ファイルエクスプローラーで、.gitignore に一致するファイルを薄く表示します。',
|
||||
'Command line Orca runs when a text recipe uses Custom command.':
|
||||
'テキストレシピで「カスタムコマンド」を使用したときに Orca が実行するコマンドライン。',
|
||||
'Let programs in the terminal copy to the system clipboard through OSC 52, including over SSH.':
|
||||
'ターミナル内のプログラムが、OSC 52 を使ってシステムのクリップボードにコピーできるようにします(SSH 経由を含む)。',
|
||||
'Choose which optional saved Codex account powers live quota reads.':
|
||||
'ライブクォータの取得に使用する、保存済みの Codex アカウント(任意)を選択します。',
|
||||
'This plugin has no worker process. Its instructional content can still cause actions when you or an agent use it. Review the instructions and commands below before enabling it.':
|
||||
'このプラグインにはワーカープロセスがありません。それでも、ユーザーや Agent が使用したときに、記載された手順が操作を引き起こす可能性があります。有効にする前に、以下の手順とコマンドを確認してください。',
|
||||
'This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.':
|
||||
'保存済みの SSH ホストとその認証情報を、このコンピュータから削除します。リモートのファイルは削除されません。',
|
||||
'Your new worktree is current, but local {{value0}} is {{value1}} {{value2}} behind. AI diffs may miss recent commits.':
|
||||
'新規ワークツリーは最新ですが、ローカル {{value0}} は {{value1}} {{value2}} 遅れています。AI の差分に最近のコミットが反映されない可能性があります。',
|
||||
'This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.':
|
||||
'マージのブロックが解除されるまでに、このチェックには GitHub 上での手動操作(例: ワークフロー実行の承認)が必要です。',
|
||||
'This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.':
|
||||
'未保存の編集がある状態で、このファイルがディスク上で変更されました。保存すると、ディスク上の最新の内容を上書きします。',
|
||||
'Local terminal reveal is unavailable while a remote runtime is active':
|
||||
'リモートランタイムがアクティブな間は、ローカルターミナルの表示は利用できません',
|
||||
'No quick commands saved.': '保存されているクイックコマンドはありません。',
|
||||
'Repository not in Orca': 'このリポジトリは Orca に登録されていません',
|
||||
'host folder not selected': 'ホストフォルダーが選択されていません',
|
||||
'Add a Git repository or folder that already exists on the selected host.':
|
||||
'選択したホストに既に存在する Git リポジトリまたはフォルダーを追加します。',
|
||||
'Add another project from the selected host.': '選択したホストから別のプロジェクトを追加します。',
|
||||
'Enter a host path for the clone destination.': 'クローン先のホスト上のパスを入力します。',
|
||||
'Use a host path to add projects from a remote host.':
|
||||
'ホスト上のパスを指定して、リモートホストからプロジェクトを追加します。',
|
||||
'Use Add Project to enter a host path.':
|
||||
'[プロジェクトの追加] から、ホスト上のパスを入力します。',
|
||||
'Opens as a project on this host · {{value0}}':
|
||||
'このホスト上のプロジェクトとして開きます · {{value0}}',
|
||||
'No project path is available on this host for attachments.':
|
||||
'このホスト上に、添付に使用できるプロジェクトパスがありません。',
|
||||
'Clears them from Orca only. Remote files, worktrees, and branches are left untouched.':
|
||||
'Orca からのみ削除します。リモートのファイル、ワークツリー、ブランチはそのまま残ります。',
|
||||
'Agent location': 'Agent の実行場所',
|
||||
upstream: 'upstream',
|
||||
Upstream: 'upstream',
|
||||
'Commit staged changes': 'ステージ済みの変更をコミット',
|
||||
'Generate the commit message from staged changes.':
|
||||
'ステージ済みの変更からコミットメッセージを生成します。',
|
||||
lan: 'LAN',
|
||||
deploy: 'deploy',
|
||||
'Connect this source to check for Hermes automations in the remote profile.':
|
||||
'このソースに接続すると、リモートプロファイル内の Hermes オートメーションを確認できます。',
|
||||
'Notes sent to active agent.': 'メモをアクティブな Agent に送信しました。',
|
||||
'Review before attaching. Captured page context may include visible site content.':
|
||||
'添付する前に確認してください。取得したページのコンテキストには、表示中のサイトの内容が含まれる場合があります。',
|
||||
'Agent took too long to start. The workspace is ready — paste the {{value0}} when the agent is idle.':
|
||||
'Agent の起動に時間がかかりすぎました。ワークスペースの準備はできています。 Agent がアイドル状態になったら {{value0}} を貼り付けてください。',
|
||||
'Install the Orca CLI before running agent skill setup.':
|
||||
'Agent スキルのセットアップを実行する前に、Orca CLI をインストールしてください。',
|
||||
'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.':
|
||||
'Orca を実行しているコンピュータのターミナルで grok を実行し、起動するまで待ってください。サインインを求められた場合は完了してから、使用状況を再取得してください。チャットメッセージを送る必要はありません。',
|
||||
'Choose an enabled agent before saving.': '保存する前に、有効な Agent を選択してください。',
|
||||
'Selected agent is disabled. Choose an enabled agent before creating.':
|
||||
'選択した Agent は無効です。作成する前に、有効な Agent を選択してください。',
|
||||
'Choose an agent before starting.': '開始する前に、 Agent を選択してください。',
|
||||
'Change this later from the project menu.': '後でプロジェクトメニューから変更できます。',
|
||||
"Orca works with every CLI agent. Choose the one you'll reach for most. Switch any time.":
|
||||
'Orca はすべての CLI Agent で動作します。最もよく使うものを選択してください。いつでも切り替えられます。',
|
||||
'Downloading paused': 'ダウンロード一時停止中',
|
||||
Canceled: 'キャンセル済み',
|
||||
'Thinking...': '思考中…',
|
||||
'No attached worktrees yet': '紐づくワークツリーはまだありません',
|
||||
'Started an AI agent for the commit failure.':
|
||||
'コミット失敗を修正するために AI Agent を開始しました。',
|
||||
'terminals until reset': 'リセットするまでターミナルを維持',
|
||||
'Failed to import repo icon': 'リポジトリアイコンのインポートに失敗しました',
|
||||
'Terminal Panes': 'ターミナルペイン',
|
||||
'Option composes special characters for your keyboard layout.':
|
||||
'Option キーで、キーボードレイアウトに応じた特殊文字を入力します。',
|
||||
'There are local terminals with running processes. Close the window anyway?':
|
||||
'プロセスが実行中のローカルターミナルがあります。このままウィンドウを閉じますか?',
|
||||
'Auto-generates a new name when you leave this text box empty.':
|
||||
'このテキストボックスを空のままにすると、名前が自動生成されます。',
|
||||
'Auto-name workspace from first agent message':
|
||||
'最初の Agent メッセージからワークスペース名を自動生成',
|
||||
'Controls the split divider line between panes in light mode.':
|
||||
'ライトモードでのペイン間の分割線を制御します。',
|
||||
'Controls the split divider line between panes in dark mode.':
|
||||
'ダークモードでのペイン間の分割線を制御します。',
|
||||
'Countdown timer showing time until prompt cache expires (Claude agents).':
|
||||
'プロンプトキャッシュが期限切れになるまでの時間を示すカウントダウンタイマー(Claude Agent )。',
|
||||
'Install and manage experimental Orca plugins.':
|
||||
'実験的機能の Orca プラグインをインストール・管理します。',
|
||||
"Resident memory held by Orca plus the processes under each worktree's terminals.":
|
||||
'Orca が保持する常駐メモリと、各ワークツリーのターミナル配下で実行中のプロセス。',
|
||||
'No local Claude, Codex, or OpenCode usage found yet. The overview will populate after the next agent session writes token logs.':
|
||||
'ローカルの Claude、Codex、OpenCode の使用状況はまだ見つかりません。次の Agent セッションがトークンログを書き込むと、概要に反映されます。',
|
||||
'Choose or add a project before creating a workspace.':
|
||||
'ワークスペースを作成する前に、プロジェクトを選択または追加してください。',
|
||||
'Choose a project to get started.': '開始するには、プロジェクトを選択してください。',
|
||||
'Please enter feedback before submitting.': '送信する前に、フィードバックを入力してください。',
|
||||
'Add a Git project under this folder to attach GitHub or GitLab tasks.':
|
||||
'GitHub または GitLab のタスクを関連付けるには、このフォルダー内に Git プロジェクトを追加してください。',
|
||||
'Enter a valid website URL.': '有効な Web サイトの URL を入力してください。',
|
||||
'Choose a WSL distro before projects can inherit WSL.':
|
||||
'プロジェクトが WSL を継承できるようにするには、先に WSL ディストリビューションを選択してください。',
|
||||
'Choose a different base branch before creating a {{value0}}.':
|
||||
'{{value0}} を作成する前に、別のベースブランチを選択してください。',
|
||||
'Optional. Orca can use your normal Codex login; add accounts only if you want quick switching in Orca.':
|
||||
'任意。Orca は通常の Codex ログインを使用できます。Orca 上ですばやく切り替えたい場合にのみ、アカウントを追加してください。',
|
||||
'Optional. Orca can use your normal Claude login; add accounts only if you want quick switching without moving chat sessions.':
|
||||
'任意。Orca は通常の Claude ログインを使用できます。チャットセッションを移さずにすばやく切り替えたい場合にのみ、アカウントを追加してください。',
|
||||
'Optional. Orca works with your existing provider logins; add accounts only if you want Orca to help switch between them.':
|
||||
'任意。Orca は既存のプロバイダーのログインと連携します。Orca に切り替えを任せたい場合にのみ、アカウントを追加してください。',
|
||||
'Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.':
|
||||
'既定ではこのコンピュータを使用します。対応するプロジェクト、ファイル、ターミナル、プロバイダーチェック、ブラウザ/モバイルの引き継ぎをそのサーバー経由で実行したい場合にのみ、保存済みサーバーを選択してください。',
|
||||
'Window Blur': 'ウィンドウのぼかし',
|
||||
'Medium section heading.': '中サイズのセクション見出し。',
|
||||
'macOS Option key': 'macOS の Option キー',
|
||||
'Failed to add folder on this host': 'このホスト上でのフォルダーの追加に失敗しました',
|
||||
'This workspace is locked by Git. Run git worktree unlock <worktree-path> from its repository, then retry deletion.':
|
||||
'このワークスペースは Git によってロックされています。リポジトリで git worktree unlock <worktree-path> を実行してから、削除を再試行してください。',
|
||||
'This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock <worktree-path> from its repository, then retry deletion.':
|
||||
'このワークスペースは Git によってロックされています。Git の報告: {{value0}}。リポジトリで git worktree unlock <worktree-path> を実行してから、削除を再試行してください。',
|
||||
'Permanently deletes the remote Git worktrees and their branches. Cannot be undone.':
|
||||
'リモートの Git ワークツリーとそのブランチを完全に削除します。この操作は取り消せません。',
|
||||
'Click a card to copy a prompt. Use these in a Linear-linked worktree after the skill is installed.':
|
||||
'カードをクリックするとプロンプトをコピーできます。スキルのインストール後、Linear にリンクしたワークツリーで使用してください。',
|
||||
'Ticket actions work best in a worktree created from Tasks so the issue stays linked as context.':
|
||||
'チケット操作は、タスクから作成したワークツリーで最も効果的です。Issue がコンテキストとしてリンクされたままになります。',
|
||||
'Choose a base branch.': 'ベースブランチを選択してください。',
|
||||
'Enter a {{value0}} title.': '{{value0}} のタイトルを入力してください。',
|
||||
'Enter a valid five-field cron before saving.':
|
||||
'保存する前に、有効な 5 フィールドの cron を入力してください。',
|
||||
'Choose an available workspace before saving.':
|
||||
'保存する前に、使用可能なワークスペースを選択してください。',
|
||||
'Enter a valid advanced schedule before saving.':
|
||||
'保存する前に、有効な詳細スケジュールを入力してください。',
|
||||
'Creating...': '作成中…',
|
||||
'Creating…': '作成中…',
|
||||
'Reopening...': '再オープン中…',
|
||||
'Thinking…': '思考中…',
|
||||
'Hide from sidebar': 'サイドバーから非表示',
|
||||
'Show in sidebar': 'サイドバーに表示',
|
||||
'Recent or tab strip.': '「最近」またはタブストリップ。',
|
||||
// Settings-search keywords: proper nouns keep their canonical spelling, and the rest were
|
||||
// translated in the wrong sense (windows→窓, gitignore→ギティ無視, component→成分).
|
||||
windows: 'Windows',
|
||||
'windows powershell': 'Windows PowerShell',
|
||||
'powershell 7': 'PowerShell 7',
|
||||
neovim: 'Neovim',
|
||||
hermes: 'Hermes',
|
||||
duckduckgo: 'DuckDuckGo',
|
||||
gitignore: 'gitignore',
|
||||
component: 'コンポーネント',
|
||||
compose: '特殊文字の入力',
|
||||
sidekick: 'Sidekick',
|
||||
'api token': 'API トークン',
|
||||
'compare base': '比較ベース',
|
||||
ahead: '進んでいる'
|
||||
}
|
||||
|
||||
export const JA_VALUE_OVERRIDES = { ...JA_AUDIT_OVERRIDES, ...JA_UNIFIED_VALUES }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs'
|
||||
import { JA_KEY_OVERRIDES } from './locale-ja-key-overrides.mjs'
|
||||
import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs'
|
||||
import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs'
|
||||
|
||||
@@ -11,6 +12,9 @@ export function mergeLocaleKeyOverrides(base) {
|
||||
// KO split overrides can share keys with zh/ja repairs; merge per locale.
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
for (const [key, overrides] of Object.entries(JA_KEY_OVERRIDES)) {
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) {
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'menu.showMobileButton': {
|
||||
ko: 'Orca 모바일 버튼 표시',
|
||||
zh: '显示 Orca Mobile 按钮',
|
||||
ja: 'Orca モバイル ボタンを表示'
|
||||
ja: 'Orca Mobile ボタンを表示'
|
||||
},
|
||||
'menu.toggleLeftSidebar': {
|
||||
ko: '왼쪽 사이드바 표시/숨기기',
|
||||
@@ -169,7 +169,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.tab.bar.TabBarCreateEntry.b27864279e': {
|
||||
ko: '에이전트 실행',
|
||||
zh: '启动代理',
|
||||
ja: 'エージェントを起動'
|
||||
ja: 'Agent を起動'
|
||||
},
|
||||
'auto.components.sidebar.SidebarNav.c39ab10000': {
|
||||
ko: 'Linear 작업 열기',
|
||||
@@ -343,7 +343,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'settings.appearance.statusBar.kimiToggleDescription': {
|
||||
ko: '활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.',
|
||||
zh: '显示当前工作区的 Kimi 订阅使用情况。',
|
||||
ja: 'Kimi サブスクリプション'
|
||||
ja: 'アクティブなワークスペースの Kimi サブスクリプション使用状況を表示します。'
|
||||
},
|
||||
'auto.components.mobile.MobileHero.cd4e5e816f': {
|
||||
ko: '주머니 속의 워크스페이스.',
|
||||
@@ -433,17 +433,17 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.settings.AgentsPane.9bccf48906': {
|
||||
ko: '에이전트 위치',
|
||||
zh: '代理位置',
|
||||
ja: 'エージェントの場所'
|
||||
ja: 'Agent の場所'
|
||||
},
|
||||
'auto.components.skills.SkillsPage.38e0951c3a': {
|
||||
ko: '에이전트 스킬',
|
||||
zh: '代理技能',
|
||||
ja: 'エージェントのスキル'
|
||||
ja: 'Agent のスキル'
|
||||
},
|
||||
'auto.components.sidebar.SidebarNav.e518f544b1': {
|
||||
ko: '감지된 에이전트 없음',
|
||||
zh: '未检测到代理',
|
||||
ja: 'エージェントが検出されません'
|
||||
ja: 'Agent が検出されません'
|
||||
},
|
||||
'auto.components.onboarding.OnboardingFlow.04ae28d8ca': {
|
||||
ko: '몇 시간 내내 보고 싶은 테마를 선택하세요.',
|
||||
@@ -478,7 +478,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.settings.ExperimentalPane.0277901cf7': {
|
||||
ko: '완료된 에이전트, 차단 질문, 읽지 않은 상태 및 작업 트리 생성 이벤트에 대한 스레드 작업 트리 피드가 있는 에이전트 항목을 왼쪽 사이드바에 추가합니다. 실험적 — 이벤트 모델과 UI가 변경될 수 있습니다.',
|
||||
zh: '将代理条目添加到左侧边栏,其中包含已完成代理、阻塞待办、未读状态和工作树创建事件的线程工作树提要。实验性——事件模型和 UI 可能会改变。',
|
||||
ja: '完了したエージェント、ブロック中の質問、未読状態、ワークツリー作成イベントのスレッドワークツリーフィード付きエージェント項目を左サイドバーに追加します。実験的 — イベントモデルと UI は変更される場合があります。'
|
||||
ja: '完了した Agent、ブロック中の質問、未読状態、ワークツリー作成イベントのスレッドワークツリーフィード付き Agent 項目を左サイドバーに追加します。実験的 — イベントモデルと UI は変更される場合があります。'
|
||||
},
|
||||
'auto.lib.fix.checks.agent.launch.9f00d7df0c': {
|
||||
ko: '검사 프롬프트가 비어 있습니다. 소스 제어 AI 설정을 업데이트하세요.',
|
||||
@@ -488,7 +488,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.TaskPage.d0e3c8f933': {
|
||||
ko: '일치하는 GitHub 작업이 없습니다',
|
||||
zh: '没有匹配的 GitHub 工作项',
|
||||
ja: '一致する GitHub 作業がありません'
|
||||
ja: '一致する GitHub の作業はありません'
|
||||
},
|
||||
'auto.components.TaskPage.2af3ab5c58': {
|
||||
ko: 'Linear에서 열 팀을 하나 선택하세요',
|
||||
@@ -513,7 +513,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.settings.IntegrationsPane.33ae9730a8': {
|
||||
ko: 'Linear 액세스를 추가해 이슈를 탐색하고 연결합니다.',
|
||||
zh: '添加 Linear 访问以浏览和链接议题。',
|
||||
ja: 'Linear アクセスを追加してイシューを参照し、リンクします。'
|
||||
ja: 'Linear アクセスを追加して、Issue を閲覧およびリンクします。'
|
||||
},
|
||||
'auto.components.GitHubItemDialog.3ab6ac0fc8': {
|
||||
ko: '선택한 GitHub 이슈 또는 PR을 미리보기하고 편집합니다.',
|
||||
|
||||
@@ -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 설정을 찾았습니다."
|
||||
},
|
||||
@@ -2330,12 +2273,6 @@
|
||||
"auto.components.settings.GitPane.976afc6b3e": {
|
||||
"ko": "워크스페이스를 만들면 Orca가 원격 기준 브랜치를 새로고침하고, 일치하는 로컬 브랜치를 안전하게 fast-forward합니다. 예:"
|
||||
},
|
||||
"auto.components.settings.GitPane.b5f534717a": {
|
||||
"ko": "공동 작성"
|
||||
},
|
||||
"auto.components.settings.GitPane.b9b5771bb1": {
|
||||
"ko": "표기"
|
||||
},
|
||||
"auto.components.settings.GitPane.b9c011fbc2": {
|
||||
"ko": "속도 제한"
|
||||
},
|
||||
@@ -2345,9 +2282,6 @@
|
||||
"auto.components.settings.GitPane.db3a127eb1": {
|
||||
"ko": "이렇게 하면 다음과 같은 명령이 유지됩니다"
|
||||
},
|
||||
"auto.components.settings.GitPane.e02ea23a32": {
|
||||
"ko": "Orca 표기"
|
||||
},
|
||||
"auto.components.settings.GitPane.f35007e6e8": {
|
||||
"ko": "git-username"
|
||||
},
|
||||
@@ -4976,9 +4910,6 @@
|
||||
"auto.components.ui.color.picker.faa855a582": {
|
||||
"ko": "Hex"
|
||||
},
|
||||
"auto.components.ui.team.multi.combobox.18ec58881e": {
|
||||
"ko": "팀 검색..."
|
||||
},
|
||||
"auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": {
|
||||
"ko": "리뷰 필요"
|
||||
},
|
||||
|
||||
@@ -44,7 +44,6 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'Issue #{{value0}}': '이슈 #{{value0}}',
|
||||
'Linked issue #{{value0}}': '연결된 이슈 #{{value0}}',
|
||||
'Issue # or GitHub URL': '이슈 # 또는 GitHub URL',
|
||||
'Add Orca attribution to commits, PRs, and issues.': '커밋, PR 및 이슈에 Orca 속성을 추가합니다.',
|
||||
'Paste an issue URL, or enter a number. Leave blank to remove the link.':
|
||||
'이슈 URL을 붙여넣거나 숫자를 입력하세요. 링크를 제거하려면 비워두세요.',
|
||||
'Connect Linear in Settings to search issues.': '이슈를 검색하려면 설정에서 Linear를 연결하세요.',
|
||||
@@ -157,8 +156,7 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'저장되지 않은 Git AI Author 변경사항이 있습니다. 떠나면 폐기됩니다.',
|
||||
'Discard unsaved Git AI Author changes?':
|
||||
'저장되지 않은 Git AI Author 변경사항을 삭제하시겠습니까?',
|
||||
'Branch naming, base refs, attribution, and Git AI Author.':
|
||||
'브랜치 이름 지정, 기본 참조, 속성 및 Git AI Author.',
|
||||
'Branch naming, base refs, and Git AI Author.': '브랜치 이름 지정, 기본 참조 및 Git AI Author.',
|
||||
'Used by repositories that inherit global hosted-review defaults.':
|
||||
'전역 호스팅 PR 기본값을 상속하는 저장소에서 사용됩니다.',
|
||||
'Defaults used when the hosted-review composer opens.':
|
||||
|
||||
@@ -106,6 +106,9 @@ export const SEARCH_KEYWORD_OVERRIDES = {
|
||||
theme: '主题',
|
||||
zoom: '缩放',
|
||||
scale: '比例',
|
||||
// Why: 用法 is "how to use". Keeping the keyword on 用量 also preserves the quota-shaped
|
||||
// query that stopped matching once the labels moved from 使用量 to 使用情况.
|
||||
usage: '用量',
|
||||
proxy: '代理',
|
||||
terminal: '终端',
|
||||
workspace: '工作区',
|
||||
@@ -161,7 +164,6 @@ export const SEARCH_KEYWORD_OVERRIDES = {
|
||||
gitlab: 'GitLab',
|
||||
gitea: 'Gitea',
|
||||
oauth: 'OAuth',
|
||||
attribution: '归因',
|
||||
skill: '技能',
|
||||
skills: '技能',
|
||||
phone: '手机',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// CSS that lives in the i18n catalog. MT rewrote a selector to [データスラッシュメニュー] and a keyframe
|
||||
// name to ブラウザフラッシュ, breaking the view in that locale only. Removing these from en.json is
|
||||
// the real fix; until then the policy pins them to English.
|
||||
|
||||
const STYLE_BLOCK =
|
||||
/@(keyframes|media|supports|font-face)\b|[.#][a-zA-Z][\w-]*[^{}]*\{[^{}]*[a-z-]+\s*:|\[[a-z-]+=["'][^"']*["']\]|\{[^{}]*[a-z-]+\s*:\s*[^{}]*[;}]/
|
||||
|
||||
// A selector with no declaration block has no braces for STYLE_BLOCK to key off.
|
||||
const SELECTOR_TOKEN = /^(?:[>+~]|[a-zA-Z.#[][\w.#:()[\]"'=^$|*-]*)$/
|
||||
const LONE_SELECTOR = /^#[a-zA-Z][\w-]*$|^\.[a-zA-Z][\w-]*-[\w-]+$/
|
||||
// One dotted, colon or bracketed token — button.primary, a:hover, wsl.exe, localhost:3000. It is
|
||||
// code whether it names a selector, a file or a host, and a translation breaks all three.
|
||||
const LONE_QUALIFIED = /^[a-zA-Z][\w-]*(?:[.#:][\w-]+|\[[^\]]+\])$/
|
||||
|
||||
function isStandaloneSelector(enValue) {
|
||||
if (/[{}]/.test(enValue)) {
|
||||
return false
|
||||
}
|
||||
const tokens = enValue.trim().split(/\s+/)
|
||||
if (!tokens.every((token) => SELECTOR_TOKEN.test(token))) {
|
||||
return false
|
||||
}
|
||||
// A selector join, not a sentence period: two sentences would otherwise clear the threshold.
|
||||
const marked = tokens.filter((token) => /^[#.][a-zA-Z]|[\w-][.#:][a-zA-Z]|\[/.test(token))
|
||||
if (marked.length >= 2) {
|
||||
return true
|
||||
}
|
||||
return tokens.length === 1 && (LONE_SELECTOR.test(tokens[0]) || LONE_QUALIFIED.test(tokens[0]))
|
||||
}
|
||||
|
||||
export function isStyleValue(enValue) {
|
||||
return STYLE_BLOCK.test(enValue) || isStandaloneSelector(enValue)
|
||||
}
|
||||
@@ -25,10 +25,10 @@ describe('locale-translation-policy es PR glossary', () => {
|
||||
it('rewrites PR inside longer sentences', () => {
|
||||
expect(
|
||||
repairEs(
|
||||
'Add Orca attribution to commits, PRs, and issues.',
|
||||
'Agregue la atribución de Orca a commits, relaciones públicas y problemas.'
|
||||
'Split a large change into smaller PRs',
|
||||
'Dividir un cambio grande en relaciones públicas más pequeños'
|
||||
)
|
||||
).toBe('Agregue la atribución de Orca a commits, PR y problemas.')
|
||||
).toBe('Dividir un cambio grande en PR más pequeños')
|
||||
expect(
|
||||
repairEs(
|
||||
'Open the PR details to view current reviewers.',
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { repairTranslatedValue, shouldPreserveEnglishValue } from './locale-translation-policy.mjs'
|
||||
|
||||
const ja = (enValue, localeValue, key = 'auto.test.relocalization') =>
|
||||
repairTranslatedValue({ key, enValue, localeValue, locale: 'ja' })
|
||||
|
||||
describe('locale-translation-policy ja relocalization', () => {
|
||||
it('heals generic terms the brand revert left in Latin mid-sentence', () => {
|
||||
expect(ja('Close terminal?', 'この terminal を閉じますか?')).toBe('このターミナルを閉じますか?')
|
||||
expect(ja('New Terminal', '新規 Terminal')).toBe('新規ターミナル')
|
||||
expect(ja('Stop the agent?', 'この agent を停止しますか?')).toBe('この Agent を停止しますか?')
|
||||
expect(ja('Nothing to commit.', 'commit するものはありません。')).toBe(
|
||||
'コミットするものはありません。'
|
||||
)
|
||||
expect(ja('Open the repo', 'repo を開く')).toBe('リポジトリを開く')
|
||||
})
|
||||
|
||||
it('leaves the second word of a two-word command in Latin', () => {
|
||||
expect(ja('a commit hook or git commit fails', 'コミットフックまたは git commit が失敗')).toBe(
|
||||
'コミットフックまたは git commit が失敗'
|
||||
)
|
||||
expect(ja('Run orca terminal', 'orca terminal を実行')).toBe('orca terminal を実行')
|
||||
expect(ja('Install with pnpm install', 'pnpm install でインストール')).toBe(
|
||||
'pnpm install でインストール'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves identifiers, flags, filenames, and Latin-only labels alone', () => {
|
||||
expect(ja('Pass --agent to select one.', '--agent を渡して選択します。')).toBe(
|
||||
'--agent を渡して選択します。'
|
||||
)
|
||||
expect(ja('Edit agents.md', 'agents.md を編集')).toBe('agents.md を編集')
|
||||
expect(ja('Agent SDK settings', 'Agent SDK の設定')).toBe('Agent SDK の設定')
|
||||
expect(ja('Agent SDK settings', 'この Agent SDK の設定')).toBe('この Agent SDK の設定')
|
||||
expect(ja('Run git commit -m', 'git commit -m を実行')).toBe('git commit -m を実行')
|
||||
})
|
||||
|
||||
it('keeps commands and identifiers in English so they still run', () => {
|
||||
expect(shouldPreserveEnglishValue('pnpm install')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('glab auth login')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue("gh pr list --json number -q '.[0].number'")).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('--model sonnet')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('localhost, 127.0.0.1, *.internal')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('stale-agent-row-{{value0}}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('text-foreground')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('{basePrompt}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('{firstPrompt}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('{assistantMessage}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('/pricing')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('/signup')).toBe(true)
|
||||
})
|
||||
|
||||
it('repairs translated code examples back to executable values', () => {
|
||||
expect(ja('--model sonnet', '--モデルソネット')).toBe('--model sonnet')
|
||||
expect(
|
||||
ja("gh pr list --json number -q '.[0].number'", "gh pr list --json 番号 -q '.[0].number'")
|
||||
).toBe("gh pr list --json number -q '.[0].number'")
|
||||
expect(ja('{basePrompt}', '{ベースプロンプト}')).toBe('{basePrompt}')
|
||||
expect(ja('/pricing', '/価格設定')).toBe('/pricing')
|
||||
})
|
||||
|
||||
it('keeps style blocks in English because a translated selector breaks the view', () => {
|
||||
expect(shouldPreserveEnglishValue('.pp-card[data-card="starter"] .pp-cta')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('@keyframes browser-flash { to { opacity: 1; } }')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('.card { background: var(--card); }')).toBe(true)
|
||||
// Why: the declaration requirement is what keeps prose containing braces translatable.
|
||||
expect(shouldPreserveEnglishValue('e.g. {{value0}}')).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes Japanese typography and settled terminology', () => {
|
||||
expect(ja('Loading…', '読み込み中...')).toBe('読み込み中…')
|
||||
expect(ja('Merge conflict', 'マージ紛争')).toBe('マージ競合')
|
||||
// Why: MT inserts a Microsoft-style separator space that Japanese compounds do not use.
|
||||
expect(ja('Status bar', 'ステータス バー')).toBe('ステータスバー')
|
||||
expect(ja('Missing credentials', '資格情報がありません')).toBe('認証情報がありません')
|
||||
expect(ja('Unknown', '未知')).toBe('不明')
|
||||
expect(ja('Restart the server', 'サーバを再起動')).toBe('サーバーを再起動')
|
||||
expect(ja('Open the browser', 'ブラウザーを開く')).toBe('ブラウザを開く')
|
||||
})
|
||||
|
||||
it('leaves ranges and token samples out of the ellipsis rule', () => {
|
||||
expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較')
|
||||
})
|
||||
|
||||
it('spaces the Latin terms the phrase fixes write back in', () => {
|
||||
expect(ja('Clear cookies', 'クッキーを消去')).toBe('Cookie を消去')
|
||||
expect(ja('Safely fast-forward the branch', 'ブランチを安全に早送りします')).toBe(
|
||||
'ブランチを安全に fast-forward します'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses ホスト for host, and leaves サーバー alone when English says server', () => {
|
||||
expect(ja('Host path', 'サーバーパス')).toBe('ホストパス')
|
||||
expect(ja('Enter a path that exists on the selected host.', 'ランタイムサーバー上のパス')).toBe(
|
||||
'ホスト上のパス'
|
||||
)
|
||||
// Why: host and server are deliberately distinct wherever the English uses both.
|
||||
expect(ja('Save the server host', 'サーバーを保存')).toBe('サーバーを保存')
|
||||
})
|
||||
|
||||
it('restores Git vocabulary and brand names the MT localized', () => {
|
||||
expect(ja('Branch upstream', 'ブランチの上流')).toBe('ブランチの upstream')
|
||||
expect(ja('origin has commits', '起源にコミットがあります')).toBe('origin にコミットがあります')
|
||||
expect(ja('Discard all staged changes?', '段階的な変更を破棄しますか?')).toBe(
|
||||
'ステージ済みの変更を破棄しますか?'
|
||||
)
|
||||
expect(ja('Create Hermes automation', 'エルメスオートメーションを作成')).toBe(
|
||||
'Hermes オートメーションを作成'
|
||||
)
|
||||
expect(ja('Delete the worktree', 'worktree を削除')).toBe('ワークツリーを削除')
|
||||
})
|
||||
|
||||
it('keeps class strings, refs, and fixture filenames in English', () => {
|
||||
for (const value of [
|
||||
'size-4 text-muted-foreground',
|
||||
'text-amber-700 dark:text-amber-300',
|
||||
'origin/main',
|
||||
'upstream/main',
|
||||
'login.spec.ts',
|
||||
'bastion.example.com'
|
||||
]) {
|
||||
expect(shouldPreserveEnglishValue(value)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a selector that has no declaration block', () => {
|
||||
for (const value of [
|
||||
'div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta',
|
||||
'.pp-cta',
|
||||
'#dialog',
|
||||
'.pp-card[data-card="starter"] .pp-cta',
|
||||
'button.primary',
|
||||
'a:hover',
|
||||
'input[type="text"]',
|
||||
'localhost:3000'
|
||||
]) {
|
||||
expect(shouldPreserveEnglishValue(value)).toBe(true)
|
||||
}
|
||||
// Why: two ordinary sentences carry two periods, which must not read as selector joins.
|
||||
for (const value of [
|
||||
'Open Settings > Git and try again.',
|
||||
'Show live workspace ports. Click it for workspace-scoped ports and external listeners.',
|
||||
'orca.yaml + local',
|
||||
'.gitignore',
|
||||
'Status:',
|
||||
'Done.',
|
||||
'#{{value0}}'
|
||||
]) {
|
||||
expect(shouldPreserveEnglishValue(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -108,18 +108,18 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
|
||||
enValue: 'Terminals',
|
||||
localeValue: '端子',
|
||||
localeValue: '端末',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Terminals')
|
||||
).toBe('ターミナル')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.skills.SkillsPage.38e0951c3a',
|
||||
enValue: 'Agent Skills',
|
||||
localeValue: 'エージェントのスキル',
|
||||
localeValue: 'Agent のスキル',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('エージェントのスキル')
|
||||
).toBe('Agent のスキル')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
|
||||
|
||||
@@ -4,11 +4,14 @@ import {
|
||||
overlapsCanonicalRendering
|
||||
} from './locale-generic-ui-terms.mjs'
|
||||
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
|
||||
import { BRAND_MISTRANSLATIONS } from './locale-brand-mistranslations.mjs'
|
||||
import { isStyleValue } from './locale-style-values.mjs'
|
||||
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
import { SEARCH_KEYWORD_OVERRIDES } from './locale-search-keyword-overrides.mjs'
|
||||
import { LOCALE_VALUE_OVERRIDES } from './locale-value-overrides.mjs'
|
||||
|
||||
export { BRAND_MISTRANSLATIONS } from './locale-brand-mistranslations.mjs'
|
||||
export { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
export { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
export { SEARCH_KEYWORD_OVERRIDES } from './locale-search-keyword-overrides.mjs'
|
||||
@@ -94,6 +97,7 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'yarn',
|
||||
'Kagi',
|
||||
'kagi',
|
||||
'kimi',
|
||||
'Bitbucket',
|
||||
'bitbucket',
|
||||
'GNOME',
|
||||
@@ -163,163 +167,55 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'lint',
|
||||
'MD',
|
||||
'/home/user/projects',
|
||||
'Claude Code'
|
||||
'Claude Code',
|
||||
// Commands, refs, class strings and code samples: a translated one no longer runs or matches.
|
||||
'pnpm install',
|
||||
'glab auth login',
|
||||
"gh pr list --json number -q '.[0].number'",
|
||||
'--model sonnet',
|
||||
'localhost, 127.0.0.1, *.internal',
|
||||
'packages/web shared/ui',
|
||||
'stale-agent-row-{{value0}}',
|
||||
'text-foreground',
|
||||
'source-control',
|
||||
'combined-branch',
|
||||
'pr-view',
|
||||
'fix-login-flow',
|
||||
'my-project',
|
||||
'serve-sim',
|
||||
'pnpm playwright test',
|
||||
'gh auth login',
|
||||
'size-4 text-muted-foreground',
|
||||
'text-amber-700 dark:text-amber-300',
|
||||
'text-emerald-700 dark:text-emerald-300',
|
||||
'src/renderer packages/ui',
|
||||
'upstream/main',
|
||||
'origin/main',
|
||||
'example.com',
|
||||
'bastion.example.com',
|
||||
'dashboard.spec.ts',
|
||||
'checkout.spec.ts',
|
||||
'login.spec.ts',
|
||||
'untitled.md',
|
||||
'review src/auth',
|
||||
'throw src/auth',
|
||||
// Rendered inside <code> or a font-mono element, so they are code the user copies or types.
|
||||
'{prompt}',
|
||||
'{basePrompt}',
|
||||
'{firstPrompt}',
|
||||
'{assistantMessage}',
|
||||
'/goal',
|
||||
'/pricing',
|
||||
'/signup',
|
||||
'npm run dev',
|
||||
'nbformat',
|
||||
'orca.yaml',
|
||||
'upstream',
|
||||
'LIN-329',
|
||||
'GH #1799',
|
||||
'orca · zsh'
|
||||
])
|
||||
|
||||
export const BRAND_MISTRANSLATIONS = {
|
||||
ko: {
|
||||
Codex: ['사본', '코덱스'],
|
||||
Gemini: ['쌍둥이자리'],
|
||||
Claude: ['클로드'],
|
||||
Grok: ['그록'],
|
||||
Orca: ['오르카', '범고래'],
|
||||
Cursor: ['커서'],
|
||||
OpenCode: ['오픈코드'],
|
||||
OpenClaw: ['오픈클로'],
|
||||
OpenClaude: ['오픈클로드'],
|
||||
Antigravity: ['반중력'],
|
||||
Continue: ['계속하다'],
|
||||
Charm: ['매력'],
|
||||
Goose: ['거위'],
|
||||
Pi: ['파이'],
|
||||
'GitHub Copilot': ['GitHub 코파일럿', '코파일럿'],
|
||||
Git: ['힘내'],
|
||||
Discord: ['디스코드'],
|
||||
Linear: ['선형'],
|
||||
Agent: ['에이전트'],
|
||||
Agents: ['에이전트'],
|
||||
agent: ['에이전트'],
|
||||
agents: ['에이전트'],
|
||||
Commit: ['커밋'],
|
||||
Commits: ['커밋'],
|
||||
commit: ['커밋'],
|
||||
commits: ['커밋'],
|
||||
Markdown: ['마크다운', '가격 인하'],
|
||||
markdown: ['마크다운', '가격 인하'],
|
||||
Repo: ['저장소', '레포'],
|
||||
Repos: ['저장소', '레포'],
|
||||
repo: ['저장소', '레포'],
|
||||
repos: ['저장소', '레포'],
|
||||
Terminal: ['터미널'],
|
||||
Terminals: ['터미널'],
|
||||
terminal: ['터미널'],
|
||||
terminals: ['터미널']
|
||||
},
|
||||
zh: {
|
||||
Codex: ['法典'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['克洛德', '克劳德'],
|
||||
Grok: ['格罗克'],
|
||||
Orca: ['虎鲸', '逆戟鲸'],
|
||||
Cursor: ['光标'],
|
||||
OpenCode: ['开放代码'],
|
||||
OpenClaw: ['开爪'],
|
||||
OpenClaude: ['开放克劳德'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['继续'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['鹅'],
|
||||
Pi: ['圆周率'],
|
||||
Droid: ['机器人'],
|
||||
'GitHub Copilot': ['GitHub 副驾驶', '副驾驶'],
|
||||
Bitbucket: ['位桶'],
|
||||
Linear: ['线性', '线形'],
|
||||
Jira: ['吉拉'],
|
||||
Tailscale: ['尾鳞', '尾鱗'],
|
||||
Agent: ['代理', '智能体'],
|
||||
Agents: ['代理', '智能体'],
|
||||
agent: ['代理', '智能体'],
|
||||
agents: ['代理', '智能体'],
|
||||
Commit: ['提交'],
|
||||
Commits: ['提交'],
|
||||
commit: ['提交'],
|
||||
commits: ['提交'],
|
||||
Markdown: ['降价'],
|
||||
markdown: ['降价'],
|
||||
Repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Terminal: ['终端', '端子'],
|
||||
Terminals: ['终端', '端子'],
|
||||
terminal: ['终端', '端子'],
|
||||
terminals: ['终端', '端子'],
|
||||
Bash: ['重击'],
|
||||
PowerShell: ['电源外壳'],
|
||||
REST: ['休息'],
|
||||
HEAD: ['头'],
|
||||
Swift: ['迅速'],
|
||||
Rust: ['锈'],
|
||||
'Claude Code': ['Claude·科德'],
|
||||
'Git AI Author': ['Git AI 作者']
|
||||
},
|
||||
ja: {
|
||||
Codex: ['法典', 'コーデックス'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['クロード'],
|
||||
Grok: ['グロック'],
|
||||
Orca: ['シャチ', '逆戟鲸', 'オルカ'],
|
||||
Cursor: ['カーソル'],
|
||||
OpenCode: ['オープンコード', 'オープン・コード'],
|
||||
OpenClaw: ['オープンクロー'],
|
||||
OpenClaude: ['オープンクロード'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['続ける', '続行'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['ガチョウ', '雁'],
|
||||
Pi: ['円周率'],
|
||||
Droid: ['ロボット', 'ドロイド'],
|
||||
'GitHub Copilot': ['GitHub コパイロット', 'コパイロット'],
|
||||
Discord: ['不和'],
|
||||
Linear: ['線形'],
|
||||
Agent: ['エージェント'],
|
||||
Agents: ['エージェント'],
|
||||
agent: ['エージェント'],
|
||||
agents: ['エージェント'],
|
||||
Commit: ['コミット'],
|
||||
Commits: ['コミット'],
|
||||
commit: ['コミット'],
|
||||
commits: ['コミット'],
|
||||
Markdown: ['マークダウン'],
|
||||
markdown: ['マークダウン'],
|
||||
Repo: ['リポジトリ', 'リポ'],
|
||||
Repos: ['リポジトリ', 'リポ'],
|
||||
repo: ['リポジトリ', 'リポ'],
|
||||
repos: ['リポジトリ', 'リポ'],
|
||||
Terminal: ['ターミナル', '端子'],
|
||||
Terminals: ['ターミナル', '端子'],
|
||||
terminal: ['ターミナル', '端子'],
|
||||
terminals: ['ターミナル', '端子']
|
||||
},
|
||||
es: {
|
||||
Codex: ['códice', 'Códice'],
|
||||
Gemini: ['Géminis'],
|
||||
Claude: ['claudia', 'Claudia'],
|
||||
Orca: ['orca', 'Orcas', 'orcas'],
|
||||
OpenCode: ['código abierto', 'Código abierto'],
|
||||
OpenClaude: ['Openclaude'],
|
||||
Antigravity: ['antigravedad', 'Antigravedad'],
|
||||
'GitHub Copilot': ['Copiloto de GitHub'],
|
||||
Discord: ['discordia'],
|
||||
Linear: ['lineal', 'Lineal'],
|
||||
Jira: ['jira'],
|
||||
Agent: ['Agente', 'agente'],
|
||||
Agents: ['Agentes', 'agentes'],
|
||||
agent: ['agente'],
|
||||
agents: ['agentes'],
|
||||
Commit: ['Confirmación', 'confirmación', 'Confirmar', 'Comprometerse'],
|
||||
Commits: ['Confirmaciones', 'confirmaciones', 'Compromisos', 'compromisos', 'Se compromete'],
|
||||
commit: ['confirmación', 'confirmar', 'comprometerse', 'compromiso'],
|
||||
commits: ['confirmaciones', 'compromisos'],
|
||||
Markdown: ['Reducción', 'reducción', 'Rebaja', 'rebaja', 'rebajas'],
|
||||
markdown: ['reducción', 'rebaja', 'rebajas'],
|
||||
Repo: ['Repositorio', 'repositorio'],
|
||||
Repos: ['Repositorios', 'repositorios'],
|
||||
repo: ['repositorio'],
|
||||
repos: ['repositorios']
|
||||
}
|
||||
}
|
||||
|
||||
export const NATIVE_PICKER_LABELS = {
|
||||
zh: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
|
||||
ko: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
|
||||
@@ -343,6 +239,9 @@ export function shouldPreserveEnglishValue(enValue, key = '') {
|
||||
if (isEnglishOnlyKey(key)) {
|
||||
return true
|
||||
}
|
||||
if (isStyleValue(enValue)) {
|
||||
return true
|
||||
}
|
||||
return NEVER_TRANSLATE_VALUES.has(enValue)
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '壊れた小切手に対して AI エージェントを開始しました。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('失敗したチェックに対して AI エージェントを開始しました。')
|
||||
).toBe('失敗したチェックに対して AI Agent を開始しました。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
|
||||
@@ -88,7 +88,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '電話機からターミナルとエージェントを制御します。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('スマートフォンからターミナルとエージェントを操作')
|
||||
).toBe('スマートフォンからターミナルと Agent を操作します。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.934add88b6',
|
||||
@@ -398,7 +398,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: 'コンピューターと携帯電話で。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('コンピューターとスマートフォンで。')
|
||||
).toBe('コンピュータとスマートフォンで。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.3ab6ac0fc8',
|
||||
|
||||
@@ -44,22 +44,14 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('显示 Orca Mobile 按钮')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.GitPane.e02ea23a32',
|
||||
enValue: 'Orca Attribution',
|
||||
localeValue: 'Orca归属',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Orca 归因')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.hooks.useSettingsNavigationMetadata.ab4b21b58e',
|
||||
enValue: 'Branch naming, base refs, attribution, and Git AI Author.',
|
||||
localeValue: '分支命名、基本引用、归属和 Git AI 作者。',
|
||||
enValue: 'Branch naming, base refs, and Git AI Author.',
|
||||
localeValue: '分支命名、基本引用和 Git AI 作者。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('分支命名、基础引用、归因和 Git AI Author。')
|
||||
).toBe('分支命名、基础引用和 Git AI Author。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
// #12881 — the status bar item menu renders "<Brand> Usage" for every provider in one dropdown, so
|
||||
// zh has to settle on a single word. Registering it here, not only in the catalog, is what makes a
|
||||
// re-translation pass reproduce the decision instead of re-splitting the labels.
|
||||
//
|
||||
// Scope is the provider usage surfaces, not the word "usage" everywhere: a measured volume
|
||||
// ("Daily usage" 每日使用量) stays 使用量. The toggle descriptions are pinned by the catalog test
|
||||
// instead — LOCALE_VALUE_OVERRIDES keys on the exact English string, so full sentences do not fit.
|
||||
//
|
||||
// ja is split the same way zh was (使用量 vs の使用状況) and is not covered here; ko is uniform.
|
||||
|
||||
// Real catalog keys, so a future zh key override shadowing one of these is caught here.
|
||||
const STATUS_BAR_PROVIDERS = [
|
||||
['auto.components.status.bar.StatusBar.3885eb74d8', 'Claude'],
|
||||
['auto.components.status.bar.StatusBar.c0909c686e', 'Codex'],
|
||||
['auto.components.status.bar.StatusBar.c1df0d67ec', 'Gemini'],
|
||||
['auto.components.status.bar.StatusBar.antigravityUsage', 'Antigravity'],
|
||||
['auto.components.status.bar.StatusBar.8c86cd77b0', 'OpenCode Go'],
|
||||
['auto.components.status.bar.StatusBar.5e59007df4', 'Kimi'],
|
||||
['auto.components.status.bar.StatusBar.3bbf140864', 'MiniMax'],
|
||||
['auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok'],
|
||||
['auto.components.settings.accounts.search.f4a8c2e1b7', 'Grok (xAI)']
|
||||
]
|
||||
|
||||
describe('locale-translation-policy zh status bar usage labels', () => {
|
||||
it('pins every provider usage label to 使用情况', () => {
|
||||
for (const [key, brand] of STATUS_BAR_PROVIDERS) {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key,
|
||||
enValue: `${brand} Usage`,
|
||||
localeValue: `${brand} 使用量`,
|
||||
locale: 'zh'
|
||||
}),
|
||||
brand
|
||||
).toBe(`${brand} 使用情况`)
|
||||
}
|
||||
})
|
||||
|
||||
it('reverts the Kimi transliteration in labels and search keywords', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.3a6c028ea8',
|
||||
enValue: 'Kimi Usage',
|
||||
localeValue: '基米用法',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Kimi 使用情况')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.40e5c3c285',
|
||||
enValue: 'kimi',
|
||||
localeValue: '基米',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('kimi')
|
||||
})
|
||||
|
||||
// Why: NEVER_TRANSLATE_VALUES is cross-locale. Moving the moonshot repair there would rewrite
|
||||
// ja's ムーンショット, so it has to stay a zh-scoped BRAND_MISTRANSLATIONS entry.
|
||||
it('keeps the moonshot keyword repair scoped to zh', () => {
|
||||
const key = 'auto.components.settings.appearance.search.35565867cb'
|
||||
expect(
|
||||
repairTranslatedValue({ key, enValue: 'moonshot', localeValue: '登月计划', locale: 'zh' })
|
||||
).toBe('moonshot')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key,
|
||||
enValue: 'moonshot',
|
||||
localeValue: 'ムーンショット',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('ムーンショット')
|
||||
})
|
||||
|
||||
// Why: the labels moving to 使用情况 removed the only 用量-shaped match, so the search keyword
|
||||
// carries it. 用法 means "how to use".
|
||||
it('keeps 用量 as the zh search keyword for "usage"', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.00a028f25f',
|
||||
enValue: 'usage',
|
||||
localeValue: '用法',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('用量')
|
||||
})
|
||||
})
|
||||
@@ -201,6 +201,12 @@ export const LOCALE_VALUE_OVERRIDES = {
|
||||
'Codex Usage': 'Codex 使用情况',
|
||||
'Claude Usage': 'Claude 使用情况',
|
||||
'Gemini Usage': 'Gemini 使用情况',
|
||||
'Antigravity Usage': 'Antigravity 使用情况',
|
||||
'OpenCode Go Usage': 'OpenCode Go 使用情况',
|
||||
'Kimi Usage': 'Kimi 使用情况',
|
||||
'MiniMax Usage': 'MiniMax 使用情况',
|
||||
'Grok Usage': 'Grok 使用情况',
|
||||
'Grok (xAI) Usage': 'Grok (xAI) 使用情况',
|
||||
'Force Delete Branch': '强制删除分支',
|
||||
'Time agents worked': '代理工作时间',
|
||||
PR: 'PR',
|
||||
|
||||
@@ -3,7 +3,6 @@ export const ZH_PHRASE_FIXES_ROUND5 = [
|
||||
{ pattern: /Orca集成开发环境/g, replacement: 'Orca IDE', whenEnIncludes: 'Orca IDE' },
|
||||
{ pattern: /Orca第一/g, replacement: 'Orca 优先', whenEnIncludes: 'Orca first' },
|
||||
{ pattern: /Orca移动/g, replacement: 'Orca Mobile', whenEnIncludes: 'Orca Mobile' },
|
||||
{ pattern: /Orca归属/g, replacement: 'Orca 归因', whenEnIncludes: 'Orca Attribution' },
|
||||
{ pattern: /Orca标志/g, replacement: 'Orca 标志', whenEnIncludes: 'Orca logo' },
|
||||
{ pattern: /喜欢Orca/g, replacement: '喜欢 Orca', whenEnIncludes: 'Enjoying Orca' },
|
||||
{ pattern: /认识Orca/g, replacement: '了解 Orca', whenEnIncludes: 'Get to know Orca' },
|
||||
|
||||
@@ -144,7 +144,6 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'Expand Orca': '展开 Orca',
|
||||
'Support Orca': '支持 Orca',
|
||||
'Restarting Orca…': '正在重启 Orca…',
|
||||
'Orca Attribution': 'Orca 归因',
|
||||
'Show Orca Mobile Button': '显示 Orca Mobile 按钮',
|
||||
'Codex Accounts': 'Codex 账户',
|
||||
'Codex Account': 'Codex 账户',
|
||||
@@ -186,8 +185,7 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
"Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see.":
|
||||
'让代理直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。',
|
||||
'X finishes, send it the review task.”': 'X 完成后,把评审任务发给它。”',
|
||||
'Branch naming, base refs, attribution, and Git AI Author.':
|
||||
'分支命名、基础引用、归因和 Git AI Author。',
|
||||
'Branch naming, base refs, and Git AI Author.': '分支命名、基础引用和 Git AI Author。',
|
||||
'You have unsaved Git AI Author changes. Leaving will discard them.':
|
||||
'您有未保存的 Git AI Author 更改。离开将丢弃它们。',
|
||||
'Discard unsaved Git AI Author changes?': '放弃未保存的 Git AI Author 更改?',
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
@@ -104,6 +104,23 @@ describe('orca CLI skill guidance', () => {
|
||||
expect(skill).not.toContain('sk_live_')
|
||||
expect(skill).not.toContain('live_sk_')
|
||||
})
|
||||
|
||||
// Publishing defaults to off, so an agent that follows the unconditional share workflow
|
||||
// just loops on denials. The guide has to teach the opt-in and the recovery.
|
||||
it('teaches the artifact publish opt-in and its recovery path', () => {
|
||||
// Normalized so the assertions survive reflowing the guide's prose.
|
||||
const skill = readSkill().replace(/\s+/gu, ' ')
|
||||
|
||||
expect(skill).toContain('**Publishing is off by default and only a human can turn it on.**')
|
||||
expect(skill).toContain('Settings → Artifacts')
|
||||
expect(skill).toContain('Allow publishing public artifact links')
|
||||
expect(skill).toContain('artifact_sharing_disabled')
|
||||
expect(skill).toContain('There is no CLI or RPC way to grant it')
|
||||
expect(skill).toContain('Do not retry')
|
||||
// The gate is device-wide, and revocation surfaces stay reachable.
|
||||
expect(skill).toContain('every caller on the device, agent or human')
|
||||
expect(skill).toContain('`list`, `unshare`, and `delete` are never gated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca CLI install stub', () => {
|
||||
|
||||
@@ -275,6 +275,16 @@ describe('orchestration skill guidance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('documents per-invocation model and effort for supervised workers', () => {
|
||||
const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop')
|
||||
|
||||
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 opus --effort high --json')
|
||||
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
|
||||
})
|
||||
|
||||
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
@@ -55,18 +56,36 @@ describe('PR workflow parallelism', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs real-zsh coverage once outside the general shards', () => {
|
||||
it('runs real-shell coverage once outside the general shards', () => {
|
||||
const shellStep = workflow.jobs.shell_contracts.steps.find(
|
||||
(step) => step.name === 'Test real shell contracts'
|
||||
)
|
||||
const shellInstall = workflow.jobs.shell_contracts.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
// Why parsed rather than substring-matched: the step name changes as shells are
|
||||
// added, and `includes('fish')` would also match a comment or a longer package.
|
||||
const aptPackages = (step) =>
|
||||
(step.run?.match(/apt-get install[^\n]*/)?.[0] ?? '')
|
||||
.split(/\s+/)
|
||||
.filter((token) => !['apt-get', 'install', 'sudo', ''].includes(token))
|
||||
.filter((token) => !token.startsWith('-'))
|
||||
const jobsInstallingPackages = Object.entries(workflow.jobs)
|
||||
.filter(([, job]) => (job.steps ?? []).some((step) => aptPackages(step).length > 0))
|
||||
.map(([name]) => name)
|
||||
|
||||
expect(workflow.jobs.test.steps.some((step) => step.name === 'Install zsh')).toBe(false)
|
||||
expect(workflow.jobs.shell_contracts.steps.some((step) => step.name === 'Install zsh')).toBe(
|
||||
true
|
||||
)
|
||||
expect(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'])
|
||||
// Why each shell is asserted: the live tests skip themselves when the binary is
|
||||
// missing, so a dropped package silently empties this lane instead of failing it.
|
||||
const shellPackages = workflow.jobs.shell_contracts.steps.flatMap(aptPackages)
|
||||
for (const shell of ['zsh', 'fish']) {
|
||||
expect(shellPackages).toContain(shell)
|
||||
}
|
||||
expect(shellInstall.with['native-runtime']).toBe('node')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(shellStep.run).toContain(testFile)
|
||||
@@ -97,6 +116,24 @@ describe('PR workflow parallelism', () => {
|
||||
expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer')
|
||||
})
|
||||
|
||||
it('smokes managed-hook companions under their supported Node 18 runtime', () => {
|
||||
const steps = workflow.jobs.managed_hook_node18.steps
|
||||
const installIndex = steps.findIndex(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const buildIndex = steps.findIndex((step) => step.run === 'pnpm run build:relay')
|
||||
const node18Index = steps.findIndex(
|
||||
(step) => step.uses === 'actions/setup-node@v6' && step.with['node-version'] === '18'
|
||||
)
|
||||
const smokeIndex = steps.findIndex(
|
||||
(step) => step.run === 'node config/scripts/smoke-managed-hook-runtime-node18.mjs'
|
||||
)
|
||||
|
||||
expect(installIndex).toBeLessThan(buildIndex)
|
||||
expect(buildIndex).toBeLessThan(node18Index)
|
||||
expect(node18Index).toBeLessThan(smokeIndex)
|
||||
})
|
||||
|
||||
it('restores the pnpm store before dependency installation', () => {
|
||||
const steps = dependencyAction.runs.steps
|
||||
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
|
||||
@@ -178,8 +215,14 @@ describe('PR workflow parallelism', () => {
|
||||
'git_compatibility',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
])
|
||||
const verifyStep = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
|
||||
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+98
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { constants } from 'node:fs'
|
||||
import { access, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const PLATFORMS = [
|
||||
'linux-x64',
|
||||
'linux-arm64',
|
||||
'darwin-x64',
|
||||
'darwin-arm64',
|
||||
'win32-x64',
|
||||
'win32-arm64'
|
||||
]
|
||||
const ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
assert.match(process.versions.node, /^18\./, 'This smoke test must run under Node 18')
|
||||
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-managed-hook-node18-'))
|
||||
const originalHome = process.env.HOME
|
||||
const originalUserProfile = process.env.USERPROFILE
|
||||
const originalGetuid = process.getuid
|
||||
process.env.HOME = home
|
||||
process.env.USERPROFILE = home
|
||||
process.getuid = undefined
|
||||
|
||||
try {
|
||||
const runtimes = PLATFORMS.map((platform) => {
|
||||
const artifact = join(ROOT, 'out', 'relay', platform, 'managed-hook-runtime.js')
|
||||
const runtime = require(artifact)
|
||||
assert.equal(typeof runtime.installManagedHooks, 'function', `${platform} installer export`)
|
||||
return runtime
|
||||
})
|
||||
|
||||
const summary = await runtimes[0].installManagedHooks({ agents: ['codex', 'claude'] })
|
||||
assert.deepEqual(summary, { installers: 2, errors: 0 })
|
||||
|
||||
const codexHooks = await readFile(join(home, '.codex', 'hooks.json'), 'utf8')
|
||||
const claudeSettings = await readFile(join(home, '.claude', 'settings.json'), 'utf8')
|
||||
assert.match(codexHooks, /\.orca\/agent-hooks\/codex-hook\.sh/)
|
||||
assert.match(claudeSettings, /\.orca\/agent-hooks\/claude-hook\.sh/)
|
||||
await access(join(home, '.orca', 'agent-hooks', 'codex-hook.sh'), constants.X_OK)
|
||||
await access(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), constants.X_OK)
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = originalHome
|
||||
}
|
||||
if (originalUserProfile === undefined) {
|
||||
delete process.env.USERPROFILE
|
||||
} else {
|
||||
process.env.USERPROFILE = originalUserProfile
|
||||
}
|
||||
process.getuid = originalGetuid
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('Node 18 managed-hook runtime smoke passed for all relay platforms.')
|
||||
@@ -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, '...')
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: the renderer-side scans that run over a whole reattach payload.
|
||||
//
|
||||
// 1. hasCursorAgentReattachPayloadScreenSignal — compares the old hand-rolled char-by-char
|
||||
// CSI strip against the shipped shape (256KB tail + shared CSI_SEQUENCE_PATTERN). Every
|
||||
// variant is asserted to agree with the baseline before it is timed.
|
||||
// 2. TerminalKittyKeyboardModeTracker.scanReplay — measured to justify leaving it alone, and
|
||||
// to record that porting the daemon mouse mirror's includes() pre-filter makes it slower.
|
||||
//
|
||||
// Payloads are generated deterministically (LCG, no Math.random) so runs compare.
|
||||
//
|
||||
// Run with: node config/scripts/terminal-reattach-payload-scan-benchmark.mjs
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { CSI_SEQUENCE_PATTERN } from '../../src/shared/ansi-escape-sequences.ts'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../src/shared/terminal-kitty-keyboard-mode-tracker.ts'
|
||||
|
||||
const ROUNDS = Number(process.env.ORCA_REATTACH_SCAN_BENCH_ROUNDS ?? '7')
|
||||
const MIN_ITERATION_MS = 120
|
||||
|
||||
// --- payload generation -----------------------------------------------------
|
||||
|
||||
function makeRng(seed) {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0
|
||||
return state / 0x100000000
|
||||
}
|
||||
}
|
||||
|
||||
const WORDS = [
|
||||
'src',
|
||||
'renderer',
|
||||
'components',
|
||||
'terminal',
|
||||
'pane',
|
||||
'connection',
|
||||
'reattach',
|
||||
'payload',
|
||||
'snapshot',
|
||||
'daemon',
|
||||
'passed',
|
||||
'failed',
|
||||
'warning',
|
||||
'building',
|
||||
'index.ts',
|
||||
'1.24s',
|
||||
'ok'
|
||||
]
|
||||
|
||||
// Heavily SGR-colored scrollback, ~120 cols/line — what a serialized daemon snapshot
|
||||
// of a colorized TUI/build log looks like.
|
||||
function buildColoredScrollback(targetBytes, seed) {
|
||||
const rng = makeRng(seed)
|
||||
const lines = []
|
||||
let size = 0
|
||||
while (size < targetBytes) {
|
||||
let line = ''
|
||||
let visible = 0
|
||||
while (visible < 118) {
|
||||
const word = WORDS[Math.floor(rng() * WORDS.length)]
|
||||
const color = 16 + Math.floor(rng() * 200)
|
||||
line += `\x1b[38;5;${color}m${word}\x1b[0m `
|
||||
visible += word.length + 1
|
||||
}
|
||||
lines.push(line)
|
||||
size += line.length + 1
|
||||
}
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
// Live TUIs also emit cursor moves / erases; keep some of those in the mix.
|
||||
function withCursorTraffic(body, seed) {
|
||||
const rng = makeRng(seed)
|
||||
return body
|
||||
.split('\r\n')
|
||||
.map((line, i) => `\x1b[${(i % 40) + 1};1H\x1b[K${line}${rng() < 0.1 ? '\x1b[?25l' : ''}`)
|
||||
.join('\r\n')
|
||||
}
|
||||
|
||||
const CURSOR_AGENT_SCREEN = [
|
||||
'\x1b[38;5;39mCursor Agent\x1b[0m',
|
||||
'',
|
||||
' \x1b[2mReady\x1b[0m',
|
||||
'',
|
||||
'\x1b[38;5;245m→ \x1b[0m'
|
||||
].join('\r\n')
|
||||
|
||||
function buildPayloads() {
|
||||
const base200k = buildColoredScrollback(200 * 1024, 1)
|
||||
const base2m = buildColoredScrollback(2 * 1024 * 1024, 2)
|
||||
const relayTail = buildColoredScrollback(100 * 1024, 3)
|
||||
return [
|
||||
{ name: '200KB snapshot, header hit (tail)', data: `${base200k}\r\n${CURSOR_AGENT_SCREEN}` },
|
||||
{ name: '200KB snapshot, no header (miss)', data: base200k },
|
||||
{ name: '2MB snapshot, header hit (tail)', data: `${base2m}\r\n${CURSOR_AGENT_SCREEN}` },
|
||||
{ name: '2MB snapshot, no header (miss)', data: base2m },
|
||||
{ name: '100KB relay tail, cursor traffic', data: withCursorTraffic(relayTail, 4) }
|
||||
]
|
||||
}
|
||||
|
||||
// --- strip variants ---------------------------------------------------------
|
||||
|
||||
// Baseline: verbatim copy of pty-connection.ts:448 (not exported).
|
||||
function stripBaseline(data) {
|
||||
let normalized = ''
|
||||
let index = 0
|
||||
while (index < data.length) {
|
||||
if (data.charCodeAt(index) === 0x1b && data[index + 1] === '[') {
|
||||
index += 2
|
||||
while (index < data.length) {
|
||||
const code = data.charCodeAt(index)
|
||||
index += 1
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
normalized += data[index]
|
||||
index += 1
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
const stripRegex = (data) => data.replace(CSI_SEQUENCE_PATTERN, '')
|
||||
|
||||
const HEADER = 'Cursor Agent'
|
||||
const TAIL_CHARS = 5000
|
||||
// Mirrors CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS in pty-connection.ts.
|
||||
const SUFFIX_RAW_CHARS = 256 * 1024
|
||||
|
||||
const signalBaseline = (data) => hasSignal(stripBaseline(data))
|
||||
const signalRegex = (data) => hasSignal(stripRegex(data))
|
||||
// The shipped shape.
|
||||
const signalSuffixRegex = (data) =>
|
||||
hasSignal(stripRegex(data.length > SUFFIX_RAW_CHARS ? data.slice(-SUFFIX_RAW_CHARS) : data))
|
||||
|
||||
function hasSignal(normalized) {
|
||||
const headerIndex = normalized.lastIndexOf(HEADER)
|
||||
if (headerIndex === -1) {
|
||||
return false
|
||||
}
|
||||
return normalized.slice(headerIndex + HEADER.length, headerIndex + TAIL_CHARS).includes(`${'→'} `)
|
||||
}
|
||||
|
||||
// --- timing -----------------------------------------------------------------
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const mid = sorted.length >> 1
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
function timeMsPerCall(fn, arg) {
|
||||
let iterations = 1
|
||||
while (true) {
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
globalThis.__sink = fn(arg)
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
if (elapsed >= MIN_ITERATION_MS || iterations >= 1 << 22) {
|
||||
return elapsed / iterations
|
||||
}
|
||||
iterations *= Math.max(2, Math.ceil(MIN_ITERATION_MS / Math.max(elapsed, 0.01)))
|
||||
}
|
||||
}
|
||||
|
||||
function measure(fn, arg) {
|
||||
timeMsPerCall(fn, arg) // warmup / JIT
|
||||
const samples = []
|
||||
for (let round = 0; round < ROUNDS; round++) {
|
||||
samples.push(timeMsPerCall(fn, arg))
|
||||
}
|
||||
return median(samples)
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
const kb = (data) => `${(data.length / 1024).toFixed(0)}KB`
|
||||
|
||||
// --- run --------------------------------------------------------------------
|
||||
|
||||
const payloads = buildPayloads()
|
||||
|
||||
console.log('Reattach payload scans, ms/call (median of %d rounds). Lower is better.\n', ROUNDS)
|
||||
console.log('== hasCursorAgentReattachPayloadScreenSignal ==')
|
||||
console.log(
|
||||
`${pad('payload', 36)} ${pad('size', 8)} ${pad('char-loop', 11)} ${pad('regex', 10)} ${pad('suffix+regex', 13)} ${pad('speedup', 9)}`
|
||||
)
|
||||
for (const { name, data } of payloads) {
|
||||
const expected = signalBaseline(data)
|
||||
for (const [label, fn] of Object.entries({
|
||||
regex: signalRegex,
|
||||
'suffix+regex': signalSuffixRegex
|
||||
})) {
|
||||
if (fn(data) !== expected) {
|
||||
throw new Error(`${label} disagrees with baseline on "${name}"`)
|
||||
}
|
||||
}
|
||||
const base = measure(signalBaseline, data)
|
||||
const re = measure(signalRegex, data)
|
||||
const suffix = measure(signalSuffixRegex, data)
|
||||
console.log(
|
||||
`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(base.toFixed(3), 11)} ${pad(re.toFixed(3), 10)} ${pad(suffix.toFixed(3), 13)} ${pad(`${(base / suffix).toFixed(0)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\n== TerminalKittyKeyboardModeTracker.scanReplay ==')
|
||||
console.log(
|
||||
`${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}`
|
||||
)
|
||||
const kittyScan = (data) => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.scanReplay(data)
|
||||
return tracker.flags
|
||||
}
|
||||
// Mirrors src/main/daemon/terminal-mouse-mode-mirror.ts:41-47.
|
||||
const kittyGated = (data) => {
|
||||
if (!data.includes('\x1b[?') && !data.includes('\x1bc') && !data.includes('\x9b')) {
|
||||
return 0
|
||||
}
|
||||
return kittyScan(data)
|
||||
}
|
||||
for (const { name, data } of payloads) {
|
||||
console.log(
|
||||
`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}`
|
||||
)
|
||||
}
|
||||
|
||||
// The gate can only pay off where no introducer exists: live plain-output chunks, not
|
||||
// snapshots (which carry ?1049h/?25l by construction).
|
||||
const liveChunk = buildColoredScrollback(4 * 1024, 5).replaceAll('\x1b', '')
|
||||
const introducerRows = [
|
||||
['4KB live chunk, no escapes', liveChunk],
|
||||
['4KB live chunk, \\x1b[?25l present', `${liveChunk}\x1b[?25l`]
|
||||
]
|
||||
console.log(
|
||||
`\n${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}`
|
||||
)
|
||||
for (const [name, data] of introducerRows) {
|
||||
console.log(
|
||||
`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nSnapshot/replay payloads always contain \\x1b[?, so the includes() gate never fires on\nthe reattach path — it only helps the live per-chunk path.'
|
||||
)
|
||||
|
||||
// Reference point: xterm parses the same bytes right after these scans run, so its cost
|
||||
// is the yardstick for whether the scans are worth cutting.
|
||||
const xtermHeadless = await import('@xterm/headless')
|
||||
const Terminal = xtermHeadless.Terminal ?? xtermHeadless.default.Terminal
|
||||
const writeToXterm = (data) =>
|
||||
new Promise((resolve) => {
|
||||
const terminal = new Terminal({ cols: 120, rows: 40, scrollback: 5000, allowProposedApi: true })
|
||||
const start = performance.now()
|
||||
terminal.write(data, () => {
|
||||
const elapsed = performance.now() - start
|
||||
terminal.dispose()
|
||||
resolve(elapsed)
|
||||
})
|
||||
})
|
||||
|
||||
console.log(`\n== xterm headless parse of the same payload (reference) ==`)
|
||||
console.log(`${pad('payload', 36)} ${pad('size', 8)} ${pad('xterm write', 11)}`)
|
||||
for (const { name, data } of payloads) {
|
||||
await writeToXterm(data)
|
||||
const samples = []
|
||||
for (let round = 0; round < ROUNDS; round++) {
|
||||
samples.push(await writeToXterm(data))
|
||||
}
|
||||
console.log(`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(median(samples).toFixed(3), 11)}`)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
@@ -0,0 +1,393 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { posix } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import process from 'node:process'
|
||||
|
||||
import { createJiti } from 'jiti'
|
||||
|
||||
const DEFAULT_SAMPLES = 20
|
||||
const DEFAULT_WARMUPS = 3
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
distro: null,
|
||||
nativeRepo: null,
|
||||
mountedRepo: null,
|
||||
samples: DEFAULT_SAMPLES,
|
||||
warmups: DEFAULT_WARMUPS,
|
||||
loginDelayMs: 0
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = argv[++index]
|
||||
if (!value) {
|
||||
throw new Error(`${arg} requires a value`)
|
||||
}
|
||||
if (arg === '--distro') {
|
||||
options.distro = value
|
||||
} else if (arg === '--native-repo') {
|
||||
options.nativeRepo = value
|
||||
} else if (arg === '--mounted-repo') {
|
||||
options.mountedRepo = value
|
||||
} else if (arg === '--samples') {
|
||||
options.samples = Number(value)
|
||||
} else if (arg === '--warmups') {
|
||||
options.warmups = Number(value)
|
||||
} else if (arg === '--login-delay-ms') {
|
||||
options.loginDelayMs = Number(value)
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
for (const [name, value, minimum] of [
|
||||
['samples', options.samples, 5],
|
||||
['warmups', options.warmups, 0],
|
||||
['login-delay-ms', options.loginDelayMs, 0]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value < minimum || value > 1_000) {
|
||||
throw new Error(`--${name} must be an integer between ${minimum} and 1000`)
|
||||
}
|
||||
}
|
||||
if (!options.nativeRepo || !options.mountedRepo) {
|
||||
throw new Error('--native-repo and --mounted-repo are required')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
env: options.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
const stdout = []
|
||||
const stderr = []
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk))
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk))
|
||||
child.on('error', reject)
|
||||
child.on('close', (status) => {
|
||||
const result = {
|
||||
status,
|
||||
stdout: Buffer.concat(stdout),
|
||||
stderr: Buffer.concat(stderr)
|
||||
}
|
||||
if (status === 0 || options.allowFailure) {
|
||||
resolve(result)
|
||||
} else {
|
||||
reject(new Error(`${command} exited ${status}: ${result.stderr.toString('utf8').trim()}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function wslArgs(distro, args) {
|
||||
return ['-d', distro, '--exec', ...args]
|
||||
}
|
||||
|
||||
function wslShellArgs(distro, args) {
|
||||
return ['-d', distro, '--', ...args]
|
||||
}
|
||||
|
||||
async function resolveDistro(requested) {
|
||||
if (requested) {
|
||||
return requested
|
||||
}
|
||||
const result = await run('wsl.exe', ['--list', '--quiet'], {
|
||||
env: { ...process.env, WSL_UTF8: '1' }
|
||||
})
|
||||
const distro = result.stdout
|
||||
.toString('utf8')
|
||||
.replaceAll('\0', '')
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.find(Boolean)
|
||||
if (!distro) {
|
||||
throw new Error('No WSL distro is installed')
|
||||
}
|
||||
return distro
|
||||
}
|
||||
|
||||
function percentile(samples, value) {
|
||||
const sorted = [...samples].sort((left, right) => left - right)
|
||||
return sorted[Math.ceil((value / 100) * sorted.length) - 1]
|
||||
}
|
||||
|
||||
function summarize(samples) {
|
||||
return {
|
||||
samples: samples.length,
|
||||
medianMs: Number(percentile(samples, 50).toFixed(1)),
|
||||
p95Ms: Number(percentile(samples, 95).toFixed(1)),
|
||||
minMs: Number(Math.min(...samples).toFixed(1)),
|
||||
maxMs: Number(Math.max(...samples).toFixed(1))
|
||||
}
|
||||
}
|
||||
|
||||
function assertRepoPath(path, expectedPrefix) {
|
||||
if (!path.startsWith(expectedPrefix) || path.includes('\0') || path.includes('\n')) {
|
||||
throw new Error(`Unexpected benchmark repository path: ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('This benchmark requires a Windows host with WSL')
|
||||
}
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
assertRepoPath(options.nativeRepo, '/')
|
||||
assertRepoPath(options.mountedRepo, '/mnt/')
|
||||
const distro = await resolveDistro(options.distro)
|
||||
const jiti = createJiti(import.meta.url)
|
||||
const { buildWslLoginShellCommand, escapeWslShCommandForWindows, quotePosixShell } =
|
||||
await jiti.import('../../src/shared/wsl-login-shell-command.ts')
|
||||
|
||||
const loginProbe = buildWslLoginShellCommand(
|
||||
`printf '\\n__ORCA_PATH__%s\\n__ORCA_GIT__%s\\n__ORCA_HOME__%s\\n' "$PATH" "$(command -v git)" "$HOME"`
|
||||
)
|
||||
const probe = await run(
|
||||
'wsl.exe',
|
||||
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(loginProbe)])
|
||||
)
|
||||
const probeText = probe.stdout.toString('utf8')
|
||||
const loginPath = /__ORCA_PATH__(.*)/.exec(probeText)?.[1]?.trim()
|
||||
const gitPath = /__ORCA_GIT__(.*)/.exec(probeText)?.[1]?.trim()
|
||||
const loginHome = /__ORCA_HOME__(.*)/.exec(probeText)?.[1]?.trim()
|
||||
if (!loginPath || !gitPath?.startsWith('/') || !loginHome?.startsWith('/')) {
|
||||
throw new Error(`Could not resolve login-shell Git environment: ${probeText.trim()}`)
|
||||
}
|
||||
const outputMarker = `__ORCA_GIT_OUTPUT_${process.pid}__\n`
|
||||
|
||||
const runGit = async (mode, repo, args) => {
|
||||
const startedAt = performance.now()
|
||||
let result
|
||||
if (mode === 'login') {
|
||||
const command = [
|
||||
`cd ${quotePosixShell(repo)} &&`,
|
||||
`printf ${quotePosixShell(outputMarker)} &&`,
|
||||
`LC_ALL=C LANG=C ${quotePosixShell('git')}`,
|
||||
...args.map(quotePosixShell)
|
||||
].join(' ')
|
||||
const delay = options.loginDelayMs > 0 ? `sleep ${options.loginDelayMs / 1_000}; ` : ''
|
||||
const script = `${delay}${buildWslLoginShellCommand(command)}`
|
||||
result = await run(
|
||||
'wsl.exe',
|
||||
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)])
|
||||
)
|
||||
const markerOffset = result.stdout.indexOf(outputMarker)
|
||||
if (markerOffset === -1) {
|
||||
throw new Error('Login shell did not emit the Git output marker')
|
||||
}
|
||||
result.stdout = result.stdout.subarray(markerOffset + Buffer.byteLength(outputMarker))
|
||||
} else {
|
||||
result = await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/usr/bin/env',
|
||||
`PATH=${loginPath}`,
|
||||
`HOME=${loginHome}`,
|
||||
'LC_ALL=C',
|
||||
'LANG=C',
|
||||
gitPath,
|
||||
'-C',
|
||||
repo,
|
||||
...args
|
||||
])
|
||||
)
|
||||
}
|
||||
return { ...result, elapsedMs: performance.now() - startedAt }
|
||||
}
|
||||
|
||||
const measure = async (label, runArm) => {
|
||||
for (let index = 0; index < options.warmups; index += 1) {
|
||||
await runArm('login')
|
||||
await runArm('fast')
|
||||
}
|
||||
const samples = { login: [], fast: [] }
|
||||
for (let index = 0; index < options.samples; index += 1) {
|
||||
const order = index % 2 === 0 ? ['login', 'fast'] : ['fast', 'login']
|
||||
const results = []
|
||||
for (const mode of order) {
|
||||
const result = await runArm(mode)
|
||||
samples[mode].push(result.elapsedMs)
|
||||
results.push(result)
|
||||
}
|
||||
if (
|
||||
results[0].status !== results[1].status ||
|
||||
!results[0].stdout.equals(results[1].stdout) ||
|
||||
!results[0].stderr.equals(results[1].stderr)
|
||||
) {
|
||||
throw new Error(
|
||||
`${label} correctness mismatch between ${order.join(' and ')} arms:\n` +
|
||||
`${order[0]}=${JSON.stringify({ stdout: results[0].stdout.toString('utf8'), stderr: results[0].stderr.toString('utf8') })}\n` +
|
||||
`${order[1]}=${JSON.stringify({ stdout: results[1].stdout.toString('utf8'), stderr: results[1].stderr.toString('utf8') })}`
|
||||
)
|
||||
}
|
||||
}
|
||||
const login = summarize(samples.login)
|
||||
const fast = summarize(samples.fast)
|
||||
return {
|
||||
login,
|
||||
fast,
|
||||
medianSpeedup: Number((login.medianMs / fast.medianMs).toFixed(2)),
|
||||
medianSavedMs: Number((login.medianMs - fast.medianMs).toFixed(1))
|
||||
}
|
||||
}
|
||||
|
||||
const benchmarkRepo = async (repo) => {
|
||||
const branch = (
|
||||
await runGit('fast', repo, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
|
||||
).stdout
|
||||
.toString('utf8')
|
||||
.trim()
|
||||
const fixtureName = `.orca-wsl-git-shell-benchmark-${process.pid}-${randomUUID()}.txt`
|
||||
const fixturePath = posix.join(repo, fixtureName)
|
||||
const prepareStage = async () => {
|
||||
await runGit('fast', repo, ['reset', '--quiet', '--', fixtureName])
|
||||
await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
'printf "benchmark\\n" > "$1"',
|
||||
'orca-wsl-git-shell-benchmark',
|
||||
fixturePath
|
||||
])
|
||||
)
|
||||
}
|
||||
const cleanupStage = async () => {
|
||||
await runGit('fast', repo, ['reset', '--quiet', '--', fixtureName])
|
||||
await run('wsl.exe', wslArgs(distro, ['/bin/rm', '-f', '--', fixturePath]))
|
||||
}
|
||||
|
||||
const operations = {
|
||||
status: [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'status',
|
||||
'--porcelain=v2',
|
||||
'--branch',
|
||||
'--untracked-files=all'
|
||||
],
|
||||
numstat: ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M'],
|
||||
upstream: [
|
||||
'for-each-ref',
|
||||
'--format=%(refname)%00%(upstream)%00%(upstream:short)',
|
||||
'--count=1',
|
||||
`refs/heads/${branch}`
|
||||
]
|
||||
}
|
||||
const result = {}
|
||||
for (const [name, args] of Object.entries(operations)) {
|
||||
result[name] = await measure(`${repo}:${name}`, (mode) => runGit(mode, repo, args))
|
||||
}
|
||||
const created = await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
'set -C; printf "benchmark\\n" > "$1"',
|
||||
'orca-wsl-git-shell-benchmark',
|
||||
fixturePath
|
||||
]),
|
||||
{ allowFailure: true }
|
||||
)
|
||||
if (created.status !== 0) {
|
||||
throw new Error(`Could not exclusively create benchmark fixture: ${fixturePath}`)
|
||||
}
|
||||
try {
|
||||
result.stageRefresh = await measure(`${repo}:stage-refresh`, async (mode) => {
|
||||
await prepareStage()
|
||||
const startedAt = performance.now()
|
||||
await runGit('login', repo, ['add', '--', fixtureName])
|
||||
const status = await runGit(mode, repo, operations.status)
|
||||
const numstat = await runGit(mode, repo, [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'diff',
|
||||
'-z',
|
||||
'--cached',
|
||||
'--numstat',
|
||||
'-M'
|
||||
])
|
||||
return {
|
||||
status: 0,
|
||||
stdout: Buffer.concat([status.stdout, numstat.stdout]),
|
||||
stderr: Buffer.concat([status.stderr, numstat.stderr]),
|
||||
elapsedMs: performance.now() - startedAt
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
await cleanupStage()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const verifyProductionRunner = async (repo) => {
|
||||
const windowsRepo = (
|
||||
await run('wsl.exe', wslArgs(distro, ['/usr/bin/wslpath', '-w', repo]))
|
||||
).stdout
|
||||
.toString('utf8')
|
||||
.trim()
|
||||
const args = [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'status',
|
||||
'--porcelain=v2',
|
||||
'--branch',
|
||||
'--untracked-files=all'
|
||||
]
|
||||
const [{ gitExecFileAsync }, { getWslGitReadEnvironment }, expected] = await Promise.all([
|
||||
jiti.import('../../src/main/git/runner.ts'),
|
||||
jiti.import('../../src/main/git/wsl-git-read-environment.ts'),
|
||||
runGit('fast', repo, args)
|
||||
])
|
||||
await getWslGitReadEnvironment(distro)
|
||||
const startedAt = performance.now()
|
||||
const actual = await gitExecFileAsync(args, {
|
||||
cwd: windowsRepo,
|
||||
preferWslDirectGit: true,
|
||||
wslDistro: distro
|
||||
})
|
||||
if (
|
||||
!Buffer.from(actual.stdout).equals(expected.stdout) ||
|
||||
!Buffer.from(actual.stderr).equals(expected.stderr)
|
||||
) {
|
||||
throw new Error(`Production runner output mismatch for ${windowsRepo}`)
|
||||
}
|
||||
return {
|
||||
windowsRepo,
|
||||
environmentPrimed: true,
|
||||
elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
|
||||
outputBytes: expected.stdout.length
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
distro,
|
||||
gitPath,
|
||||
gitVersion: (await runGit('fast', options.nativeRepo, ['--version'])).stdout
|
||||
.toString('utf8')
|
||||
.trim(),
|
||||
samples: options.samples,
|
||||
warmups: options.warmups,
|
||||
injectedLoginDelayMs: options.loginDelayMs,
|
||||
loginProbePreambleBytes: Buffer.byteLength(probeText.split('__ORCA_PATH__', 1)[0]),
|
||||
guestProcessShape: {
|
||||
login: 'sh -> interactive login shell -> git',
|
||||
fast: 'env -> git'
|
||||
},
|
||||
native: await benchmarkRepo(options.nativeRepo),
|
||||
mounted: await benchmarkRepo(options.mountedRepo),
|
||||
productionRunnerVerification: {
|
||||
native: await verifyProductionRunner(options.nativeRepo),
|
||||
mounted: await verifyProductionRunner(options.mountedRepo)
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Minimal `electron` module for wsl-hook-relay-reattach-benchmark.mjs, which loads the real
|
||||
* src/main/ipc/pty.ts under plain node. Only the surfaces that main's PTY graph touches are
|
||||
* implemented; everything else stays undefined so an unexpected dependency fails loudly.
|
||||
*/
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const userDataPath = process.env.ORCA_USER_DATA_PATH ?? tmpdir()
|
||||
|
||||
export const BrowserWindow = undefined
|
||||
|
||||
export const app = {
|
||||
isPackaged: false,
|
||||
getVersion: () => '0.0.0-wsl-relay-benchmark',
|
||||
getName: () => 'orca-wsl-relay-benchmark',
|
||||
getPath: (name) => {
|
||||
if (name === 'userData') {
|
||||
return userDataPath
|
||||
}
|
||||
if (name === 'temp') {
|
||||
return tmpdir()
|
||||
}
|
||||
if (name === 'home') {
|
||||
return homedir()
|
||||
}
|
||||
return join(userDataPath, name)
|
||||
},
|
||||
on: () => {},
|
||||
once: () => {},
|
||||
whenReady: () => Promise.resolve()
|
||||
}
|
||||
|
||||
export const ipcMain = {
|
||||
handle: () => {},
|
||||
handleOnce: () => {},
|
||||
on: () => {},
|
||||
once: () => {},
|
||||
removeHandler: () => {},
|
||||
removeAllListeners: () => {}
|
||||
}
|
||||
|
||||
export const powerMonitor = { on: () => {}, once: () => {} }
|
||||
export const nativeTheme = { shouldUseDarkColors: false, on: () => {} }
|
||||
export const shell = { openPath: async () => '', openExternal: async () => {} }
|
||||
export const dialog = { showMessageBox: async () => ({ response: 0 }) }
|
||||
export const safeStorage = { isEncryptionAvailable: () => false }
|
||||
export const clipboard = { readText: () => '', writeText: () => {} }
|
||||
export const screen = { getPrimaryDisplay: () => ({ workAreaSize: { width: 0, height: 0 } }) }
|
||||
export const session = { defaultSession: undefined }
|
||||
|
||||
export default {
|
||||
BrowserWindow,
|
||||
app,
|
||||
ipcMain,
|
||||
powerMonitor,
|
||||
nativeTheme,
|
||||
shell,
|
||||
dialog,
|
||||
safeStorage,
|
||||
clipboard,
|
||||
screen,
|
||||
session
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user