mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
Merge origin/main into mobile host picker
This commit is contained in:
+1
-1
@@ -2,4 +2,4 @@
|
||||
/src/renderer/src/i18n/locales/ @brennanb2025
|
||||
/config/scripts/*localization*.mjs @brennanb2025
|
||||
/config/scripts/*locale*.mjs @brennanb2025
|
||||
/i18next.config.ts @brennanb2025
|
||||
/config/i18next.config.ts @brennanb2025
|
||||
|
||||
@@ -10,6 +10,8 @@ body:
|
||||
value: |
|
||||
Use this form for Orca bugs. Include enough detail for someone else to reproduce the issue.
|
||||
|
||||
Please write your issue in English.
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
|
||||
@@ -10,6 +10,8 @@ body:
|
||||
value: |
|
||||
Use this form for new features or workflow improvements.
|
||||
|
||||
Please write your issue in English.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
|
||||
@@ -2,6 +2,11 @@ name: Other
|
||||
description: Report another Orca issue type
|
||||
title: '[Other]: '
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please write your issue in English.
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,418 @@
|
||||
name: Adhoc macOS Dev Build
|
||||
|
||||
# Why: lets anyone cut a signed macOS build of an unlanded branch so the team can
|
||||
# actually run an experimental feature for a few days, instead of reasoning about
|
||||
# it from a diff. Hourly covers main; this covers everything that is not main yet.
|
||||
#
|
||||
# Deliberately narrow scope, same trade as hourly:
|
||||
# - macOS only. Other platforms keep using RC/stable.
|
||||
# - No tests, no lint, no e2e. PR CI and release-cut remain the gates.
|
||||
# - Signed AND notarized, exactly like a release. macOS anchors a notarized
|
||||
# app's TCC grants on identifier + team rather than on its cdhash, so those
|
||||
# grants survive an update; an unnotarized build reads as a new client and
|
||||
# silently loses file access under Documents/Desktop/Downloads.
|
||||
#
|
||||
# Artifacts publish to stablyai/orca-adhoc — separate from both orca and
|
||||
# orca-hourly. Separate from orca because the main repo's releases atom feed
|
||||
# exposes only its 10 newest entries. Separate from orca-hourly because a build
|
||||
# from someone's branch must never be picked up by a developer who only meant to
|
||||
# ride main; the two are different levels of "unvetted".
|
||||
#
|
||||
# From the Actions tab: pick this workflow, "Run workflow", leave "Use workflow
|
||||
# from" on main, and type your branch in the first field. Or from the CLI:
|
||||
#
|
||||
# gh workflow run adhoc-mac-build.yml --ref main -f ref=my-branch -f label=wasm-terminal
|
||||
#
|
||||
# Leaving the ref field empty builds whatever "Use workflow from" is set to, which
|
||||
# is what someone who only touched that picker means. Naming the branch explicitly
|
||||
# is still better: the workflow file is always read from the dispatch ref, so a
|
||||
# branch carrying a stale copy of this file would otherwise run that copy.
|
||||
#
|
||||
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the
|
||||
# same GitHub App as hourly, additionally installed on orca-adhoc with
|
||||
# Contents: Read and write. The secret names below are historical — one App, one
|
||||
# private key, both dev-channel repos — and rotating it stays a single operation.
|
||||
# Provision with `bash config/scripts/setup-hourly-release-token.sh`.
|
||||
#
|
||||
# The requested ref is vetted before checkout: it must be a branch or tag of this
|
||||
# repo, or a commit reachable from one. PR refs are refused outright — this
|
||||
# workflow runs the checked-out code next to MAC_CERTS and the notary password,
|
||||
# so "just build that community PR" must not become a way to hand fork code the
|
||||
# release identity. A branch here always belongs to someone with write access,
|
||||
# which is the same trust the dispatch button itself already requires.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
# Why optional: the Actions UI already shows its own "Use workflow from"
|
||||
# branch picker directly above this field, and picking a branch there is
|
||||
# what most people will read as "build this". Defaulting to that branch
|
||||
# makes the obvious action correct. Fill this in only to build a ref other
|
||||
# than the one the workflow file itself is read from — normally leave the
|
||||
# picker on main and name your branch here, so a stale copy of this
|
||||
# workflow on an old branch is not what runs.
|
||||
description: 'Branch, tag, or SHA to build — must live in stablyai/orca; PR refs are refused (default: the branch selected above)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
label:
|
||||
description: 'Short name shown in the release title (default: the ref)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Why keyed on the ref rather than global: two people cutting builds from two
|
||||
# different branches at the same time is the ordinary case here, and serialising
|
||||
# them would make each wait out the other's notary queue. Re-dispatching the
|
||||
# *same* branch still queues, so a push mid-build cannot race itself.
|
||||
group: adhoc-mac-build-${{ inputs.ref || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
ADHOC_REPO: stablyai/orca-adhoc
|
||||
# Why age and not a count like hourly: this channel is low-volume and bursty, so
|
||||
# a count would either hold one week's experiments forever or evict a build
|
||||
# someone is still running after a busy afternoon. A month is well past the "few
|
||||
# days" these exist for, and by then the branch has landed or been abandoned.
|
||||
ADHOC_RETAIN_DAYS: 30
|
||||
|
||||
jobs:
|
||||
build-adhoc-mac:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
# Why an environment: it gives the signing/notary/App secrets somewhere to
|
||||
# live that a stale copy of this workflow on an old branch cannot reach.
|
||||
# Referencing it is a no-op until repo settings give it teeth; the intended
|
||||
# follow-up is to move MAC_CERTS, MAC_CERTS_PASSWORD, APPLE_ID,
|
||||
# APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, HOURLY_RELEASE_APP_ID, and
|
||||
# HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch
|
||||
# policy to main so only main's copy of this file can read them.
|
||||
environment: adhoc-mac-build
|
||||
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.
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
steps:
|
||||
# Why vet before checkout: everything after this step runs the checked-out
|
||||
# code with release signing credentials in reach. Branches and tags of this
|
||||
# repo are the intended audience; refs/pull/* would smuggle in fork code,
|
||||
# and a raw SHA is only accepted when some branch or tag of this repo can
|
||||
# actually reach it. Resolving to a pinned SHA here also means the commit
|
||||
# that was vetted is the commit that gets checked out — a push to the
|
||||
# branch between the two steps cannot swap it.
|
||||
- name: Vet the requested ref
|
||||
id: vetted
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_REF: ${{ inputs.ref || github.ref_name }}
|
||||
REPO_URL: https://github.com/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$REQUESTED_REF" in
|
||||
refs/pull/*|pull/*)
|
||||
echo "::error::Refusing to build PR ref '$REQUESTED_REF': this workflow signs with release credentials, so it only builds branches, tags, or commits of stablyai/orca. Push the code to a branch of this repo instead."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# Bare: a work-tree repo refuses to fetch over its own checked-out
|
||||
# branch. tree:0 keeps the fetch to the commit graph — no trees, no
|
||||
# blobs — so this stays cheap next to the build it fronts.
|
||||
scratch="$RUNNER_TEMP/vet-requested-ref"
|
||||
git init -q --bare "$scratch"
|
||||
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
# Branch first to keep actions/checkout's old tie-break: bare
|
||||
# rev-parse would prefer the tag when a branch shares its name.
|
||||
sha=""
|
||||
for cand in "refs/heads/$REQUESTED_REF" "refs/tags/$REQUESTED_REF" "$REQUESTED_REF"; do
|
||||
if sha="$(git -C "$scratch" rev-parse --verify --quiet "$cand^{commit}")"; then
|
||||
break
|
||||
fi
|
||||
sha=""
|
||||
done
|
||||
if [[ -z "$sha" ]]; then
|
||||
echo "::error::'$REQUESTED_REF' does not resolve to a branch, tag, or commit of stablyai/orca."
|
||||
exit 1
|
||||
fi
|
||||
# The object resolving locally is not proof a branch or tag reaches
|
||||
# it: a partial clone can lazily fetch a bare SHA on demand, and
|
||||
# GitHub serves PR-only commits by SHA. Reachability is the actual
|
||||
# trust test.
|
||||
if [[ -z "$(git -C "$scratch" for-each-ref --contains "$sha" refs/heads refs/tags | head -1)" ]]; then
|
||||
echo "::error::Commit $REQUESTED_REF is not reachable from any branch or tag of stablyai/orca; refusing to build it with release credentials."
|
||||
exit 1
|
||||
fi
|
||||
echo "Vetted $REQUESTED_REF -> $sha"
|
||||
echo "sha=$sha" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout the requested ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why an input at all rather than just github.ref: the whole point is to
|
||||
# build code that has not landed, and the workflow definition itself
|
||||
# always comes from the dispatch ref — naming the branch here instead
|
||||
# applies main's current copy of this file to an arbitrary branch.
|
||||
ref: ${{ steps.vetted.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
# This job only reads stablyai/orca and never pushes; every write goes
|
||||
# to the adhoc 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: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/electron
|
||||
~/Library/Caches/electron-builder
|
||||
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-mac-
|
||||
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: signing is what makes an adhoc build installable over an existing
|
||||
# Orca, so a missing cert must fail here rather than after a 20-minute build.
|
||||
- name: Verify macOS signing environment
|
||||
run: node config/scripts/verify-macos-release-env.mjs
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Compute adhoc version
|
||||
id: adhoc
|
||||
shell: bash
|
||||
env:
|
||||
REF: ${{ inputs.ref || github.ref_name }}
|
||||
LABEL: ${{ inputs.label }}
|
||||
MAIN_REPO_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why this check: the version scripts are read from the branch being built,
|
||||
# not from main, so a branch cut before the adhoc channel landed has no
|
||||
# copy of them. Say that plainly instead of failing with a module-not-found.
|
||||
for script in adhoc-build-version dev-channel-base-version; do
|
||||
if [[ ! -f "config/scripts/$script.mjs" ]]; then
|
||||
echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
|
||||
# Why the main repo's tags: package.json on a branch is as stale as the
|
||||
# main it forked from, and stable patches never merge back into it.
|
||||
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
|
||||
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
|
||||
--json tagName --jq '.[].tagName' || true)"
|
||||
ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \
|
||||
node config/scripts/adhoc-build-version.mjs \
|
||||
>"$RUNNER_TEMP/adhoc-identity.txt"
|
||||
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
|
||||
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."
|
||||
exit 1
|
||||
fi
|
||||
cat "$RUNNER_TEMP/adhoc-identity.txt" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build app
|
||||
run: pnpm build:release
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# Why: adhoc 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 branch artifacts.
|
||||
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
|
||||
|
||||
# Why the token is minted here and not at the top: installation tokens live
|
||||
# one hour, everything before this point writes nothing, and the notary round
|
||||
# trip inside the publish step can be tens of minutes. Minting after the build
|
||||
# starts the clock at the first call that actually uses it.
|
||||
- name: Mint adhoc 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-adhoc
|
||||
|
||||
- name: Create adhoc release
|
||||
id: release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: v${{ steps.adhoc.outputs.version }}
|
||||
NAME: ${{ steps.adhoc.outputs.name }}
|
||||
SHA: ${{ steps.adhoc.outputs.head_sha }}
|
||||
REF: ${{ inputs.ref || github.ref_name }}
|
||||
# Via env, not inline `${{ }}`: both land inside a shell string, and an
|
||||
# expression expanded there is substituted before bash parses the line
|
||||
# (zizmor: template-injection).
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
short_sha="${SHA:0:12}"
|
||||
# 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.
|
||||
gh release create "$TAG" \
|
||||
--repo "$ADHOC_REPO" \
|
||||
--title "$NAME" \
|
||||
--draft \
|
||||
--notes "Adhoc macOS dev build of \`$REF\` at commit \`$short_sha\`.
|
||||
|
||||
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA), cut by @$ACTOR.
|
||||
|
||||
**Unlanded and unvetted.** This is somebody's branch, not main. No tests
|
||||
ran. Signed and notarized like a release, so it installs through Orca's
|
||||
in-app updater and opens without a Gatekeeper prompt — but the branch may
|
||||
never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish adhoc macOS artifacts
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
# Why 45: an attempt is pack + notarize + upload, and the notary queue is
|
||||
# the unbounded part. Two attempts, because a failed adhoc build has a
|
||||
# person waiting on it who can simply dispatch again.
|
||||
timeout_minutes: 45
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 30
|
||||
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_ADHOC=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-adhoc.
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
ORCA_ADHOC_BUILD_VERSION: ${{ steps.adhoc.outputs.version }}
|
||||
ORCA_BUILD_COMMIT: ${{ steps.adhoc.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.
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# 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
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets="$(gh release view "$TAG" --repo "$ADHOC_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.
|
||||
for required in latest-mac.yml; do
|
||||
if ! grep -qx "$required" <<<"$assets"; then
|
||||
echo "::error::Adhoc draft $TAG is missing $required; the updater could not install it."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! grep -q '\.zip$' <<<"$assets"; then
|
||||
echo "::error::Adhoc 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
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
NAME: ${{ steps.adhoc.outputs.name }}
|
||||
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.
|
||||
gh release edit "$TAG" --repo "$ADHOC_REPO" --draft=false --prerelease --title "$NAME"
|
||||
echo "Published $TAG as \"$NAME\""
|
||||
|
||||
# Why: a draft left behind by a failed publish is invisible to users but still
|
||||
# holds its tag name. Gated on publish_live not having succeeded so a later
|
||||
# failure (the prune step) cannot delete a release that already went live and
|
||||
# that people may already be installing. 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:
|
||||
GH_TOKEN: ${{ steps.app_token.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 "$ADHOC_REPO" --yes ||
|
||||
echo "::warning::Could not discard draft $TAG; remove it manually."
|
||||
|
||||
- name: Prune expired adhoc releases
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why compute the cutoff in bash rather than with jq's `now`: this runs
|
||||
# once per dispatch, and a fixed epoch makes the threshold visible in the
|
||||
# log when someone asks where their build went.
|
||||
cutoff=$(( $(date -u +%s) - ADHOC_RETAIN_DAYS * 86400 ))
|
||||
echo "Pruning adhoc releases created before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
# --cleanup-tag so pruning does not leave orphan tags with no release or
|
||||
# assets attached. Drafts are excluded: a stale draft is the failure
|
||||
# path's business, not the retention window's.
|
||||
stale="$(gh release list --repo "$ADHOC_REPO" --limit 200 --json tagName,createdAt,isDraft \
|
||||
--jq "map(select(.isDraft | not)) | map(select((.createdAt | fromdateiso8601) < $cutoff)) | .[].tagName")"
|
||||
if [[ -z "$stale" ]]; then
|
||||
echo "Nothing to prune."
|
||||
exit 0
|
||||
fi
|
||||
while read -r tag; do
|
||||
[[ -n "$tag" ]] || continue
|
||||
echo "Pruning $tag"
|
||||
gh release delete "$tag" --repo "$ADHOC_REPO" --yes --cleanup-tag || \
|
||||
echo "::warning::Could not prune $tag"
|
||||
done <<<"$stale"
|
||||
@@ -9,18 +9,25 @@ on:
|
||||
- 'config/scripts/build-windows-cli-launcher.mjs'
|
||||
- 'config/scripts/build-windows-cli-launcher.test.mjs'
|
||||
- 'config/scripts/computer-e2e-workflow.test.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-processes.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
- 'config/scripts/computer-use-modifier-safety.test.mjs'
|
||||
- 'config/scripts/computer-use-skill-guidance.test.mjs'
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
- 'config/scripts/computer-use-smoke.test.mjs'
|
||||
- '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
|
||||
# Node, so any change to the daemon bundle graph or the main build must
|
||||
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
|
||||
- 'electron.vite.config.ts'
|
||||
- 'build-plugins/**'
|
||||
- 'config/build-plugins/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
@@ -59,6 +66,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
@@ -85,6 +94,8 @@ jobs:
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
|
||||
config/scripts/computer-use-modifier-safety.test.mjs
|
||||
config/scripts/computer-use-skill-guidance.test.mjs
|
||||
config/scripts/computer-use-smoke.test.mjs
|
||||
@@ -125,20 +136,41 @@ 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
|
||||
if: runner.os == 'Windows'
|
||||
run: node config/scripts/windows-daemon-workspace-close-repro.mjs
|
||||
- if: runner.os == 'Linux'
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
ACCESSIBILITY_ENABLED: '1'
|
||||
run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
|
||||
- if: runner.os == 'Windows'
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts
|
||||
mac-native-owner-smoke:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Owner-loss benchmark process cleanup
|
||||
run: >-
|
||||
pnpm vitest run
|
||||
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
|
||||
- name: Authenticated helper owner-loss smoke
|
||||
run: pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1
|
||||
- name: Swift tests and signed universal helper verification
|
||||
run: pnpm verify:computer-native
|
||||
|
||||
mac:
|
||||
# macOS Accessibility and Screen Recording require user-granted TCC entries.
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
branches:
|
||||
- Jinwoo-H/windows-update-survival
|
||||
paths:
|
||||
- 'tools/daemon-relocation-spike/**'
|
||||
- 'tests/tools/daemon-relocation-spike/**'
|
||||
- '.github/workflows/daemon-relocation-spike.yml'
|
||||
workflow_dispatch: {}
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
# --keep-work-dir so the per-tier daemon stdout/stderr logs survive
|
||||
# for the artifact upload (the spike otherwise removes work-dir),
|
||||
# which is what diagnoses a tier that fails to reach ready.
|
||||
node tools/daemon-relocation-spike/spike.mjs `
|
||||
node tests/tools/daemon-relocation-spike/spike.mjs `
|
||||
--app-dir dist/win-unpacked `
|
||||
--work-dir "$work" `
|
||||
--tier $tier `
|
||||
|
||||
@@ -13,6 +13,10 @@ on:
|
||||
description: Ref to check out (defaults to the calling workflow's ref)
|
||||
required: false
|
||||
type: string
|
||||
test_files:
|
||||
description: JSON array of changed specs; empty runs the full suite
|
||||
required: false
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -64,11 +68,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: building once avoids five parallel electron-vite builds inside
|
||||
# Playwright globalSetup, which otherwise contends for CPU/RAM on OSS
|
||||
# runners before the sharded tests even start.
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
- name: Build Electron app for E2E
|
||||
run: npx electron-vite build --mode e2e
|
||||
env:
|
||||
VITE_EXPOSE_STORE: 'true'
|
||||
run: |
|
||||
npx electron-vite build --mode e2e
|
||||
pnpm run build:web-from-renderer
|
||||
|
||||
- name: Upload E2E build output
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -81,6 +88,7 @@ jobs:
|
||||
e2e:
|
||||
name: e2e ${{ matrix.shard_name }}
|
||||
needs: build
|
||||
if: inputs.test_files == ''
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
@@ -180,9 +188,67 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
changed-e2e:
|
||||
name: changed e2e specs
|
||||
needs: build
|
||||
if: inputs.test_files != ''
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: e2e-build-out
|
||||
path: out/
|
||||
|
||||
- name: Run changed E2E specs
|
||||
env:
|
||||
TEST_FILES_JSON: ${{ inputs.test_files }}
|
||||
run: |
|
||||
mapfile -t TEST_FILES < <(jq -r '.[]' <<<"$TEST_FILES_JSON")
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 \
|
||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-traces-changed
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
ssh-docker-watcher-isolation:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
if: inputs.test_files == ''
|
||||
runs-on: ubuntu-latest
|
||||
# Why 35: the terminal parking + retention specs below add two more
|
||||
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
name: Golden E2E Experiment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/golden-e2e-experiment.yml'
|
||||
- 'package.json'
|
||||
- 'tests/e2e/golden-core-flows.spec.ts'
|
||||
- 'tests/e2e/fixtures/terminal-emoji-table.md'
|
||||
- 'tests/e2e/helpers/**'
|
||||
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
|
||||
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
|
||||
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
|
||||
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -44,9 +32,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why: pull_request merge refs disappear after merge, but experiment
|
||||
# reruns still need a stable commit to checkout.
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }}
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build tools
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
name: Hourly macOS Dev Build
|
||||
|
||||
# Why: gives developers a signed macOS build of main every hour that the in-app
|
||||
# updater can install directly, without waiting for an RC cut.
|
||||
#
|
||||
# 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 hourly reads as a new client and silently loses file access
|
||||
# under Documents/Desktop/Downloads — 24 times a day.
|
||||
#
|
||||
# Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main
|
||||
# repo's releases atom feed exposes only its 10 newest entries, so 24 hourly
|
||||
# tags a day would evict every stable/RC entry and break updates for real users.
|
||||
#
|
||||
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use a
|
||||
# GitHub App installed on orca-hourly with Contents: Read and write. Its private
|
||||
# key does not expire, unlike a PAT — nothing here needs yearly rotation, and the
|
||||
# credential belongs to the org rather than to whoever created it.
|
||||
#
|
||||
# Provision the two secrets with `bash config/scripts/setup-hourly-release-token.sh`:
|
||||
# HOURLY_RELEASE_APP_ID the App's numeric id
|
||||
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
|
||||
#
|
||||
# Installation tokens live one hour, which is why this mints twice. 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`.
|
||||
# A pathological retry can still outrun the second token, but the release is an
|
||||
# unpublished draft until the manifest check passes, so the damage is a stranded
|
||||
# invisible draft — and the build-number query counts drafts, so it holds its
|
||||
# number and the next run does not reuse it.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Top of every hour. Skipped automatically when main has not moved.
|
||||
- cron: '0 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: Build even if main has not moved since the last hourly
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: hourly-mac-build
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
HOURLY_REPO: stablyai/orca-hourly
|
||||
# Keep ~3 days of history so a regression can be bisected across a weekend.
|
||||
HOURLY_RETAIN_COUNT: 72
|
||||
|
||||
jobs:
|
||||
build-hourly-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 hourly 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 hourly 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-hourly
|
||||
|
||||
# Why: main is often idle overnight. 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 hourly
|
||||
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 hourly 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 "$HOURLY_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 hourly release found; building $head_sha."
|
||||
exit 0
|
||||
fi
|
||||
last_sha="$(gh release view "$last_body" --repo "$HOURLY_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 hourly 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 an hourly 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 hourly version
|
||||
id: hourly
|
||||
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 "$HOURLY_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
|
||||
# hourly 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_HOURLY_RELEASE_NAMES="$names" \
|
||||
node config/scripts/hourly-build-version.mjs \
|
||||
>"$RUNNER_TEMP/hourly-identity.txt"
|
||||
grep -E '^(version|build_number)=' "$RUNNER_TEMP/hourly-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/hourly-identity.txt"; then
|
||||
echo "::error::hourly-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/hourly-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: hourly 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 hourly 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 hourly 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-hourly
|
||||
|
||||
- name: Create hourly release
|
||||
id: release
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
|
||||
TAG: v${{ steps.hourly.outputs.version }}
|
||||
NAME: ${{ steps.hourly.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 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 "$HOURLY_REPO" \
|
||||
--title "$NAME" \
|
||||
--draft \
|
||||
--notes "Automated hourly 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."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish hourly 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 hourly costs an hour, and the next cron picks the same
|
||||
# commit up, so a third attempt buys less than it costs in runner time.
|
||||
timeout_minutes: 45
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 30
|
||||
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=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-hourly.
|
||||
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
|
||||
ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }}
|
||||
ORCA_BUILD_COMMIT: ${{ steps.hourly.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: 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_publish.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets="$(gh release view "$TAG" --repo "$HOURLY_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::Hourly draft $TAG is missing $required; the updater could not install it."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! grep -q '\.zip$' <<<"$assets"; then
|
||||
echo "::error::Hourly 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_publish.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
NAME: ${{ steps.hourly.outputs.name }}
|
||||
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.
|
||||
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease --title "$NAME"
|
||||
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:
|
||||
GH_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 "$HOURLY_REPO" --yes ||
|
||||
echo "::warning::Could not discard draft $TAG; remove it manually."
|
||||
|
||||
- name: Prune old hourly releases
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
|
||||
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")"
|
||||
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
|
||||
echo "Pruning $tag"
|
||||
gh release delete "$tag" --repo "$HOURLY_REPO" --yes --cleanup-tag || \
|
||||
echo "::warning::Could not prune $tag"
|
||||
done <<<"$stale"
|
||||
@@ -1,20 +1,6 @@
|
||||
name: Linux Wayland GPU Sandbox
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/linux-wayland-gpu-sandbox.yml
|
||||
- config/scripts/linux-wayland-renderer-diagnostics.mjs
|
||||
- config/scripts/linux-wayland-terminal-exercise.mjs
|
||||
- config/scripts/linux-wayland-validation-watchdog.mjs
|
||||
- config/scripts/verify-linux-wayland-gpu-sandbox.mjs
|
||||
- src/main/startup/configure-process.ts
|
||||
- src/main/startup/configure-process.test.ts
|
||||
- src/preload/api-types.ts
|
||||
- src/preload/index.ts
|
||||
- src/renderer/src/components/terminal-pane/pty-connection.ts
|
||||
- src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts
|
||||
- src/renderer/src/web/web-preload-api.ts
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -85,34 +71,6 @@ jobs:
|
||||
done
|
||||
test -S "$XDG_RUNTIME_DIR/wayland-1"
|
||||
|
||||
- name: Reproduce terminal input freeze without the workaround
|
||||
id: reproduce_base
|
||||
if: github.event_name == 'pull_request'
|
||||
# Why: still collect fixed-path Wayland evidence when the base repro
|
||||
# stops reproducing; the final gate below fails the job in that case.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}"
|
||||
# Why: once the PR base includes this workaround, the partial
|
||||
# checkout cannot reconstruct an unfixed Wayland negative control.
|
||||
if git show "${{ github.event.pull_request.base.sha }}:src/main/startup/configure-process.ts" | grep -Eq "appendSwitch\\(['\"]disable-gpu-sandbox['\"]\\)"; then
|
||||
echo "The base build already contains the Linux Wayland GPU sandbox workaround; skipping unfixed-path reproduction."
|
||||
exit 0
|
||||
fi
|
||||
# Why: keep the new verifier scripts, but run them against the
|
||||
# unfixed production terminal/GPU path instead of a hybrid checkout.
|
||||
git checkout "${{ github.event.pull_request.base.sha }}" -- \
|
||||
src/main/startup/configure-process.ts \
|
||||
src/preload/api-types.ts \
|
||||
src/preload/index.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-connection.ts \
|
||||
src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts \
|
||||
src/renderer/src/web/web-preload-api.ts
|
||||
rm -rf out
|
||||
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs --mode=expect-repro
|
||||
|
||||
- name: Verify terminal input under Wayland GPU sandbox workaround
|
||||
if: ${{ !cancelled() }}
|
||||
timeout-minutes: 10
|
||||
@@ -122,12 +80,6 @@ jobs:
|
||||
rm -rf out
|
||||
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs
|
||||
|
||||
- name: Require base reproduction
|
||||
if: ${{ github.event_name == 'pull_request' && steps.reproduce_base.outcome != 'success' }}
|
||||
run: |
|
||||
echo "The unfixed base build did not reproduce the Wayland terminal input failure."
|
||||
exit 1
|
||||
|
||||
- name: Upload Weston log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
+114
-16
@@ -93,6 +93,23 @@ jobs:
|
||||
- name: Verify macOS entitlements
|
||||
run: pnpm verify:macos-entitlements
|
||||
|
||||
root_directory_guard:
|
||||
name: root directory guard
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Reject new root-level files and folders
|
||||
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"
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -216,8 +233,32 @@ jobs:
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Why fetch-depth 0: the harness extracts the newest release tag to skew
|
||||
# current code against it. The default shallow clone has no tags, which is
|
||||
# why this cannot ride along in the sharded `test` job.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
# A path filter that matches nothing exits 1 ("No test files found"), so this
|
||||
# lane cannot report success while running zero tests.
|
||||
- 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
|
||||
|
||||
package:
|
||||
name: package
|
||||
runs-on: ubuntu-latest
|
||||
@@ -273,11 +314,62 @@ jobs:
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
|
||||
|
||||
# Why: regression specs under tests/e2e/** used to merge green without ever
|
||||
# running — e2e.yml only fired on schedule/release (#10518). Path-filter so
|
||||
# ordinary PRs stay light; any E2E suite change still gets a full shard run.
|
||||
- name: Smoke packaged hang watchdog worker
|
||||
run: xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked
|
||||
|
||||
package_windows:
|
||||
name: package (windows)
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: electron-builder-windows-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-windows-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build package inputs
|
||||
run: pnpm run build:release
|
||||
|
||||
- name: Prepare Electron native runtime
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
|
||||
- name: Package unpacked app
|
||||
env:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
|
||||
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked
|
||||
|
||||
# Why: PR E2E is advisory and only validates changed specs; scheduled and
|
||||
# release runs retain full-suite coverage.
|
||||
e2e-paths:
|
||||
name: detect e2e path changes
|
||||
name: detect changed e2e specs
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft != true
|
||||
# Why: detector only needs to read the checkout; do not inherit repo defaults.
|
||||
@@ -285,6 +377,7 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
test_files: ${{ steps.filter.outputs.test_files }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -292,25 +385,22 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Filter E2E-relevant paths
|
||||
- name: Filter changed E2E specs
|
||||
id: filter
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
# Why: capture first so a failed git diff does not look like "no matches"
|
||||
# (pipeline status in `if` is not aborted by set -e). Merge-base limits the
|
||||
# list to files this PR introduced, not base-branch drift.
|
||||
CHANGED="$(git diff --name-only --merge-base "$BASE" "$HEAD")"
|
||||
# Why: tests/playwright.config.ts sits beside tests/e2e/, not inside it, so
|
||||
# it needs its own pattern — a bare `playwright.` prefix matches no tracked
|
||||
# file and would silently skip E2E when the runner config changes.
|
||||
if printf '%s\n' "$CHANGED" | grep -E '^(tests/e2e/|tests/playwright\.|\.github/workflows/e2e\.yml$)' >/dev/null; then
|
||||
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
|
||||
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "E2E path changes detected"
|
||||
echo "Changed E2E specs: $TEST_FILES_JSON"
|
||||
else
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No E2E path changes"
|
||||
echo "No changed E2E specs"
|
||||
fi
|
||||
|
||||
e2e:
|
||||
@@ -321,16 +411,20 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
test_files: ${{ needs.e2e-paths.outputs.test_files }}
|
||||
|
||||
verify:
|
||||
if: always()
|
||||
needs:
|
||||
- static_analysis
|
||||
- root_directory_guard
|
||||
- typecheck
|
||||
- git_compatibility
|
||||
- shell_contracts
|
||||
- test
|
||||
- package
|
||||
- package_windows
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -345,19 +439,23 @@ jobs:
|
||||
- name: Require successful checks
|
||||
env:
|
||||
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
|
||||
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
|
||||
run: |
|
||||
for result in \
|
||||
"$STATIC_ANALYSIS" \
|
||||
"$ROOT_DIRECTORY_GUARD" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$PACKAGE"; do
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1015,6 +1015,27 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
# Why: `uses: ./…` resolves from the checked-out tag, not from the workflow
|
||||
# ref, so cutting from an older/off-main ref whose tree predates a composite
|
||||
# action would fail the step with "Can't find 'action.yml'". Restore the
|
||||
# actions directory from the commit this workflow file itself came from.
|
||||
- name: Restore composite actions from the workflow ref
|
||||
if: matrix.platform == 'win'
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
action_path=".github/actions/install-signpath-module/action.yml"
|
||||
if [ -f "$action_path" ]; then
|
||||
echo "Composite actions already present at the cut ref."
|
||||
exit 0
|
||||
fi
|
||||
echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA."
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- .github/actions
|
||||
test -f "$action_path"
|
||||
|
||||
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
name: Terminal IME E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/terminal-ime-e2e.yml'
|
||||
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
|
||||
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
|
||||
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
|
||||
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
|
||||
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
|
||||
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
|
||||
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
|
||||
- 'tests/e2e/terminal-ime-*.ts'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 9 * * *'
|
||||
|
||||
@@ -6,56 +6,10 @@ name: Windows Crash-Survival E2E
|
||||
# silent-installs it, opens a terminal, force-kills ONLY the app main (no
|
||||
# tree-kill), and asserts the daemon + shell stay alive, a relaunch adopts the
|
||||
# same daemon and shell, and post-crash input causes no pwsh FailFast. Targeted
|
||||
# pull requests keep it as a durable regression gate. A CI runner is the only
|
||||
# manual runs keep it as a durable regression proof. A CI runner is the only
|
||||
# safe place to install — see the relocation post-mortem.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- '.github/workflows/win-crash-survival-e2e.yml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'electron.vite.config.ts'
|
||||
- 'build-plugins/**'
|
||||
- 'config/electron-builder.config.cjs'
|
||||
- 'config/patches/**'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'config/scripts/verify-packaged-plugin-resources.cjs'
|
||||
- 'native/**'
|
||||
# Byte-hashed at package time, and CRLF-sensitive on Windows.
|
||||
- '.gitattributes'
|
||||
- 'resources/plugins/**'
|
||||
- 'resources/win32/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/index.ts'
|
||||
- 'src/main/ipc/pty*.ts'
|
||||
- 'src/main/persistence.ts'
|
||||
- 'src/main/providers/**'
|
||||
- 'src/main/pty/**'
|
||||
- 'src/main/startup/first-window-startup-services.ts'
|
||||
- 'src/main/window/attach-main-window-services.ts'
|
||||
- 'src/preload/**'
|
||||
- 'src/renderer/src/App.tsx'
|
||||
- 'src/renderer/src/components/terminal-pane/**'
|
||||
- 'src/renderer/src/hooks/useIpcEvents.ts'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
- 'src/renderer/src/lib/session-write-subscriber.ts'
|
||||
- 'src/renderer/src/lib/workspace-session-host-persistence.ts'
|
||||
- 'src/renderer/src/store/slices/terminals.ts'
|
||||
- 'src/shared/pty-session-id-format.ts'
|
||||
- 'tools/win-crash-survival-e2e/**'
|
||||
- 'tools/win-update-e2e/**'
|
||||
# Why: source tests and benchmarks do not change the packaged artifact;
|
||||
# their own verify jobs cover them without spending a Windows build slot.
|
||||
- '!src/**/*.test.*'
|
||||
- '!src/**/*.bench.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expect:
|
||||
@@ -123,7 +77,7 @@ jobs:
|
||||
'!config/reliability-gates.jsonc',
|
||||
'!config/max-lines-baseline.txt',
|
||||
'!config/vitest.config.ts',
|
||||
'build-plugins/**',
|
||||
'config/build-plugins/**',
|
||||
'native/**',
|
||||
'resources/**',
|
||||
'electron.vite.config.ts',
|
||||
@@ -181,7 +135,7 @@ jobs:
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
$log = "artifacts/crash-survival-output.log"
|
||||
node tools/win-crash-survival-e2e/run.mjs `
|
||||
node tests/tools/win-crash-survival-e2e/run.mjs `
|
||||
--expect "$env:EXPECT" `
|
||||
--exe-path "$env:ORCA_EXE" `
|
||||
--soak-seconds 8 2>&1 | Tee-Object -FilePath $log
|
||||
|
||||
@@ -20,7 +20,7 @@ on:
|
||||
branches:
|
||||
- Jinwoo-H/windows-update-survival
|
||||
paths:
|
||||
- 'tools/win-update-e2e/**'
|
||||
- 'tests/tools/win-update-e2e/**'
|
||||
- '.github/workflows/win-update-e2e.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
ORCA_E2E_DIAG_DIR: artifacts/diag
|
||||
run: |
|
||||
$log = "artifacts/harness-output.log"
|
||||
node tools/win-update-e2e/run.mjs `
|
||||
node tests/tools/win-update-e2e/run.mjs `
|
||||
--from "$env:FROM_EXE" `
|
||||
--to "$env:TO_EXE" `
|
||||
--expect "$env:EXPECT" `
|
||||
|
||||
@@ -16,7 +16,7 @@ on:
|
||||
paths:
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/pty/**'
|
||||
- 'tools/win-update-e2e/**'
|
||||
- 'tests/tools/win-update-e2e/**'
|
||||
- 'config/electron-builder.config.cjs'
|
||||
- '.github/workflows/win-update-survival-e2e.yml'
|
||||
workflow_dispatch:
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
$exe = "dist/orca-windows-setup.exe"
|
||||
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
|
||||
$log = "artifacts/survival-output.log"
|
||||
node tools/win-update-e2e/run.mjs `
|
||||
node tests/tools/win-update-e2e/run.mjs `
|
||||
--from "$exe" `
|
||||
--to "$exe" `
|
||||
--expect "$env:EXPECT" `
|
||||
|
||||
@@ -1,41 +1,6 @@
|
||||
name: Windows terminal restart E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- '.github/workflows/windows-terminal-restart-e2e.yml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'electron.vite.config.ts'
|
||||
- 'config/patches/**'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'tests/playwright.config.ts'
|
||||
- 'tests/e2e/global-setup.ts'
|
||||
- 'tests/e2e/global-teardown.ts'
|
||||
- 'tests/e2e/helpers/**'
|
||||
- 'tests/e2e/restart-restore-terminal-input.spec.ts'
|
||||
- 'tests/e2e/restored-terminal-input-readiness.unit.test.ts'
|
||||
- 'tests/e2e/terminal-probe-input-sequence.ts'
|
||||
- 'tests/e2e/terminal-probe-input-sequence.unit.test.ts'
|
||||
- 'tests/e2e/terminal-restart-persistence.spec.ts'
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/ipc/pty*.ts'
|
||||
- 'src/main/providers/**'
|
||||
- 'src/main/pty/**'
|
||||
- 'src/main/pty-descendant-termination.ts'
|
||||
- 'src/main/windows-process-tree-kill.ts'
|
||||
- 'src/main/windows-pty-root-identity.ts'
|
||||
- 'src/preload/**'
|
||||
- 'src/renderer/src/components/terminal-pane/**'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
- 'src/shared/process-table-snapshot.ts'
|
||||
- 'src/shared/pty-session-id-format.ts'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
|
||||
+8
-2
@@ -70,7 +70,7 @@ coverage/
|
||||
prod-release-scan-*.md
|
||||
|
||||
# Benchmark run output
|
||||
/tools/benchmarks/results/*.json
|
||||
/tests/tools/benchmarks/results/*.json
|
||||
/.bench-fixtures/
|
||||
|
||||
# Temp
|
||||
@@ -99,6 +99,9 @@ docs/**
|
||||
!docs/reference/git-compatibility.md
|
||||
!docs/reference/headless-linux-server.md
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/remote-wire-compatibility.md
|
||||
!docs/reference/windows-setup-shell.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
@@ -132,4 +135,7 @@ src/renderer/src/i18n/locales/.ja-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.es-catalog-cache.json
|
||||
|
||||
# Bench result JSONs are working artifacts
|
||||
tools/benchmarks/results/terminal-pipeline-*.json
|
||||
tests/tools/benchmarks/results/terminal-pipeline-*.json
|
||||
|
||||
# Old release trees the cross-version wire harness extracts on demand
|
||||
tests/e2e/.cross-version-checkouts/
|
||||
|
||||
+3
-1
@@ -27,6 +27,8 @@
|
||||
"app-store-performance/no-identity-selector": "error",
|
||||
"app-store-performance/no-fresh-selector-result": "error",
|
||||
"no-fallthrough": "error",
|
||||
"eslint/no-useless-call": "error",
|
||||
"oxc/no-accumulating-spread": "error",
|
||||
"quadratic-buffer-concat/no-loop-carried-concat": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
@@ -148,5 +150,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ All UI work — layout, color, typography, spacing, component selection, UX beha
|
||||
# Style
|
||||
## Concise/Brief Non-obviosu comments ONLY
|
||||
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
* DO: BE CONCISE. 1 LINE if possible
|
||||
* BE CONCISE. 1 LINE if possible
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
@@ -29,6 +29,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
||||
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
|
||||
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
|
||||
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
|
||||
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
|
||||
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
|
||||
|
||||
## SSH Use Case
|
||||
@@ -39,6 +40,10 @@ All changes must consider the SSH use case. Don't assume local-only execution.
|
||||
|
||||
All changes must consider folder workspaces as well as git worktrees. Don't assume every workspace is a git worktree.
|
||||
|
||||
## Remote Wire Compatibility
|
||||
|
||||
Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change.
|
||||
|
||||
## Git Binary Compatibility
|
||||
|
||||
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/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.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
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.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -238,10 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
|
||||
- **WeChat:** If group 5 is full, you can join group 6.
|
||||
- **WeChat:** Scan to join the Orca community WeChat group 7.
|
||||
|
||||
<img src="docs/assets/wechat-qr.jpg" alt="WeChat group 5 QR code for the Orca community" width="160" style="margin-right: 16px;" />
|
||||
<img src="docs/assets/wechat-qr-group6.jpg" alt="WeChat group 6 QR code for the Orca community" width="160" />
|
||||
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 QR code for the Orca community" width="160" />
|
||||
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
|
||||
export const BOOTSTRAP_FATAL_LOG_ENV_VAR = 'ORCA_BOOTSTRAP_FATAL_LOG'
|
||||
export const BOOTSTRAP_FATAL_LOG_FILE_NAME = 'bootstrap-fatal.log'
|
||||
const BOOTSTRAP_FATAL_LOG_MAX_BYTES = 262_144
|
||||
|
||||
export function createBootstrapFatalExitBanner(): string {
|
||||
// Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement.
|
||||
// Suppressing that dialog also removes the only account of the failure, so record one first:
|
||||
// a partially copied resources tree otherwise exits silently on every launch, with nothing
|
||||
// for the user or a support log to name the module that was missing.
|
||||
return `
|
||||
;(() => {
|
||||
const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)}
|
||||
if (typeof globalThis[guardKey] === 'function') {
|
||||
return
|
||||
}
|
||||
const describeBootstrapError = (error) => {
|
||||
try {
|
||||
const detail = error && typeof error === 'object' && error.stack ? error.stack : error
|
||||
return String(detail).split('\\n').slice(0, 12).join(' | ').slice(0, 4000)
|
||||
} catch {
|
||||
return '<unprintable error>'
|
||||
}
|
||||
}
|
||||
const readBootstrapFatalLogOverride = () => {
|
||||
const override = typeof process.env === 'object' && process.env ? process.env.${BOOTSTRAP_FATAL_LOG_ENV_VAR} : undefined
|
||||
return typeof override === 'string' && override.length > 0 ? override : undefined
|
||||
}
|
||||
const resolveDefaultBootstrapFatalLogPath = () => {
|
||||
let directory
|
||||
try {
|
||||
directory = require('electron').app.getPath('userData')
|
||||
} catch {
|
||||
// Why: a bootstrap fault can predate a usable app object; temp still outlives the process.
|
||||
directory = require('node:os').tmpdir()
|
||||
}
|
||||
return require('node:path').join(directory, ${JSON.stringify(BOOTSTRAP_FATAL_LOG_FILE_NAME)})
|
||||
}
|
||||
const appendBootstrapFatalLine = (fs, logPath, entry) => {
|
||||
try {
|
||||
// Why: an override can name a directory nothing has created yet, and a missing
|
||||
// parent would drop the only account of the failure.
|
||||
fs.mkdirSync(require('node:path').dirname(logPath), { recursive: true })
|
||||
// Why: a broken install repeats this on every relaunch; keep the trail bounded.
|
||||
let flags = 'a'
|
||||
try {
|
||||
flags = fs.statSync(logPath).size > ${BOOTSTRAP_FATAL_LOG_MAX_BYTES} ? 'w' : 'a'
|
||||
} catch {
|
||||
flags = 'a'
|
||||
}
|
||||
const descriptor = fs.openSync(logPath, flags, 0o600)
|
||||
try {
|
||||
fs.writeSync(descriptor, entry)
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const recordBootstrapFailure = (error) => {
|
||||
const line = '[bootstrap] fatal-exit pid=' + process.pid + ' error=' + describeBootstrapError(error) + '\\n'
|
||||
let fs
|
||||
try {
|
||||
fs = require('node:fs')
|
||||
} catch {
|
||||
fs = undefined
|
||||
}
|
||||
try {
|
||||
fs.writeSync(2, line)
|
||||
} catch {
|
||||
try {
|
||||
process.stderr.write(line)
|
||||
} catch {
|
||||
// Diagnostics must never replace the exit below.
|
||||
}
|
||||
}
|
||||
try {
|
||||
const entry = new Date().toISOString() + ' ' + line
|
||||
const override = readBootstrapFatalLogOverride()
|
||||
// Why: an unwritable override must cost the user a location, not the diagnostic.
|
||||
if (!override || !appendBootstrapFatalLine(fs, override, entry)) {
|
||||
appendBootstrapFatalLine(fs, resolveDefaultBootstrapFatalLogPath(), entry)
|
||||
}
|
||||
} catch {
|
||||
// Diagnostics must never replace the exit below.
|
||||
}
|
||||
}
|
||||
let exitScheduled = false
|
||||
const exitAfterBootstrapFailure = (error) => {
|
||||
if (exitScheduled) {
|
||||
return
|
||||
}
|
||||
exitScheduled = true
|
||||
recordBootstrapFailure(error)
|
||||
process.exitCode = 1
|
||||
setImmediate(() => process.exit(1))
|
||||
}
|
||||
globalThis[guardKey] = () => {
|
||||
process.off('uncaughtException', exitAfterBootstrapFailure)
|
||||
delete globalThis[guardKey]
|
||||
}
|
||||
process.once('uncaughtException', exitAfterBootstrapFailure)
|
||||
})();
|
||||
`
|
||||
}
|
||||
+28
-4
@@ -21,12 +21,28 @@ type OutputChunk = Rollup.OutputChunk
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'computer-sidecar',
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'codex/codex-app-server-grant-entry'
|
||||
] as const
|
||||
|
||||
// Entries executed as worker threads of the main process. Electron's module is
|
||||
// not registered on worker threads, so require("electron") throws
|
||||
// "Cannot find module 'electron'" there too (verified on Electron 43) and kills
|
||||
// the worker at startup. These carry hand-written "must stay electron-free"
|
||||
// comments, which is convention, not enforcement — and the port-scan worker in
|
||||
// particular sits one import away from a client module that deliberately does
|
||||
// require electron.
|
||||
const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'stt-worker',
|
||||
'warp-theme-parser-worker',
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'port-scan-command-worker-entry'
|
||||
] as const
|
||||
|
||||
type EntryRuntime = 'plain-Node process' | 'worker thread'
|
||||
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
|
||||
|
||||
function collectReachableChunks(
|
||||
@@ -57,13 +73,14 @@ function collectReachableChunks(
|
||||
function assertNoElectronRequire(
|
||||
entryName: string,
|
||||
entry: OutputChunk,
|
||||
byFileName: Map<string, OutputChunk>
|
||||
byFileName: Map<string, OutputChunk>,
|
||||
runtime: EntryRuntime = 'plain-Node process'
|
||||
): void {
|
||||
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
||||
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
|
||||
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
|
||||
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
|
||||
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
|
||||
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
|
||||
)
|
||||
@@ -126,7 +143,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName)
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process')
|
||||
}
|
||||
}
|
||||
|
||||
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'worker thread')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,27 @@ const {
|
||||
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
|
||||
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
|
||||
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
|
||||
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
|
||||
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
|
||||
// Why: dev-channel builds must carry the *release* identity — same bundle id,
|
||||
// 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 isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || 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
|
||||
// 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
|
||||
const appId = 'com.stablyai.orca'
|
||||
const featureWallResources = {
|
||||
from: 'resources/onboarding/feature-wall',
|
||||
@@ -64,7 +81,11 @@ const winSpeechNativeResource = {
|
||||
module.exports = {
|
||||
appId,
|
||||
productName: 'Orca',
|
||||
...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}),
|
||||
...(devChannelBuildVersion
|
||||
? { extraMetadata: { version: devChannelBuildVersion } }
|
||||
: localBuildVersion
|
||||
? { extraMetadata: { version: localBuildVersion } }
|
||||
: {}),
|
||||
directories: {
|
||||
buildResources: 'resources/build'
|
||||
},
|
||||
@@ -143,21 +164,16 @@ module.exports = {
|
||||
'out/main/claude/**',
|
||||
'out/main/claude-accounts/keychain.js',
|
||||
'out/main/codex/**',
|
||||
'out/main/codex-cli/command.js',
|
||||
'out/main/copilot/**',
|
||||
'out/main/cursor/**',
|
||||
'out/main/droid/**',
|
||||
'out/main/gemini/**',
|
||||
'out/main/grok/**',
|
||||
'out/main/hermes/**',
|
||||
'out/main/ipc/local-agent-install-dir-detection.js',
|
||||
'out/main/ipc/tui-agent-detection-commands.js',
|
||||
'out/main/win32-utils.js',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
'out/main/main-thread-hang-watchdog-entry.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/ws/**',
|
||||
@@ -183,7 +199,7 @@ module.exports = {
|
||||
)
|
||||
: join(context.appOutDir, 'resources')
|
||||
if (!existsSync(resourcesDir)) {
|
||||
return
|
||||
throw new Error(`Missing packaged resources directory: ${resourcesDir}`)
|
||||
}
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
const architectureByEnum = { 1: 'x64', 3: 'arm64' }
|
||||
@@ -213,7 +229,16 @@ module.exports = {
|
||||
// arm64=3, universal=4 (universal contains the host slice, so run it).
|
||||
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
|
||||
const hostArchEnum = archEnumByNodeArch[process.arch]
|
||||
if (context.arch === hostArchEnum || context.arch === 4) {
|
||||
const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4
|
||||
verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, {
|
||||
executeCommands: canExecuteTargetArch
|
||||
})
|
||||
if (!canExecuteTargetArch) {
|
||||
console.log(
|
||||
`[verify-skills-cli-runtime] skipped command probes on cross-arch slice (target ${context.arch}, host ${process.arch})`
|
||||
)
|
||||
}
|
||||
if (canExecuteTargetArch) {
|
||||
verifyPackagedDaemonEntryBoots(resourcesDir)
|
||||
} else {
|
||||
// Why: a cross-arch slice can't be booted by the host Node, but the
|
||||
@@ -316,6 +341,13 @@ module.exports = {
|
||||
// explicit release path so production artifacts remain strict while dev
|
||||
// artifacts do not fail with broken ad-hoc launch behavior.
|
||||
hardenedRuntime: isMacRelease,
|
||||
// Why dev builds notarize too, despite the ~10min notary round trip: TCC
|
||||
// anchors a notarized Developer ID app's permission grants on identifier +
|
||||
// team, which is cdhash-independent and so survives an update. Without a
|
||||
// ticket there is no such stable identity, so every build reads as a
|
||||
// different client — the grant row stays but stops matching, and file access
|
||||
// under Documents/Desktop/Downloads fails with EPERM and no re-prompt. At 24
|
||||
// builds a day that revokes the user's grants faster than they can re-grant.
|
||||
notarize: isMacRelease,
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
@@ -456,8 +488,8 @@ module.exports = {
|
||||
publish: {
|
||||
provider: 'github',
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
releaseType: 'release'
|
||||
repo: devChannelRepo ?? 'orca',
|
||||
releaseType: devChannelRepo ? 'prerelease' : 'release'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
# I18n Translation Source and Automation
|
||||
|
||||
Date: 2026-07-29 (revised 2026-07-30)
|
||||
|
||||
Status: accepted architecture decision; PR A
|
||||
([#8512](https://github.com/stablyai/orca/pull/8512)) merged; PR B not yet
|
||||
implemented
|
||||
|
||||
Revision note: the original decision selected a constrained XLIFF 2.0 profile
|
||||
at 80% confidence, with gettext PO as the named fallback. A tooling,
|
||||
contributor-workflow, and repository-evidence review on 2026-07-30 resolved
|
||||
that uncertainty against XLIFF 2.0 before PR B was built. The architecture is
|
||||
unchanged; the canonical format is now gettext PO. The evidence is recorded
|
||||
under "Alternatives considered".
|
||||
|
||||
## Goal
|
||||
|
||||
Reduce the recurring engineering and human work required to localize Orca
|
||||
without presenting missing, copied-English, or stale values as completed
|
||||
translations.
|
||||
|
||||
The format is not the goal. The intended steady-state workflow is:
|
||||
|
||||
- a feature developer adds a stable message ID and current English copy once;
|
||||
- extraction discovers new and changed messages automatically;
|
||||
- feature work does not wait for every target locale;
|
||||
- translation automation processes only missing and stale units;
|
||||
- humans review a small, risk-prioritized delta rather than whole catalogs;
|
||||
- missing or stale translations safely fall back to current English; and
|
||||
- deterministic, offline runtime bundles ship with the app.
|
||||
|
||||
## Decision
|
||||
|
||||
Use gettext PO as Orca's canonical bilingual translation source, under a
|
||||
constrained profile:
|
||||
|
||||
- `msgctxt` — the stable Orca message ID;
|
||||
- `msgid` — the English source, current as of the entry's last
|
||||
reconciliation;
|
||||
- `msgstr` — the target translation;
|
||||
- `#, fuzzy` — not approved for runtime, whether stale or newly
|
||||
machine-translated; never compiled into runtime bundles;
|
||||
- `#| msgid` — the prior English source associated with the retained
|
||||
translation;
|
||||
- `#.` — translator context and notes;
|
||||
- `#:` — source references (optional, informational); and
|
||||
- `#~` — retired entries, kept only under the retention policy below.
|
||||
|
||||
Product source owns stable message IDs and current English defaults. Maintained
|
||||
extraction produces the current English translation source. Each target locale
|
||||
owns a sparse PO file containing only translations that exist, each carrying
|
||||
the English source it is keyed against.
|
||||
|
||||
Responsibilities are split so target catalogs never change in feature work:
|
||||
|
||||
- **Feature PRs** change English only: source code, inline defaults, and the
|
||||
extracted English source.
|
||||
- **A read-only compiler** joins the current English source with each target
|
||||
PO file by `msgctxt` and generates the existing i18next JSON bundle shape.
|
||||
An entry whose stored `msgid` differs from current English is stale; one
|
||||
whose `msgid` is current but fuzzy is pending approval. Both are omitted so
|
||||
i18next falls back to current English.
|
||||
The compiler never writes to PO files. Generated runtime JSON is disposable
|
||||
and must not be hand-edited.
|
||||
- **Post-merge localization automation** owns all PO mutations. Reconciliation
|
||||
finds entries affected by merged English changes, updates `msgid` to current
|
||||
English, preserves the old source in `#| msgid`, and sets `#, fuzzy` — so
|
||||
translators and tools see the current source, the previous source, and the
|
||||
prior translation in one unit. Reconciliation lands in localization-only
|
||||
changes, never in the originating feature change.
|
||||
|
||||
The reconciler — not gettext's own merge tooling — owns the join. `msgmerge`
|
||||
keys entries on the `(msgctxt, msgid)` pair, so under stable IDs a source
|
||||
change orphans the entry instead of marking it fuzzy. Reconciliation must join
|
||||
by `msgctxt` alone.
|
||||
|
||||
Absence of `#, fuzzy` means approved to ship, whether approval came from a
|
||||
human or an explicitly configured low-risk automation policy. Machine or human
|
||||
provenance is recorded separately in a documented comment or flag; it must not
|
||||
be conflated with runtime eligibility.
|
||||
|
||||
PO is an authoring and translation-workflow boundary, not a runtime
|
||||
dependency. Orca must not require network access or a translation service to
|
||||
display localized UI.
|
||||
|
||||
## Why this solves the work problem
|
||||
|
||||
The system is organized around translating only the delta:
|
||||
|
||||
```text
|
||||
feature change: stable ID + English
|
||||
|
|
||||
v
|
||||
extraction and validation
|
||||
|
|
||||
v
|
||||
missing/changed translation queue
|
||||
|
|
||||
v
|
||||
machine translation + glossary/context
|
||||
|
|
||||
v
|
||||
placeholder and policy validation
|
||||
|
|
||||
v
|
||||
sparse locale bundle or English fallback
|
||||
```
|
||||
|
||||
Feature developers do not synchronize target catalogs, copy English into
|
||||
untranslated locales, run whole-catalog repair, or wait for translation
|
||||
completion. A source change invalidates only the affected target units.
|
||||
Unchanged translations remain byte-stable.
|
||||
|
||||
The bilingual source gives the compiler enough information to distinguish:
|
||||
|
||||
- **missing**: the locale has no entry for the ID;
|
||||
- **stale**: the stored `msgid` differs from current English — true the
|
||||
moment the English change merges, before any reconciliation runs;
|
||||
- **pending approval**: the stored `msgid` is current, a target exists, and
|
||||
`#, fuzzy` is set; and
|
||||
- **current/approved**: the stored `msgid` is current, a target exists, and
|
||||
`#, fuzzy` is absent.
|
||||
|
||||
Missing, stale, and pending-approval entries are omitted from generated
|
||||
target bundles so i18next uses current English. Staleness must not be inferred from Git history,
|
||||
file timestamps, copied-English ratios, or a second hand-maintained database.
|
||||
|
||||
## Constrained PO profile
|
||||
|
||||
Orca will support only the subset needed for its message model. PR B must
|
||||
define and validate that subset rather than accepting arbitrary PO documents.
|
||||
|
||||
Each supported entry must carry:
|
||||
|
||||
- one stable Orca message ID (`msgctxt`), unique within the file;
|
||||
- the English source (`msgid`), current as of the entry's last
|
||||
reconciliation;
|
||||
- an optional target value (`msgstr`) in a target-locale file;
|
||||
- translator notes or UI context when provided (`#.`);
|
||||
- runtime workflow state (`#, fuzzy`), with provenance recorded in the
|
||||
documented separate comment or flag; and
|
||||
- the information needed to validate interpolation placeholders.
|
||||
|
||||
Plurals use Orca's existing per-key convention: each i18next plural-suffixed
|
||||
key (`…_one`, `…_other`) is its own PO entry. The profile must not use
|
||||
`msgid_plural`/`msgstr[n]`, which would collide with i18next's own CLDR plural
|
||||
selection and gettext's `nplurals` model.
|
||||
|
||||
The compiler must reject:
|
||||
|
||||
- duplicate or unstable IDs (including two entries sharing a `msgctxt`);
|
||||
- empty targets represented as completed translations;
|
||||
- unsupported states or constructs;
|
||||
- malformed source/target entries;
|
||||
- nondeterministic ordering or output — the profile must document exact
|
||||
serialization normalization (entry order by `msgctxt`, fixed line folding,
|
||||
LF endings, escaping rules) so every tool in the pipeline emits identical
|
||||
bytes for identical content; and
|
||||
- locale files whose declared language headers are incorrect.
|
||||
|
||||
Placeholder validation is state-dependent (an entry is current only when its
|
||||
stored `msgid` matches current English and it carries no fuzzy flag):
|
||||
|
||||
- **stale or pending-approval entries**: a placeholder mismatch is expected
|
||||
mid-flight; the entry is already omitted from bundles, so validation
|
||||
reports it without failing;
|
||||
- **current/approved entries**: a placeholder mismatch is a hard validation
|
||||
failure; and
|
||||
- **automation and import pipelines**: a produced target with mismatched
|
||||
placeholders is demoted to fuzzy rather than committed as current.
|
||||
|
||||
Source comparison is a semantic comparison of parsed source strings after
|
||||
documented text normalization. Line folding, wrapping, and PO escaping are
|
||||
serialization concerns and must never affect comparison. Reconciliation must
|
||||
never update an entry's stored source without either confirming or replacing
|
||||
the target, or marking the entry fuzzy.
|
||||
|
||||
If translator notes or placeholder semantics can change the required
|
||||
translation without changing visible English, the source-signature contract
|
||||
must include that information or explicitly invalidate the entry.
|
||||
|
||||
Retired entries (`#~`) are kept only while they provide useful translation
|
||||
memory for automation; a deterministic cleanup policy removes them rather than
|
||||
letting them accumulate indefinitely.
|
||||
|
||||
## Message ID and placeholder policy
|
||||
|
||||
The current catalog undermines the stable-ID premise in two ways the format
|
||||
alone cannot fix: 8,981 of 11,564 keys (77.7%) are codemod-generated content
|
||||
hashes, and 825 of 954 interpolated strings use positional placeholders
|
||||
(`{{value0}}`) that carry no meaning for a translator or a machine-translation
|
||||
prompt.
|
||||
|
||||
Policy:
|
||||
|
||||
- hashed IDs already in the desktop catalog as of this decision (2026-07-30)
|
||||
are grandfathered as opaque stable IDs; the generating codemod must not be
|
||||
rerun against existing keys, and no mass rename happens during this
|
||||
migration;
|
||||
- newly minted hash IDs — including in-flight bridge catalogs not yet merged
|
||||
or merged after this decision — are **not** grandfathered: they are renamed
|
||||
to intent-named IDs in a dedicated change adjacent to their landing, before
|
||||
PO becomes canonical for that surface, never buried inside an otherwise
|
||||
reviewed feature PR;
|
||||
- new keys must use intent-named IDs and named interpolation placeholders;
|
||||
- converting positional placeholders to named ones requires per-message
|
||||
semantic judgment (the name is part of the translatable contract), not a
|
||||
mechanical rename; and
|
||||
- legacy IDs and positional placeholders are improved opportunistically when
|
||||
the copy itself is touched, never in bulk.
|
||||
|
||||
## Runtime and source ownership
|
||||
|
||||
The ownership chain is:
|
||||
|
||||
```text
|
||||
product code and explicit dynamic declarations
|
||||
|
|
||||
v
|
||||
extracted current English source
|
||||
|
|
||||
v
|
||||
sparse canonical target-locale PO files
|
||||
|
|
||||
v
|
||||
deterministic i18next JSON bundles
|
||||
|
|
||||
v
|
||||
lazy-loaded offline app resources
|
||||
```
|
||||
|
||||
Inline English defaults remain readable at call sites and provide extraction
|
||||
input. Static extraction and explicit declarations for genuinely dynamic keys
|
||||
must reconcile to one current English translation source. CI must reject
|
||||
missing declarations and incompatible defaults rather than creating a
|
||||
permanent exception database.
|
||||
|
||||
Renderer, Electron main, web, SSH, WSL, and packaged execution continue to
|
||||
consume the same logical runtime bundles. The compiler and build paths must use
|
||||
cross-platform path handling and deterministic line endings.
|
||||
|
||||
Two additional surfaces are in scope:
|
||||
|
||||
- **Mobile.** The mobile app is acquiring its own catalog tree with its own
|
||||
key scheme. Mobile must adopt the same canonical contract, ID policy, and
|
||||
compiler — with its own PO files — rather than a second bespoke pipeline.
|
||||
Mobile keys must not be folded into the desktop catalogs, and in-flight
|
||||
mobile catalog JSON is a bridge the migration replaces, not a canonical
|
||||
source. Mobile requires **two deterministic projections** from the same PO
|
||||
source: the mobile i18next JSON bundle, and the native metadata resources
|
||||
(iOS `InfoPlist.strings`, Android resources) that render before the JS
|
||||
runtime exists. i18next fallback cannot cover pre-JS surfaces, so for a
|
||||
missing, stale, or pending-approval native entry the compiler must either
|
||||
omit the locale-specific native key with proven OS/base-locale fallback on
|
||||
both platforms, or emit current English under a documented platform rule.
|
||||
The intentional locale-ID mapping (JS `zh` vs native `zh-Hans`) is part of
|
||||
the projection contract. The migration covers both projections.
|
||||
- **Plugin language packs.** Language packs remain external overrides
|
||||
validated against compiled core keys. They are consumers of the contract,
|
||||
not another canonical source, and the compiler must leave their runtime
|
||||
path intact.
|
||||
|
||||
## Translation automation
|
||||
|
||||
The architecture is incomplete until translation work is delta-driven.
|
||||
After the compiler and migration are stable, automation should:
|
||||
|
||||
1. reconcile entries affected by merged English changes (update `msgid`,
|
||||
preserve `#| msgid`, set fuzzy) and find target entries that are missing
|
||||
or stale;
|
||||
2. batch only those entries into a translation change;
|
||||
3. supply translator notes, glossary terms, placeholder rules, the previous
|
||||
source from `#| msgid`, and relevant existing translations;
|
||||
4. preserve all unaffected target entries byte-for-byte;
|
||||
5. mark runtime eligibility and provenance honestly;
|
||||
6. run placeholder, terminology, formatting, and catalog validation, demoting
|
||||
invalid output to fuzzy; and
|
||||
7. fall back to English rather than shipping an invalid target.
|
||||
|
||||
Human review should be risk-based. Destructive actions, authentication,
|
||||
billing, privacy, security, legal copy, OS permission prompts (camera,
|
||||
microphone, photos, local network), native app metadata, and other sensitive
|
||||
flows require review. Routine labels and descriptions may use validated machine translation
|
||||
under the adopted release policy. A model-provided confidence score alone is
|
||||
not sufficient release evidence.
|
||||
|
||||
Catalog QA should include an identical-to-English guard implemented as a
|
||||
ratchet with reviewed exemptions, not a blanket prohibition: brand names,
|
||||
commands, and terms that legitimately remain English live in the exemption
|
||||
inputs, and the untranslated ratio for everything else must not rise.
|
||||
|
||||
Glossary and forbidden-translation rules belong in stable QA inputs. The
|
||||
existing repair-script policy data (glossaries, never-translate lists,
|
||||
per-locale overrides) is converted into those inputs. They must replace
|
||||
historical whole-catalog repair scripts rather than coexist with them as
|
||||
another source of product behavior.
|
||||
|
||||
## Rollout
|
||||
|
||||
### PR A: decouple feature copy — merged
|
||||
|
||||
PR #8512:
|
||||
|
||||
- permits sparse target catalogs;
|
||||
- makes English runtime fallback authoritative for missing values;
|
||||
- makes catalog synchronization update English only;
|
||||
- preserves existing target values and rejects placeholder incompatibility;
|
||||
- adds maintained extraction and CI verification; and
|
||||
- removes whole-catalog translation and repair commands from ordinary product
|
||||
workflows.
|
||||
|
||||
PR A is independently useful and format-neutral. It stops new parity work, but
|
||||
it does not permanently model reviewed or stale translation state.
|
||||
|
||||
PR A also created a deletion ratchet: extra target-locale keys hard-fail
|
||||
verification and sync no longer edits targets, so an English key cannot be
|
||||
retired without hand-editing every target catalog. Orphaned English keys
|
||||
(2,055 unreferenced at time of writing) grow with every rename until PR C
|
||||
lands. PR C should not wait long.
|
||||
|
||||
### PR B: define the source and compiler
|
||||
|
||||
PR B must remain a small architecture and implementation proof. It should add:
|
||||
|
||||
1. the constrained PO profile and its serialization normalization;
|
||||
2. extraction into the current English source;
|
||||
3. sparse source/target parsing via maintained PO tooling;
|
||||
4. deterministic, read-only i18next bundle compilation, omitting entries
|
||||
whose stored source mismatches current English or that are fuzzy — with a
|
||||
compiler shape that admits additional per-locale projections (the mobile
|
||||
native-resource output) even though PR B implements only the i18next
|
||||
bundle;
|
||||
5. a separate reconciliation command owning all PO mutations (`msgid`
|
||||
update, `#| msgid` preservation, `#, fuzzy`);
|
||||
6. state-dependent placeholder validation;
|
||||
7. representative fixtures for interpolation, plural-suffixed keys, multiline
|
||||
copy, translator notes, and source changes; and
|
||||
8. packaged-build integration across local and remote execution boundaries.
|
||||
|
||||
Acceptance tests must assert every workflow field — state, previous source,
|
||||
notes, retirement — field-by-field through parse → modify → serialize cycles.
|
||||
Byte-identical round-trip alone is not evidence: a lossy parser faithfully
|
||||
reproduces its own losses, so a round-trip gate can pass while the fields that
|
||||
justify the format are silently destroyed. This failure mode was demonstrated
|
||||
against the dominant XLIFF library during the format review.
|
||||
|
||||
It must not migrate the complete locale inventory. That keeps the format and
|
||||
compiler independently reviewable and makes rejection inexpensive.
|
||||
|
||||
### PR C: migrate existing translations once
|
||||
|
||||
The migration must:
|
||||
|
||||
- export all current stable English IDs and values;
|
||||
- import real target values without retranslating them;
|
||||
- preserve known reviewed corrections;
|
||||
- mark unproven values as pending approval, with imported provenance —
|
||||
bridge catalogs in particular mix reused desktop translations, machine
|
||||
translation, and manual corrections, and must be classified rather than
|
||||
assumed approved;
|
||||
- remove copied-English parity filler unless it is intentionally English,
|
||||
seeding the identical-to-English exemption inputs from the existing policy
|
||||
data;
|
||||
- turn terminology and known mistranslations into glossary and QA inputs;
|
||||
- drain the orphaned-English-key ratchet and delete the retired bootstrap and
|
||||
repair script files; and
|
||||
- explain every difference between current and compiled runtime bundles.
|
||||
|
||||
Every existing target value must be byte-preserved, explicitly retired, or
|
||||
classified as filler. No Git-history inference should remain in the permanent
|
||||
runtime or verification path.
|
||||
|
||||
### PR D: switch ownership and delete the bridge
|
||||
|
||||
After bundle equivalence is proven:
|
||||
|
||||
- PO becomes the only editable target translation source;
|
||||
- generated runtime JSON stops being a canonical input;
|
||||
- development, test, build, and packaging compile the bundles
|
||||
deterministically;
|
||||
- a JSON→PO importer ships for contributors, so existing full-catalog
|
||||
community PRs can be converted instead of abandoned;
|
||||
- legacy bootstrap, repair, override, and migration-only bookkeeping is
|
||||
removed; and
|
||||
- ordinary CI runs one focused extraction and compile verification path.
|
||||
|
||||
### Translation automation
|
||||
|
||||
The first automation follow-up should create or update localization-only
|
||||
changes containing only missing and stale entries. It should not rewrite a
|
||||
whole locale, modify feature code, or block the originating feature change.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Constrained XLIFF 2.0 — original choice, reversed 2026-07-30
|
||||
|
||||
XLIFF 2.0 has the best abstract schema fit: stable IDs, source and target in
|
||||
one unit, standardized workflow state, and categorized notes. The decision was
|
||||
reversed when the stated uncertainties resolved against it:
|
||||
|
||||
- the dominant JS XLIFF library silently discards `state`, `subState`, and
|
||||
note categories — the fields that motivate the format — while reporting a
|
||||
clean round-trip, so the planned acceptance gate would have passed
|
||||
vacuously; the only spec-faithful JS alternative has ~90 weekly downloads,
|
||||
a single maintainer, and EPL-1.0 licensing;
|
||||
- XLIFF 2.0 core defines no previous-source mechanism, so the "what changed"
|
||||
payload for translation automation requires a custom extension that breaks
|
||||
the interoperability that justified the format (leaving the old `<source>`
|
||||
in the target file and comparing against a separate current-English file is
|
||||
possible, but then translators and tools see the wrong current source
|
||||
unless another projection is built — PO represents the joined working state
|
||||
natively);
|
||||
- tool and platform support for XLIFF 2.x remains weaker than 1.2 more than a
|
||||
decade after standardization; no verified case of XLIFF 2.0 as an in-repo
|
||||
canonical source was found, and the closest public precedent for this exact
|
||||
compiler design chose 1.2; and
|
||||
- XML is the format most likely to let a hand-edited community PR fail on a
|
||||
malformed entity, and there is no cross-tool XML formatting convention, so
|
||||
external editors produce whole-file diffs; Orca's translation contributions
|
||||
arrive overwhelmingly as direct catalog PRs.
|
||||
|
||||
If a translation provider requires XLIFF, Orca should add a deterministic
|
||||
import/export adapter at the boundary rather than change the canonical
|
||||
representation.
|
||||
|
||||
### XLIFF 1.2
|
||||
|
||||
Broader legacy tool support than 2.0, but more tool-specific dialects and a
|
||||
less coherent data model, and it shares XML's contributor and formatting
|
||||
costs. Adapter-only, as above.
|
||||
|
||||
### Direct JSON or TypeScript catalogs
|
||||
|
||||
These are simple runtime inputs, but they do not standardize source snapshots,
|
||||
translator context, or review state. Meeting Orca's requirements would require
|
||||
custom sidecars or object schemas and synchronization rules. That recreates
|
||||
much of a bilingual standard while retaining the parity and stale-state risks
|
||||
the migration is intended to remove.
|
||||
|
||||
### Bespoke bilingual JSON — the current fallback
|
||||
|
||||
A JSON schema with embedded per-locale state, source snapshots, and notes is a
|
||||
proven model (a major platform vendor ships exactly this shape, with per-key
|
||||
extraction state including staleness and per-locale review state) and would
|
||||
add no parsing dependency. It is second to PO because PO's workflow fields are
|
||||
standard rather than bespoke, existing translation editors and open tooling
|
||||
understand them, and its diffs are the smallest of the three formats. If the
|
||||
PR B PO proof fails, adopt this — not XLIFF — before PR C.
|
||||
|
||||
### Platform-native catalogs
|
||||
|
||||
Platform-native string catalogs provide strong translation state on their
|
||||
own platform but are not a suitable authority for Orca's macOS, Linux,
|
||||
Windows, web, WSL, and SSH surfaces.
|
||||
|
||||
### Proprietary translation platform
|
||||
|
||||
A translation platform may later operate on the PO boundary, but it should
|
||||
not become Orca's sole source of truth or a runtime dependency. Selecting one
|
||||
before the repository contract exists would create premature vendor coupling.
|
||||
|
||||
### Peer practice
|
||||
|
||||
Inspectable applications validate the architecture more strongly than any one
|
||||
format:
|
||||
|
||||
- mature systems use stable keys or source messages, authoritative English,
|
||||
sparse targets, and runtime fallback;
|
||||
- applications that keep canonical translations in-repo with PR-based
|
||||
contribution use PO or stateful JSON catalogs; none inspected uses XLIFF
|
||||
2.0 as a canonical source, and the only XLIFF observed was 1.2 as
|
||||
interchange into an external pipeline;
|
||||
- the delta-driven machine-translation model this document targets is already
|
||||
shipping in peer practice, keyed by per-entry source tracking;
|
||||
- structural key parity is not a completeness signal — peer catalogs exist
|
||||
with perfect parity and majority copied-English content, which is what the
|
||||
identical-to-English ratchet detects; and
|
||||
- gates that are written but not enforced decay — every verification in this
|
||||
plan must run in CI from the PR that introduces it.
|
||||
|
||||
Closed-source applications do not publish enough of their authoring pipelines
|
||||
to establish a format decision. Orca therefore chooses based on its own
|
||||
requirements rather than presumed competitor internals.
|
||||
|
||||
## Confidence and decision gate
|
||||
|
||||
Confidence is:
|
||||
|
||||
- **97%** in the overall sparse-source, source-snapshot, deterministic-compiler,
|
||||
and English-fallback architecture;
|
||||
- **90%** in PO over constrained XLIFF 2.0;
|
||||
- **85%** in PO over bespoke bilingual JSON; and
|
||||
- **95%** that a migration-free PR B prototype on PO is the correct next step.
|
||||
|
||||
The remaining PO uncertainty concerns exact serialization normalization
|
||||
(folding, escaping) across the tools contributors use, the provenance
|
||||
convention recorded alongside `#, fuzzy`, and the `#~` retention policy.
|
||||
|
||||
PR B closes the decision gate only if its representative fixtures preserve
|
||||
every workflow field through parse → modify → serialize verified
|
||||
field-by-field, output is deterministic, diffs remain reviewable, and the
|
||||
compiler/build integration is maintainable. If that proof fails, adopt bespoke
|
||||
bilingual JSON before PR C. Do not fall back to XLIFF 2.0, add compatibility
|
||||
layers, or create custom sidecar state merely to preserve a format choice.
|
||||
|
||||
This is a deliberate, reversible senior-engineering decision — and it has
|
||||
already been exercised once: the original XLIFF 2.0 selection was reversed
|
||||
when evidence resolved its stated uncertainties, at zero migration cost
|
||||
because the staged rollout deferred format commitment until PR B.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
The completed system must not:
|
||||
|
||||
- require every target locale to contain every English key;
|
||||
- copy English into target catalogs to simulate coverage;
|
||||
- infer permanent translation state from Git history;
|
||||
- retranslate or rewrite complete catalogs for ordinary copy changes;
|
||||
- make generated runtime JSON another hand-edited source;
|
||||
- rename existing hashed message IDs in bulk;
|
||||
- treat plugin language packs as a canonical source;
|
||||
- block feature PRs while translations catch up;
|
||||
- require runtime network access; or
|
||||
- maintain multiple canonical translation databases.
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"entry": [
|
||||
"src/main/index.ts",
|
||||
"src/preload/index.ts",
|
||||
"src/preload/browser-window-close.ts",
|
||||
"src/main/daemon/daemon-entry.ts",
|
||||
"src/main/plugins/plugin-host-entry.ts",
|
||||
"src/main/computer/sidecar-entry.ts",
|
||||
"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/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",
|
||||
"src/main/codex/codex-app-server-grant-entry.ts",
|
||||
"src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"src/main/claude-accounts/keychain.ts",
|
||||
"src/renderer/src/main.tsx",
|
||||
"src/renderer/src/popout.tsx",
|
||||
"src/renderer/src/web/main.tsx",
|
||||
"src/renderer/src/**/*.worker.ts",
|
||||
"src/cli/index.ts",
|
||||
"src/relay/relay.ts",
|
||||
"src/relay/wsl-agent-hook-relay.ts",
|
||||
"config/scripts/**/*.{mjs,cjs,js,ts}",
|
||||
"config/build-plugins/**/*.ts",
|
||||
"electron.vite.config.ts",
|
||||
"vite.web.config.ts",
|
||||
"config/vitest.config.ts",
|
||||
"config/i18next.config.ts",
|
||||
"config/electron-builder.config.cjs",
|
||||
"config/oxlint-plugins/**/*.{js,mjs,ts}",
|
||||
"tests/**/*.{ts,tsx,mjs}",
|
||||
"**/*.test.{ts,tsx}"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"config/scripts/**/*.{mjs,cjs,js,ts}",
|
||||
"config/build-plugins/**/*.ts"
|
||||
],
|
||||
"ignore": ["mobile/**", "out/**", "dist/**", "node_modules/**", "resources/**"],
|
||||
"ignoreDependencies": ["electron", "@types/*"],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"includeEntryExports": false
|
||||
}
|
||||
@@ -4,6 +4,9 @@ This is the pre-work artifact for migrating Orca to a localized UI. The goal is
|
||||
to make coverage repeatable: every detected user-facing string is either moved
|
||||
behind the localization layer or explicitly excluded with a reason.
|
||||
|
||||
The accepted translation-source architecture and staged migration are recorded
|
||||
in [`i18n-translation-source.md`](./i18n-translation-source.md).
|
||||
|
||||
## Coverage Contract
|
||||
|
||||
Coverage means all strings matching the audit scope below are accounted for:
|
||||
@@ -75,9 +78,11 @@ have no package-script entry points. Ordinary product and localization work must
|
||||
not invoke tools that can overwrite an entire target catalog.
|
||||
|
||||
The coverage gate compares current candidates against
|
||||
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
|
||||
new candidates fail the check and must be localized or added with a reviewed
|
||||
reason in the same change.
|
||||
`config/localization-coverage-allowlist.json`. The committed allowlist is
|
||||
small (10 reviewed entries — one test fixture title, five non-English
|
||||
language-name search keywords, and four reviewed product-name search
|
||||
keywords): new candidates fail the check and must be localized or added with
|
||||
a reviewed reason in the same change.
|
||||
|
||||
The script scans `src/renderer/src` by default. That is the primary UI surface.
|
||||
Use `--source-root src` for a wider audit when checking renderer-adjacent shared
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline mobile/src/constants/marine-creatures.ts
|
||||
inline src/cli/handlers/automations.ts
|
||||
inline src/cli/handlers/orchestration.ts
|
||||
inline src/cli/help.ts
|
||||
|
||||
@@ -44,6 +44,14 @@ const PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM = {
|
||||
linux: 'watcher-linux',
|
||||
win32: 'watcher-win32'
|
||||
}
|
||||
const ELECTRON_ARCHITECTURE_BY_ENUM = {
|
||||
0: 'ia32',
|
||||
1: 'x64',
|
||||
2: 'arm',
|
||||
3: 'arm64',
|
||||
4: 'universal'
|
||||
}
|
||||
const PACKAGED_NATIVE_ARCHITECTURES = new Set(['ia32', 'x64', 'arm', 'arm64'])
|
||||
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
|
||||
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
|
||||
|
||||
@@ -170,7 +178,7 @@ function collectPackagedRuntimePackages(electronPlatformName = process.platform)
|
||||
// optionalDependency (e.g. @parcel/watcher-linux-x64-glibc) that the
|
||||
// dependencies graph above never reaches. Include the ones installed for the
|
||||
// build's supported architectures; afterPack pruning trims non-target
|
||||
// platforms. Without this the packaged main bundle's import of
|
||||
// platform/architecture variants. Without this the packaged main bundle's import of
|
||||
// '@parcel/watcher' resolves at runtime but throws loading its binary.
|
||||
const parcelWatcherDir = packages.get('@parcel/watcher')
|
||||
if (parcelWatcherDir) {
|
||||
@@ -246,13 +254,44 @@ function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/a
|
||||
}
|
||||
|
||||
function normalizeNodePtyWindowsArch(electronArch) {
|
||||
if (electronArch === 'x64' || electronArch === 1) {
|
||||
return 'x64'
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
if (architecture !== 'x64' && architecture !== 'arm64') {
|
||||
throw new Error(`Unsupported packaged node-pty Windows architecture: ${architecture}`)
|
||||
}
|
||||
if (electronArch === 'arm64' || electronArch === 3) {
|
||||
return 'arm64'
|
||||
return architecture
|
||||
}
|
||||
|
||||
function normalizeElectronArchitecture(electronArch) {
|
||||
const architecture =
|
||||
typeof electronArch === 'number'
|
||||
? ELECTRON_ARCHITECTURE_BY_ENUM[electronArch]
|
||||
: electronArch === 'armv7l'
|
||||
? 'arm'
|
||||
: electronArch
|
||||
if (!PACKAGED_NATIVE_ARCHITECTURES.has(architecture)) {
|
||||
throw new Error(`Unsupported packaged runtime architecture: ${String(electronArch)}`)
|
||||
}
|
||||
return architecture
|
||||
}
|
||||
|
||||
function pruneNodePtyNativeDirectories(directory, platformPrefix, electronArch, allowsSuffix) {
|
||||
if (!existsSync(directory)) {
|
||||
return
|
||||
}
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
const targetPrefix = `${platformPrefix}${architecture}`
|
||||
const platformPrefixes = Object.values(NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM)
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !platformPrefixes.some((prefix) => entry.name.startsWith(prefix))) {
|
||||
continue
|
||||
}
|
||||
const matchesTarget =
|
||||
entry.name.startsWith(platformPrefix) &&
|
||||
(entry.name === targetPrefix || (allowsSuffix && entry.name.startsWith(`${targetPrefix}-`)))
|
||||
if (!matchesTarget) {
|
||||
rmSync(join(directory, entry.name), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
return process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
}
|
||||
|
||||
function findNodePtyConptySourceDir(nodePtyDir, windowsArch) {
|
||||
@@ -308,14 +347,19 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
|
||||
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
if (allowedPrebuildPrefix) {
|
||||
const prebuildsDir = join(nodePtyDir, 'prebuilds')
|
||||
if (existsSync(prebuildsDir)) {
|
||||
for (const entry of readdirSync(prebuildsDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith(allowedPrebuildPrefix)) {
|
||||
rmSync(join(prebuildsDir, entry.name), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
pruneNodePtyNativeDirectories(
|
||||
join(nodePtyDir, 'prebuilds'),
|
||||
allowedPrebuildPrefix,
|
||||
electronArch,
|
||||
false
|
||||
)
|
||||
// Why: sequential cross-arch rebuilds accumulate ABI-tagged outputs here.
|
||||
pruneNodePtyNativeDirectories(
|
||||
join(nodePtyDir, 'bin'),
|
||||
allowedPrebuildPrefix,
|
||||
electronArch,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
if (electronPlatformName === 'win32') {
|
||||
@@ -328,7 +372,7 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
}
|
||||
}
|
||||
|
||||
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
|
||||
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName, electronArch) {
|
||||
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
|
||||
if (!existsSync(parcelDir)) {
|
||||
return
|
||||
@@ -336,9 +380,11 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
|
||||
|
||||
// Why: we package every installed @parcel/watcher-<platform> optional
|
||||
// subpackage (supportedArchitectures fetches all), but each build only needs
|
||||
// its own platform's binary. Keep the core package and the matching platform
|
||||
// subpackages; drop the rest so a Linux serve doesn't ship macOS/Windows .node.
|
||||
// its own platform/architecture binaries. Keep the core package and matching
|
||||
// native variants; drop the rest.
|
||||
const keepPrefix = PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
const targetPrefix = keepPrefix ? `${keepPrefix}-${architecture}` : null
|
||||
for (const entry of readdirSync(parcelDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name === 'watcher') {
|
||||
continue
|
||||
@@ -348,7 +394,11 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
|
||||
if (!entry.name.startsWith('watcher-')) {
|
||||
continue
|
||||
}
|
||||
if (keepPrefix && entry.name.startsWith(keepPrefix)) {
|
||||
if (
|
||||
keepPrefix &&
|
||||
entry.name.startsWith(keepPrefix) &&
|
||||
(entry.name === targetPrefix || entry.name.startsWith(`${targetPrefix}-`))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
rmSync(join(parcelDir, entry.name), { recursive: true, force: true })
|
||||
@@ -395,8 +445,9 @@ function prunePackagedZodSources(resourcesDir) {
|
||||
}
|
||||
|
||||
function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, electronArch) {
|
||||
prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
prunePackagedParcelWatcher(resourcesDir, electronPlatformName)
|
||||
const architecture = normalizeElectronArchitecture(electronArch)
|
||||
prunePackagedNodePty(resourcesDir, electronPlatformName, architecture)
|
||||
prunePackagedParcelWatcher(resourcesDir, electronPlatformName, architecture)
|
||||
prunePackagedRuntimeTypeDeclarations(resourcesDir)
|
||||
prunePackagedSherpaOnnx(resourcesDir, electronPlatformName)
|
||||
prunePackagedZodSources(resourcesDir)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2025
-84
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
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'
|
||||
|
||||
/** Long enough to name a feature, short enough that a picker row stays readable. */
|
||||
export const ADHOC_LABEL_MAX_LENGTH = 32
|
||||
|
||||
/**
|
||||
* `1.4.160-adhoc.20260728140533` — UTC to the second, so tags sort
|
||||
* chronologically by semver and every build is uniquely versioned.
|
||||
*
|
||||
* Why seconds when hourly uses minutes: hourly runs under a concurrency group and
|
||||
* cannot overlap itself. Adhoc builds are dispatched on demand, so two people
|
||||
* cutting from different branches in the same minute is ordinary — and a
|
||||
* minute-resolution tag would collide and fail the second build after its whole
|
||||
* pack-and-notarize run.
|
||||
*/
|
||||
export function createAdhocBuildVersion(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('Adhoc 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()),
|
||||
pad(date.getUTCSeconds())
|
||||
].join('')
|
||||
// Why: drop any -rc.N tail, same as hourly. Keeping it would make every adhoc
|
||||
// build semver-NEWER than the RC it was cut from, letting an ordinary
|
||||
// RC-channel check offer an unreviewed branch build to RC users. Stripping to
|
||||
// the base parks adhoc below rc.N, hourly, and stable ('adhoc' sorts first
|
||||
// alphabetically), reachable only by an explicit pinned jump.
|
||||
return `${match[1]}-adhoc.${stamp}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a dispatch input into a label safe to put in a release title.
|
||||
*
|
||||
* The input is free text from whoever ran the workflow, so it cannot be trusted
|
||||
* to stay inside the title's shape: a stray `•` would forge a field separator,
|
||||
* and a newline would break the `$GITHUB_OUTPUT` line the workflow parses. Both
|
||||
* collapse to `-` here, which is why this replaces rather than rejects.
|
||||
*/
|
||||
export function normalizeAdhocLabel(label) {
|
||||
const cleaned = String(label ?? '')
|
||||
// `refs/heads/x` and `origin/x` are what a ref input tends to arrive as; the
|
||||
// prefix is noise in a title where every row is already a branch build.
|
||||
.replace(/^(?:refs\/heads\/|origin\/)/, '')
|
||||
.replace(/[^\p{L}\p{N}._/-]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, ADHOC_LABEL_MAX_LENGTH)
|
||||
// Truncation can land mid-separator, leaving a title ending in `-` or `/`.
|
||||
.replace(/[-._/]+$/, '')
|
||||
if (!cleaned) {
|
||||
throw new Error(`Adhoc label has no usable characters: ${JSON.stringify(label)}`)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • wasm-terminal • Aug 1, 2:25PM • abc1234` — the human-facing release
|
||||
* title, shown verbatim in both the GitHub releases list and the build picker.
|
||||
*
|
||||
* Why the label sits where hourly puts its build number: several adhoc builds
|
||||
* from different branches coexist in the channel, so the picker needs the branch
|
||||
* to tell them apart. A counter would say nothing about which one to pick.
|
||||
*/
|
||||
export function formatAdhocReleaseName(version, label, commit, date) {
|
||||
return [
|
||||
version.split('-')[0],
|
||||
normalizeAdhocLabel(label),
|
||||
formatReleaseTitleTimestamp(date),
|
||||
commit.slice(0, 7)
|
||||
].join(' • ')
|
||||
}
|
||||
|
||||
export function getAdhocBuildIdentity(now = new Date(), label = '', publishedVersions = []) {
|
||||
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 = createAdhocBuildVersion(base, now)
|
||||
return {
|
||||
commit,
|
||||
version,
|
||||
label: normalizeAdhocLabel(label),
|
||||
name: formatAdhocReleaseName(version, label, commit, now)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const identity = getAdhocBuildIdentity(
|
||||
new Date(),
|
||||
process.env.ORCA_ADHOC_LABEL ?? '',
|
||||
readPublishedVersionsFromEnv()
|
||||
)
|
||||
// Consumed by the workflow via $GITHUB_OUTPUT.
|
||||
process.stdout.write(
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createAdhocBuildVersion,
|
||||
formatAdhocReleaseName,
|
||||
normalizeAdhocLabel
|
||||
} from './adhoc-build-version.mjs'
|
||||
import { createHourlyBuildVersion } from './hourly-build-version.mjs'
|
||||
import { compareAppVersions } from '../../src/shared/app-version'
|
||||
|
||||
describe('createAdhocBuildVersion', () => {
|
||||
it('stamps the version with a zero-padded UTC timestamp to the second', () => {
|
||||
expect(createAdhocBuildVersion('1.4.160', new Date('2026-07-28T04:05:09Z'))).toBe(
|
||||
'1.4.160-adhoc.20260728040509'
|
||||
)
|
||||
})
|
||||
|
||||
// Why seconds matter: adhoc builds are dispatched on demand, so two people
|
||||
// cutting from different branches inside the same minute is ordinary. At
|
||||
// hourly's resolution the second one would collide on the tag and die after its
|
||||
// whole pack-and-notarize run.
|
||||
it('distinguishes two builds cut in the same minute', () => {
|
||||
const first = createAdhocBuildVersion('1.4.160', new Date('2026-07-28T14:05:02Z'))
|
||||
const second = createAdhocBuildVersion('1.4.160', new Date('2026-07-28T14:05:41Z'))
|
||||
expect(first).not.toBe(second)
|
||||
expect(compareAppVersions(first, second)).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('drops an in-flight rc tail so adhoc builds never outrank the rc series', () => {
|
||||
const version = createAdhocBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z'))
|
||||
expect(version).toBe('1.4.160-adhoc.20260728140000')
|
||||
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
|
||||
expect(compareAppVersions(version, '1.4.160')).toBeLessThan(0)
|
||||
})
|
||||
|
||||
// Why this ordering is load-bearing: an adhoc build is somebody's unlanded
|
||||
// branch. It must sit below every other channel of the same base version so no
|
||||
// routine check can walk a developer onto one — only an explicit pinned jump.
|
||||
it('sorts below the hourly build of the same base version', () => {
|
||||
expect(
|
||||
compareAppVersions(
|
||||
createAdhocBuildVersion('1.4.160', new Date('2026-07-28T23:59:59Z')),
|
||||
createHourlyBuildVersion('1.4.160', new Date('2026-07-28T00:00:00Z'))
|
||||
)
|
||||
).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('rejects invalid input', () => {
|
||||
expect(() => createAdhocBuildVersion('nope', new Date())).toThrow(/valid semver/)
|
||||
expect(() => createAdhocBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeAdhocLabel', () => {
|
||||
it('keeps an ordinary branch name intact', () => {
|
||||
expect(normalizeAdhocLabel('wasm-terminal')).toBe('wasm-terminal')
|
||||
expect(normalizeAdhocLabel('nwparker/wasm-terminal')).toBe('nwparker/wasm-terminal')
|
||||
})
|
||||
|
||||
it('strips the ref prefixes a dispatch input tends to arrive with', () => {
|
||||
expect(normalizeAdhocLabel('refs/heads/wasm-terminal')).toBe('wasm-terminal')
|
||||
expect(normalizeAdhocLabel('origin/wasm-terminal')).toBe('wasm-terminal')
|
||||
})
|
||||
|
||||
// Why replaced rather than rejected: the label is free text from whoever ran
|
||||
// the workflow. A `•` would forge the title's field separator and a newline
|
||||
// would break the `$GITHUB_OUTPUT` line the workflow parses.
|
||||
it('neutralizes characters that would corrupt the title or the output line', () => {
|
||||
expect(normalizeAdhocLabel('a • b')).toBe('a-b')
|
||||
expect(normalizeAdhocLabel('a\nname=evil')).toBe('a-name-evil')
|
||||
expect(normalizeAdhocLabel(' spaced out ')).toBe('spaced-out')
|
||||
})
|
||||
|
||||
it('truncates without leaving a trailing separator', () => {
|
||||
expect(normalizeAdhocLabel('a'.repeat(80))).toHaveLength(32)
|
||||
expect(normalizeAdhocLabel(`${'a'.repeat(31)}-tail`)).toBe('a'.repeat(31))
|
||||
})
|
||||
|
||||
it('rejects a label with nothing usable in it', () => {
|
||||
expect(() => normalizeAdhocLabel('')).toThrow(/no usable characters/)
|
||||
expect(() => normalizeAdhocLabel(' ')).toThrow(/no usable characters/)
|
||||
expect(() => normalizeAdhocLabel('•••')).toThrow(/no usable characters/)
|
||||
expect(() => normalizeAdhocLabel(null)).toThrow(/no usable characters/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatAdhocReleaseName', () => {
|
||||
const name = (iso, label = 'wasm-terminal', commit = 'e698241abcde') =>
|
||||
formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso))
|
||||
|
||||
it('renders version, label, Pacific timestamp, and short sha', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • Jul 31, 1:54PM • 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-15T02:30:00Z')).toContain(' Jan 14, 6:30PM ')
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' Jul 31, 12:00AM ')
|
||||
})
|
||||
|
||||
it('sanitizes the label before it reaches the title', () => {
|
||||
expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe(
|
||||
'1.4.163 • fix-now • Jul 31, 1:54PM • e698241'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an invalid timestamp', () => {
|
||||
expect(() => formatAdhocReleaseName('1.4.163', 'x', 'abcdefg', new Date('nope'))).toThrow(
|
||||
/invalid/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,12 @@
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
@@ -6,6 +14,21 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const itCrossHost = process.platform === 'win32' ? it.skip : it
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
const WINDOWS_LOCK_CODES = ['EBUSY', 'ENOTEMPTY', 'EPERM']
|
||||
|
||||
// Why: Windows releases the image handle on a just-executed exe (and finishes the
|
||||
// AV scan of the freshly compiled one) after the process exits, so tearing down the
|
||||
// fixture races those locks. Retry, then leave the temp tree rather than reporting a
|
||||
// teardown lock as a launcher failure.
|
||||
function removeFixtureTree(path) {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
} catch (error) {
|
||||
if (process.platform !== 'win32' || !WINDOWS_LOCK_CODES.includes(error?.code)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
|
||||
// keep the larger allowance scoped to the real compiler integration test.
|
||||
function itWindows(name, test) {
|
||||
@@ -27,10 +50,24 @@ describe('Windows CLI launcher', () => {
|
||||
expect(result.stderr).toContain('Windows CLI launcher')
|
||||
expect(result.stderr).toContain('Windows host')
|
||||
} finally {
|
||||
rmSync(outputRoot, { recursive: true, force: true })
|
||||
removeFixtureTree(outputRoot)
|
||||
}
|
||||
})
|
||||
|
||||
itCrossHost('never materializes the child environment block from ProcessStartInfo', () => {
|
||||
// Why: both ProcessStartInfo env properties copy the process block into a case-insensitive
|
||||
// dictionary that throws when the inherited block holds PATH and Path (stablyai/orca#12046).
|
||||
const source = readFileSync(
|
||||
join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs'),
|
||||
'utf8'
|
||||
)
|
||||
const code = source.replace(/^\s*\/\/.*$/gm, '')
|
||||
|
||||
expect(code).not.toContain('EnvironmentVariables')
|
||||
expect(code).not.toContain('startInfo.Environment')
|
||||
expect(code).toContain('Environment.SetEnvironmentVariable')
|
||||
})
|
||||
|
||||
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
|
||||
try {
|
||||
@@ -86,7 +123,73 @@ describe('Windows CLI launcher', () => {
|
||||
orcaNodeOptions: '--no-warnings'
|
||||
})
|
||||
} finally {
|
||||
rmSync(appRoot, { recursive: true, force: true })
|
||||
removeFixtureTree(appRoot)
|
||||
}
|
||||
})
|
||||
|
||||
itWindows('survives an inherited environment block containing PATH and Path', () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher '))
|
||||
try {
|
||||
const resourcesPath = join(appRoot, 'resources')
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
|
||||
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
|
||||
const outputPath = join(appRoot, 'child-result.json')
|
||||
const harnessSourcePath = join(
|
||||
projectRoot,
|
||||
'config',
|
||||
'scripts',
|
||||
'fixtures',
|
||||
'DuplicatePathProcessLauncher.cs'
|
||||
)
|
||||
const harnessPath = join(appRoot, 'DuplicatePathLauncher.exe')
|
||||
mkdirSync(dirname(launcherPath), { recursive: true })
|
||||
mkdirSync(dirname(cliPath), { recursive: true })
|
||||
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
|
||||
writeFileSync(
|
||||
cliPath,
|
||||
`require('node:fs').writeFileSync(process.env.ORCA_TEST_OUTPUT, JSON.stringify({
|
||||
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
|
||||
pathKeys: Object.keys(process.env).filter((key) => key.toLowerCase() === 'path')
|
||||
}))\n`,
|
||||
'utf8'
|
||||
)
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
||||
|
||||
const compiler = findFrameworkCompiler()
|
||||
expect(compiler).not.toBeNull()
|
||||
const compileHarness = spawnSync(
|
||||
compiler,
|
||||
['/nologo', '/target:exe', `/out:${harnessPath}`, harnessSourcePath],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
expect(compileHarness.status, `${compileHarness.stdout}\n${compileHarness.stderr}`).toBe(0)
|
||||
|
||||
const launch = spawnSync(harnessPath, [launcherPath, outputPath], { encoding: 'utf8' })
|
||||
expect(launch.status, `${launch.stdout}\n${launch.stderr}`).toBe(0)
|
||||
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
|
||||
electronRunAsNode: '1',
|
||||
pathKeys: ['PATH', 'Path']
|
||||
})
|
||||
} finally {
|
||||
removeFixtureTree(appRoot)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function findFrameworkCompiler() {
|
||||
const windowsDirectory = process.env.WINDIR ?? process.env.SystemRoot
|
||||
if (!windowsDirectory) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
[
|
||||
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
|
||||
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
|
||||
].find((candidate) => existsSync(candidate)) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
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 tempDirs = []
|
||||
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim()
|
||||
}
|
||||
|
||||
function makeFixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-root-directory-guard-'))
|
||||
tempDirs.push(root)
|
||||
git(root, ['init', '--quiet'])
|
||||
git(root, ['config', 'user.email', 'root-directory-guard-test@example.com'])
|
||||
git(root, ['config', 'user.name', 'Root Directory Guard Test'])
|
||||
mkdirSync(join(root, 'config'), { recursive: true })
|
||||
writeFileSync(join(root, 'config', 'base.txt'), 'base\n')
|
||||
git(root, ['add', '-A'])
|
||||
git(root, ['commit', '--quiet', '-m', 'base'])
|
||||
return { root, base: git(root, ['rev-parse', 'HEAD']) }
|
||||
}
|
||||
|
||||
function commitFiles(root, files) {
|
||||
for (const [relativePath, contents] of files) {
|
||||
const target = join(root, relativePath)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, contents)
|
||||
}
|
||||
git(root, ['add', '-A'])
|
||||
git(root, ['commit', '--quiet', '-m', 'head'])
|
||||
return git(root, ['rev-parse', 'HEAD'])
|
||||
}
|
||||
|
||||
function runGuard({ root, base, head }) {
|
||||
return spawnSync('bash', [guardScript, base, head], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('root directory guard', () => {
|
||||
it('allows additions inside an existing top-level directory', () => {
|
||||
const fixture = makeFixture()
|
||||
const head = commitFiles(fixture.root, [['config/new.txt', 'nested\n']])
|
||||
|
||||
const result = runGuard({ ...fixture, head })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('no new root-level files or folders')
|
||||
})
|
||||
|
||||
it('rejects a new root-level file with the landing-page message', () => {
|
||||
const fixture = makeFixture()
|
||||
const head = commitFiles(fixture.root, [['new-root.md', 'too prominent\n']])
|
||||
|
||||
const result = runGuard({ ...fixture, head })
|
||||
const output = `${result.stdout}\n${result.stderr}`
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output).toContain('bloat the GitHub landing page')
|
||||
expect(output).toContain('new-root.md')
|
||||
})
|
||||
|
||||
it('rejects a new top-level directory', () => {
|
||||
const fixture = makeFixture()
|
||||
const head = commitFiles(fixture.root, [['new-folder/file.txt', 'too prominent\n']])
|
||||
|
||||
const result = runGuard({ ...fixture, head })
|
||||
const output = `${result.stdout}\n${result.stderr}`
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output).toContain('new-folder')
|
||||
})
|
||||
|
||||
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
|
||||
const guardStep = guardJob.steps.find(
|
||||
(step) => step.name === 'Reject new root-level files and folders'
|
||||
)
|
||||
|
||||
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(workflow.jobs.verify.needs).toContain('root_directory_guard')
|
||||
})
|
||||
})
|
||||
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.mjs',
|
||||
@@ -70,9 +71,14 @@ describe('computer-use e2e workflow', () => {
|
||||
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const checkout = workflow.jobs['native-smoke'].steps.find(
|
||||
(step) => step.uses === 'actions/checkout@v6'
|
||||
)
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
@@ -106,12 +112,68 @@ describe('computer-use e2e workflow', () => {
|
||||
'src/shared/remote-runtime-client.test.ts'
|
||||
]
|
||||
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(regressionRun).toBeTruthy()
|
||||
for (const file of expectedRegressionFiles) {
|
||||
expect(regressionRun).toContain(file)
|
||||
}
|
||||
})
|
||||
|
||||
it('builds and tests the macOS helper on pull requests without TCC e2e', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const job = workflow.jobs['mac-native-owner-smoke']
|
||||
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
|
||||
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
|
||||
|
||||
expect(job.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(job['runs-on']).toBe('macos-15')
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
|
||||
const cleanupRun = runs.find((run) =>
|
||||
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
|
||||
)
|
||||
expect(cleanupRun).toContain(
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs'
|
||||
)
|
||||
expect(runs).toContain('pnpm verify:computer-native')
|
||||
expect(runs.join('\n')).not.toContain('test:e2e:computer')
|
||||
expect(workflow.on.pull_request.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-metrics.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('runs deterministic macOS owner-loss benchmark cleanup coverage', () => {
|
||||
const benchmark = readFileSync(
|
||||
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
const cleanup = readFileSync(
|
||||
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(benchmark).toContain('spawnBenchmarkProcess(executable, [launcherDir]')
|
||||
expect(benchmark).toContain("stdio: ['ignore', stdoutDescriptor, stderrDescriptor]")
|
||||
expect(benchmark).toContain('cleanupOwnerLossTrial({')
|
||||
const parseIndex = benchmark.indexOf('parseBenchmarkTrialResult(serializedResult)')
|
||||
const cleanupIndex = benchmark.indexOf('cleanupOwnerLossTrial({')
|
||||
expect(parseIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(cleanupIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(parseIndex).toBeLessThan(cleanupIndex)
|
||||
expect(benchmark).toContain('trialCleanupSha256: artifactSha256(trialCleanupPath)')
|
||||
expect(cleanup).toContain('killRecordedAndMatchingProcesses(options.recordPath')
|
||||
expect(cleanup).toContain("signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL'")
|
||||
})
|
||||
|
||||
it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
@@ -157,26 +219,21 @@ describe('computer-use e2e workflow', () => {
|
||||
'config/scripts/daemon-boot-smoke.mjs',
|
||||
'config/scripts/windows-daemon-workspace-close-repro.mjs',
|
||||
'electron.vite.config.ts',
|
||||
'build-plugins/**',
|
||||
'config/build-plugins/**',
|
||||
'src/main/daemon/**'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('runs Linux computer-use e2e in the PR native-smoke job under Xvfb', () => {
|
||||
it('does not run computer-use e2e in PR smoke jobs', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const installRun = nativeSmokeRuns.find((run) => run.includes('apt-get install'))
|
||||
|
||||
expect(installRun).toContain('gedit')
|
||||
expect(installRun).toContain('xvfb')
|
||||
expect(nativeSmokeRuns).toContain(
|
||||
'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts'
|
||||
)
|
||||
expect(nativeSmokeRuns.join('\n')).not.toContain('test:e2e:computer')
|
||||
})
|
||||
|
||||
it('builds Electron main output before every computer-use e2e run', () => {
|
||||
@@ -205,7 +262,7 @@ describe('computer-use e2e workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs core Windows computer-use e2e in the PR native-smoke job', () => {
|
||||
it('keeps computer-use e2e in scheduled jobs only', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
@@ -221,9 +278,8 @@ describe('computer-use e2e workflow', () => {
|
||||
.filter((run) => typeof run === 'string')
|
||||
]
|
||||
|
||||
expect(nativeSmokeRuns).toContain(
|
||||
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts'
|
||||
)
|
||||
expect(nativeSmokeRuns.join('\n')).not.toContain('test:e2e:computer')
|
||||
expect(allRuns.join('\n')).toContain('test:e2e:computer')
|
||||
expect(allRuns.join('\n')).not.toContain('test:e2e:computer -- --reporter')
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@ 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.steps(clickCount: count)')
|
||||
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,27 @@ 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) => {
|
||||
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 +97,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 +119,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 +215,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 +242,39 @@ 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')
|
||||
}
|
||||
if (process.platform !== 'win32' && existsSync(socketPath)) {
|
||||
throw new Error('daemon left its endpoint socket behind after shutdown')
|
||||
}
|
||||
// The private bind name is consumed by the publish; nothing may linger in the runtime dir.
|
||||
const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b'))
|
||||
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,164 @@
|
||||
/**
|
||||
* Endpoint handover smoke — guards the split-brain failure with real daemon processes.
|
||||
*
|
||||
* The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still
|
||||
* alive used to delete the *replacement's* socket when it finally exited, because libuv
|
||||
* unlinks the pathname a server bound to with no ownership check. The replacement stayed
|
||||
* alive hosting PTYs that nothing could reach — terminals that acknowledge input and never
|
||||
* run it, and that a user cannot fix by restarting the app.
|
||||
*
|
||||
* 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, rmSync, statSync, unlinkSync } 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')
|
||||
const READY_TIMEOUT_MS = 20_000
|
||||
const EXIT_TIMEOUT_MS = 15_000
|
||||
|
||||
const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`)
|
||||
|
||||
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')
|
||||
})
|
||||
return 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, tokenPath, pidPath })
|
||||
}
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isReachable(socketPath) {
|
||||
return new Promise((resolveReachable) => {
|
||||
const socket = connect({ path: socketPath })
|
||||
socket.on('connect', () => {
|
||||
socket.destroy()
|
||||
resolveReachable(true)
|
||||
})
|
||||
socket.on('error', () => resolveReachable(false))
|
||||
})
|
||||
}
|
||||
|
||||
function killAndWait(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolveExit, rejectExit) => {
|
||||
const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS)
|
||||
child.on('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolveExit()
|
||||
})
|
||||
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')
|
||||
let replaced
|
||||
let replacement
|
||||
try {
|
||||
replaced = await bootDaemon('replaced', dir, socketPath)
|
||||
const replacedInode = statSync(socketPath).ino
|
||||
log('daemon A published the endpoint')
|
||||
|
||||
// Reclaim the endpoint name the way daemon replacement does, while A is still alive.
|
||||
unlinkSync(socketPath)
|
||||
replacement = await bootDaemon('replacement', dir, socketPath)
|
||||
const replacementInode = statSync(socketPath).ino
|
||||
if (replacedInode === replacementInode) {
|
||||
throw new Error('daemon B did not publish a distinct endpoint')
|
||||
}
|
||||
if (!(await isReachable(socketPath))) {
|
||||
throw new Error('daemon B is not reachable through the canonical endpoint')
|
||||
}
|
||||
log('daemon B took over the endpoint and is reachable')
|
||||
|
||||
// A exits long after losing the endpoint. This is the step that used to break B.
|
||||
await killAndWait(replaced.child)
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
|
||||
if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) {
|
||||
throw new Error("daemon A's late exit deleted daemon B's endpoint")
|
||||
}
|
||||
if (!(await isReachable(socketPath))) {
|
||||
throw new Error('daemon B became unreachable after daemon A exited')
|
||||
}
|
||||
if (!existsSync(replacement.pidPath)) {
|
||||
throw new Error("daemon A's late exit removed daemon B's ownership record")
|
||||
}
|
||||
if (existsSync(replaced.pidPath)) {
|
||||
throw new Error('daemon A left its own ownership record behind')
|
||||
}
|
||||
|
||||
log('PASS: the endpoint owner and the session host stayed the same daemon')
|
||||
} finally {
|
||||
for (const daemon of [replaced, replacement]) {
|
||||
if (daemon && 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)
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
||||
|
||||
function parseVersionTriple(value) {
|
||||
const match = SEMVER.exec(String(value ?? '').trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease: match[4] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function compareTriples(a, b) {
|
||||
return a.major - b.major || a.minor - b.minor || a.patch - b.patch
|
||||
}
|
||||
|
||||
/**
|
||||
* The base `X.Y.Z` an hourly or adhoc build should carry.
|
||||
*
|
||||
* Why not package.json alone: main's version only moves on `release:` commits, and
|
||||
* stable patches are cut from release branches that never merge back. On
|
||||
* 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and
|
||||
* 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while
|
||||
* carrying code newer than 1.4.167, and sorted *below* the stable their user was
|
||||
* already running. Published tags are the only honest answer to "what number is
|
||||
* taken"; package.json is a floor, not a source of truth.
|
||||
*/
|
||||
export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) {
|
||||
const fromPackage = parseVersionTriple(packageVersion)
|
||||
if (!fromPackage) {
|
||||
throw new Error(`Package version is not valid semver: ${packageVersion}`)
|
||||
}
|
||||
|
||||
// Unparseable tags are skipped rather than fatal: the main repo carries old tags
|
||||
// that predate the current scheme, and one of them must not fail every build.
|
||||
const published = publishedVersions.map(parseVersionTriple).filter(Boolean)
|
||||
|
||||
let base = fromPackage
|
||||
if (published.length > 0) {
|
||||
const highest = published.reduce((best, entry) =>
|
||||
compareTriples(entry, best) > 0 ? entry : best
|
||||
)
|
||||
// A shipped stable owns its number, so the next dev build belongs on the patch
|
||||
// above it. A bare prerelease does not — rc.1 of 1.4.168 means 1.4.168 is still
|
||||
// the version being worked toward, which is exactly what main is building.
|
||||
const shipped = published.some(
|
||||
(entry) => !entry.prerelease && compareTriples(entry, highest) === 0
|
||||
)
|
||||
const next = shipped ? { ...highest, patch: highest.patch + 1 } : highest
|
||||
if (compareTriples(next, base) > 0) {
|
||||
base = next
|
||||
}
|
||||
}
|
||||
|
||||
return `${base.major}.${base.minor}.${base.patch}`
|
||||
}
|
||||
|
||||
/** Tag list the workflow reads out of the main repo, newline separated. */
|
||||
export function readPublishedVersionsFromEnv(value = process.env.ORCA_PUBLISHED_VERSIONS) {
|
||||
return String(value ?? '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
readPublishedVersionsFromEnv,
|
||||
resolveDevChannelBaseVersion
|
||||
} from './dev-channel-base-version.mjs'
|
||||
|
||||
describe('dev channel base version', () => {
|
||||
it('falls back to package.json when no tags are supplied', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.168-rc.1')).toBe('1.4.168')
|
||||
expect(resolveDevChannelBaseVersion('1.4.168', [])).toBe('1.4.168')
|
||||
})
|
||||
|
||||
// The bug this exists for: main sat at 1.4.165-rc.0 for twenty hours while three
|
||||
// stables shipped, so hourlies claimed a version their users had already passed.
|
||||
it('climbs past stables that shipped while main stood still', () => {
|
||||
expect(
|
||||
resolveDevChannelBaseVersion('1.4.165-rc.0', [
|
||||
'v1.4.165',
|
||||
'v1.4.166',
|
||||
'v1.4.167',
|
||||
'v1.4.165-rc.0'
|
||||
])
|
||||
).toBe('1.4.168')
|
||||
})
|
||||
|
||||
// Why +1 on a stable but not on a prerelease: 1.4.167 is spent, so the next dev
|
||||
// build is 1.4.168. But 1.4.168-rc.1 means 1.4.168 is still being built toward,
|
||||
// which is what main holds — claiming 1.4.169 would jump a release nobody cut.
|
||||
it('takes the patch above a shipped stable and holds at an open prerelease', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.167', ['v1.4.167'])).toBe('1.4.168')
|
||||
expect(resolveDevChannelBaseVersion('1.4.168-rc.1', ['v1.4.168-rc.1'])).toBe('1.4.168')
|
||||
})
|
||||
|
||||
// Why max and not most-recent: a hotfix on an old line published today would
|
||||
// otherwise drag every subsequent hourly backwards.
|
||||
it('reads the highest tag, not the last one listed', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.160', ['v1.4.167', 'v1.3.99', 'v1.4.120'])).toBe(
|
||||
'1.4.168'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats package.json as a floor when it leads the tags', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0')
|
||||
})
|
||||
|
||||
// Why skipped rather than fatal: the main repo carries legacy tags, and one
|
||||
// unparseable entry must not fail every hourly build.
|
||||
it('ignores tags it cannot parse', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.160', ['nightly', '', 'v1.4.167', 'latest'])).toBe(
|
||||
'1.4.168'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a package version that is not semver', () => {
|
||||
expect(() => resolveDevChannelBaseVersion('not-a-version')).toThrow(/not valid semver/)
|
||||
})
|
||||
|
||||
it('carries major and minor rollovers through the bump', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.0', ['v2.0.0'])).toBe('2.0.1')
|
||||
})
|
||||
|
||||
it('splits an env tag list on any whitespace', () => {
|
||||
expect(readPublishedVersionsFromEnv('v1.4.167\nv1.4.166\n')).toEqual(['v1.4.167', 'v1.4.166'])
|
||||
expect(readPublishedVersionsFromEnv('')).toEqual([])
|
||||
expect(readPublishedVersionsFromEnv(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,42 @@ 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(
|
||||
@@ -158,11 +194,7 @@ describe('electron-builder config', () => {
|
||||
'out/package.json',
|
||||
'out/cli/**',
|
||||
'out/shared/**',
|
||||
'out/main/claude-accounts/keychain.js',
|
||||
'out/main/codex-cli/command.js',
|
||||
'out/main/ipc/local-agent-install-dir-detection.js',
|
||||
'out/main/ipc/tui-agent-detection-commands.js',
|
||||
'out/main/win32-utils.js'
|
||||
'out/main/claude-accounts/keychain.js'
|
||||
])
|
||||
)
|
||||
})
|
||||
@@ -175,11 +207,9 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
|
||||
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
|
||||
it('unpacks the forked main-thread hang-watchdog entry', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
|
||||
it('keeps the worker-thread hang watchdog inside app.asar', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).not.toContain(
|
||||
'out/main/main-thread-hang-watchdog-entry.js'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -273,6 +303,89 @@ 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)
|
||||
@@ -307,40 +420,42 @@ describe('electron-builder config', () => {
|
||||
expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
|
||||
})
|
||||
|
||||
it('prunes non-target node-pty prebuilds from packaged runtime resources', async () => {
|
||||
it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
|
||||
try {
|
||||
const prebuildsDir = join(resourcesDir, 'node_modules', 'node-pty', 'prebuilds')
|
||||
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
|
||||
const prebuildsDir = join(nodePtyDir, 'prebuilds')
|
||||
const binDir = join(nodePtyDir, 'bin')
|
||||
await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party', 'conpty'), {
|
||||
recursive: true
|
||||
})
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps', 'winpty'), {
|
||||
await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true })
|
||||
await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true })
|
||||
await mkdir(join(nodePtyDir, 'third_party', 'conpty'), {
|
||||
recursive: true
|
||||
})
|
||||
await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true })
|
||||
|
||||
prunePackagedNodePty(resourcesDir, 'darwin')
|
||||
prunePackagedNodePty(resourcesDir, 'darwin', 3)
|
||||
|
||||
await expect(readdir(prebuildsDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'darwin-arm64',
|
||||
'darwin-x64'
|
||||
])
|
||||
await expect(
|
||||
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party'))
|
||||
).resolves.toEqual([])
|
||||
await expect(
|
||||
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))
|
||||
).resolves.toEqual([])
|
||||
await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64'])
|
||||
await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148'])
|
||||
await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([])
|
||||
await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([])
|
||||
expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow(
|
||||
'Unsupported packaged runtime architecture: 4'
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
|
||||
for (const arch of ['x64', 'arm64']) {
|
||||
for (const [arch, electronArch] of [
|
||||
['x64', 1],
|
||||
['arm64', 3]
|
||||
]) {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
|
||||
try {
|
||||
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
|
||||
@@ -359,7 +474,7 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
}
|
||||
|
||||
prunePackagedNodePty(resourcesDir, 'win32', arch)
|
||||
prunePackagedNodePty(resourcesDir, 'win32', electronArch)
|
||||
|
||||
await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
|
||||
`dll payload ${arch}`
|
||||
@@ -387,7 +502,7 @@ describe('electron-builder config', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('prunes non-target @parcel/watcher platform subpackages from packaged runtime resources', async () => {
|
||||
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
|
||||
try {
|
||||
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
|
||||
@@ -398,13 +513,15 @@ describe('electron-builder config', () => {
|
||||
await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
|
||||
await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
|
||||
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux')
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64')
|
||||
|
||||
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'watcher',
|
||||
'watcher-linux-arm64-glibc',
|
||||
'watcher-linux-x64-glibc'
|
||||
'watcher-linux-arm64-glibc'
|
||||
])
|
||||
expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow(
|
||||
'Unsupported packaged runtime architecture: universal'
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -420,7 +537,7 @@ describe('electron-builder config', () => {
|
||||
// A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
|
||||
await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
|
||||
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux')
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux', 1)
|
||||
|
||||
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'transformer-js',
|
||||
@@ -486,6 +603,20 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('fails when the packaged resources directory is missing', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
|
||||
try {
|
||||
await expect(
|
||||
electronBuilderConfig.afterPack({
|
||||
appOutDir: root,
|
||||
electronPlatformName: 'win32'
|
||||
})
|
||||
).rejects.toThrow(/Missing packaged resources directory/)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'marks packaged Unix CLI launchers executable',
|
||||
async () => {
|
||||
@@ -509,11 +640,25 @@ describe('electron-builder config', () => {
|
||||
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n',
|
||||
'utf8'
|
||||
)
|
||||
const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
|
||||
await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
|
||||
await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
|
||||
await writeFile(
|
||||
join(unpackedCliDir, 'index.js'),
|
||||
[
|
||||
'const args = process.argv.slice(2)',
|
||||
"if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))",
|
||||
"else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)",
|
||||
'else console.log(JSON.stringify({ executed: false }))'
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
|
||||
|
||||
await electronBuilderConfig.afterPack({
|
||||
appOutDir: join(root, 'linux-unpacked'),
|
||||
electronPlatformName: 'linux'
|
||||
electronPlatformName: 'linux',
|
||||
arch: 1
|
||||
})
|
||||
|
||||
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
|
||||
@@ -522,4 +667,39 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Why: the .deb/.rpm update-recovery path keys entirely off the resources/package-type marker that
|
||||
// app-builder-lib's FpmTarget writes. If packaging silently stops shipping an fpm target, or adds
|
||||
// one the recovery path does not cover, getLinuxRootPackageType() returns null, autoInstallOnAppQuit
|
||||
// quietly goes back to true, and no unit test notices.
|
||||
describe('linux root-package update recovery contract', () => {
|
||||
// FpmTarget writes resources/package-type only for targets it supports auto-update for.
|
||||
const MARKER_TARGETS = new Set(['deb', 'rpm', 'pacman'])
|
||||
const RECOVERABLE_TARGETS = new Set(['deb', 'rpm'])
|
||||
const linuxTargets = electronBuilderConfig.linux.target.map((entry) =>
|
||||
typeof entry === 'string' ? entry : entry.target
|
||||
)
|
||||
|
||||
it('still ships an AppImage plus at least one root-package target', () => {
|
||||
expect(linuxTargets).toContain('AppImage')
|
||||
expect(linuxTargets.some((target) => MARKER_TARGETS.has(target))).toBe(true)
|
||||
})
|
||||
|
||||
it('ships no root-package target the recovery path cannot recover', () => {
|
||||
const unrecoverable = linuxTargets.filter(
|
||||
(target) => MARKER_TARGETS.has(target) && !RECOVERABLE_TARGETS.has(target)
|
||||
)
|
||||
expect(unrecoverable).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts exactly the markers electron-updater maps to a root-package updater', async () => {
|
||||
const source = await readFile(
|
||||
new URL('../../src/main/linux-update-package-type.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
for (const target of linuxTargets.filter((entry) => RECOVERABLE_TARGETS.has(entry))) {
|
||||
expect(source).toContain(`value === '${target}'`)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,72 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as nodeFs from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import * as nodePath from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createBootstrapFatalExitBanner, electronViteConfig } from '../../electron.vite.config'
|
||||
import {
|
||||
BOOTSTRAP_FATAL_LOG_ENV_VAR,
|
||||
BOOTSTRAP_FATAL_LOG_FILE_NAME,
|
||||
createBootstrapFatalExitBanner
|
||||
} from '../build-plugins/bootstrap-fatal-exit-banner'
|
||||
import { electronViteConfig } from '../../electron.vite.config'
|
||||
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
|
||||
|
||||
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
|
||||
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
|
||||
|
||||
type BootstrapProcessMock = EventEmitter & {
|
||||
env: Record<string, string>
|
||||
pid: number
|
||||
exit: (code: number) => void
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
/** Runs the banner in a bare context and raises the bootstrap fault it guards against. */
|
||||
function failBootstrapWithBanner(options: {
|
||||
env: Record<string, string>
|
||||
tmpdir?: string
|
||||
stderrWrites?: string[]
|
||||
}): BootstrapProcessMock {
|
||||
const processMock = new EventEmitter() as BootstrapProcessMock
|
||||
processMock.env = options.env
|
||||
processMock.pid = 4242
|
||||
processMock.exit = () => {}
|
||||
const fsShim = {
|
||||
...nodeFs,
|
||||
writeSync: (descriptor: number, data: string) => {
|
||||
if (descriptor === 2) {
|
||||
options.stderrWrites?.push(data)
|
||||
return data.length
|
||||
}
|
||||
return nodeFs.writeSync(descriptor, data)
|
||||
}
|
||||
}
|
||||
const context = {
|
||||
process: processMock,
|
||||
setImmediate: () => {},
|
||||
require: (specifier: string) => {
|
||||
if (specifier === 'node:fs') {
|
||||
return fsShim
|
||||
}
|
||||
if (specifier === 'node:path') {
|
||||
return nodePath
|
||||
}
|
||||
if (specifier === 'node:os' && options.tmpdir !== undefined) {
|
||||
return { tmpdir: () => options.tmpdir }
|
||||
}
|
||||
// Electron's own module is unreachable from a bootstrap fault this early.
|
||||
throw new Error(`unexpected require: ${specifier}`)
|
||||
}
|
||||
}
|
||||
|
||||
runInNewContext(createBootstrapFatalExitBanner(), context)
|
||||
processMock.emit('uncaughtException', new Error("Cannot find module 'ws'"))
|
||||
return processMock
|
||||
}
|
||||
|
||||
describe('Electron Vite output contract', () => {
|
||||
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
|
||||
const output = electronViteConfig.main?.build?.rollupOptions?.output
|
||||
@@ -20,7 +79,7 @@ describe('Electron Vite output contract', () => {
|
||||
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
|
||||
})
|
||||
|
||||
it('externalizes packaged dependencies but bundles the daemon xterm graph', () => {
|
||||
it('externalizes packaged dependencies but bundles self-contained main dependencies', () => {
|
||||
const external = electronViteConfig.main?.build?.rollupOptions?.external
|
||||
if (typeof external !== 'function') {
|
||||
throw new Error('Expected main-process external predicate')
|
||||
@@ -32,7 +91,9 @@ describe('Electron Vite output contract', () => {
|
||||
expect(external('node:fs', undefined, false)).toBe(true)
|
||||
expect(external('@xterm/headless', undefined, false)).toBe(false)
|
||||
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
|
||||
expect(external('psl', undefined, false)).toBe(false)
|
||||
expect(external('zod', undefined, false)).toBe(false)
|
||||
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl')
|
||||
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
|
||||
})
|
||||
|
||||
@@ -40,12 +101,20 @@ describe('Electron Vite output contract', () => {
|
||||
const processMock = new EventEmitter() as EventEmitter & {
|
||||
exit: (code: number) => void
|
||||
exitCode?: number
|
||||
stderr: { write: (chunk: string) => boolean }
|
||||
}
|
||||
let scheduledExit: (() => void) | null = null
|
||||
let exitedWith: number | null = null
|
||||
const stderrWrites: string[] = []
|
||||
processMock.exit = (code) => {
|
||||
exitedWith = code
|
||||
}
|
||||
processMock.stderr = {
|
||||
write: (chunk) => {
|
||||
stderrWrites.push(chunk)
|
||||
return true
|
||||
}
|
||||
}
|
||||
const context = {
|
||||
process: processMock,
|
||||
setImmediate: (callback: () => void) => {
|
||||
@@ -61,6 +130,63 @@ describe('Electron Vite output contract', () => {
|
||||
scheduledExit?.()
|
||||
expect(exitedWith).toBe(1)
|
||||
expect(context).toHaveProperty(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)
|
||||
expect(stderrWrites.join('')).toContain("Cannot find module 'zod'")
|
||||
})
|
||||
|
||||
it('records the bootstrap failure it exits on, since the guard hides Electron dialog', () => {
|
||||
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
|
||||
const logPath = join(logDirectory, 'fatal.log')
|
||||
const stderrWrites: string[] = []
|
||||
|
||||
try {
|
||||
const processMock = failBootstrapWithBanner({
|
||||
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath },
|
||||
stderrWrites
|
||||
})
|
||||
|
||||
expect(stderrWrites.join('')).toContain("Cannot find module 'ws'")
|
||||
const recorded = readFileSync(logPath, 'utf8')
|
||||
expect(recorded).toContain("Cannot find module 'ws'")
|
||||
expect(recorded).toContain('pid=4242')
|
||||
expect(processMock.exitCode).toBe(1)
|
||||
} finally {
|
||||
rmSync(logDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('creates the parent directory an overridden log path names but does not have', () => {
|
||||
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
|
||||
const logPath = join(logDirectory, 'nested', 'diagnostics', 'fatal.log')
|
||||
|
||||
try {
|
||||
const processMock = failBootstrapWithBanner({
|
||||
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath }
|
||||
})
|
||||
|
||||
expect(readFileSync(logPath, 'utf8')).toContain("Cannot find module 'ws'")
|
||||
expect(processMock.exitCode).toBe(1)
|
||||
} finally {
|
||||
rmSync(logDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the default location when the overridden log path is unwritable', () => {
|
||||
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
|
||||
const fallbackDirectory = join(logDirectory, 'fallback')
|
||||
|
||||
try {
|
||||
const processMock = failBootstrapWithBanner({
|
||||
// A directory can never be opened as the log file, so the override must yield.
|
||||
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logDirectory },
|
||||
tmpdir: fallbackDirectory
|
||||
})
|
||||
|
||||
const recorded = readFileSync(join(fallbackDirectory, BOOTSTRAP_FATAL_LOG_FILE_NAME), 'utf8')
|
||||
expect(recorded).toContain("Cannot find module 'ws'")
|
||||
expect(processMock.exitCode).toBe(1)
|
||||
} finally {
|
||||
rmSync(logDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates renderer entry side effects behind strict facades', () => {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
internal static class DuplicatePathProcessLauncher
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct StartupInfo
|
||||
{
|
||||
public int cb;
|
||||
public string lpReserved;
|
||||
public string lpDesktop;
|
||||
public string lpTitle;
|
||||
public int dwX;
|
||||
public int dwY;
|
||||
public int dwXSize;
|
||||
public int dwYSize;
|
||||
public int dwXCountChars;
|
||||
public int dwYCountChars;
|
||||
public int dwFillAttribute;
|
||||
public int dwFlags;
|
||||
public short wShowWindow;
|
||||
public short cbReserved2;
|
||||
public IntPtr lpReserved2;
|
||||
public IntPtr hStdInput;
|
||||
public IntPtr hStdOutput;
|
||||
public IntPtr hStdError;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct ProcessInformation
|
||||
{
|
||||
public IntPtr hProcess;
|
||||
public IntPtr hThread;
|
||||
public int dwProcessId;
|
||||
public int dwThreadId;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern bool CreateProcess(
|
||||
string applicationName,
|
||||
StringBuilder commandLine,
|
||||
IntPtr processAttributes,
|
||||
IntPtr threadAttributes,
|
||||
bool inheritHandles,
|
||||
uint creationFlags,
|
||||
IntPtr environment,
|
||||
string currentDirectory,
|
||||
ref StartupInfo startupInfo,
|
||||
out ProcessInformation processInformation
|
||||
);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
List<string> entries = new List<string>();
|
||||
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
|
||||
{
|
||||
if (!String.Equals((string)entry.Key, "PATH", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
entries.Add((string)entry.Key + "=" + (string)entry.Value);
|
||||
}
|
||||
}
|
||||
entries.Add("PATH=C:\\live");
|
||||
entries.Add("Path=C:\\shadowed");
|
||||
entries.Add("ORCA_TEST_OUTPUT=" + args[1]);
|
||||
|
||||
IntPtr environment = Marshal.StringToHGlobalUni(String.Join("\0", entries.ToArray()) + "\0\0");
|
||||
StartupInfo startupInfo = new StartupInfo();
|
||||
startupInfo.cb = Marshal.SizeOf(startupInfo);
|
||||
ProcessInformation processInformation;
|
||||
try
|
||||
{
|
||||
bool started = CreateProcess(
|
||||
args[0],
|
||||
new StringBuilder("\"" + args[0] + "\""),
|
||||
IntPtr.Zero,
|
||||
IntPtr.Zero,
|
||||
false,
|
||||
0x00000400,
|
||||
environment,
|
||||
null,
|
||||
ref startupInfo,
|
||||
out processInformation
|
||||
);
|
||||
if (!started)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(environment);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WaitForSingleObject(processInformation.hProcess, 0xffffffff);
|
||||
uint exitCode;
|
||||
if (!GetExitCodeProcess(processInformation.hProcess, out exitCode))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
return (int)exitCode;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(processInformation.hThread);
|
||||
CloseHandle(processInformation.hProcess);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, fork, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import {
|
||||
childRssBytes,
|
||||
median,
|
||||
physicalFootprintBytes,
|
||||
sampleMemory,
|
||||
sampleProductionPerformance
|
||||
} from './hang-watchdog-process-metrics.mjs'
|
||||
|
||||
const INTERNAL_ENV = 'ORCA_HANG_WATCHDOG_BENCH_INTERNAL'
|
||||
const BOUNDARY_ENV = 'ORCA_HANG_WATCHDOG_BENCH_BOUNDARY'
|
||||
const RESULT_PREFIX = 'ORCA_HANG_WATCHDOG_BENCH_RESULT='
|
||||
const DEFAULT_TRIALS = 7
|
||||
const SETTLE_MS = 2_000
|
||||
const SAMPLE_COUNT = 5
|
||||
const SAMPLE_INTERVAL_MS = 200
|
||||
const VERIFY_TIMEOUT_MS = 500
|
||||
const VERIFY_CHECK_INTERVAL_MS = 50
|
||||
const VERIFY_BLOCK_MS = 1_200
|
||||
const PRODUCTION_HEARTBEAT_INTERVAL_MS = 2_000
|
||||
const PRODUCTION_TIMEOUT_MS = 45_000
|
||||
const PRODUCTION_CHECK_INTERVAL_MS = 5_000
|
||||
const PRODUCTION_SAMPLE_MS = 30_000
|
||||
const MAX_LAUNCH_ATTEMPTS = 3
|
||||
const MIB = 1024 * 1024
|
||||
const scriptPath = import.meta.filename
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
|
||||
const entryPath = path.join(repoRoot, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function forceGc() {
|
||||
if (typeof global.gc !== 'function') {
|
||||
throw new Error('Electron did not expose GC; keep --js-flags=--expose-gc in the harness')
|
||||
}
|
||||
global.gc()
|
||||
global.gc()
|
||||
}
|
||||
|
||||
function blockMainThread(ms) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < ms) {
|
||||
// Intentional synchronous stall.
|
||||
}
|
||||
return { startedAt, endedAt: Date.now() }
|
||||
}
|
||||
|
||||
function readMarker(markerPath) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(markerPath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyBlockedMainDetection(markerPath, sendHeartbeat) {
|
||||
const block = blockMainThread(VERIFY_BLOCK_MS)
|
||||
const detected = readMarker(markerPath)
|
||||
sendHeartbeat()
|
||||
const deadline = Date.now() + VERIFY_TIMEOUT_MS
|
||||
let resolved
|
||||
do {
|
||||
resolved = readMarker(markerPath)
|
||||
if (resolved?.selfRecovered === true) {
|
||||
break
|
||||
}
|
||||
await sleep(VERIFY_CHECK_INTERVAL_MS)
|
||||
} while (Date.now() < deadline)
|
||||
const verified =
|
||||
detected?.detectedAt >= block.startedAt &&
|
||||
detected.detectedAt <= block.endedAt &&
|
||||
detected.selfRecovered === false &&
|
||||
resolved?.selfRecovered === true
|
||||
if (!verified) {
|
||||
throw new Error(
|
||||
`Built watchdog failed blocked-main verification: ${JSON.stringify({ detected, resolved })}`
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function startChild(markerPath, timeoutMs, checkIntervalMs) {
|
||||
const startedAt = process.hrtime.bigint()
|
||||
const child = fork(entryPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid),
|
||||
ORCA_HANG_WATCHDOG_MARKER_PATH: markerPath,
|
||||
ORCA_HANG_WATCHDOG_TIMEOUT_MS: String(timeoutMs),
|
||||
ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS: String(checkIntervalMs)
|
||||
}
|
||||
})
|
||||
const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6
|
||||
return {
|
||||
pids: [process.pid, child.pid],
|
||||
startupMs,
|
||||
sendHeartbeat: () => child.send?.({ type: 'heartbeat' }),
|
||||
shutdown: async () => {
|
||||
if (child.exitCode !== null) {
|
||||
return
|
||||
}
|
||||
const exitPromise = new Promise((resolve) => child.once('exit', resolve))
|
||||
child.send?.({ type: 'shutdown' })
|
||||
if (child.connected) {
|
||||
child.disconnect()
|
||||
}
|
||||
await exitPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startWorker(markerPath, timeoutMs, checkIntervalMs) {
|
||||
const startedAt = process.hrtime.bigint()
|
||||
const worker = new Worker(entryPath, {
|
||||
workerData: {
|
||||
parentPid: process.pid,
|
||||
markerPath,
|
||||
timeoutMs,
|
||||
checkIntervalMs
|
||||
}
|
||||
})
|
||||
const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6
|
||||
return {
|
||||
pids: [process.pid],
|
||||
startupMs,
|
||||
sendHeartbeat: () => worker.postMessage({ type: 'heartbeat' }),
|
||||
shutdown: async () => {
|
||||
if (worker.threadId === -1) {
|
||||
return
|
||||
}
|
||||
const exitPromise = new Promise((resolve) => worker.once('exit', resolve))
|
||||
worker.postMessage({ type: 'shutdown' })
|
||||
await exitPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyBoundary(markerPath, startBoundary) {
|
||||
const boundary = startBoundary(markerPath, VERIFY_TIMEOUT_MS, VERIFY_CHECK_INTERVAL_MS)
|
||||
const heartbeat = setInterval(boundary.sendHeartbeat, 100)
|
||||
try {
|
||||
await sleep(SETTLE_MS)
|
||||
return await verifyBlockedMainDetection(markerPath, boundary.sendHeartbeat)
|
||||
} finally {
|
||||
clearInterval(heartbeat)
|
||||
await boundary.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
async function measureChild(markerPath) {
|
||||
forceGc()
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const before = await sampleMemory(
|
||||
() => process.memoryUsage().rss,
|
||||
() => physicalFootprintBytes([process.pid]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const child = startChild(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS)
|
||||
let measurements
|
||||
try {
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const childRss = await sampleMemory(
|
||||
() => childRssBytes(child.pids[1]),
|
||||
() => physicalFootprintBytes([child.pids[1]]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const total = await sampleMemory(
|
||||
() => process.memoryUsage().rss + childRssBytes(child.pids[1]),
|
||||
() => physicalFootprintBytes(child.pids),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const performance = await sampleProductionPerformance(child, {
|
||||
heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
|
||||
sampleMs: PRODUCTION_SAMPLE_MS,
|
||||
sleep
|
||||
})
|
||||
measurements = {
|
||||
rssBytes: childRss.rssBytes,
|
||||
summedProcessRssDeltaBytes: Math.max(0, total.rssBytes - before.rssBytes),
|
||||
physicalFootprintDeltaBytes: Math.max(
|
||||
0,
|
||||
total.physicalFootprintBytes - before.physicalFootprintBytes
|
||||
),
|
||||
startupMs: child.startupMs,
|
||||
...performance
|
||||
}
|
||||
} finally {
|
||||
await child.shutdown()
|
||||
}
|
||||
rmSync(markerPath, { force: true })
|
||||
return {
|
||||
...measurements,
|
||||
blockedMainThreadVerified: await verifyBoundary(markerPath, startChild)
|
||||
}
|
||||
}
|
||||
|
||||
async function measureWorker(markerPath) {
|
||||
forceGc()
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const before = await sampleMemory(
|
||||
() => process.memoryUsage().rss,
|
||||
() => physicalFootprintBytes([process.pid]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const worker = startWorker(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS)
|
||||
let measurements
|
||||
try {
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const after = await sampleMemory(
|
||||
() => process.memoryUsage().rss,
|
||||
() => physicalFootprintBytes([process.pid]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const performance = await sampleProductionPerformance(worker, {
|
||||
heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
|
||||
sampleMs: PRODUCTION_SAMPLE_MS,
|
||||
sleep
|
||||
})
|
||||
const rssBytes = Math.max(0, after.rssBytes - before.rssBytes)
|
||||
measurements = {
|
||||
rssBytes,
|
||||
summedProcessRssDeltaBytes: rssBytes,
|
||||
physicalFootprintDeltaBytes: Math.max(
|
||||
0,
|
||||
after.physicalFootprintBytes - before.physicalFootprintBytes
|
||||
),
|
||||
startupMs: worker.startupMs,
|
||||
...performance
|
||||
}
|
||||
} finally {
|
||||
await worker.shutdown()
|
||||
}
|
||||
rmSync(markerPath, { force: true })
|
||||
return {
|
||||
...measurements,
|
||||
blockedMainThreadVerified: await verifyBoundary(markerPath, startWorker)
|
||||
}
|
||||
}
|
||||
|
||||
async function runInternal() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('The production watchdog is macOS-only; run this benchmark on macOS')
|
||||
}
|
||||
const { app } = await import('electron')
|
||||
const boundary = process.env[BOUNDARY_ENV]
|
||||
const profileDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-'))
|
||||
app.setPath('userData', profileDir)
|
||||
try {
|
||||
await app.whenReady()
|
||||
const markerPath = path.join(profileDir, 'main-thread-hang.json')
|
||||
const result =
|
||||
boundary === 'child'
|
||||
? await measureChild(markerPath)
|
||||
: boundary === 'worker'
|
||||
? await measureWorker(markerPath)
|
||||
: (() => {
|
||||
throw new Error(`Unsupported boundary: ${boundary}`)
|
||||
})()
|
||||
process.stdout.write(`${RESULT_PREFIX}${JSON.stringify(result)}\n`)
|
||||
} finally {
|
||||
app.quit()
|
||||
rmSync(profileDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { boundary: '', trials: DEFAULT_TRIALS, output: '' }
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (arg === '--boundary' || arg === '--trials' || arg === '--output') {
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for ${arg}`)
|
||||
}
|
||||
options[arg.slice(2)] = arg === '--trials' ? Number(value) : value
|
||||
index += 1
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
if (!['child', 'worker'].includes(options.boundary)) {
|
||||
throw new Error('--boundary must be child or worker')
|
||||
}
|
||||
if (!Number.isInteger(options.trials) || options.trials < 1) {
|
||||
throw new Error('--trials must be a positive integer')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function electronPath() {
|
||||
const requirePath = import.meta.resolve('electron')
|
||||
const electronModulePath = fileURLToPath(requirePath)
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`],
|
||||
{
|
||||
encoding: 'utf8'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function runTrial(executable, boundary) {
|
||||
for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt += 1) {
|
||||
const env = { ...process.env, [INTERNAL_ENV]: '1', [BOUNDARY_ENV]: boundary }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-launcher-'))
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'package.json'),
|
||||
JSON.stringify({ name: 'orca-watchdog-benchmark', main: 'main.cjs' })
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'main.cjs'),
|
||||
`import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})\n`
|
||||
)
|
||||
let result
|
||||
try {
|
||||
result = spawnSync(executable, ['--js-flags=--expose-gc', launcherDir], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 90_000
|
||||
})
|
||||
} finally {
|
||||
rmSync(launcherDir, { recursive: true, force: true })
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n` +
|
||||
`${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
const line = result.stdout.split('\n').find((candidate) => candidate.startsWith(RESULT_PREFIX))
|
||||
if (line) {
|
||||
return { ...JSON.parse(line.slice(RESULT_PREFIX.length)), launchAttempts: attempt }
|
||||
}
|
||||
if (attempt === MAX_LAUNCH_ATTEMPTS || result.stderr || result.stdout) {
|
||||
throw new Error(`Electron trial did not report a result (status ${result.status})`)
|
||||
}
|
||||
}
|
||||
throw new Error('Electron trial exhausted launcher attempts')
|
||||
}
|
||||
|
||||
function runBenchmark() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('The production watchdog is macOS-only; run this benchmark on macOS')
|
||||
}
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`Missing ${entryPath}; run pnpm exec electron-vite build first`)
|
||||
}
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const builtEntry = readFileSync(entryPath, 'utf8')
|
||||
const hasChildContract = builtEntry.includes('ORCA_HANG_WATCHDOG_PARENT_PID')
|
||||
const hasWorkerContract = builtEntry.includes('workerData') && builtEntry.includes('parentPort')
|
||||
if (
|
||||
(options.boundary === 'child' && !hasChildContract) ||
|
||||
(options.boundary === 'worker' && !hasWorkerContract)
|
||||
) {
|
||||
throw new Error(
|
||||
`Built watchdog does not implement the requested ${options.boundary} boundary; rebuild the matching revision`
|
||||
)
|
||||
}
|
||||
const executable = electronPath()
|
||||
const results = Array.from({ length: options.trials }, () =>
|
||||
runTrial(executable, options.boundary)
|
||||
)
|
||||
const rssBytes = results.map((result) => result.rssBytes)
|
||||
const summedProcessRssDeltaBytes = results.map((result) => result.summedProcessRssDeltaBytes)
|
||||
const physicalFootprintDeltaBytes = results.map((result) => result.physicalFootprintDeltaBytes)
|
||||
const startupMs = results.map((result) => result.startupMs)
|
||||
const cpuMs = results.map((result) => result.cpuMs)
|
||||
const eventLoopDelayP95Ms = results.map((result) => result.eventLoopDelayP95Ms)
|
||||
const eventLoopDelayP99Ms = results.map((result) => result.eventLoopDelayP99Ms)
|
||||
const eventLoopDelayMaxMs = results.map((result) => result.eventLoopDelayMaxMs)
|
||||
const report = {
|
||||
benchmark: 'hang-watchdog-memory',
|
||||
boundary: options.boundary,
|
||||
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8'
|
||||
}).trim(),
|
||||
electron: execFileSync(executable, ['-e', 'process.stdout.write(process.versions.electron)'], {
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
encoding: 'utf8'
|
||||
}).trim(),
|
||||
settleMs: SETTLE_MS,
|
||||
samplesPerTrial: SAMPLE_COUNT,
|
||||
productionHeartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
|
||||
productionCheckIntervalMs: PRODUCTION_CHECK_INTERVAL_MS,
|
||||
productionSampleMs: PRODUCTION_SAMPLE_MS,
|
||||
trials: options.trials,
|
||||
rssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))),
|
||||
medianRssMiB: Number((median(rssBytes) / MIB).toFixed(2)),
|
||||
summedProcessRssDeltaMiB: summedProcessRssDeltaBytes.map((value) =>
|
||||
Number((value / MIB).toFixed(2))
|
||||
),
|
||||
medianSummedProcessRssDeltaMiB: Number((median(summedProcessRssDeltaBytes) / MIB).toFixed(2)),
|
||||
physicalFootprintDeltaMiB: physicalFootprintDeltaBytes.map((value) =>
|
||||
Number((value / MIB).toFixed(2))
|
||||
),
|
||||
medianPhysicalFootprintDeltaMiB: Number((median(physicalFootprintDeltaBytes) / MIB).toFixed(2)),
|
||||
startupMs: startupMs.map((value) => Number(value.toFixed(3))),
|
||||
medianStartupMs: Number(median(startupMs).toFixed(3)),
|
||||
cpuMs: cpuMs.map((value) => Number(value.toFixed(2))),
|
||||
medianCpuMs: Number(median(cpuMs).toFixed(2)),
|
||||
eventLoopDelayP95Ms: eventLoopDelayP95Ms.map((value) => Number(value.toFixed(3))),
|
||||
medianEventLoopDelayP95Ms: Number(median(eventLoopDelayP95Ms).toFixed(3)),
|
||||
eventLoopDelayP99Ms: eventLoopDelayP99Ms.map((value) => Number(value.toFixed(3))),
|
||||
medianEventLoopDelayP99Ms: Number(median(eventLoopDelayP99Ms).toFixed(3)),
|
||||
eventLoopDelayMaxMs: eventLoopDelayMaxMs.map((value) => Number(value.toFixed(3))),
|
||||
medianEventLoopDelayMaxMs: Number(median(eventLoopDelayMaxMs).toFixed(3)),
|
||||
heartbeatCounts: results.map((result) => result.heartbeatCount),
|
||||
launchAttempts: results.map((result) => result.launchAttempts),
|
||||
blockedMainThreadVerified: results.every((result) => result.blockedMainThreadVerified)
|
||||
}
|
||||
const serialized = `${JSON.stringify(report, null, 2)}\n`
|
||||
process.stdout.write(serialized)
|
||||
if (options.output) {
|
||||
writeFileSync(path.resolve(options.output), serialized)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env[INTERNAL_ENV] === '1') {
|
||||
await runInternal()
|
||||
} else {
|
||||
runBenchmark()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { monitorEventLoopDelay } from 'node:perf_hooks'
|
||||
|
||||
export 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]
|
||||
}
|
||||
|
||||
export async function sampleMemory(readRss, readPhysicalFootprint, options) {
|
||||
const rssSamples = []
|
||||
const physicalFootprintSamples = []
|
||||
for (let index = 0; index < options.sampleCount; index += 1) {
|
||||
rssSamples.push(readRss())
|
||||
physicalFootprintSamples.push(readPhysicalFootprint())
|
||||
await options.sleep(options.sampleIntervalMs)
|
||||
}
|
||||
return {
|
||||
rssBytes: median(rssSamples),
|
||||
physicalFootprintBytes: median(physicalFootprintSamples)
|
||||
}
|
||||
}
|
||||
|
||||
export function childRssBytes(pid) {
|
||||
const raw = execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const rssKiB = Number(raw)
|
||||
if (!Number.isFinite(rssKiB) || rssKiB <= 0) {
|
||||
throw new Error(`Could not read watchdog child RSS for PID ${pid}`)
|
||||
}
|
||||
return rssKiB * 1024
|
||||
}
|
||||
|
||||
export function parsePhysicalFootprintBytes(output, processCount) {
|
||||
const match =
|
||||
processCount > 1
|
||||
? output.match(/^Summary Footprint:\s+(\d+) B$/m)
|
||||
: output.match(/^[^\s].*\sFootprint:\s+(\d+) B/m)
|
||||
const bytes = Number(match?.[1])
|
||||
return Number.isFinite(bytes) && bytes > 0 ? bytes : null
|
||||
}
|
||||
|
||||
export function physicalFootprintBytes(pids) {
|
||||
const pidArgs = pids.flatMap((pid) => ['--pid', String(pid)])
|
||||
const output = execFileSync(
|
||||
'/usr/bin/footprint',
|
||||
[...pidArgs, '--format', 'bytes', '--noCategories'],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
const bytes = parsePhysicalFootprintBytes(output, pids.length)
|
||||
if (bytes === null) {
|
||||
throw new Error(`Could not read physical footprint for PIDs ${pids.join(', ')}`)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function parseProcessCpuTimeMs(raw) {
|
||||
if (!raw.trim()) {
|
||||
return null
|
||||
}
|
||||
const parts = raw.split(':').map(Number)
|
||||
if (!parts.length || parts.some((part) => !Number.isFinite(part))) {
|
||||
return null
|
||||
}
|
||||
const seconds = parts.reduce((total, part) => total * 60 + part, 0)
|
||||
const milliseconds = seconds * 1_000
|
||||
return milliseconds >= 0 ? milliseconds : null
|
||||
}
|
||||
|
||||
function processCpuTimeMs(pid) {
|
||||
const raw = execFileSync('ps', ['-o', 'time=', '-p', String(pid)], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const milliseconds = parseProcessCpuTimeMs(raw)
|
||||
if (milliseconds === null) {
|
||||
throw new Error(`Could not read CPU time for PID ${pid}`)
|
||||
}
|
||||
return milliseconds
|
||||
}
|
||||
|
||||
function combinedCpuTimeMs(pids) {
|
||||
return pids.reduce((total, pid) => total + processCpuTimeMs(pid), 0)
|
||||
}
|
||||
|
||||
export async function sampleProductionPerformance(boundary, options) {
|
||||
const loopDelay = monitorEventLoopDelay({ resolution: 10 })
|
||||
let heartbeatCount = 0
|
||||
const heartbeat = setInterval(() => {
|
||||
heartbeatCount += 1
|
||||
boundary.sendHeartbeat()
|
||||
}, options.heartbeatIntervalMs)
|
||||
const cpuBefore = combinedCpuTimeMs(boundary.pids)
|
||||
loopDelay.enable()
|
||||
try {
|
||||
await options.sleep(options.sampleMs)
|
||||
} finally {
|
||||
loopDelay.disable()
|
||||
clearInterval(heartbeat)
|
||||
}
|
||||
return {
|
||||
cpuMs: Math.max(0, combinedCpuTimeMs(boundary.pids) - cpuBefore),
|
||||
heartbeatCount,
|
||||
eventLoopDelayP95Ms: loopDelay.percentile(95) / 1e6,
|
||||
eventLoopDelayP99Ms: loopDelay.percentile(99) / 1e6,
|
||||
eventLoopDelayMaxMs: loopDelay.max / 1e6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parsePhysicalFootprintBytes,
|
||||
parseProcessCpuTimeMs
|
||||
} from './hang-watchdog-process-metrics.mjs'
|
||||
|
||||
describe('hang watchdog process metrics', () => {
|
||||
it('uses the de-duplicated summary for multiple processes', () => {
|
||||
const output = `
|
||||
Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page)
|
||||
phys_footprint: 5100000 B
|
||||
Electron Helper [102]: 64-bit Footprint: 2000000 B (16384 bytes per page)
|
||||
phys_footprint: 2100000 B
|
||||
Summary Footprint: 6259264 B
|
||||
`
|
||||
expect(parsePhysicalFootprintBytes(output, 2)).toBe(6_259_264)
|
||||
})
|
||||
|
||||
it('uses the process footprint rather than auxiliary accounting for one process', () => {
|
||||
const output = `
|
||||
Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page)
|
||||
phys_footprint: 5100000 B
|
||||
`
|
||||
expect(parsePhysicalFootprintBytes(output, 1)).toBe(5_000_000)
|
||||
})
|
||||
|
||||
it('rejects missing or zero footprint summaries', () => {
|
||||
expect(parsePhysicalFootprintBytes('phys_footprint: 100 B', 2)).toBeNull()
|
||||
expect(parsePhysicalFootprintBytes('Summary Footprint: 0 B', 2)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['0:00.04', 40],
|
||||
['1:02.50', 62_500],
|
||||
['2:01:02.50', 7_262_500]
|
||||
])('parses ps CPU time %s', (value, expected) => {
|
||||
expect(parseProcessCpuTimeMs(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it('rejects invalid CPU times', () => {
|
||||
expect(parseProcessCpuTimeMs('')).toBeNull()
|
||||
expect(parseProcessCpuTimeMs('not-a-time')).toBeNull()
|
||||
expect(parseProcessCpuTimeMs('-1:00')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
installHappyDomMutationObserverRetention,
|
||||
retainedMutationCallbackCount
|
||||
} from './happy-dom-mutation-observer-retention'
|
||||
|
||||
function readListenerCallbacks(target: Node): unknown[] {
|
||||
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
|
||||
(candidate) => candidate.description === 'mutationListeners'
|
||||
)
|
||||
const listeners = listenersSymbol
|
||||
? (target as unknown as Record<symbol, unknown>)[listenersSymbol]
|
||||
: []
|
||||
if (!Array.isArray(listeners)) {
|
||||
return []
|
||||
}
|
||||
return listeners.map((listener: { callback?: { deref: () => unknown } }) =>
|
||||
listener.callback?.deref()
|
||||
)
|
||||
}
|
||||
|
||||
describe('happy-dom MutationObserver retention', () => {
|
||||
it('pins the internal callback that happy-dom only holds weakly', () => {
|
||||
expect(installHappyDomMutationObserverRetention()).toBe(true)
|
||||
const target = document.createElement('div')
|
||||
document.body.append(target)
|
||||
const observer = new MutationObserver(() => {})
|
||||
observer.observe(target, { childList: true, subtree: true })
|
||||
|
||||
const callbacks = readListenerCallbacks(target)
|
||||
expect(callbacks.length).toBe(1)
|
||||
expect(callbacks[0]).toBeTypeOf('function')
|
||||
expect(retainedMutationCallbackCount(observer)).toBe(1)
|
||||
|
||||
observer.disconnect()
|
||||
expect(retainedMutationCallbackCount(observer)).toBe(0)
|
||||
target.remove()
|
||||
})
|
||||
|
||||
it('keeps delivering records after the weak callback would have been collected', async () => {
|
||||
installHappyDomMutationObserverRetention()
|
||||
const target = document.createElement('div')
|
||||
document.body.append(target)
|
||||
let deliveries = 0
|
||||
const observer = new MutationObserver(() => {
|
||||
deliveries += 1
|
||||
})
|
||||
observer.observe(target, { childList: true, subtree: true })
|
||||
|
||||
target.replaceChildren(document.createElement('div'))
|
||||
await Promise.resolve()
|
||||
expect(deliveries).toBe(1)
|
||||
|
||||
const collectGarbage = (globalThis as { gc?: () => void }).gc
|
||||
if (collectGarbage) {
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
collectGarbage()
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
}
|
||||
|
||||
target.replaceChildren(document.createElement('span'))
|
||||
await Promise.resolve()
|
||||
expect(deliveries).toBe(2)
|
||||
|
||||
observer.disconnect()
|
||||
target.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// Why: happy-dom stores each MutationObserver's internal callback in a WeakRef, so any GC pause
|
||||
// under parallel test load silently and permanently stops a still-connected observer. Browsers
|
||||
// keep that callback reachable for as long as the observer observes; mirror that lifetime here so
|
||||
// DOM-driven tests never lose mutation records mid-run.
|
||||
|
||||
type HappyDomMutationListener = {
|
||||
callback?: { deref: () => unknown }
|
||||
}
|
||||
|
||||
type PatchableMutationObserver = {
|
||||
observe: (target: Node, options?: MutationObserverInit) => void
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
const MUTATION_LISTENERS_SYMBOL_DESCRIPTION = 'mutationListeners'
|
||||
const RETENTION_INSTALLED = Symbol.for('orca.happyDomMutationObserverRetention')
|
||||
|
||||
const retainedCallbacks = new WeakMap<object, Set<unknown>>()
|
||||
|
||||
function readMutationListeners(target: Node): HappyDomMutationListener[] {
|
||||
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
|
||||
(candidate) => candidate.description === MUTATION_LISTENERS_SYMBOL_DESCRIPTION
|
||||
)
|
||||
if (!listenersSymbol) {
|
||||
return []
|
||||
}
|
||||
const listeners = (target as unknown as Record<symbol, unknown>)[listenersSymbol]
|
||||
return Array.isArray(listeners) ? (listeners as HappyDomMutationListener[]) : []
|
||||
}
|
||||
|
||||
/** Number of internal callbacks pinned for `observer`; drops to 0 once it disconnects. */
|
||||
export function retainedMutationCallbackCount(observer: MutationObserver): number {
|
||||
return retainedCallbacks.get(observer)?.size ?? 0
|
||||
}
|
||||
|
||||
export function installHappyDomMutationObserverRetention(): boolean {
|
||||
const observerClass = (globalThis as { MutationObserver?: typeof MutationObserver })
|
||||
.MutationObserver
|
||||
if (!observerClass) {
|
||||
return false
|
||||
}
|
||||
const prototype = observerClass.prototype as unknown as PatchableMutationObserver &
|
||||
Record<symbol, unknown>
|
||||
if (prototype[RETENTION_INSTALLED] === true) {
|
||||
return true
|
||||
}
|
||||
const observe = prototype.observe
|
||||
const disconnect = prototype.disconnect
|
||||
|
||||
prototype.observe = function patchedObserve(
|
||||
this: object,
|
||||
target: Node,
|
||||
options?: MutationObserverInit
|
||||
): void {
|
||||
const existing = new Set(readMutationListeners(target))
|
||||
observe.call(this as unknown as PatchableMutationObserver, target, options)
|
||||
const pinned = retainedCallbacks.get(this) ?? new Set<unknown>()
|
||||
for (const listener of readMutationListeners(target)) {
|
||||
if (existing.has(listener)) {
|
||||
continue
|
||||
}
|
||||
const callback = listener.callback?.deref()
|
||||
if (callback) {
|
||||
pinned.add(callback)
|
||||
}
|
||||
}
|
||||
if (pinned.size > 0) {
|
||||
retainedCallbacks.set(this, pinned)
|
||||
}
|
||||
}
|
||||
|
||||
prototype.disconnect = function patchedDisconnect(this: object): void {
|
||||
disconnect.call(this as unknown as PatchableMutationObserver)
|
||||
retainedCallbacks.delete(this)
|
||||
}
|
||||
|
||||
prototype[RETENTION_INSTALLED] = true
|
||||
return true
|
||||
}
|
||||
|
||||
installHappyDomMutationObserverRetention()
|
||||
@@ -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-hourly.202607281400` — UTC to the minute, so tags sort chronologically
|
||||
* by semver and every build is uniquely versioned. */
|
||||
export function createHourlyBuildVersion(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('Hourly 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 hourly semver-NEWER than the
|
||||
// RC it was cut from (1.4.160-rc.3-hourly.X > 1.4.160-rc.3), which would let an
|
||||
// ordinary RC-channel check offer untested hourly builds to RC users. Stripping
|
||||
// to the base parks hourlies below both rc.N and stable ('hourly' < 'rc'
|
||||
// alphabetically), reachable only by an explicit pinned jump.
|
||||
return `${match[1]}-hourly.${stamp}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The next build number for `baseVersion`, counting from the titles of existing
|
||||
* hourly 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
|
||||
* HOURLY_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 nextHourlyBuildNumber(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 • Jul 31, 1:54PM • e698241` — the human-facing release title,
|
||||
* shown verbatim in both the GitHub releases list and the in-app build picker.
|
||||
*/
|
||||
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
|
||||
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
|
||||
throw new Error(`Hourly 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 getHourlyBuildIdentity(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 = createHourlyBuildVersion(base, now)
|
||||
const buildNumber = nextHourlyBuildNumber(base, releaseNames ?? [])
|
||||
return {
|
||||
commit,
|
||||
version,
|
||||
buildNumber,
|
||||
name: formatHourlyReleaseName(version, buildNumber, commit, now)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const identity = getHourlyBuildIdentity(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_HOURLY_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,122 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createHourlyBuildVersion,
|
||||
formatHourlyReleaseName,
|
||||
nextHourlyBuildNumber
|
||||
} from './hourly-build-version.mjs'
|
||||
import { compareAppVersions } from '../../src/shared/app-version'
|
||||
|
||||
describe('createHourlyBuildVersion', () => {
|
||||
it('stamps the version with a zero-padded UTC timestamp', () => {
|
||||
expect(createHourlyBuildVersion('1.4.160', new Date('2026-07-28T04:05:00Z'))).toBe(
|
||||
'1.4.160-hourly.202607280405'
|
||||
)
|
||||
})
|
||||
|
||||
// Why: main's package.json carries the in-flight RC tail. Keeping it would make
|
||||
// every hourly semver-NEWER than the RC it was cut from (1.4.160-rc.3-hourly.X >
|
||||
// 1.4.160-rc.3), so an ordinary RC-channel check would offer untested hourly
|
||||
// builds to RC users. Dropping it parks hourlies below both rc.N and stable,
|
||||
// reachable only by an explicit pinned jump.
|
||||
it('drops an in-flight rc tail so hourlies never outrank the rc series', () => {
|
||||
const version = createHourlyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z'))
|
||||
expect(version).toBe('1.4.160-hourly.202607281400')
|
||||
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
|
||||
expect(compareAppVersions('1.4.160-rc.3-hourly.202607281400', '1.4.160-rc.3')).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid input', () => {
|
||||
expect(() => createHourlyBuildVersion('nope', new Date())).toThrow(/valid semver/)
|
||||
expect(() => createHourlyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatHourlyReleaseName', () => {
|
||||
const name = (iso, buildNumber = 1, commit = 'e698241abcde') =>
|
||||
formatHourlyReleaseName('1.4.163-hourly.x', buildNumber, commit, new Date(iso))
|
||||
|
||||
it('renders version, number, Pacific timestamp, and short sha', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • Jul 31, 1:54PM • 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-15T02:30:00Z')).toBe('1.4.163 • 01 • Jan 14, 6:30PM • e698241')
|
||||
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • Jul 31, 12:00AM • e698241')
|
||||
})
|
||||
|
||||
// Why pinned: 12-hour clocks are where off-by-twelve bugs live, and midnight in
|
||||
// particular renders as 0:00 or 24:00 on a formatter set up carelessly.
|
||||
it('renders both noon and midnight as 12', () => {
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain('12:00AM')
|
||||
expect(name('2026-07-31T19:00:00Z')).toContain('12:00PM')
|
||||
})
|
||||
|
||||
// Recent ICU puts U+202F before AM/PM; the title must carry no separator at all.
|
||||
it('joins the meridiem with no whitespace of any kind', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toMatch(/\d:\d{2}(AM|PM) •/)
|
||||
expect(name('2026-07-31T20:54:00Z')).not.toMatch(/\s[AP]M/u)
|
||||
})
|
||||
|
||||
it('pads to two digits and grows past them', () => {
|
||||
expect(name('2026-07-31T20:54:00Z', 9)).toContain(' • 09 • ')
|
||||
expect(name('2026-07-31T20:54:00Z', 42)).toContain(' • 42 • ')
|
||||
expect(name('2026-07-31T20:54:00Z', 1234)).toContain(' • 1234 • ')
|
||||
})
|
||||
|
||||
it('rejects a build number that is not a positive integer', () => {
|
||||
expect(() => name('2026-07-31T20:54:00Z', 0)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-31T20:54:00Z', -1)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-31T20:54:00Z', 1.5)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-31T20:54:00Z', Number.NaN)).toThrow(/positive integer/)
|
||||
})
|
||||
|
||||
it('rejects an invalid timestamp', () => {
|
||||
expect(() => formatHourlyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow(
|
||||
/invalid/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextHourlyBuildNumber', () => {
|
||||
const titles = [
|
||||
'1.4.163 • 01 • Jul 31, 1:54PM • e698241',
|
||||
'1.4.163 • 02 • Jul 31, 2:54PM • aaaaaaa',
|
||||
'1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'
|
||||
]
|
||||
|
||||
it('continues the series for the version being built', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163', titles)).toBe(38)
|
||||
})
|
||||
|
||||
// The point of the change: 1.4.164 opens its own series at 01 rather than
|
||||
// inheriting 1.4.163's count, which said nothing about 1.4.164.
|
||||
it('restarts at 1 when the base version moves', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.164', titles)).toBe(1)
|
||||
expect(
|
||||
nextHourlyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 8:00AM • ccccccc'])
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
// Why max and not count: pruning trims to HOURLY_RETAIN_COUNT, so counting
|
||||
// would roll backwards and reissue a number already used.
|
||||
it('takes the highest number, not the count', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 1:54PM • e698241'])).toBe(10)
|
||||
})
|
||||
|
||||
it('starts at 1 with no history at all', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163')).toBe(1)
|
||||
expect(nextHourlyBuildNumber('1.4.163', [])).toBe(1)
|
||||
})
|
||||
|
||||
// Legacy titles were the raw tag, and a prefix match must not treat 1.4.16 as
|
||||
// a prefix of 1.4.163's series.
|
||||
it('ignores titles that are not this version', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.16', ['1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'])).toBe(1)
|
||||
expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
export class BoundedLiveFreezeHistory {
|
||||
#entries = []
|
||||
#limit
|
||||
#nextIndex = 0
|
||||
#totalCount = 0
|
||||
|
||||
constructor(limit) {
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new Error(`History limit must be a positive integer, got ${limit}`)
|
||||
}
|
||||
this.#limit = limit
|
||||
}
|
||||
|
||||
add(entry) {
|
||||
this.#totalCount += 1
|
||||
if (this.#entries.length < this.#limit) {
|
||||
this.#entries.push(entry)
|
||||
return
|
||||
}
|
||||
this.#entries[this.#nextIndex] = entry
|
||||
this.#nextIndex = (this.#nextIndex + 1) % this.#limit
|
||||
}
|
||||
|
||||
get retainedCount() {
|
||||
return this.#entries.length
|
||||
}
|
||||
|
||||
get totalCount() {
|
||||
return this.#totalCount
|
||||
}
|
||||
|
||||
values() {
|
||||
if (this.#entries.length < this.#limit || this.#nextIndex === 0) {
|
||||
return [...this.#entries]
|
||||
}
|
||||
return [...this.#entries.slice(this.#nextIndex), ...this.#entries.slice(0, this.#nextIndex)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
|
||||
describe('BoundedLiveFreezeHistory', () => {
|
||||
it('retains the newest entries in insertion order and counts the full run', () => {
|
||||
const history = new BoundedLiveFreezeHistory(3)
|
||||
|
||||
for (let value = 1; value <= 7; value += 1) {
|
||||
history.add(value)
|
||||
}
|
||||
|
||||
expect(history.values()).toEqual([5, 6, 7])
|
||||
expect(history.retainedCount).toBe(3)
|
||||
expect(history.totalCount).toBe(7)
|
||||
})
|
||||
|
||||
it('rejects invalid retention limits', () => {
|
||||
expect(() => new BoundedLiveFreezeHistory(0)).toThrow('positive integer')
|
||||
expect(() => new BoundedLiveFreezeHistory(1.5)).toThrow('positive integer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Pure metrics helpers for the live remote bulk-open freeze harness.
|
||||
* Kept separate so unit tests can drive the same code the repro uses.
|
||||
*/
|
||||
|
||||
export const DEFAULT_SOFT_MS = 2000
|
||||
export const DEFAULT_HARD_MS = 5000
|
||||
|
||||
export function readFreezeNumberEnv(name, fallback) {
|
||||
const raw = process.env[name]
|
||||
if (raw == null || raw.trim() === '') {
|
||||
return fallback
|
||||
}
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error(`Invalid ${name}: expected a finite number, got ${JSON.stringify(raw)}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function extractTerminalHandle(result) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null
|
||||
}
|
||||
const candidates = [
|
||||
result.handle,
|
||||
result.terminalHandle,
|
||||
result.agentTerminalHandle,
|
||||
typeof result.terminal === 'string' ? result.terminal : result.terminal?.handle,
|
||||
result.startupTerminal?.handle,
|
||||
result.tab?.terminal,
|
||||
result.tab?.handle
|
||||
]
|
||||
for (const value of candidates) {
|
||||
if (typeof value === 'string' && value.startsWith('term_')) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for (const value of Object.values(result)) {
|
||||
if (typeof value === 'string' && value.startsWith('term_')) {
|
||||
return value
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const nested of Object.values(value)) {
|
||||
if (typeof nested === 'string' && nested.startsWith('term_')) {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function worktreeSelector(wt) {
|
||||
if (typeof wt?.id === 'string' && wt.id.length > 0) {
|
||||
return `id:${wt.id}`
|
||||
}
|
||||
if (typeof wt?.path === 'string' && wt.path.length > 0) {
|
||||
return `path:${wt.path}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak stall across individual switch latency and concurrent batch wall.
|
||||
* Hard freeze when peak >= hardMs (default 5000).
|
||||
*/
|
||||
export function evaluateFreezeSignals({
|
||||
maxSwitchMs = 0,
|
||||
maxBatchWallMs = 0,
|
||||
statusProbeMs = 0,
|
||||
memoryProbeMs = null,
|
||||
softMs = DEFAULT_SOFT_MS,
|
||||
hardMs = DEFAULT_HARD_MS
|
||||
}) {
|
||||
const peakLatencyMs = Math.max(maxSwitchMs, maxBatchWallMs)
|
||||
const softFreeze =
|
||||
peakLatencyMs >= softMs ||
|
||||
statusProbeMs >= softMs ||
|
||||
(memoryProbeMs != null && memoryProbeMs >= softMs)
|
||||
const hardFreeze =
|
||||
peakLatencyMs >= hardMs ||
|
||||
statusProbeMs >= hardMs ||
|
||||
(memoryProbeMs != null && memoryProbeMs >= hardMs)
|
||||
return { peakLatencyMs, softFreeze, hardFreeze }
|
||||
}
|
||||
|
||||
export function shouldCapSwitchTargets(maxSwitchTargets) {
|
||||
return Number.isFinite(maxSwitchTargets) && maxSwitchTargets > 0
|
||||
}
|
||||
|
||||
export function applySwitchTargetCap(targets, maxSwitchTargets) {
|
||||
if (!shouldCapSwitchTargets(maxSwitchTargets)) {
|
||||
return targets
|
||||
}
|
||||
return targets.slice(0, maxSwitchTargets)
|
||||
}
|
||||
|
||||
/** Scenarios that model real user recovery, not concurrent CLI pileup. */
|
||||
export const REALISTIC_SCENARIOS = [
|
||||
'idle-backlog-open',
|
||||
'idle-backlog-reconnect-open',
|
||||
'restart-proxy',
|
||||
/** Idle + flood + reconnect storm overlapped with concurrent open fan-out. */
|
||||
'lockup-storm'
|
||||
]
|
||||
|
||||
/**
|
||||
* Permanent lockup: app/host stops making progress — not a single recovered timeout.
|
||||
* Distinct from multi-second hard stall that still recovers (status answers, most opens ok).
|
||||
*/
|
||||
export function evaluatePermanentLockup({
|
||||
timedOutOps = 0,
|
||||
statusHangMs = 0,
|
||||
consecutiveSwitchFailures = 0,
|
||||
openFailed = 0,
|
||||
openTotal = 0,
|
||||
permanentTimeoutMs = 60_000,
|
||||
/** Fraction of opens that must fail to count as lockup without status hang. */
|
||||
failRateThreshold = 0.25,
|
||||
minTimedOutOps = 3
|
||||
}) {
|
||||
const failRate = openTotal > 0 ? openFailed / openTotal : 0
|
||||
const permanentLockup =
|
||||
statusHangMs >= permanentTimeoutMs ||
|
||||
timedOutOps >= minTimedOutOps ||
|
||||
consecutiveSwitchFailures >= 5 ||
|
||||
(openTotal >= 8 && failRate >= failRateThreshold)
|
||||
return {
|
||||
permanentLockup,
|
||||
timedOutOps,
|
||||
statusHangMs,
|
||||
consecutiveSwitchFailures,
|
||||
failRate,
|
||||
recoveredHardStallCandidate:
|
||||
!permanentLockup && timedOutOps < minTimedOutOps && statusHangMs < permanentTimeoutMs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak across open latencies + optional reconnect-refresh wall + probes.
|
||||
* Used by the naturalistic harness (no parallel switch amp).
|
||||
*/
|
||||
export function evaluateRealisticFreezeSignals({
|
||||
maxOpenMs = 0,
|
||||
firstOpenMs = 0,
|
||||
reconnectRefreshMs = 0,
|
||||
statusProbeMs = 0,
|
||||
memoryProbeMs = null,
|
||||
softMs = DEFAULT_SOFT_MS,
|
||||
hardMs = DEFAULT_HARD_MS
|
||||
}) {
|
||||
const peakLatencyMs = Math.max(maxOpenMs, firstOpenMs, reconnectRefreshMs)
|
||||
return evaluateFreezeSignals({
|
||||
maxSwitchMs: peakLatencyMs,
|
||||
maxBatchWallMs: 0,
|
||||
statusProbeMs,
|
||||
memoryProbeMs,
|
||||
softMs,
|
||||
hardMs
|
||||
})
|
||||
}
|
||||
|
||||
export function humanPaceDelayMs(baseMs, jitterMs = 0) {
|
||||
const base = Math.max(0, baseMs)
|
||||
const jitter = Math.max(0, jitterMs)
|
||||
if (jitter === 0) {
|
||||
return base
|
||||
}
|
||||
return base + Math.floor(Math.random() * (jitter + 1))
|
||||
}
|
||||
|
||||
/** Full-app forever freeze: host RPC dead for a continuous window, not a recovered stall. */
|
||||
export const DEFAULT_FOREVER_WINDOW_MS = 30_000
|
||||
export const DEFAULT_STATUS_SLOW_MS = 15_000
|
||||
|
||||
/**
|
||||
* Analyze mid-storm status samples for a continuous unhealthy window.
|
||||
* Sample: { tMs, ms, ok, hang }
|
||||
*/
|
||||
export function evaluateFullAppFreeze({
|
||||
statusSamples = [],
|
||||
statusSummary = {},
|
||||
foreverWindowMs = DEFAULT_FOREVER_WINDOW_MS,
|
||||
statusSlowMs = DEFAULT_STATUS_SLOW_MS,
|
||||
killOnlyRecovery = false
|
||||
}) {
|
||||
const infrastructureErrors = statusSamples.filter((sample) => sample.infrastructureError)
|
||||
const infrastructureErrorCount = Math.max(
|
||||
infrastructureErrors.length,
|
||||
statusSummary.infrastructureErrorCount ?? 0
|
||||
)
|
||||
const maxStatusMs = Math.max(
|
||||
0,
|
||||
...statusSamples.map((s) => s.ms || 0),
|
||||
statusSummary.maxStatusMs ?? 0
|
||||
)
|
||||
if (killOnlyRecovery) {
|
||||
return {
|
||||
foreverUiLockupObserved: true,
|
||||
longestUnhealthyWindowMs: foreverWindowMs,
|
||||
maxStatusMs,
|
||||
unhealthySampleCount: statusSummary.sampleCount ?? statusSamples.length,
|
||||
infrastructureErrorCount,
|
||||
reason: 'kill-only recovery documented'
|
||||
}
|
||||
}
|
||||
|
||||
const unhealthy = statusSamples.map((s) => {
|
||||
const hang = !s.infrastructureError && (Boolean(s.hang) || s.ok === false)
|
||||
const slow = !s.infrastructureError && (s.ms || 0) >= statusSlowMs
|
||||
return { ...s, unhealthy: hang || slow }
|
||||
})
|
||||
|
||||
let longest = statusSummary.longestUnhealthyWindowMs ?? 0
|
||||
let runStart = null
|
||||
for (const s of unhealthy) {
|
||||
if (s.unhealthy) {
|
||||
runStart ??= s.tMs ?? 0
|
||||
const end = (s.tMs ?? 0) + (s.ms || 0)
|
||||
longest = Math.max(longest, end - runStart)
|
||||
} else {
|
||||
runStart = null
|
||||
}
|
||||
}
|
||||
|
||||
// If timestamps missing, fall back to consecutive unhealthy count * assumed interval.
|
||||
if (longest === 0 && unhealthy.some((s) => s.unhealthy)) {
|
||||
let run = 0
|
||||
for (const s of unhealthy) {
|
||||
if (s.unhealthy) {
|
||||
run += 1
|
||||
longest = Math.max(longest, run)
|
||||
} else {
|
||||
run = 0
|
||||
}
|
||||
}
|
||||
// Without wall clock, consecutive count alone is not a ms window.
|
||||
longest = 0
|
||||
}
|
||||
|
||||
const foreverUiLockupObserved = longest >= foreverWindowMs
|
||||
const unhealthySampleCount = Math.max(
|
||||
unhealthy.filter((s) => s.unhealthy).length,
|
||||
statusSummary.unhealthySampleCount ?? 0
|
||||
)
|
||||
|
||||
return {
|
||||
foreverUiLockupObserved,
|
||||
longestUnhealthyWindowMs: longest,
|
||||
maxStatusMs,
|
||||
unhealthySampleCount,
|
||||
infrastructureErrorCount,
|
||||
reason: foreverUiLockupObserved
|
||||
? `status unhealthy ≥${foreverWindowMs}ms continuous`
|
||||
: infrastructureErrorCount > 0
|
||||
? `status watchdog infrastructure errors: ${infrastructureErrorCount}`
|
||||
: maxStatusMs >= statusSlowMs
|
||||
? `status slow peak ${maxStatusMs}ms but no ≥${foreverWindowMs}ms window`
|
||||
: 'status remained healthy through storm'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applySwitchTargetCap,
|
||||
evaluateFreezeSignals,
|
||||
evaluateFullAppFreeze,
|
||||
evaluatePermanentLockup,
|
||||
evaluateRealisticFreezeSignals,
|
||||
extractTerminalHandle,
|
||||
humanPaceDelayMs,
|
||||
readFreezeNumberEnv,
|
||||
REALISTIC_SCENARIOS,
|
||||
shouldCapSwitchTargets,
|
||||
worktreeSelector
|
||||
} from './live-remote-bulk-open-freeze-metrics.mjs'
|
||||
|
||||
describe('live-remote-bulk-open-freeze-metrics', () => {
|
||||
it('extracts term_ handles from nested create payloads', () => {
|
||||
expect(extractTerminalHandle({ handle: 'term_abc' })).toBe('term_abc')
|
||||
expect(extractTerminalHandle({ terminal: { handle: 'term_nested' } })).toBe('term_nested')
|
||||
expect(extractTerminalHandle({ tab: { terminal: 'term_tab' } })).toBe('term_tab')
|
||||
expect(extractTerminalHandle({ startupTerminal: { handle: 'term_start' } })).toBe('term_start')
|
||||
expect(extractTerminalHandle({ junk: { deep: 'term_deep' } })).toBe('term_deep')
|
||||
expect(extractTerminalHandle({ handle: 'not-a-term' })).toBeNull()
|
||||
expect(extractTerminalHandle(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('builds worktree selectors from id/path', () => {
|
||||
expect(
|
||||
worktreeSelector({ id: 'repo::C:/Users/neil/orca/orca', path: 'C:/Users/neil/orca/orca' })
|
||||
).toBe('id:repo::C:/Users/neil/orca/orca')
|
||||
expect(worktreeSelector({ path: '/tmp/x' })).toBe('path:/tmp/x')
|
||||
expect(worktreeSelector({})).toBeNull()
|
||||
})
|
||||
|
||||
it('does not cap switch targets when max is 0 (regression for Math.max(2,0) bug)', () => {
|
||||
expect(shouldCapSwitchTargets(0)).toBe(false)
|
||||
expect(shouldCapSwitchTargets(-1)).toBe(false)
|
||||
expect(shouldCapSwitchTargets(2)).toBe(true)
|
||||
const many = Array.from({ length: 111 }, (_, i) => `term_${i}`)
|
||||
expect(applySwitchTargetCap(many, 0)).toHaveLength(111)
|
||||
expect(applySwitchTargetCap(many, 2)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('classifies hard freeze at >=5000ms peak (individual or batch wall)', () => {
|
||||
expect(evaluateFreezeSignals({ maxSwitchMs: 3874, maxBatchWallMs: 3874 }).hardFreeze).toBe(
|
||||
false
|
||||
)
|
||||
expect(evaluateFreezeSignals({ maxSwitchMs: 3874, maxBatchWallMs: 3874 }).softFreeze).toBe(true)
|
||||
|
||||
const hardIndividual = evaluateFreezeSignals({ maxSwitchMs: 19954, maxBatchWallMs: 1000 })
|
||||
expect(hardIndividual.hardFreeze).toBe(true)
|
||||
expect(hardIndividual.peakLatencyMs).toBe(19954)
|
||||
|
||||
const hardBatch = evaluateFreezeSignals({ maxSwitchMs: 900, maxBatchWallMs: 20201 })
|
||||
expect(hardBatch.hardFreeze).toBe(true)
|
||||
expect(hardBatch.peakLatencyMs).toBe(20201)
|
||||
})
|
||||
|
||||
it('evaluates naturalistic peaks without requiring parallel batch amp', () => {
|
||||
expect(REALISTIC_SCENARIOS).toContain('idle-backlog-open')
|
||||
expect(REALISTIC_SCENARIOS).toContain('idle-backlog-reconnect-open')
|
||||
expect(REALISTIC_SCENARIOS).toContain('lockup-storm')
|
||||
const soft = evaluateRealisticFreezeSignals({
|
||||
maxOpenMs: 3200,
|
||||
firstOpenMs: 2800,
|
||||
reconnectRefreshMs: 900
|
||||
})
|
||||
expect(soft.softFreeze).toBe(true)
|
||||
expect(soft.hardFreeze).toBe(false)
|
||||
expect(soft.peakLatencyMs).toBe(3200)
|
||||
|
||||
const hardFromReconnect = evaluateRealisticFreezeSignals({
|
||||
maxOpenMs: 800,
|
||||
firstOpenMs: 700,
|
||||
reconnectRefreshMs: 6200
|
||||
})
|
||||
expect(hardFromReconnect.hardFreeze).toBe(true)
|
||||
expect(hardFromReconnect.peakLatencyMs).toBe(6200)
|
||||
})
|
||||
|
||||
it('flags full-app freeze only for continuous unhealthy status window ≥30s', () => {
|
||||
const healthy = evaluateFullAppFreeze({
|
||||
statusSamples: [
|
||||
{ tMs: 0, ms: 150, ok: true },
|
||||
{ tMs: 2000, ms: 180, ok: true },
|
||||
{ tMs: 4000, ms: 140, ok: true }
|
||||
],
|
||||
foreverWindowMs: 30_000,
|
||||
statusSlowMs: 15_000
|
||||
})
|
||||
expect(healthy.foreverUiLockupObserved).toBe(false)
|
||||
|
||||
const forever = evaluateFullAppFreeze({
|
||||
statusSamples: [
|
||||
{ tMs: 0, ms: 16_000, ok: true },
|
||||
{ tMs: 16_000, ms: 16_000, ok: true },
|
||||
{ tMs: 32_000, ms: 16_000, ok: false, hang: true }
|
||||
],
|
||||
foreverWindowMs: 30_000,
|
||||
statusSlowMs: 15_000
|
||||
})
|
||||
expect(forever.foreverUiLockupObserved).toBe(true)
|
||||
expect(forever.longestUnhealthyWindowMs).toBeGreaterThanOrEqual(30_000)
|
||||
|
||||
expect(
|
||||
evaluateFullAppFreeze({ statusSamples: [], killOnlyRecovery: true }).foreverUiLockupObserved
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not classify watchdog infrastructure errors as an app freeze', () => {
|
||||
const result = evaluateFullAppFreeze({
|
||||
statusSamples: Array.from({ length: 25 }, (_, index) => ({
|
||||
tMs: index * 1500,
|
||||
ms: 1,
|
||||
ok: false,
|
||||
infrastructureError: true,
|
||||
error: 'spawn ENOENT'
|
||||
}))
|
||||
})
|
||||
|
||||
expect(result.foreverUiLockupObserved).toBe(false)
|
||||
expect(result.unhealthySampleCount).toBe(0)
|
||||
expect(result.infrastructureErrorCount).toBe(25)
|
||||
})
|
||||
|
||||
it('preserves full-run watchdog peaks after sample retention rotates', () => {
|
||||
const result = evaluateFullAppFreeze({
|
||||
statusSamples: [{ tMs: 60_000, ms: 100, ok: true }],
|
||||
statusSummary: {
|
||||
sampleCount: 40,
|
||||
maxStatusMs: 16_000,
|
||||
unhealthySampleCount: 3,
|
||||
infrastructureErrorCount: 2,
|
||||
longestUnhealthyWindowMs: 32_000
|
||||
},
|
||||
foreverWindowMs: 30_000,
|
||||
statusSlowMs: 15_000
|
||||
})
|
||||
|
||||
expect(result.foreverUiLockupObserved).toBe(true)
|
||||
expect(result.longestUnhealthyWindowMs).toBe(32_000)
|
||||
expect(result.maxStatusMs).toBe(16_000)
|
||||
expect(result.unhealthySampleCount).toBe(3)
|
||||
expect(result.infrastructureErrorCount).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects invalid numeric environment values', () => {
|
||||
process.env.ORCA_FREEZE_TEST_NUMBER = 'not-a-number'
|
||||
expect(() => readFreezeNumberEnv('ORCA_FREEZE_TEST_NUMBER', 5)).toThrow(
|
||||
'Invalid ORCA_FREEZE_TEST_NUMBER'
|
||||
)
|
||||
delete process.env.ORCA_FREEZE_TEST_NUMBER
|
||||
expect(readFreezeNumberEnv('ORCA_FREEZE_TEST_NUMBER', 5)).toBe(5)
|
||||
})
|
||||
|
||||
it('distinguishes recovered hard stall from permanent lockup', () => {
|
||||
// Single reveal timeout with healthy status is NOT permanent app lockup.
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 1,
|
||||
statusHangMs: 0,
|
||||
consecutiveSwitchFailures: 1,
|
||||
openFailed: 1,
|
||||
openTotal: 64
|
||||
}).permanentLockup
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 3,
|
||||
statusHangMs: 0,
|
||||
consecutiveSwitchFailures: 0,
|
||||
openFailed: 3,
|
||||
openTotal: 64
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 0,
|
||||
statusHangMs: 60_000,
|
||||
consecutiveSwitchFailures: 0,
|
||||
permanentTimeoutMs: 60_000
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 0,
|
||||
statusHangMs: 0,
|
||||
consecutiveSwitchFailures: 5
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
expect(
|
||||
evaluatePermanentLockup({
|
||||
timedOutOps: 0,
|
||||
openFailed: 20,
|
||||
openTotal: 40
|
||||
}).permanentLockup
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('human pace delay stays within base+jitter', () => {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const d = humanPaceDelayMs(250, 150)
|
||||
expect(d).toBeGreaterThanOrEqual(250)
|
||||
expect(d).toBeLessThanOrEqual(400)
|
||||
}
|
||||
expect(humanPaceDelayMs(100, 0)).toBe(100)
|
||||
})
|
||||
|
||||
it('reads the real hard-freeze lab report when present', async () => {
|
||||
const { readdirSync, readFileSync, existsSync } = await import('node:fs')
|
||||
const { resolve } = await import('node:path')
|
||||
const reportDir = resolve(process.cwd(), 'test-results/freeze-repro')
|
||||
if (!existsSync(reportDir)) {
|
||||
// Local clones without lab artifacts still pass pure metrics tests above.
|
||||
return
|
||||
}
|
||||
const reportName = readdirSync(reportDir).find(
|
||||
(name) => name.startsWith('live-bulk-open-freeze-') && name.endsWith('.json')
|
||||
)
|
||||
if (reportName == null) {
|
||||
return
|
||||
}
|
||||
const report = JSON.parse(readFileSync(resolve(reportDir, reportName), 'utf8'))
|
||||
const evaluated = evaluateFreezeSignals({
|
||||
maxSwitchMs: report.maxSwitchMs,
|
||||
maxBatchWallMs: report.maxBatchWallMs ?? 0,
|
||||
statusProbeMs: report.statusProbeMs ?? 0,
|
||||
memoryProbeMs: report.memoryProbeMs,
|
||||
softMs: report.softMs,
|
||||
hardMs: report.hardMs
|
||||
})
|
||||
expect(evaluated.hardFreeze).toBe(report.hardFreeze)
|
||||
expect(evaluated.peakLatencyMs).toBeGreaterThanOrEqual(5000)
|
||||
expect(typeof report.environment).toBe('string')
|
||||
expect(report.environment.length).toBeGreaterThan(0)
|
||||
expect(report.switchTargets).toBeGreaterThan(50)
|
||||
expect(report.parallel).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Live freeze repro against a running Orca desktop + paired remote runtime.
|
||||
*
|
||||
* Models bulk-open of remote sessions under multi-worktree load.
|
||||
*
|
||||
* Usage:
|
||||
* node config/scripts/live-remote-bulk-open-freeze-repro.mjs
|
||||
* ORCA_FREEZE_ENV=paired-remote ORCA_FREEZE_CREATE=12 ORCA_FREEZE_SWITCH_PASSES=5 \
|
||||
* ORCA_FREEZE_PARALLEL=8 node config/scripts/live-remote-bulk-open-freeze-repro.mjs
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync, copyFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
import {
|
||||
applySwitchTargetCap,
|
||||
DEFAULT_HARD_MS,
|
||||
DEFAULT_SOFT_MS,
|
||||
evaluateFreezeSignals,
|
||||
extractTerminalHandle,
|
||||
readFreezeNumberEnv,
|
||||
shouldCapSwitchTargets,
|
||||
worktreeSelector
|
||||
} from './live-remote-bulk-open-freeze-metrics.mjs'
|
||||
import { createOrcaRpc } from './live-remote-freeze-rpc.mjs'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..')
|
||||
const reportDir = path.join(root, 'test-results', 'freeze-repro')
|
||||
const envName = process.env.ORCA_FREEZE_ENV || 'paired-remote'
|
||||
const createCount = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_CREATE', 0))
|
||||
const switchPasses = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_SWITCH_PASSES', 3))
|
||||
const parallel = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_PARALLEL', 1))
|
||||
// 0 = no cap (use all live terminals). Only positive env values limit targets.
|
||||
const maxSwitchTargets = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_MAX_SWITCH_TARGETS', 0))
|
||||
const softMs = readFreezeNumberEnv('ORCA_FREEZE_SOFT_MS', DEFAULT_SOFT_MS)
|
||||
const hardMs = readFreezeNumberEnv('ORCA_FREEZE_HARD_MS', DEFAULT_HARD_MS)
|
||||
const createWorktreeSpan = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_CREATE_WT_SPAN', 16))
|
||||
const preFloodMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PRE_FLOOD_MS', 3000))
|
||||
const scratchDir = process.env.ORCA_FREEZE_SCRATCH || ''
|
||||
|
||||
const { orcaJsonSync, orcaJsonAsync } = createOrcaRpc({ envName })
|
||||
|
||||
async function mapPool(items, concurrency, worker) {
|
||||
const results = Array.from({ length: items.length })
|
||||
let next = 0
|
||||
async function run() {
|
||||
while (next < items.length) {
|
||||
const index = next
|
||||
next += 1
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
const runners = Array.from({ length: Math.min(concurrency, items.length) }, () => run())
|
||||
await Promise.all(runners)
|
||||
return results
|
||||
}
|
||||
|
||||
function sampleOrcaIfPossible() {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const status = orcaJsonSync(['status'], { local: true }).result
|
||||
const pid = status?.app?.pid
|
||||
if (!pid) {
|
||||
return null
|
||||
}
|
||||
const out = path.join(reportDir, `orca-sample-${Date.now()}.txt`)
|
||||
const sampled = spawnSync('sample', [String(pid), '5', '-file', out], {
|
||||
timeout: 20_000,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
return sampled.status === 0 ? out : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function floodCommand(marker) {
|
||||
// Continuous 2KB frames @ ~8ms — agent-like remote output.
|
||||
const script =
|
||||
"const m=process.argv[1];process.stdout.write('READY:'+m+'\\n');let f=0;const c='A'.repeat(2048);setInterval(()=>{f++;process.stdout.write('BG:'+m+':'+f+':'+c+'\\n')},8);process.stdin.resume()"
|
||||
return `node -e ${JSON.stringify(script)} ${JSON.stringify(marker)}`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(reportDir, { recursive: true })
|
||||
const notes = []
|
||||
const timings = new BoundedLiveFreezeHistory(120)
|
||||
const amplificationSteps = []
|
||||
|
||||
console.log(
|
||||
`[live-freeze] env=${envName} create=${createCount} passes=${switchPasses} parallel=${parallel}`
|
||||
)
|
||||
|
||||
const status = orcaJsonSync(['status'])
|
||||
notes.push(
|
||||
`remote version=${status.result?.runtime?.appVersion} state=${status.result?.runtime?.state}`
|
||||
)
|
||||
const local = orcaJsonSync(['status'], { local: true })
|
||||
notes.push(`local version=${local.result?.runtime?.appVersion} pid=${local.result?.app?.pid}`)
|
||||
|
||||
const worktrees = orcaJsonSync(['worktree', 'list']).result
|
||||
const wtList = worktrees?.worktrees || worktrees?.items || worktrees || []
|
||||
if (!Array.isArray(wtList) || wtList.length === 0) {
|
||||
throw new Error(`No worktrees on environment ${envName}`)
|
||||
}
|
||||
notes.push(`remote worktrees=${wtList.length}`)
|
||||
amplificationSteps.push(`baseline worktrees=${wtList.length}`)
|
||||
|
||||
const targets = wtList.slice(0, Math.min(createWorktreeSpan, wtList.length))
|
||||
const created = []
|
||||
|
||||
// Parallel flood-terminal creates across many worktrees.
|
||||
if (createCount > 0) {
|
||||
amplificationSteps.push(`create=${createCount} parallel=${Math.min(parallel, createCount)}`)
|
||||
const createJobs = Array.from({ length: createCount }, (_, i) => i)
|
||||
await mapPool(createJobs, Math.min(parallel, createCount), async (i) => {
|
||||
const wt = targets[i % targets.length]
|
||||
const selector = worktreeSelector(wt)
|
||||
if (!selector) {
|
||||
notes.push(`create ${i} skipped: no selector`)
|
||||
return
|
||||
}
|
||||
const marker = `LIVE_BULK_${Date.now()}_${i}`
|
||||
try {
|
||||
const createdTerm = await orcaJsonAsync(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
selector,
|
||||
'--title',
|
||||
`freeze-repro-${i}`,
|
||||
'--command',
|
||||
floodCommand(marker)
|
||||
],
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
timings.add({ op: 'terminal.create', ms: createdTerm.elapsedMs, ok: true, index: i })
|
||||
const handle = extractTerminalHandle(createdTerm.result)
|
||||
if (handle) {
|
||||
created.push({ handle, marker, worktree: selector })
|
||||
console.log(
|
||||
`[live-freeze] created ${handle} on ${selector} in ${createdTerm.elapsedMs.toFixed(0)}ms`
|
||||
)
|
||||
} else {
|
||||
notes.push(
|
||||
`create ${i} missing handle: ${JSON.stringify(createdTerm.result).slice(0, 400)}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
timings.add({ op: 'terminal.create', ms: null, ok: false, error: String(error), index: i })
|
||||
notes.push(`create ${i} failed: ${String(error).slice(0, 300)}`)
|
||||
console.warn(`[live-freeze] create failed: ${String(error)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (preFloodMs > 0 && created.length > 0) {
|
||||
amplificationSteps.push(`preFloodMs=${preFloodMs}`)
|
||||
await new Promise((r) => setTimeout(r, preFloodMs))
|
||||
}
|
||||
|
||||
let live = []
|
||||
try {
|
||||
const listed = orcaJsonSync(['terminal', 'list'])
|
||||
const terms = listed.result?.terminals || []
|
||||
live = terms
|
||||
.filter(
|
||||
(t) => typeof t.handle === 'string' && t.handle.startsWith('term_') && t.connected !== false
|
||||
)
|
||||
.map((t) => ({ handle: t.handle, title: t.title, worktreeId: t.worktreeId }))
|
||||
notes.push(`live terminals listed=${live.length}`)
|
||||
} catch (error) {
|
||||
notes.push(`terminal list failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
let switchTargets = [...created.map((c) => c.handle), ...live.map((t) => t.handle)].filter(
|
||||
(v, i, a) => typeof v === 'string' && a.indexOf(v) === i
|
||||
)
|
||||
|
||||
if (shouldCapSwitchTargets(maxSwitchTargets) && switchTargets.length > maxSwitchTargets) {
|
||||
switchTargets = applySwitchTargetCap(switchTargets, maxSwitchTargets)
|
||||
amplificationSteps.push(`capped switchTargets=${maxSwitchTargets}`)
|
||||
}
|
||||
|
||||
if (switchTargets.length < 2) {
|
||||
throw new Error(
|
||||
`Need ≥2 terminals to bulk-switch; got ${switchTargets.length}. notes=${notes.join('; ')}`
|
||||
)
|
||||
}
|
||||
|
||||
amplificationSteps.push(
|
||||
`switchTargets=${switchTargets.length} passes=${switchPasses} parallel=${parallel}`
|
||||
)
|
||||
console.log(
|
||||
`[live-freeze] bulk-switching ${switchTargets.length} terminals × ${switchPasses} passes (parallel=${parallel})`
|
||||
)
|
||||
|
||||
let maxSwitchMs = 0
|
||||
let maxBatchWallMs = 0
|
||||
let sumSwitchMs = 0
|
||||
let switchCount = 0
|
||||
const switchStarted = performance.now()
|
||||
|
||||
for (let pass = 0; pass < switchPasses; pass += 1) {
|
||||
// Chunk targets into concurrent batches — piles load onto client/UI path.
|
||||
for (let offset = 0; offset < switchTargets.length; offset += parallel) {
|
||||
const batch = switchTargets.slice(offset, offset + parallel)
|
||||
const batchStarted = performance.now()
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (handle) => {
|
||||
try {
|
||||
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
|
||||
timeoutMs: 90_000
|
||||
})
|
||||
return { handle, ms: sw.elapsedMs, ok: true }
|
||||
} catch (error) {
|
||||
return { handle, error: String(error), ok: false }
|
||||
}
|
||||
})
|
||||
)
|
||||
const batchWall = performance.now() - batchStarted
|
||||
maxBatchWallMs = Math.max(maxBatchWallMs, batchWall)
|
||||
for (const item of batchResults) {
|
||||
if (item.ok) {
|
||||
maxSwitchMs = Math.max(maxSwitchMs, item.ms)
|
||||
sumSwitchMs += item.ms
|
||||
switchCount += 1
|
||||
timings.add({ op: 'terminal.switch', handle: item.handle, ms: item.ms, batchWall })
|
||||
if (item.ms >= softMs) {
|
||||
console.warn(`[live-freeze] SOFT lag on switch ${item.handle}: ${item.ms.toFixed(0)}ms`)
|
||||
}
|
||||
if (item.ms >= hardMs) {
|
||||
console.warn(`[live-freeze] HARD lag on switch ${item.handle}: ${item.ms.toFixed(0)}ms`)
|
||||
}
|
||||
} else {
|
||||
timings.add({ op: 'terminal.switch', handle: item.handle, error: item.error })
|
||||
notes.push(`switch ${item.handle} failed: ${String(item.error).slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
if (batchWall >= softMs) {
|
||||
console.warn(`[live-freeze] SOFT batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`)
|
||||
}
|
||||
if (batchWall >= hardMs) {
|
||||
console.warn(`[live-freeze] HARD batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bulkWallMs = performance.now() - switchStarted
|
||||
const avgSwitchMs = switchCount ? sumSwitchMs / switchCount : 0
|
||||
|
||||
const statusProbe = orcaJsonSync(['status'], { local: true })
|
||||
let memoryProbeMs = null
|
||||
try {
|
||||
const mem = orcaJsonSync(['diagnostics', 'memory'], { local: true, timeoutMs: 120_000 })
|
||||
memoryProbeMs = mem.elapsedMs
|
||||
notes.push(`memory diagnostic ms=${mem.elapsedMs.toFixed(0)}`)
|
||||
} catch (error) {
|
||||
notes.push(`memory diagnostic failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const { peakLatencyMs, softFreeze, hardFreeze } = evaluateFreezeSignals({
|
||||
maxSwitchMs,
|
||||
maxBatchWallMs,
|
||||
statusProbeMs: statusProbe.elapsedMs,
|
||||
memoryProbeMs,
|
||||
softMs,
|
||||
hardMs
|
||||
})
|
||||
|
||||
let samplePath = null
|
||||
if (softFreeze || hardFreeze) {
|
||||
samplePath = sampleOrcaIfPossible()
|
||||
if (samplePath) {
|
||||
notes.push(`sample=${samplePath}`)
|
||||
} else {
|
||||
notes.push('sample unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
topology: 'live-paired-remote',
|
||||
environment: envName,
|
||||
localVersion: local.result?.runtime?.appVersion,
|
||||
remoteVersion: status.result?.runtime?.appVersion,
|
||||
remoteWorktreeCount: wtList.length,
|
||||
createdTerminals: created.length,
|
||||
switchTargets: switchTargets.length,
|
||||
switchPasses,
|
||||
parallel,
|
||||
maxSwitchMs,
|
||||
maxBatchWallMs,
|
||||
peakLatencyMs,
|
||||
avgSwitchMs,
|
||||
bulkWallMs,
|
||||
statusProbeMs: statusProbe.elapsedMs,
|
||||
memoryProbeMs,
|
||||
softFreeze,
|
||||
hardFreeze,
|
||||
softMs,
|
||||
hardMs,
|
||||
amplificationSteps,
|
||||
notes,
|
||||
timingCount: timings.totalCount,
|
||||
timings: timings.values()
|
||||
}
|
||||
|
||||
const outPath = path.join(reportDir, `live-bulk-open-freeze-${envName}.json`)
|
||||
writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
// Also write a stamped peak report so amplification runs don't overwrite history.
|
||||
const stamped = path.join(reportDir, `live-bulk-open-freeze-${envName}-peak-${Date.now()}.json`)
|
||||
writeFileSync(stamped, `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(`[live-freeze] report ${outPath}`)
|
||||
console.log(`[live-freeze] stamped ${stamped}`)
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
|
||||
if (scratchDir) {
|
||||
try {
|
||||
mkdirSync(scratchDir, { recursive: true })
|
||||
copyFileSync(outPath, path.join(scratchDir, 'live-bulk-open-freeze-report.json'))
|
||||
writeFileSync(
|
||||
path.join(scratchDir, 'live-freeze-amplify-summary.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
peakLatencyMs,
|
||||
hardFreeze,
|
||||
softFreeze,
|
||||
amplificationSteps,
|
||||
stamped
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
} catch (error) {
|
||||
notes.push(`scratch copy failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (hardFreeze) {
|
||||
process.exitCode = 2
|
||||
console.error('[live-freeze] HARD FREEZE SIGNAL')
|
||||
} else if (softFreeze) {
|
||||
process.exitCode = 1
|
||||
console.error('[live-freeze] SOFT FREEZE SIGNAL')
|
||||
} else {
|
||||
console.log('[live-freeze] no freeze signal under thresholds')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[live-freeze] failed', error)
|
||||
process.exit(3)
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
|
||||
export const MAX_ORCA_RPC_OUTPUT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
export function appendOrcaRpcOutput(output, chunk, bytes, limit = MAX_ORCA_RPC_OUTPUT_BYTES) {
|
||||
const nextBytes = bytes + Buffer.byteLength(chunk)
|
||||
return {
|
||||
output: nextBytes > limit ? output : output + chunk,
|
||||
bytes: nextBytes,
|
||||
exceeded: nextBytes > limit
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOrcaCliCommand({ env = process.env, platform = process.platform } = {}) {
|
||||
if (env.ORCA_CLI_COMMAND?.trim()) {
|
||||
return env.ORCA_CLI_COMMAND.trim()
|
||||
}
|
||||
if (env.ORCA_DEV_REPO_ROOT) {
|
||||
return 'orca-dev'
|
||||
}
|
||||
return platform === 'linux' ? 'orca-ide' : 'orca'
|
||||
}
|
||||
|
||||
export function resolveOrcaCliInvocation({
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
nodeExecutable = process.execPath
|
||||
} = {}) {
|
||||
const command = resolveOrcaCliCommand({ env, platform })
|
||||
const commandName = platform === 'win32' ? path.win32.basename(command).toLowerCase() : command
|
||||
if (
|
||||
platform === 'win32' &&
|
||||
env.ORCA_DEV_REPO_ROOT &&
|
||||
(commandName === 'orca-dev' || commandName === 'orca-dev.cmd')
|
||||
) {
|
||||
const defaultUserDataPath = path.win32.join(
|
||||
env.APPDATA ?? path.win32.join(env.USERPROFILE ?? '', 'AppData', 'Roaming'),
|
||||
'orca-dev'
|
||||
)
|
||||
return {
|
||||
command: nodeExecutable,
|
||||
prefixArgs: [path.win32.join(env.ORCA_DEV_REPO_ROOT, 'out', 'cli', 'index.js')],
|
||||
env: {
|
||||
...env,
|
||||
ORCA_USER_DATA_PATH:
|
||||
env.ORCA_USER_DATA_PATH ?? env.ORCA_DEV_USER_DATA_PATH ?? defaultUserDataPath,
|
||||
ORCA_DEV_CLI_INVOCATION: '1',
|
||||
ORCA_APP_EXECUTABLE:
|
||||
env.ORCA_APP_EXECUTABLE ??
|
||||
path.win32.join(
|
||||
env.ORCA_DEV_REPO_ROOT,
|
||||
'node_modules',
|
||||
'electron',
|
||||
'dist',
|
||||
'electron.exe'
|
||||
),
|
||||
ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1'
|
||||
}
|
||||
}
|
||||
}
|
||||
return { command, prefixArgs: [] }
|
||||
}
|
||||
|
||||
export function createOrcaRpc({
|
||||
envName,
|
||||
cliCommand,
|
||||
env = process.env,
|
||||
platform = process.platform
|
||||
}) {
|
||||
const cliInvocation = cliCommand
|
||||
? { command: cliCommand, prefixArgs: [] }
|
||||
: resolveOrcaCliInvocation({ env, platform })
|
||||
const commandLabel = cliCommand ?? resolveOrcaCliCommand({ env, platform })
|
||||
const commandArgs = (args, local) => [
|
||||
...cliInvocation.prefixArgs,
|
||||
...args,
|
||||
...(local ? [] : ['--environment', envName]),
|
||||
'--json'
|
||||
]
|
||||
|
||||
function orcaJsonSync(args, opts = {}) {
|
||||
const started = performance.now()
|
||||
const result = spawnSync(cliInvocation.command, commandArgs(args, opts.local), {
|
||||
encoding: 'utf8',
|
||||
env: cliInvocation.env,
|
||||
maxBuffer: MAX_ORCA_RPC_OUTPUT_BYTES,
|
||||
timeout: opts.timeoutMs ?? 120_000
|
||||
})
|
||||
const elapsedMs = performance.now() - started
|
||||
if (result.error) {
|
||||
throw new Error(`${commandLabel} ${args.join(' ')} failed to start: ${String(result.error)}`)
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${commandLabel} ${args.join(' ')} failed (${result.status}): ${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout)
|
||||
if (parsed.ok === false) {
|
||||
throw new Error(`${commandLabel} ${args.join(' ')} ok=false: ${JSON.stringify(parsed)}`)
|
||||
}
|
||||
return { parsed, elapsedMs, result: parsed.result }
|
||||
}
|
||||
|
||||
function orcaJsonAsync(args, opts = {}) {
|
||||
const started = performance.now()
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cliInvocation.command, commandArgs(args, opts.local), {
|
||||
env: cliInvocation.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let outputBytes = 0
|
||||
let settled = false
|
||||
let timer
|
||||
const fail = (error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
}
|
||||
const append = (stream, chunk) => {
|
||||
if (settled) {
|
||||
return stream
|
||||
}
|
||||
const appended = appendOrcaRpcOutput(stream, chunk, outputBytes)
|
||||
outputBytes = appended.bytes
|
||||
if (appended.exceeded) {
|
||||
child.kill('SIGKILL')
|
||||
fail(new Error(`${commandLabel} ${args.join(' ')} exceeded 20 MiB output limit`))
|
||||
return stream
|
||||
}
|
||||
return appended.output
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} ${args.join(' ')} timed out after ${opts.timeoutMs ?? 120_000}ms`
|
||||
)
|
||||
)
|
||||
}, opts.timeoutMs ?? 120_000)
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout = append(stdout, chunk)
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr = append(stderr, chunk)
|
||||
})
|
||||
child.on('error', fail)
|
||||
child.on('close', (code) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
const elapsedMs = performance.now() - started
|
||||
if (code !== 0) {
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} ${args.join(' ')} failed (${code}): ${stderr || stdout}`.slice(
|
||||
0,
|
||||
800
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stdout)
|
||||
if (parsed.ok === false) {
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} ${args.join(' ')} ok=false: ${JSON.stringify(parsed)}`.slice(
|
||||
0,
|
||||
800
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve({ parsed, elapsedMs, result: parsed.result })
|
||||
} catch (error) {
|
||||
fail(
|
||||
new Error(
|
||||
`${commandLabel} parse failed: ${String(error)}; stdout=${stdout.slice(0, 400)}`
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runReconnectRefreshStorm(notes) {
|
||||
const started = performance.now()
|
||||
const jobs = [
|
||||
() => orcaJsonAsync(['status'], { timeoutMs: 90_000 }),
|
||||
() => orcaJsonAsync(['worktree', 'list'], { timeoutMs: 120_000 }),
|
||||
() => orcaJsonAsync(['terminal', 'list'], { timeoutMs: 120_000 }),
|
||||
() => orcaJsonAsync(['status'], { local: true, timeoutMs: 60_000 }),
|
||||
() => orcaJsonAsync(['worktree', 'list'], { timeoutMs: 120_000 }),
|
||||
() => orcaJsonAsync(['terminal', 'list'], { timeoutMs: 120_000 })
|
||||
]
|
||||
const results = await Promise.all(
|
||||
jobs.map(async (job, index) => {
|
||||
try {
|
||||
const result = await job()
|
||||
return { index, ok: true, ms: result.elapsedMs }
|
||||
} catch (error) {
|
||||
notes.push(`reconnect-refresh job ${index} failed: ${String(error).slice(0, 200)}`)
|
||||
return { index, ok: false, ms: null, error: String(error) }
|
||||
}
|
||||
})
|
||||
)
|
||||
const wallMs = performance.now() - started
|
||||
const maxJobMs = Math.max(0, ...results.map((result) => result.ms || 0))
|
||||
notes.push(
|
||||
`reconnect-refresh wall=${wallMs.toFixed(0)}ms maxJob=${maxJobMs.toFixed(0)}ms ok=${results.filter((result) => result.ok).length}/${results.length}`
|
||||
)
|
||||
return { wallMs, maxJobMs, results }
|
||||
}
|
||||
|
||||
async function runRestartProxy(notes) {
|
||||
const started = performance.now()
|
||||
try {
|
||||
const opened = await orcaJsonAsync(['open'], { local: true, timeoutMs: 120_000 })
|
||||
notes.push(`orca open ms=${opened.elapsedMs.toFixed(0)}`)
|
||||
} catch (error) {
|
||||
notes.push(`orca open failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
const storm = await runReconnectRefreshStorm(notes)
|
||||
return { wallMs: performance.now() - started, storm }
|
||||
}
|
||||
|
||||
return { orcaJsonSync, orcaJsonAsync, runReconnectRefreshStorm, runRestartProxy }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendOrcaRpcOutput,
|
||||
resolveOrcaCliCommand,
|
||||
resolveOrcaCliInvocation
|
||||
} from './live-remote-freeze-rpc.mjs'
|
||||
|
||||
describe('live remote freeze RPC', () => {
|
||||
it('resolves the Orca CLI for managed, dev, Linux, and default runtimes', () => {
|
||||
expect(resolveOrcaCliCommand({ env: { ORCA_CLI_COMMAND: 'custom-orca' } })).toBe('custom-orca')
|
||||
expect(resolveOrcaCliCommand({ env: { ORCA_DEV_REPO_ROOT: '/repo' } })).toBe('orca-dev')
|
||||
expect(resolveOrcaCliCommand({ env: {}, platform: 'linux' })).toBe('orca-ide')
|
||||
expect(resolveOrcaCliCommand({ env: {}, platform: 'win32' })).toBe('orca')
|
||||
})
|
||||
|
||||
it('bypasses the Windows dev cmd shim with the built Node CLI', () => {
|
||||
const invocation = resolveOrcaCliInvocation({
|
||||
env: {
|
||||
APPDATA: 'C:\\Users\\dev\\AppData\\Roaming',
|
||||
ORCA_CLI_COMMAND: 'C:\\repo\\out\\bin\\orca-dev.cmd',
|
||||
ORCA_DEV_REPO_ROOT: 'C:\\repo'
|
||||
},
|
||||
platform: 'win32',
|
||||
nodeExecutable: 'C:\\Program Files\\nodejs\\node.exe'
|
||||
})
|
||||
|
||||
expect(invocation).toMatchObject({
|
||||
command: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
prefixArgs: ['C:\\repo\\out\\cli\\index.js'],
|
||||
env: {
|
||||
ORCA_USER_DATA_PATH: 'C:\\Users\\dev\\AppData\\Roaming\\orca-dev',
|
||||
ORCA_DEV_CLI_INVOCATION: '1',
|
||||
ORCA_APP_EXECUTABLE: 'C:\\repo\\node_modules\\electron\\dist\\electron.exe',
|
||||
ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('caps combined asynchronous output before retaining the overflow chunk', () => {
|
||||
const first = appendOrcaRpcOutput('', '1234', 0, 5)
|
||||
expect(first).toEqual({ output: '1234', bytes: 4, exceeded: false })
|
||||
|
||||
const overflow = appendOrcaRpcOutput(first.output, '67', first.bytes, 5)
|
||||
expect(overflow).toEqual({ output: '1234', bytes: 6, exceeded: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,645 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Naturalistic freeze repro — idle/reconnect recovery stories on large remotes.
|
||||
*
|
||||
* Unlike the bulk parallel-switch amplifier, this models:
|
||||
* 1) agents streaming on remote while user is idle (backlog builds)
|
||||
* 2) user returns and opens sessions one-by-one (or after reconnect refresh)
|
||||
*
|
||||
* Scenarios:
|
||||
* idle-backlog-open — idle with flood, then human-paced sequential open
|
||||
* idle-backlog-reconnect-open — same + wake-like metadata refresh storm, then open
|
||||
* restart-proxy — idle, then orca open + status/list storm + open
|
||||
* (does NOT kill the desktop; proxies restore work)
|
||||
*
|
||||
* Usage:
|
||||
* ORCA_FREEZE_ENV=paired-remote ORCA_FREEZE_SCENARIO=idle-backlog-open \
|
||||
* node config/scripts/live-remote-realistic-freeze-repro.mjs
|
||||
*
|
||||
* pnpm run repro:live-remote-realistic-freeze
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createOrcaRpc } from './live-remote-freeze-rpc.mjs'
|
||||
import { startStatusWatchdog } from './live-remote-status-watchdog.mjs'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
import {
|
||||
DEFAULT_FOREVER_WINDOW_MS,
|
||||
DEFAULT_HARD_MS,
|
||||
DEFAULT_SOFT_MS,
|
||||
DEFAULT_STATUS_SLOW_MS,
|
||||
evaluateFullAppFreeze,
|
||||
evaluatePermanentLockup,
|
||||
evaluateRealisticFreezeSignals,
|
||||
extractTerminalHandle,
|
||||
humanPaceDelayMs,
|
||||
readFreezeNumberEnv,
|
||||
REALISTIC_SCENARIOS,
|
||||
worktreeSelector
|
||||
} from './live-remote-bulk-open-freeze-metrics.mjs'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..')
|
||||
const reportDir = path.join(root, 'test-results', 'freeze-repro')
|
||||
const envName = process.env.ORCA_FREEZE_ENV || 'paired-remote'
|
||||
const scenario = process.env.ORCA_FREEZE_SCENARIO || 'idle-backlog-open'
|
||||
const createCount = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_CREATE', 0))
|
||||
const openCount = Math.max(2, readFreezeNumberEnv('ORCA_FREEZE_OPEN_COUNT', 20))
|
||||
const idleMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_IDLE_MS', 45_000))
|
||||
const paceMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PACE_MS', 250))
|
||||
const paceJitterMs = Math.max(0, readFreezeNumberEnv('ORCA_FREEZE_PACE_JITTER_MS', 150))
|
||||
const createWorktreeSpan = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_CREATE_WT_SPAN', 12))
|
||||
const softMs = readFreezeNumberEnv('ORCA_FREEZE_SOFT_MS', DEFAULT_SOFT_MS)
|
||||
const hardMs = readFreezeNumberEnv('ORCA_FREEZE_HARD_MS', DEFAULT_HARD_MS)
|
||||
/** Concurrent opens during lockup-storm (wake refresh overlaps fan-out). */
|
||||
const stormParallel = Math.max(1, readFreezeNumberEnv('ORCA_FREEZE_STORM_PARALLEL', 16))
|
||||
/** Kill a switch if it exceeds this — counts toward permanent lockup. */
|
||||
const opTimeoutMs = Math.max(10_000, readFreezeNumberEnv('ORCA_FREEZE_OP_TIMEOUT_MS', 60_000))
|
||||
const permanentTimeoutMs = Math.max(15_000, readFreezeNumberEnv('ORCA_FREEZE_PERMANENT_MS', 60_000))
|
||||
const foreverWindowMs = Math.max(
|
||||
10_000,
|
||||
readFreezeNumberEnv('ORCA_FREEZE_FOREVER_WINDOW_MS', DEFAULT_FOREVER_WINDOW_MS)
|
||||
)
|
||||
const statusSlowMs = Math.max(
|
||||
5_000,
|
||||
readFreezeNumberEnv('ORCA_FREEZE_STATUS_SLOW_MS', DEFAULT_STATUS_SLOW_MS)
|
||||
)
|
||||
const watchdogIntervalMs = Math.max(
|
||||
500,
|
||||
readFreezeNumberEnv('ORCA_FREEZE_WATCHDOG_INTERVAL_MS', 1500)
|
||||
)
|
||||
const scratchDir = process.env.ORCA_FREEZE_SCRATCH || ''
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
const rpc = createOrcaRpc({ envName })
|
||||
const { orcaJsonSync, orcaJsonAsync, runReconnectRefreshStorm, runRestartProxy } = rpc
|
||||
|
||||
async function mapPool(items, concurrency, worker) {
|
||||
const results = Array.from({ length: items.length })
|
||||
let next = 0
|
||||
async function run() {
|
||||
while (next < items.length) {
|
||||
const index = next
|
||||
next += 1
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, Math.max(items.length, 1)) }, () => run())
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
function floodCommand(marker) {
|
||||
const script =
|
||||
"const m=process.argv[1];process.stdout.write('READY:'+m+'\n');let f=0;const c='A'.repeat(2048);setInterval(()=>{f++;process.stdout.write('BG:'+m+':'+f+':'+c+'\n')},8);process.stdin.resume()"
|
||||
return `node -e ${JSON.stringify(script)} ${JSON.stringify(marker)}`
|
||||
}
|
||||
|
||||
function sampleOrcaIfPossible() {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const status = orcaJsonSync(['status'], { local: true }).result
|
||||
const pid = status?.app?.pid
|
||||
if (!pid) {
|
||||
return null
|
||||
}
|
||||
const out = path.join(reportDir, `orca-sample-realistic-${Date.now()}.txt`)
|
||||
const sampled = spawnSync('sample', [String(pid), '5', '-file', out], {
|
||||
timeout: 20_000,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
return sampled.status === 0 ? out : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function listLiveTerminalHandles() {
|
||||
const listed = orcaJsonSync(['terminal', 'list'])
|
||||
const terms = listed.result?.terminals || []
|
||||
return terms
|
||||
.filter((t) => typeof t.handle === 'string' && t.handle.startsWith('term_'))
|
||||
.map((t) => ({
|
||||
handle: t.handle,
|
||||
title: t.title,
|
||||
worktreeId: t.worktreeId,
|
||||
connected: t.connected
|
||||
}))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!REALISTIC_SCENARIOS.includes(scenario)) {
|
||||
throw new Error(
|
||||
`Unknown ORCA_FREEZE_SCENARIO=${scenario}. Expected one of: ${REALISTIC_SCENARIOS.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
mkdirSync(reportDir, { recursive: true })
|
||||
const notes = []
|
||||
const phases = []
|
||||
const openTimings = new BoundedLiveFreezeHistory(100)
|
||||
|
||||
console.log(
|
||||
`[realistic-freeze] scenario=${scenario} env=${envName} create=${createCount} idleMs=${idleMs} openCount=${openCount} paceMs=${paceMs}`
|
||||
)
|
||||
|
||||
const local = orcaJsonSync(['status'], { local: true })
|
||||
const remote = orcaJsonSync(['status'])
|
||||
notes.push(
|
||||
`local version=${local.result?.runtime?.appVersion} pid=${local.result?.app?.pid}`,
|
||||
`remote version=${remote.result?.runtime?.appVersion} state=${remote.result?.runtime?.state}`
|
||||
)
|
||||
|
||||
const worktrees = orcaJsonSync(['worktree', 'list']).result
|
||||
const wtList = worktrees?.worktrees || worktrees?.items || worktrees || []
|
||||
if (!Array.isArray(wtList) || wtList.length === 0) {
|
||||
throw new Error(`No worktrees on environment ${envName}`)
|
||||
}
|
||||
notes.push(`remote worktrees=${wtList.length}`)
|
||||
phases.push({ phase: 'baseline', worktrees: wtList.length })
|
||||
|
||||
// --- Phase: seed flood terminals (agent-like backlog sources) ---
|
||||
const created = []
|
||||
if (createCount > 0) {
|
||||
const targets = wtList.slice(0, Math.min(createWorktreeSpan, wtList.length))
|
||||
await mapPool(
|
||||
Array.from({ length: createCount }, (_, i) => i),
|
||||
Math.min(4, createCount),
|
||||
async (i) => {
|
||||
const wt = targets[i % targets.length]
|
||||
const selector = worktreeSelector(wt)
|
||||
if (!selector) {
|
||||
return
|
||||
}
|
||||
const marker = `REALISTIC_${Date.now()}_${i}`
|
||||
try {
|
||||
const createdTerm = await orcaJsonAsync(
|
||||
[
|
||||
'terminal',
|
||||
'create',
|
||||
'--worktree',
|
||||
selector,
|
||||
'--title',
|
||||
`realistic-freeze-${i}`,
|
||||
'--command',
|
||||
floodCommand(marker)
|
||||
],
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
const handle = extractTerminalHandle(createdTerm.result)
|
||||
if (handle) {
|
||||
created.push({ handle, marker, worktree: selector })
|
||||
console.log(
|
||||
`[realistic-freeze] flood terminal ${handle} (${createdTerm.elapsedMs.toFixed(0)}ms)`
|
||||
)
|
||||
} else {
|
||||
notes.push(
|
||||
`create ${i} missing handle: ${JSON.stringify(createdTerm.result).slice(0, 300)}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
notes.push(`create ${i} failed: ${String(error).slice(0, 250)}`)
|
||||
console.warn(`[realistic-freeze] create failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
phases.push({ phase: 'seed-flood', created: created.length })
|
||||
}
|
||||
|
||||
// Prefer created floods for open pass; fill with existing live terminals.
|
||||
let live = []
|
||||
try {
|
||||
live = listLiveTerminalHandles()
|
||||
notes.push(`live terminals listed=${live.length}`)
|
||||
} catch (error) {
|
||||
notes.push(`terminal list failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const openTargets = [...created.map((c) => c.handle), ...live.map((t) => t.handle)].filter(
|
||||
(v, i, a) => typeof v === 'string' && a.indexOf(v) === i
|
||||
)
|
||||
|
||||
if (openTargets.length < 2) {
|
||||
throw new Error(`Need ≥2 terminals; got ${openTargets.length}. ${notes.join('; ')}`)
|
||||
}
|
||||
|
||||
const openList = openTargets.slice(0, Math.min(openCount, openTargets.length))
|
||||
|
||||
// --- Phase: park — leave one session focused, rest accumulate flood while "away" ---
|
||||
try {
|
||||
const parkHandle = openList[0]
|
||||
const parked = await orcaJsonAsync(['terminal', 'switch', '--terminal', parkHandle], {
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
notes.push(`park switch ms=${parked.elapsedMs.toFixed(0)} handle=${parkHandle}`)
|
||||
} catch (error) {
|
||||
notes.push(`park switch failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
console.log(`[realistic-freeze] idle ${idleMs}ms while remotes stream (user away / asleep)`)
|
||||
const idleStarted = performance.now()
|
||||
await sleep(idleMs)
|
||||
phases.push({ phase: 'idle', idleMs, actualMs: performance.now() - idleStarted })
|
||||
|
||||
// --- Phase: recovery trigger ---
|
||||
let reconnectRefreshMs = 0
|
||||
let timedOutOps = 0
|
||||
let consecutiveSwitchFailures = 0
|
||||
let maxConsecutiveSwitchFailures = 0
|
||||
|
||||
if (scenario === 'idle-backlog-reconnect-open' || scenario === 'lockup-storm') {
|
||||
console.log(
|
||||
'[realistic-freeze] wake/reconnect proxy: parallel status/worktree/terminal refresh'
|
||||
)
|
||||
const storm = await runReconnectRefreshStorm(notes)
|
||||
reconnectRefreshMs = Math.max(storm.wallMs, storm.maxJobMs)
|
||||
phases.push({
|
||||
phase: 'reconnect-refresh',
|
||||
wallMs: storm.wallMs,
|
||||
maxJobMs: storm.maxJobMs
|
||||
})
|
||||
} else if (scenario === 'restart-proxy') {
|
||||
console.log('[realistic-freeze] restart proxy: orca open + refresh storm (no process kill)')
|
||||
const restart = await runRestartProxy(notes)
|
||||
reconnectRefreshMs = Math.max(restart.wallMs, restart.storm.wallMs, restart.storm.maxJobMs)
|
||||
phases.push({
|
||||
phase: 'restart-proxy',
|
||||
wallMs: restart.wallMs,
|
||||
reconnectWallMs: restart.storm.wallMs
|
||||
})
|
||||
}
|
||||
|
||||
// --- Phase: open sessions ---
|
||||
// lockup-storm: overlap a second reconnect storm with concurrent switch fan-out
|
||||
// (models wake + bulk session restore, not human serial clicks).
|
||||
let maxOpenMs = 0
|
||||
let firstOpenMs = 0
|
||||
let sumOpenMs = 0
|
||||
let openOk = 0
|
||||
let maxBatchWallMs = 0
|
||||
const openStarted = performance.now()
|
||||
|
||||
let statusWatch = null
|
||||
if (scenario === 'lockup-storm') {
|
||||
console.log(
|
||||
`[realistic-freeze] LOCKUP STORM: concurrent open parallel=${stormParallel} + overlapping reconnect refresh (timeout=${opTimeoutMs}ms); mid-storm status watchdog every ${watchdogIntervalMs}ms`
|
||||
)
|
||||
statusWatch = startStatusWatchdog({
|
||||
intervalMs: watchdogIntervalMs,
|
||||
timeoutMs: Math.min(permanentTimeoutMs, foreverWindowMs),
|
||||
statusSlowMs
|
||||
})
|
||||
// Fire reconnect storm again concurrently with first open wave.
|
||||
const overlapStormPromise = runReconnectRefreshStorm(notes)
|
||||
for (let offset = 0; offset < openList.length; offset += stormParallel) {
|
||||
const batch = openList.slice(offset, offset + stormParallel)
|
||||
const batchStarted = performance.now()
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (handle, batchIndex) => {
|
||||
const index = offset + batchIndex
|
||||
try {
|
||||
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
|
||||
timeoutMs: opTimeoutMs
|
||||
})
|
||||
return { handle, index, ms: sw.elapsedMs, ok: true, timedOut: false }
|
||||
} catch (error) {
|
||||
const msg = String(error)
|
||||
const timedOut = /timed out/i.test(msg)
|
||||
return { handle, index, error: msg, ok: false, timedOut }
|
||||
}
|
||||
})
|
||||
)
|
||||
const batchWall = performance.now() - batchStarted
|
||||
maxBatchWallMs = Math.max(maxBatchWallMs, batchWall)
|
||||
for (const item of batchResults) {
|
||||
if (item.ok) {
|
||||
openOk += 1
|
||||
sumOpenMs += item.ms
|
||||
maxOpenMs = Math.max(maxOpenMs, item.ms)
|
||||
if (item.index === 0 || firstOpenMs === 0) {
|
||||
firstOpenMs = item.ms
|
||||
}
|
||||
consecutiveSwitchFailures = 0
|
||||
openTimings.add({
|
||||
handle: item.handle,
|
||||
ms: item.ms,
|
||||
index: item.index,
|
||||
batchWall
|
||||
})
|
||||
if (item.ms >= hardMs) {
|
||||
console.warn(
|
||||
`[realistic-freeze] HARD open #${item.index} ${item.handle}: ${item.ms.toFixed(0)}ms`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (item.timedOut) {
|
||||
timedOutOps += 1
|
||||
}
|
||||
consecutiveSwitchFailures += 1
|
||||
maxConsecutiveSwitchFailures = Math.max(
|
||||
maxConsecutiveSwitchFailures,
|
||||
consecutiveSwitchFailures
|
||||
)
|
||||
openTimings.add({
|
||||
handle: item.handle,
|
||||
error: item.error,
|
||||
index: item.index,
|
||||
timedOut: item.timedOut
|
||||
})
|
||||
notes.push(
|
||||
`open ${item.handle} failed${item.timedOut ? ' (TIMEOUT)' : ''}: ${String(item.error).slice(0, 160)}`
|
||||
)
|
||||
console.warn(
|
||||
`[realistic-freeze] open FAIL #${item.index}${item.timedOut ? ' TIMEOUT' : ''}: ${item.handle}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (batchWall >= hardMs) {
|
||||
console.warn(
|
||||
`[realistic-freeze] HARD batch wall=${batchWall.toFixed(0)}ms size=${batch.length}`
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const overlap = await overlapStormPromise
|
||||
reconnectRefreshMs = Math.max(reconnectRefreshMs, overlap.wallMs, overlap.maxJobMs)
|
||||
phases.push({
|
||||
phase: 'overlap-reconnect-refresh',
|
||||
wallMs: overlap.wallMs,
|
||||
maxJobMs: overlap.maxJobMs
|
||||
})
|
||||
} catch (error) {
|
||||
notes.push(`overlap reconnect failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
phases.push({
|
||||
phase: 'lockup-storm-open',
|
||||
count: openList.length,
|
||||
ok: openOk,
|
||||
maxOpenMs,
|
||||
firstOpenMs,
|
||||
maxBatchWallMs,
|
||||
timedOutOps,
|
||||
parallel: stormParallel
|
||||
})
|
||||
} else {
|
||||
console.log(
|
||||
`[realistic-freeze] human-paced open of ${openList.length} sessions (pace≈${paceMs}ms + jitter)`
|
||||
)
|
||||
for (let i = 0; i < openList.length; i += 1) {
|
||||
const handle = openList[i]
|
||||
try {
|
||||
const sw = await orcaJsonAsync(['terminal', 'switch', '--terminal', handle], {
|
||||
timeoutMs: opTimeoutMs
|
||||
})
|
||||
openOk += 1
|
||||
sumOpenMs += sw.elapsedMs
|
||||
maxOpenMs = Math.max(maxOpenMs, sw.elapsedMs)
|
||||
if (i === 0) {
|
||||
firstOpenMs = sw.elapsedMs
|
||||
}
|
||||
consecutiveSwitchFailures = 0
|
||||
openTimings.add({ handle, ms: sw.elapsedMs, index: i })
|
||||
if (sw.elapsedMs >= softMs) {
|
||||
console.warn(`[realistic-freeze] SOFT open #${i} ${handle}: ${sw.elapsedMs.toFixed(0)}ms`)
|
||||
}
|
||||
if (sw.elapsedMs >= hardMs) {
|
||||
console.warn(`[realistic-freeze] HARD open #${i} ${handle}: ${sw.elapsedMs.toFixed(0)}ms`)
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = String(error)
|
||||
const timedOut = /timed out/i.test(msg)
|
||||
if (timedOut) {
|
||||
timedOutOps += 1
|
||||
}
|
||||
consecutiveSwitchFailures += 1
|
||||
maxConsecutiveSwitchFailures = Math.max(
|
||||
maxConsecutiveSwitchFailures,
|
||||
consecutiveSwitchFailures
|
||||
)
|
||||
openTimings.add({ handle, error: msg, index: i, timedOut })
|
||||
notes.push(`open ${handle} failed${timedOut ? ' (TIMEOUT)' : ''}: ${msg.slice(0, 200)}`)
|
||||
}
|
||||
if (i < openList.length - 1) {
|
||||
await sleep(humanPaceDelayMs(paceMs, paceJitterMs))
|
||||
}
|
||||
}
|
||||
phases.push({
|
||||
phase: 'human-paced-open',
|
||||
count: openList.length,
|
||||
ok: openOk,
|
||||
maxOpenMs,
|
||||
firstOpenMs,
|
||||
openWallMs: performance.now() - openStarted
|
||||
})
|
||||
}
|
||||
|
||||
const openWallMs = performance.now() - openStarted
|
||||
|
||||
let midStormWatch = {
|
||||
samples: [],
|
||||
durationMs: 0,
|
||||
sampleCount: 0,
|
||||
maxStatusMs: 0,
|
||||
unhealthySampleCount: 0,
|
||||
infrastructureErrorCount: 0,
|
||||
longestUnhealthyWindowMs: 0
|
||||
}
|
||||
if (statusWatch) {
|
||||
midStormWatch = await statusWatch.stop()
|
||||
notes.push(
|
||||
`mid-storm status samples=${midStormWatch.sampleCount} durationMs=${midStormWatch.durationMs.toFixed(0)}`
|
||||
)
|
||||
phases.push({
|
||||
phase: 'mid-storm-status-watchdog',
|
||||
samples: midStormWatch.sampleCount,
|
||||
durationMs: midStormWatch.durationMs,
|
||||
maxStatusMs: midStormWatch.maxStatusMs
|
||||
})
|
||||
}
|
||||
|
||||
// Post-storm health: does local status still answer?
|
||||
let statusProbeMs = null
|
||||
let statusHangMs = 0
|
||||
const statusStarted = performance.now()
|
||||
try {
|
||||
const statusProbe = await orcaJsonAsync(['status'], {
|
||||
local: true,
|
||||
timeoutMs: permanentTimeoutMs
|
||||
})
|
||||
statusProbeMs = statusProbe.elapsedMs
|
||||
} catch (error) {
|
||||
statusHangMs = performance.now() - statusStarted
|
||||
notes.push(
|
||||
`status probe FAILED after ${statusHangMs.toFixed(0)}ms: ${String(error).slice(0, 200)}`
|
||||
)
|
||||
console.error(`[realistic-freeze] status probe failed — possible permanent lockup`)
|
||||
}
|
||||
|
||||
let memoryProbeMs = null
|
||||
try {
|
||||
const mem = await orcaJsonAsync(['diagnostics', 'memory'], {
|
||||
local: true,
|
||||
timeoutMs: permanentTimeoutMs
|
||||
})
|
||||
memoryProbeMs = mem.elapsedMs
|
||||
notes.push(`memory diagnostic ms=${mem.elapsedMs.toFixed(0)}`)
|
||||
} catch (error) {
|
||||
notes.push(`memory diagnostic failed: ${String(error).slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const peakForSignals = Math.max(maxOpenMs, firstOpenMs, maxBatchWallMs)
|
||||
const signals = evaluateRealisticFreezeSignals({
|
||||
maxOpenMs: peakForSignals,
|
||||
firstOpenMs,
|
||||
reconnectRefreshMs,
|
||||
statusProbeMs: statusProbeMs ?? 0,
|
||||
memoryProbeMs,
|
||||
softMs,
|
||||
hardMs
|
||||
})
|
||||
|
||||
const lockup = evaluatePermanentLockup({
|
||||
timedOutOps,
|
||||
statusHangMs,
|
||||
consecutiveSwitchFailures: maxConsecutiveSwitchFailures,
|
||||
openFailed: openList.length - openOk,
|
||||
openTotal: openList.length,
|
||||
permanentTimeoutMs
|
||||
})
|
||||
|
||||
const fullApp = evaluateFullAppFreeze({
|
||||
statusSamples: midStormWatch.samples,
|
||||
statusSummary: midStormWatch,
|
||||
foreverWindowMs,
|
||||
statusSlowMs
|
||||
})
|
||||
const watchdogInfrastructureErrorCount = midStormWatch.infrastructureErrorCount
|
||||
if (statusHangMs >= foreverWindowMs) {
|
||||
fullApp.foreverUiLockupObserved = true
|
||||
fullApp.longestUnhealthyWindowMs = Math.max(fullApp.longestUnhealthyWindowMs, statusHangMs)
|
||||
fullApp.reason = `post-storm status hang ${statusHangMs.toFixed(0)}ms`
|
||||
}
|
||||
|
||||
const recoveredHardStall = signals.hardFreeze && !fullApp.foreverUiLockupObserved && openOk > 0
|
||||
|
||||
let samplePath = null
|
||||
if (signals.softFreeze || signals.hardFreeze || fullApp.foreverUiLockupObserved) {
|
||||
samplePath = sampleOrcaIfPossible()
|
||||
if (samplePath) {
|
||||
notes.push(`sample=${samplePath}`)
|
||||
} else {
|
||||
notes.push('sample unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
const storyByScenario = {
|
||||
'idle-backlog-open': 'User away while remotes stream; returns and opens sessions one-by-one.',
|
||||
'idle-backlog-reconnect-open':
|
||||
'User away; wake-like reconnect metadata storm; then opens sessions.',
|
||||
'restart-proxy': 'User away; restart-proxy discovery; then opens sessions.',
|
||||
'lockup-storm':
|
||||
'Idle flood + reconnect refresh + concurrent open + mid-storm status watchdog (full-app freeze bar).'
|
||||
}
|
||||
|
||||
const report = {
|
||||
topology: 'live-paired-remote-realistic',
|
||||
scenario,
|
||||
story: storyByScenario[scenario] || scenario,
|
||||
environment: envName,
|
||||
localVersion: local.result?.runtime?.appVersion,
|
||||
remoteVersion: remote.result?.runtime?.appVersion,
|
||||
remoteWorktreeCount: wtList.length,
|
||||
createdFloodTerminals: created.length,
|
||||
openTargets: openList.length,
|
||||
idleMs,
|
||||
paceMs,
|
||||
paceJitterMs,
|
||||
stormParallel: scenario === 'lockup-storm' ? stormParallel : 1,
|
||||
firstOpenMs,
|
||||
maxOpenMs,
|
||||
maxBatchWallMs,
|
||||
avgOpenMs: openOk ? sumOpenMs / openOk : 0,
|
||||
openWallMs,
|
||||
openOk,
|
||||
openFailed: openList.length - openOk,
|
||||
reconnectRefreshMs,
|
||||
peakLatencyMs: Math.max(signals.peakLatencyMs, maxBatchWallMs),
|
||||
statusProbeMs,
|
||||
statusHangMs,
|
||||
memoryProbeMs,
|
||||
softFreeze: signals.softFreeze,
|
||||
hardFreeze: signals.hardFreeze,
|
||||
recoveredHardStall,
|
||||
permanentLockup: lockup.permanentLockup,
|
||||
foreverUiLockupObserved: fullApp.foreverUiLockupObserved,
|
||||
foreverFreeze: fullApp,
|
||||
midStormStatusSamples: midStormWatch.samples,
|
||||
midStormStatusSampleCount: midStormWatch.sampleCount,
|
||||
watchdogInfrastructureErrorCount,
|
||||
timedOutOps,
|
||||
maxConsecutiveSwitchFailures,
|
||||
softMs,
|
||||
hardMs,
|
||||
foreverWindowMs,
|
||||
statusSlowMs,
|
||||
permanentTimeoutMs,
|
||||
opTimeoutMs,
|
||||
phases,
|
||||
notes,
|
||||
openTimingCount: openTimings.totalCount,
|
||||
openTimings: openTimings.values()
|
||||
}
|
||||
|
||||
const outPath = path.join(reportDir, `live-realistic-freeze-${envName}-${scenario}.json`)
|
||||
const stamped = path.join(
|
||||
reportDir,
|
||||
`live-realistic-freeze-${envName}-${scenario}-peak-${Date.now()}.json`
|
||||
)
|
||||
writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
writeFileSync(stamped, `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(`[realistic-freeze] report ${outPath}`)
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
|
||||
if (scratchDir) {
|
||||
try {
|
||||
mkdirSync(scratchDir, { recursive: true })
|
||||
copyFileSync(outPath, path.join(scratchDir, 'live-realistic-freeze-report.json'))
|
||||
} catch (error) {
|
||||
console.warn(`[realistic-freeze] scratch copy failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (watchdogInfrastructureErrorCount > 0) {
|
||||
process.exitCode = 3
|
||||
console.error('[realistic-freeze] WATCHDOG INFRASTRUCTURE FAILURE')
|
||||
} else if (fullApp.foreverUiLockupObserved) {
|
||||
process.exitCode = 5
|
||||
console.error('[realistic-freeze] FULL-APP FOREVER FREEZE (status unhealthy ≥ forever window)')
|
||||
} else if (lockup.permanentLockup) {
|
||||
process.exitCode = 4
|
||||
console.error(
|
||||
'[realistic-freeze] PERMANENT LOCKUP HEURISTIC (timeouts/fail-rate) — check foreverUiLockupObserved'
|
||||
)
|
||||
} else if (signals.hardFreeze) {
|
||||
process.exitCode = 2
|
||||
console.error(
|
||||
'[realistic-freeze] HARD FREEZE SIGNAL (recovered multi-second stall — not forever lockup)'
|
||||
)
|
||||
} else if (signals.softFreeze) {
|
||||
process.exitCode = 1
|
||||
console.error('[realistic-freeze] SOFT FREEZE SIGNAL')
|
||||
} else {
|
||||
console.log('[realistic-freeze] no freeze signal under thresholds')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[realistic-freeze] failed', error)
|
||||
process.exit(3)
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Mid-storm host health samples for forever-freeze detection.
|
||||
* Polls `orca status --json` on an interval while a load storm runs.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { BoundedLiveFreezeHistory } from './live-freeze-bounded-history.mjs'
|
||||
import { resolveOrcaCliInvocation } from './live-remote-freeze-rpc.mjs'
|
||||
|
||||
/**
|
||||
* @param {{ intervalMs?: number, timeoutMs?: number, cliCommand?: string, sampleHistoryLimit?: number, statusSlowMs?: number }} opts
|
||||
*/
|
||||
export function startStatusWatchdog(opts = {}) {
|
||||
const intervalMs = opts.intervalMs ?? 2000
|
||||
const timeoutMs = opts.timeoutMs ?? 30_000
|
||||
const cliInvocation = opts.cliCommand
|
||||
? { command: opts.cliCommand, prefixArgs: [] }
|
||||
: resolveOrcaCliInvocation()
|
||||
const samples = new BoundedLiveFreezeHistory(opts.sampleHistoryLimit ?? 240)
|
||||
const statusSlowMs = opts.statusSlowMs ?? 15_000
|
||||
let stopped = false
|
||||
let inFlight = false
|
||||
let infrastructureErrorCount = 0
|
||||
let longestUnhealthyWindowMs = 0
|
||||
let maxStatusMs = 0
|
||||
let runStartMs = null
|
||||
let unhealthySampleCount = 0
|
||||
const startedAt = performance.now()
|
||||
|
||||
const record = (sample) => {
|
||||
samples.add(sample)
|
||||
maxStatusMs = Math.max(maxStatusMs, sample.ms || 0)
|
||||
if (sample.infrastructureError) {
|
||||
infrastructureErrorCount += 1
|
||||
}
|
||||
const unhealthy =
|
||||
!sample.infrastructureError &&
|
||||
(Boolean(sample.hang) || sample.ok === false || (sample.ms || 0) >= statusSlowMs)
|
||||
if (!unhealthy) {
|
||||
runStartMs = null
|
||||
return
|
||||
}
|
||||
unhealthySampleCount += 1
|
||||
runStartMs ??= sample.tMs ?? 0
|
||||
longestUnhealthyWindowMs = Math.max(
|
||||
longestUnhealthyWindowMs,
|
||||
(sample.tMs ?? 0) + (sample.ms || 0) - runStartMs
|
||||
)
|
||||
}
|
||||
|
||||
const summary = () => ({
|
||||
sampleCount: samples.totalCount,
|
||||
maxStatusMs,
|
||||
unhealthySampleCount,
|
||||
infrastructureErrorCount,
|
||||
longestUnhealthyWindowMs
|
||||
})
|
||||
|
||||
const probe = () =>
|
||||
new Promise((resolve) => {
|
||||
const t0 = performance.now()
|
||||
const child = spawn(
|
||||
cliInvocation.command,
|
||||
[...cliInvocation.prefixArgs, 'status', '--json'],
|
||||
{
|
||||
env: cliInvocation.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
let settled = false
|
||||
const finish = (result) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
finish({
|
||||
tMs: t0 - startedAt,
|
||||
ms: performance.now() - t0,
|
||||
ok: false,
|
||||
hang: true
|
||||
})
|
||||
}, timeoutMs)
|
||||
child.stdout.on('data', () => {})
|
||||
child.stderr.on('data', () => {})
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
finish({
|
||||
tMs: t0 - startedAt,
|
||||
ms: performance.now() - t0,
|
||||
ok: false,
|
||||
hang: false,
|
||||
infrastructureError: true,
|
||||
error: String(error)
|
||||
})
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
finish({
|
||||
tMs: t0 - startedAt,
|
||||
ms: performance.now() - t0,
|
||||
ok: code === 0,
|
||||
hang: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const tick = async ({ force = false } = {}) => {
|
||||
if ((!force && stopped) || inFlight) {
|
||||
return
|
||||
}
|
||||
inFlight = true
|
||||
try {
|
||||
const sample = await probe()
|
||||
record(sample)
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void tick()
|
||||
}, intervalMs)
|
||||
void tick()
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
stopped = true
|
||||
clearInterval(interval)
|
||||
// Wait for in-flight probe, then force one final sample.
|
||||
const deadline = performance.now() + timeoutMs + 1000
|
||||
while (inFlight && performance.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
}
|
||||
await tick({ force: true })
|
||||
return {
|
||||
samples: samples.values(),
|
||||
...summary(),
|
||||
durationMs: performance.now() - startedAt
|
||||
}
|
||||
},
|
||||
getSamples: () => samples.values(),
|
||||
getSummary: summary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { startStatusWatchdog } from './live-remote-status-watchdog.mjs'
|
||||
|
||||
describe('startStatusWatchdog', () => {
|
||||
it('collects status samples and stops cleanly', async () => {
|
||||
// Real path: actually invokes `orca status --json` (must be available in CI/dev with Orca or fail soft).
|
||||
const watch = startStatusWatchdog({ intervalMs: 50, timeoutMs: 5_000 })
|
||||
await new Promise((r) => setTimeout(r, 180))
|
||||
const result = await watch.stop()
|
||||
expect(result.samples.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result.durationMs).toBeGreaterThan(0)
|
||||
for (const s of result.samples) {
|
||||
expect(typeof s.ms).toBe('number')
|
||||
expect(typeof s.ok).toBe('boolean')
|
||||
expect(typeof s.hang).toBe('boolean')
|
||||
}
|
||||
})
|
||||
|
||||
it('marks CLI spawn failures as infrastructure errors', async () => {
|
||||
const watch = startStatusWatchdog({
|
||||
intervalMs: 50,
|
||||
timeoutMs: 1000,
|
||||
cliCommand: 'orca-freeze-watchdog-missing-command'
|
||||
})
|
||||
const result = await watch.stop()
|
||||
|
||||
expect(result.samples.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result.samples.every((sample) => sample.infrastructureError === true)).toBe(true)
|
||||
expect(result.samples.every((sample) => sample.hang === false)).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds retained samples while preserving full-run counters', async () => {
|
||||
const watch = startStatusWatchdog({
|
||||
intervalMs: 50,
|
||||
timeoutMs: 1000,
|
||||
cliCommand: 'orca-freeze-watchdog-missing-command',
|
||||
sampleHistoryLimit: 1
|
||||
})
|
||||
const result = await watch.stop()
|
||||
|
||||
expect(result.samples).toHaveLength(1)
|
||||
expect(result.sampleCount).toBeGreaterThan(result.samples.length)
|
||||
expect(result.infrastructureErrorCount).toBe(result.sampleCount)
|
||||
expect(result.maxStatusMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
@@ -134,5 +134,30 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.hooks.useSettingsNavigationMetadata.40d80bad8a': {
|
||||
zh: '测试版',
|
||||
ja: 'ベータ'
|
||||
},
|
||||
// Issue/PR state picker; it sits beside 已关闭, so 进行中 is a different state.
|
||||
'auto.components.PullRequestPage.7b8f6bf6d8': {
|
||||
zh: '开放'
|
||||
},
|
||||
// This "Open" is the button that opens the config file, not an issue state.
|
||||
'auto.components.settings.McpConfigFileRow.e720c139cd': {
|
||||
zh: '打开'
|
||||
},
|
||||
// Terminal cursor-color group: the on-screen cursor, not the Cursor editor.
|
||||
'auto.components.settings.TerminalWindowSection.c9e1fdf42f': {
|
||||
ko: '커서',
|
||||
zh: '光标'
|
||||
},
|
||||
'auto.components.onboarding.ThemeStep.ab2a583a97': {
|
||||
ko: '커서',
|
||||
zh: '光标'
|
||||
},
|
||||
// Tailwind swatch labels: bare 天空 (the sky), 锌 and 空 (the metal, the sky) are not colors.
|
||||
'auto.components.sidebar.workspace.status.6437a8c253': {
|
||||
zh: '天蓝',
|
||||
ja: '空色'
|
||||
},
|
||||
'auto.components.sidebar.workspace.status.caabd5ca85': {
|
||||
zh: '锌灰'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Ordinary UI vocabulary that reads like a brand but is not one. Every locale should translate
|
||||
// these, so the policy must neither pin the whole value to English nor revert the translation
|
||||
// inside a sentence. The product-name homonyms (Continue.dev, the agent entries) stay Latin
|
||||
// through ENGLISH_ONLY_KEY_PREFIXES, which matches on the catalog key rather than the word.
|
||||
|
||||
// Renderings a locale is expected to use. They are translations, never machine-translation
|
||||
// errors, so a brand revert must leave them alone. Nonsense forms that share the same English
|
||||
// term (zh 回购 "repurchase agreement", ja 端子 "electrical connector", es "Comprometerse")
|
||||
// stay in BRAND_MISTRANSLATIONS and keep getting reverted.
|
||||
const GENERIC_TERM_FAMILIES = [
|
||||
{
|
||||
terms: ['Agent', 'Agents', 'agent', 'agents'],
|
||||
renderings: {
|
||||
ko: ['에이전트'],
|
||||
ja: ['エージェント'],
|
||||
zh: ['代理', '智能体'],
|
||||
es: ['Agente', 'agente', 'Agentes', 'agentes']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Commit', 'Commits', 'commit', 'commits'],
|
||||
renderings: {
|
||||
ko: ['커밋'],
|
||||
ja: ['コミット'],
|
||||
zh: ['提交'],
|
||||
es: [
|
||||
'Confirmación',
|
||||
'confirmación',
|
||||
'Confirmaciones',
|
||||
'confirmaciones',
|
||||
'Confirmar',
|
||||
'confirmar'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Continue'],
|
||||
renderings: {
|
||||
ko: ['계속하다', '계속'],
|
||||
ja: ['続ける', '続行'],
|
||||
zh: ['继续'],
|
||||
es: ['Continuar', 'continuar']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Repo', 'Repos', 'repo', 'repos'],
|
||||
renderings: {
|
||||
ko: ['저장소', '레포'],
|
||||
ja: ['リポジトリ', 'リポ'],
|
||||
zh: ['存储库', '仓库'],
|
||||
es: ['Repositorio', 'repositorio', 'Repositorios', 'repositorios']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Terminal', 'Terminals', 'terminal', 'terminals'],
|
||||
renderings: {
|
||||
ko: ['터미널'],
|
||||
ja: ['ターミナル'],
|
||||
zh: ['终端'],
|
||||
es: ['Terminales', 'terminales']
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export const LOCALIZABLE_GENERIC_TERMS = new Set(
|
||||
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms)
|
||||
)
|
||||
|
||||
const RENDERINGS_BY_TERM = new Map(
|
||||
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms.map((term) => [term, family.renderings]))
|
||||
)
|
||||
|
||||
export function isLocalizableGenericTerm(term) {
|
||||
return LOCALIZABLE_GENERIC_TERMS.has(term)
|
||||
}
|
||||
|
||||
export function isCanonicalGenericRendering(term, locale, form) {
|
||||
return RENDERINGS_BY_TERM.get(term)?.[locale]?.includes(form) ?? false
|
||||
}
|
||||
|
||||
export function canonicalGenericRenderings(locale) {
|
||||
return GENERIC_TERM_FAMILIES.flatMap((family) =>
|
||||
(family.renderings[locale] ?? []).map((form) => ({ form, terms: family.terms }))
|
||||
)
|
||||
}
|
||||
|
||||
function spansOf(value, needle) {
|
||||
const spans = []
|
||||
for (let at = value.indexOf(needle); at !== -1; at = value.indexOf(needle, at + 1)) {
|
||||
spans.push([at, at + needle.length])
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// Why: zh 终端子进程 ("terminal sub-process") contains 端子, so a blind revert of the
|
||||
// electrical-connector mistranslation would cut a valid word in half.
|
||||
export function overlapsCanonicalRendering(term, locale, value, start, end) {
|
||||
const renderings = RENDERINGS_BY_TERM.get(term)?.[locale] ?? []
|
||||
return renderings.some((form) =>
|
||||
spansOf(value, form).some(([from, to]) => start < to && from < end)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LOCALIZABLE_GENERIC_TERMS } from './locale-generic-ui-terms.mjs'
|
||||
import { NEVER_TRANSLATE_VALUES, repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
// Why: #12113 — repair-locale-catalog rewrote ~2000 translated values back to English because
|
||||
// generic UI words (agent, terminal, commit, repo, Continue) were treated as brands. These pin
|
||||
// the boundary: generic words stay translated, real brand names stay Latin.
|
||||
describe('locale generic UI terms', () => {
|
||||
it('keeps a translated generic term when the whole value is that term', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.AppearancePane.terminalTitle',
|
||||
enValue: 'Terminal',
|
||||
localeValue: '터미널',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.QuickCommandsPane.4ccc63da87',
|
||||
enValue: 'Agent',
|
||||
localeValue: '에이전트',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('에이전트')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.mobile.MobileHero.a8fb43cf1c',
|
||||
enValue: 'Continue',
|
||||
localeValue: '继续',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('继续')
|
||||
})
|
||||
|
||||
it('keeps a translated generic term inside a sentence', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.EditorFontSection.matchTerminal',
|
||||
enValue: 'Match terminal font',
|
||||
localeValue: '터미널 글꼴과 동일',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널 글꼴과 동일')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel',
|
||||
enValue: 'Toggle Agent Dashboard',
|
||||
localeValue: '에이전트 대시보드 전환',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('에이전트 대시보드 전환')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'components.agentSessionContinuation.dialogTitle',
|
||||
enValue: 'Continue in New Session',
|
||||
localeValue: '新しいセッションで続ける',
|
||||
locale: 'ja'
|
||||
})
|
||||
// 新しい → 新規 is an unrelated phrase fix; 続ける is what must survive.
|
||||
).toBe('新規セッションで続ける')
|
||||
})
|
||||
|
||||
it('still reverts real brand names that machine translation localized', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.stats.StatsPane.7d26110cea',
|
||||
enValue: 'Codex',
|
||||
localeValue: '사본',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Codex')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.9ae151b26b',
|
||||
enValue: 'linear',
|
||||
localeValue: '线性',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Linear')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.NetworkPane.tailscale',
|
||||
enValue: 'Connect over Tailscale',
|
||||
localeValue: '通过尾鳞连接',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('通过 Tailscale 连接')
|
||||
})
|
||||
|
||||
it('still reverts nonsense renderings of a generic term', () => {
|
||||
// 端子 is an electrical connector, 回购 a repurchase agreement, Comprometerse "to pledge".
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.agent.AgentCombobox.986f946354',
|
||||
enValue: 'Blank Terminal',
|
||||
localeValue: '空白端子',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('空白 Terminal')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
enValue: 'Commit',
|
||||
localeValue: 'Comprometerse',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Commit')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
enValue: 'Markdown',
|
||||
localeValue: '가격 인하',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Markdown')
|
||||
})
|
||||
|
||||
it('does not cut a valid word that contains a nonsense rendering', () => {
|
||||
// 终端子进程 is "terminal sub-process" — 端子 sits inside 终端 + 子.
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.AdvancedNetworkSettingsSection.823e0f15b1',
|
||||
enValue: 'Proxy URL for Orca network requests and local terminal subprocesses.',
|
||||
localeValue: '用于 Orca 网络请求和本地终端子进程的代理 URL。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('用于 Orca 网络请求和本地终端子进程的代理 URL。')
|
||||
})
|
||||
|
||||
it('still fills in a translation when the catalog holds English', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
enValue: 'Terminal 1',
|
||||
localeValue: 'Terminal 1',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.store.slices.worktrees.889487d8bb',
|
||||
enValue: 'Dismiss',
|
||||
localeValue: '해고하다',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('닫기')
|
||||
})
|
||||
|
||||
it('keeps agent catalog product names in English by key, not by word', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.lib.agent.catalog.9e2a9bb87b',
|
||||
enValue: 'Continue',
|
||||
localeValue: '继续',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Continue')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.lib.agent.catalog.760bc6883d',
|
||||
enValue: 'Codex',
|
||||
localeValue: '사본',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Codex')
|
||||
})
|
||||
|
||||
it('does not list a generic term as never-translatable', () => {
|
||||
const pinned = [...LOCALIZABLE_GENERIC_TERMS].filter((term) => NEVER_TRANSLATE_VALUES.has(term))
|
||||
expect(pinned).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -94,7 +94,7 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Launch plan': '起動プラン',
|
||||
'Launch agent': 'エージェントを起動',
|
||||
'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動',
|
||||
Play: 'Play',
|
||||
Play: '再生',
|
||||
Action: '操作',
|
||||
Actions: '操作',
|
||||
action: '操作',
|
||||
@@ -135,7 +135,7 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Pick a base branch below': '以下のベースブランチを選択',
|
||||
'Choose floating workspace directory': 'フローティング ワークスペース ディレクトリを選択',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー',
|
||||
'ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
|
||||
'Enter passphrase': 'パスフレーズを入力',
|
||||
'Enter password': 'パスワードを入力',
|
||||
'Enter the passphrase for': 'のパスフレーズを入力',
|
||||
|
||||
@@ -162,8 +162,8 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
ja: 'クローズ'
|
||||
},
|
||||
'auto.components.GitHubItemDialog.dc1ca081a8': {
|
||||
ko: '진행 중',
|
||||
zh: '进行中',
|
||||
ko: '열림',
|
||||
zh: '开放',
|
||||
ja: 'オープン'
|
||||
},
|
||||
'auto.components.tab.bar.TabBarCreateEntry.b27864279e': {
|
||||
@@ -176,10 +176,11 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
zh: '打开 Linear 任务',
|
||||
ja: 'Linear タスクを開く'
|
||||
},
|
||||
// Onboarding pill beside Orca Mobile: "New" marks a new feature, not a create action.
|
||||
'auto.components.sidebar.SidebarNav.c86d83b5c3': {
|
||||
ko: '새로 만들기',
|
||||
zh: '新建',
|
||||
ja: '新規'
|
||||
ko: '신규',
|
||||
zh: '新功能',
|
||||
ja: '新機能'
|
||||
},
|
||||
'auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b': {
|
||||
ko: 'Discord',
|
||||
|
||||
@@ -752,6 +752,21 @@
|
||||
"auto.components.contextual.tours.ContextualTourProgressDots.dcd6e6b03e": {
|
||||
"ko": "{{value1}}단계 중 {{value0}}단계"
|
||||
},
|
||||
"auto.components.contextual.tours.ContextualTourOverlaySurface.complete": {
|
||||
"ko": "완료"
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body": {
|
||||
"ko": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요."
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title": {
|
||||
"ko": "자동화란 무엇인가요?"
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body": {
|
||||
"ko": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다."
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title": {
|
||||
"ko": "결과 확인"
|
||||
},
|
||||
"auto.components.crash.report.CrashReportDialog.88fea8e84e": {
|
||||
"ko": "보내지 않음"
|
||||
},
|
||||
@@ -1868,6 +1883,9 @@
|
||||
"auto.components.right.sidebar.SourceControl.3a231c845b": {
|
||||
"ko": "알 수 없음"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.6d7f2a47e5": {
|
||||
"ko": "폴더의 변경 사항 취소"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.812cb992ee": {
|
||||
"ko": "{{value0}}에서 열기"
|
||||
},
|
||||
@@ -1880,6 +1898,9 @@
|
||||
"auto.components.right.sidebar.SourceControl.9bb062a886": {
|
||||
"ko": "commit 되지 않음"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.a5e5a11090": {
|
||||
"ko": "모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다."
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.aaf1451654": {
|
||||
"ko": "초안 {{value0}} 만들기"
|
||||
},
|
||||
@@ -1994,6 +2015,9 @@
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.2ae5a785b3": {
|
||||
"ko": "스테이징되지 않은 변경 사항을 모두 삭제하시겠습니까?"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a": {
|
||||
"ko": "이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다."
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.5ddd8cac7f": {
|
||||
"ko": "스테이징된 변경 사항을 모두 삭제하시겠습니까?"
|
||||
},
|
||||
@@ -2015,6 +2039,9 @@
|
||||
"auto.components.right.sidebar.source.control.primary.action.1d47e850cf": {
|
||||
"ko": "연결된 리뷰 브랜치에 업데이트 푸시"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.primary.action.5a477d80cb": {
|
||||
"ko": "모든 변경 사항 스테이징"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.primary.action.a5d1f7b036": {
|
||||
"ko": "{{value0}}을(를) 만들기 전에 commits을 게시하세요."
|
||||
},
|
||||
@@ -2162,6 +2189,9 @@
|
||||
"auto.components.settings.AutoRenameBranchFromWorkSetting.a4fa380b67": {
|
||||
"ko": "{assistantMessage}"
|
||||
},
|
||||
"auto.components.settings.AutoRenameBranchFromWorkSetting.d9b65054ef": {
|
||||
"ko": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다."
|
||||
},
|
||||
"auto.components.settings.AutoRenameBranchPromptEditor.ebb942a2ec": {
|
||||
"ko": "fix-login-flow"
|
||||
},
|
||||
@@ -2228,6 +2258,9 @@
|
||||
"auto.components.settings.DefaultWindowsProjectRuntimeSetting.wslUnavailable": {
|
||||
"ko": "WSL을 사용할 수 없습니다. WSL을 상속하는 프로젝트는 복구가 필요합니다."
|
||||
},
|
||||
"auto.components.settings.DevToolsPane.orcaCloudDescription": {
|
||||
"ko": "자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다."
|
||||
},
|
||||
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
|
||||
"ko": "마이크"
|
||||
},
|
||||
@@ -2255,6 +2288,12 @@
|
||||
"auto.components.settings.ExperimentalPane.newWorktreeCardStyle.description": {
|
||||
"ko": "업데이트된 워크트리 카드 레이아웃, 메타데이터 배치, 카드 표시 메뉴 옵션 및 상태 표시를 미리 봅니다."
|
||||
},
|
||||
"auto.components.settings.EphemeralVmsPane.recipes": {
|
||||
"ko": "레시피"
|
||||
},
|
||||
"auto.components.settings.EphemeralVmsPane.whatTitle": {
|
||||
"ko": "이 스킬로 함께 하는 작업"
|
||||
},
|
||||
"auto.components.settings.GeneralEditorSettingsSection.45c6e85c4d": {
|
||||
"ko": "편집기"
|
||||
},
|
||||
@@ -2727,7 +2766,7 @@
|
||||
"ko": "이 SSH 대상의 원격 릴레이를 강제로 중지합니다. 이 대상의 활성 원격 terminals과 포트 포워딩이 종료됩니다."
|
||||
},
|
||||
"auto.components.settings.SshTargetForm.137e88ce8d": {
|
||||
"ko": "연결 해제 후 릴레이가 terminals을 활성 상태로 유지하는 기간입니다. 기본값: 10800(3시간). 최대:"
|
||||
"ko": "Orca가 이 호스트에서 연결 해제된 뒤에도 원격 터미널은 계속 실행됩니다."
|
||||
},
|
||||
"auto.components.settings.SshTargetForm.2ee9bcd2e8": {
|
||||
"ko": "server, deploy@server:2222, ssh://server"
|
||||
@@ -3710,6 +3749,9 @@
|
||||
"auto.components.settings.ssh.search.f9493b80c0": {
|
||||
"ko": "서버"
|
||||
},
|
||||
"auto.components.settings.source.control.action.recipe.options.commitMessage": {
|
||||
"ko": "스테이징된 변경 사항에서 commit 메시지를 생성합니다."
|
||||
},
|
||||
"auto.components.settings.task.tracker.integration.cards.1a12e33fe5": {
|
||||
"ko": "Linear 액세스 추가"
|
||||
},
|
||||
@@ -4683,7 +4725,7 @@
|
||||
"ko": "중지 중"
|
||||
},
|
||||
"auto.components.status.bar.WorkspaceSpaceCompactPanel.8ff597593d": {
|
||||
"ko": "Space"
|
||||
"ko": "저장 공간"
|
||||
},
|
||||
"auto.components.status.bar.WorkspaceSpaceCompactPanel.9be86c46a0": {
|
||||
"ko": "확보 가능"
|
||||
@@ -4953,7 +4995,7 @@
|
||||
"ko": "선택한 리포지토리와 일치하는 비활성 워크스페이스가 없습니다."
|
||||
},
|
||||
"auto.components.workspace.space.WorkspaceSpacePage.45f6302dbc": {
|
||||
"ko": "Space"
|
||||
"ko": "저장 공간"
|
||||
},
|
||||
"auto.components.workspace.space.WorkspaceSpacePage.8d0048e1cb": {
|
||||
"ko": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간."
|
||||
@@ -5038,5 +5080,14 @@
|
||||
},
|
||||
"settings.appearance.statusBar.sshToggleDescription": {
|
||||
"ko": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다."
|
||||
},
|
||||
"auto.components.right.sidebar.AiVaultPanelControls.selectAllAgents": {
|
||||
"ko": "모두 선택"
|
||||
},
|
||||
"auto.components.right.sidebar.AiVaultPanelControls.clearAgents": {
|
||||
"ko": "모두 해제"
|
||||
},
|
||||
"auto.components.right.sidebar.AiVaultPanel.noAgentsSelected": {
|
||||
"ko": "선택된 에이전트가 없습니다"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'Install the Orca skill so agents know to use the Orca CLI.':
|
||||
'에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'로컬 프로젝트, Git repo 또는 repos가 많은 폴더',
|
||||
'로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
|
||||
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.':
|
||||
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.',
|
||||
'changed since you last approved. Re-review before it runs':
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
export const MACOS_TCC_KEY_OVERRIDES = {
|
||||
'auto.hooks.useMacosTccPromptNotice.title': {
|
||||
es: '¿Ves avisos de “Orca quiere acceder…”?',
|
||||
ja: '「Orca がアクセスしようとしています…」という確認が表示されますか?',
|
||||
ko: '“Orca에서 접근하려고 합니다…” 권한 요청이 표시되나요?',
|
||||
zh: '看到“Orca 想要访问…”提示?'
|
||||
},
|
||||
'auto.hooks.useMacosTccPromptNotice.description': {
|
||||
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
|
||||
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
|
||||
ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
|
||||
zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。'
|
||||
es: 'Los mensajes de permisos de macOS pueden aparecer cuando un agente o una herramienta de terminal que se ejecuta en Orca intenta acceder a archivos protegidos. Concede acceso total al disco en Ajustes para reducir estos avisos.',
|
||||
ja: 'Orca で実行中のエージェントやターミナルツールが保護されたファイルにアクセスしようとすると、macOS の権限メッセージが表示されることがあります。これらの確認を減らすには、設定でフルディスクアクセスを許可してください。',
|
||||
ko: 'Orca에서 실행 중인 에이전트나 터미널 도구가 보호된 파일에 접근하려고 하면 macOS 권한 메시지가 표시될 수 있습니다. 이러한 요청을 줄이려면 설정에서 전체 디스크 접근 권한을 허용하세요.',
|
||||
zh: '当 Orca 中运行的代理或终端工具尝试访问受保护的文件时,macOS 可能会显示权限信息。请在“设置”中授予“完全磁盘访问权限”,以减少此类提示。'
|
||||
},
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
|
||||
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
|
||||
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
|
||||
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
|
||||
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
|
||||
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca para reducir esos avisos. Después, cierra y vuelve a abrir Orca.',
|
||||
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca にこの権限を許可してください。その後、Orca を終了して再度開いてください。',
|
||||
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca에 이 권한을 허용하세요. 그런 다음 Orca를 종료했다가 다시 여세요.',
|
||||
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 授予此权限,以减少此类提示。然后退出并重新打开 Orca。'
|
||||
},
|
||||
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
|
||||
ko: '전체 디스크 접근 권한',
|
||||
|
||||
@@ -371,9 +371,10 @@ export const LOCALE_PHRASE_FIXES = {
|
||||
{ pattern: /指挥进展/g, replacement: 'Conductor 进度', whenEnIncludes: 'Conductor Progress' },
|
||||
{ pattern: /指挥评论/g, replacement: 'Conductor 评审', whenEnIncludes: 'Conductor Review' },
|
||||
{ pattern: /指挥完成/g, replacement: 'Conductor 完成', whenEnIncludes: 'Conductor Done' },
|
||||
{ pattern: /琥珀色/g, replacement: 'Amber', whenEnIncludes: 'Amber' },
|
||||
{ pattern: /蓝色的/g, replacement: 'Blue', whenEnIncludes: 'Blue' },
|
||||
{ pattern: /中性的/g, replacement: 'Neutral', whenEnIncludes: 'Neutral' },
|
||||
// Swatch labels want the bare color noun, not the adjectival 的 form — and not English.
|
||||
{ pattern: /琥珀色/g, replacement: '琥珀', whenEnIncludes: 'Amber' },
|
||||
{ pattern: /蓝色的/g, replacement: '蓝色', whenEnIncludes: 'Blue' },
|
||||
{ pattern: /中性的/g, replacement: '中性', whenEnIncludes: 'Neutral' },
|
||||
{ pattern: /破坏性的/g, replacement: 'destructive', whenEnIncludes: 'destructive' },
|
||||
{ pattern: /注解/g, replacement: '批注', whenEnIncludes: 'Annotation' },
|
||||
...ZH_PHRASE_FIXES_ROUND5
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const LOCALIZED_PROSE_TERM_KEYS = new Set([
|
||||
'auto.hooks.useMacosTccPromptNotice.description',
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
|
||||
])
|
||||
|
||||
const LOCALIZABLE_PROSE_TERMS = new Set([
|
||||
'Agent',
|
||||
'Agents',
|
||||
'agent',
|
||||
'agents',
|
||||
'Terminal',
|
||||
'Terminals',
|
||||
'terminal',
|
||||
'terminals'
|
||||
])
|
||||
|
||||
export function isLocalizedProseTermContext(term, key) {
|
||||
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { repairCatalog } from './locale-translation-policy.mjs'
|
||||
|
||||
// Regression: en.json routinely carries keys a locale catalog has not been bootstrapped with yet
|
||||
// (~190 per locale at the time of writing), which crashed the whole repair run before it did any work.
|
||||
describe('repairCatalog with un-bootstrapped keys', () => {
|
||||
const enCatalog = {
|
||||
auto: {
|
||||
lib: { agent: { catalog: { '760bc6883d': 'Codex' } } },
|
||||
components: { untranslated: 'Continue', nested: { alsoMissing: 'Refresh' } }
|
||||
}
|
||||
}
|
||||
|
||||
const translatedOnly = () => ({ auto: { lib: { agent: { catalog: { '760bc6883d': '사본' } } } } })
|
||||
|
||||
it('skips leaves the locale catalog is missing instead of throwing', () => {
|
||||
for (const locale of ['ko', 'ja', 'zh', 'es']) {
|
||||
const localeCatalog = translatedOnly()
|
||||
expect(() => repairCatalog(enCatalog, localeCatalog, locale), locale).not.toThrow()
|
||||
expect(localeCatalog.auto.components, locale).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('still repairs the leaves that are present', () => {
|
||||
const localeCatalog = translatedOnly()
|
||||
expect(repairCatalog(enCatalog, localeCatalog, 'ko')).toBe(1)
|
||||
expect(localeCatalog.auto.lib.agent.catalog['760bc6883d']).toBe('Codex')
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
|
||||
).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.editor.MarkdownPreview.322afab6ff',
|
||||
@@ -92,7 +92,8 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
).toBe('파이프라인')
|
||||
})
|
||||
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Korean.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
@@ -100,7 +101,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '터미널 1',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Terminal 1')
|
||||
).toBe('터미널 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.skills.SkillsPage.38e0951c3a',
|
||||
@@ -108,7 +109,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '에이전트 스킬',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Agent 스킬')
|
||||
).toBe('에이전트 스킬')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -124,7 +125,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '푸시되지 않은 커밋',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('푸시되지 않은 commits')
|
||||
).toBe('푸시되지 않은 커밋')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
|
||||
@@ -132,7 +133,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '레포',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('레포')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -140,7 +141,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더')
|
||||
).toBe('로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더')
|
||||
})
|
||||
|
||||
it('re-glues Korean particles after Latin terms without gluing content words', () => {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
describe('locale-translation-policy es round 5', () => {
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Spanish.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -43,7 +44,7 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'mensaje de confirmación',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('mensaje de Commit')
|
||||
).toBe('mensaje de confirmación')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
|
||||
@@ -51,7 +52,7 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'repositorio',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('repositorio')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -59,6 +60,6 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Proyecto local, repo de Git o carpeta con muchos repos')
|
||||
).toBe('Proyecto local, repositorio de Git o carpeta con muchos repositorios')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: '遊ぶ',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Play')
|
||||
).toBe('再生')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.TaskPage.8396825a14',
|
||||
@@ -102,7 +102,8 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
).toBe('まずプロジェクトを追加')
|
||||
})
|
||||
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Japanese.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
|
||||
@@ -118,7 +119,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'エージェントのスキル',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Agent のスキル')
|
||||
).toBe('エージェントのスキル')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
|
||||
@@ -134,7 +135,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'コミット',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('commits')
|
||||
).toBe('コミット')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df',
|
||||
@@ -142,7 +143,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'リポ',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('リポ')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -151,6 +152,6 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー')
|
||||
).toBe('ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
|
||||
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
|
||||
import {
|
||||
isCanonicalGenericRendering,
|
||||
overlapsCanonicalRendering
|
||||
} from './locale-generic-ui-terms.mjs'
|
||||
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
|
||||
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
@@ -17,9 +20,10 @@ const OPEN_IN_APP_CATALOG_PREFIX = 'auto.lib.open.in.app.catalog.'
|
||||
// Why: product names and agent labels stay Latin — MT reads them as common words (Codex→copy, Gemini→zodiac).
|
||||
export const ENGLISH_ONLY_KEY_PREFIXES = [AGENT_CATALOG_PREFIX, OPEN_IN_APP_CATALOG_PREFIX]
|
||||
|
||||
// Only genuine brand, product, and code tokens belong here. Ordinary UI words that happen to
|
||||
// name a product (agent, terminal, commit, repo, Continue) live in locale-generic-ui-terms.mjs
|
||||
// and are translated; their product sense is pinned by ENGLISH_ONLY_KEY_PREFIXES instead.
|
||||
export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Agent',
|
||||
'Agents',
|
||||
'Aider',
|
||||
'Amp',
|
||||
'Android',
|
||||
@@ -33,7 +37,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Codebuff',
|
||||
'Codex',
|
||||
'Command Code',
|
||||
'Continue',
|
||||
'Cursor',
|
||||
'Droid',
|
||||
'Devin',
|
||||
@@ -60,30 +63,16 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Pi',
|
||||
'PostHog',
|
||||
'Qwen Code',
|
||||
'Repo',
|
||||
'Repos',
|
||||
'Rovo Dev',
|
||||
'Commit',
|
||||
'Commits',
|
||||
'Markdown',
|
||||
'Terminal',
|
||||
'Terminals',
|
||||
'VS Code',
|
||||
'Warp',
|
||||
'Zed',
|
||||
'agent',
|
||||
'agents',
|
||||
'android',
|
||||
'codex',
|
||||
'commit',
|
||||
'commits',
|
||||
'gemini',
|
||||
'claude',
|
||||
'markdown',
|
||||
'repo',
|
||||
'repos',
|
||||
'terminal',
|
||||
'terminals',
|
||||
'gh',
|
||||
'idle',
|
||||
'anthropic',
|
||||
@@ -368,6 +357,18 @@ function includesPreservedLatinTerm(value, term) {
|
||||
return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value)
|
||||
}
|
||||
|
||||
function replaceMistranslatedForm(value, wrong, brand, locale) {
|
||||
let result = ''
|
||||
let cursor = 0
|
||||
for (let at = value.indexOf(wrong); at !== -1; at = value.indexOf(wrong, cursor)) {
|
||||
const end = at + wrong.length
|
||||
result += value.slice(cursor, at)
|
||||
result += overlapsCanonicalRendering(brand, locale, value, at, end) ? wrong : brand
|
||||
cursor = end
|
||||
}
|
||||
return result + value.slice(cursor)
|
||||
}
|
||||
|
||||
function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
let result = localeValue
|
||||
const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {}
|
||||
@@ -383,9 +384,6 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
if (isScreenCursorContext(brand, enValue, key)) {
|
||||
continue
|
||||
}
|
||||
if (isLocalizedProseTermContext(brand, key)) {
|
||||
continue
|
||||
}
|
||||
if (includesPreservedLatinTerm(result, brand)) {
|
||||
continue
|
||||
}
|
||||
@@ -393,11 +391,16 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
if (!result.includes(wrong)) {
|
||||
continue
|
||||
}
|
||||
// Why: #12113 — a generic term's correct translation is not a mistranslation; reverting it
|
||||
// rewrote ~2000 translated values back to English on every repair run.
|
||||
if (isCanonicalGenericRendering(brand, locale, wrong)) {
|
||||
continue
|
||||
}
|
||||
// Why: "Copy identifier" legitimately uses 사본/复制 — only swap when English names the brand.
|
||||
if (brand === 'Codex' && /\bCopy\b/i.test(enValue)) {
|
||||
continue
|
||||
}
|
||||
result = result.replaceAll(wrong, brand)
|
||||
result = replaceMistranslatedForm(result, wrong, brand, locale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +551,11 @@ export function repairCatalog(enCatalog, localeCatalog, locale) {
|
||||
|
||||
for (const leaf of leaves) {
|
||||
const current = leaf.key.split('.').reduce((cursor, part) => cursor?.[part], localeCatalog)
|
||||
// Why: en.json carries keys the locale catalog has not been bootstrapped with yet; repair only
|
||||
// rewrites values that already exist, so skip instead of crashing on undefined.
|
||||
if (typeof current !== 'string') {
|
||||
continue
|
||||
}
|
||||
const next = repairTranslatedValue({
|
||||
key: leaf.key,
|
||||
enValue: leaf.value,
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '壊れた小切手に対して AI エージェントを開始しました。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('失敗したチェックに対して AI agent を開始しました。')
|
||||
).toBe('失敗したチェックに対して AI エージェントを開始しました。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
|
||||
@@ -88,7 +88,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '電話機からターミナルとエージェントを制御します。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('スマートフォンから terminals と agents を操作')
|
||||
).toBe('スマートフォンからターミナルとエージェントを操作')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.934add88b6',
|
||||
@@ -267,7 +267,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '未已检测代理',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('未检测到 agents')
|
||||
).toBe('未检测到代理')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.skills.SkillsPage.38e0951c3a',
|
||||
@@ -275,7 +275,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '代理技巧',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Agent 技能')
|
||||
).toBe('代理技能')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.9ae151b26b',
|
||||
@@ -358,7 +358,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: 'コミットするものは何もありません。 PR はすでに統合されています。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('commit するものはありません。PR はすでにマージされています。')
|
||||
).toBe('コミットするものはありません。PR はすでにマージされています。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.integrations.search.581844769a',
|
||||
@@ -546,7 +546,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '玩',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Play')
|
||||
).toBe('播放')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e',
|
||||
|
||||
@@ -86,7 +86,9 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
).toBe('集成')
|
||||
})
|
||||
|
||||
it('keeps Terminal as a product surface term', () => {
|
||||
// Why: #12113 — 终端 is the correct rendering and survives; only the 端子 ("electrical
|
||||
// connector") mistranslation still reverts to Latin.
|
||||
it('keeps terminal translated but reverts the 端子 mistranslation', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.Settings.3de4bbb841',
|
||||
@@ -94,7 +96,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal')
|
||||
).toBe('终端')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
@@ -102,7 +104,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端 1',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal 1')
|
||||
).toBe('终端 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.agent.AgentCombobox.986f946354',
|
||||
@@ -118,7 +120,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '分体式端子右',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('向右拆分 Terminal')
|
||||
).toBe('向右拆分终端')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.TerminalAppearanceSection.abcb4dd019',
|
||||
@@ -126,7 +128,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端Cursor',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal Cursor')
|
||||
).toBe('终端 Cursor')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.terminal.FloatingTerminalPanel.3215fc73e9',
|
||||
@@ -137,7 +139,8 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
).toBe('新 Terminal')
|
||||
})
|
||||
|
||||
it('keeps workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Chinese.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.SidebarNav.9c95e1ce91',
|
||||
@@ -145,7 +148,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '代理',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Agents')
|
||||
).toBe('代理')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.28986b3747',
|
||||
@@ -153,7 +156,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '已启动 AI 代理处理失败的检查。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('已启动 AI agent 处理失败的检查。')
|
||||
).toBe('已启动 AI 代理处理失败的检查。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -177,7 +180,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '次提交',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('commits')
|
||||
).toBe('次提交')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.store.slices.worktrees.d1d78a7baa',
|
||||
@@ -187,7 +190,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
'Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地 commits。')
|
||||
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。')
|
||||
})
|
||||
|
||||
it('does not confuse proxy copy with Agent terminology', () => {
|
||||
@@ -234,7 +237,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '本地项目、Git 存储库或包含多个存储库的文件夹',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('本地项目、Git repo 或包含多个 repos 的文件夹')
|
||||
).toBe('本地项目、Git 仓库或包含多个仓库的文件夹')
|
||||
})
|
||||
|
||||
it('keeps product, provider, code, and shell tokens untranslated', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const LOCALE_VALUE_OVERRIDES = {
|
||||
'OpenCode Go': 'OpenCode Go',
|
||||
'Open in Cursor': 'Abrir en Cursor',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'Proyecto local, repo de Git o carpeta con muchos repos'
|
||||
'Proyecto local, repositorio de Git o carpeta con muchos repositorios'
|
||||
},
|
||||
ko: {
|
||||
Save: '저장',
|
||||
@@ -305,7 +305,8 @@ export const LOCALE_VALUE_OVERRIDES = {
|
||||
teams: '团队',
|
||||
passing: '通过',
|
||||
'opened this issue': '创建了此议题',
|
||||
Open: '进行中',
|
||||
// No blanket "Open" override: it is a verb on buttons (打开) and a state next to 已关闭 (开放),
|
||||
// so a single value-wide mapping is wrong for half the call sites. Per-key catalog values decide.
|
||||
'Join Discord': '加入 Discord',
|
||||
'Pull request merged': '拉取请求已合并',
|
||||
'Agent Skills': '代理技能',
|
||||
|
||||
@@ -67,8 +67,8 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
Inline: '内联',
|
||||
thread: '帖子串',
|
||||
destructive: 'destructive',
|
||||
sheet: 'sheet',
|
||||
page: 'page',
|
||||
sheet: '面板',
|
||||
page: '页面',
|
||||
Annotation: '批注',
|
||||
'check #': '检查 #',
|
||||
by: '由',
|
||||
@@ -92,16 +92,18 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'仓库操作方案。在此仓库自定义之前,将使用全局设置。',
|
||||
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.':
|
||||
'为源代码管理的提交、拉取请求、分支命名和修复操作添加操作方案。',
|
||||
Play: 'Play',
|
||||
Flag: 'Flag',
|
||||
Zinc: 'Zinc',
|
||||
Rose: 'Rose',
|
||||
Emerald: 'Emerald',
|
||||
Amber: 'Amber',
|
||||
Violet: 'Violet',
|
||||
Sky: 'Sky',
|
||||
Blue: 'Blue',
|
||||
Neutral: 'Neutral',
|
||||
// Workspace status icon and color swatch labels; siblings below are translated, so English
|
||||
// pins here would leave the picker half-Chinese on the next catalog regeneration.
|
||||
Play: '播放',
|
||||
Flag: '标记',
|
||||
Zinc: '锌灰',
|
||||
Rose: '玫瑰',
|
||||
Emerald: '翡翠',
|
||||
Amber: '琥珀',
|
||||
Violet: '紫罗兰',
|
||||
Sky: '天蓝',
|
||||
Blue: '蓝色',
|
||||
Neutral: '中性',
|
||||
Dashed: '虚线',
|
||||
Dot: '圆点',
|
||||
Circle: '圆形',
|
||||
@@ -201,8 +203,7 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。',
|
||||
'Show Linear in the Tasks source picker and sidebar shortcuts.':
|
||||
'在任务源选择器和侧边栏快捷方式中显示 Linear。',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'本地项目、Git repo 或包含多个 repos 的文件夹',
|
||||
'Local project, Git repo, or folder with many repos': '本地项目、Git 仓库或包含多个仓库的文件夹',
|
||||
'Staged Changes': '已暂存的更改',
|
||||
Changes: '更改',
|
||||
'Untracked Files': '未跟踪文件',
|
||||
@@ -210,8 +211,8 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'Split Down': '向下拆分',
|
||||
'Split Left': '向左拆分',
|
||||
'Split Right': '向右拆分',
|
||||
'Split Terminal Down': '向下拆分 Terminal',
|
||||
'Split Terminal Right': '向右拆分 Terminal',
|
||||
'Split Terminal Down': '向下拆分终端',
|
||||
'Split Terminal Right': '向右拆分终端',
|
||||
'Optional account switching for Claude while preserving shared chat context.':
|
||||
'Claude 的可选账户切换,同时保留共享聊天上下文。',
|
||||
'Countdown timer showing time until prompt cache expires (Claude agents).':
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync, fork } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, openSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import {
|
||||
median,
|
||||
percentile,
|
||||
processSnapshot,
|
||||
sampleProcess
|
||||
} from './macos-computer-helper-owner-loss-metrics.mjs'
|
||||
import {
|
||||
benchmarkTrialNeedsCleanup,
|
||||
parseBenchmarkTrialResult,
|
||||
processIdentityIsCurrent,
|
||||
signalProcessIdentity,
|
||||
spawnBenchmarkProcess,
|
||||
throwBenchmarkTrialFailures,
|
||||
writeProcessRecord
|
||||
} from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
|
||||
const INTERNAL_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_INTERNAL'
|
||||
const EXPECTATION_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_EXPECTATION'
|
||||
const HELPER_RECORD_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_HELPER_RECORD_PATH'
|
||||
const RESULT_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_RESULT_PATH'
|
||||
const ACTIVE_REQUEST_COUNT = 100_000
|
||||
const DEFAULT_TRIALS = 3
|
||||
const OWNER_HOLD_MS = 31_000
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 5_000
|
||||
const RETAIN_PROOF_MS = 3_000
|
||||
const TRIAL_TIMEOUT_MS = OWNER_HOLD_MS + 4 * PROCESS_EXIT_TIMEOUT_MS + 120_000
|
||||
const MIB = 1024 * 1024
|
||||
const scriptPath = import.meta.filename
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
|
||||
const metricsPath = path.join(import.meta.dirname, 'macos-computer-helper-owner-loss-metrics.mjs')
|
||||
const processCleanupPath = path.join(
|
||||
import.meta.dirname,
|
||||
'macos-computer-helper-owner-loss-processes.mjs'
|
||||
)
|
||||
const trialCleanupPath = path.join(
|
||||
import.meta.dirname,
|
||||
'macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
)
|
||||
const sidecarPath = path.join(repoRoot, 'out', 'main', 'computer-sidecar.js')
|
||||
const helperAppPath = path.join(
|
||||
repoRoot,
|
||||
'native',
|
||||
'computer-use-macos',
|
||||
'.build',
|
||||
'release',
|
||||
'Orca Computer Use.app'
|
||||
)
|
||||
const helperPath = path.join(helperAppPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessExit(identity, timeoutMs) {
|
||||
const startedAt = performance.now()
|
||||
while (performance.now() - startedAt < timeoutMs) {
|
||||
if (!processIdentityIsCurrent(identity)) {
|
||||
return performance.now() - startedAt
|
||||
}
|
||||
await sleep(50)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function stopProcess(identity) {
|
||||
if (!identity || !processIdentityIsCurrent(identity)) {
|
||||
return
|
||||
}
|
||||
signalProcessIdentity(identity, helperPath, 'SIGTERM')
|
||||
if ((await waitForProcessExit(identity, 2_000)) !== null) {
|
||||
return
|
||||
}
|
||||
signalProcessIdentity(identity, helperPath, 'SIGKILL')
|
||||
await waitForProcessExit(identity, 2_000)
|
||||
}
|
||||
|
||||
function startSidecar() {
|
||||
const errors = []
|
||||
const child = fork(sidecarPath, [], {
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
ORCA_COMPUTER_SIDECAR: '1',
|
||||
ORCA_COMPUTER_MACOS_HELPER_APP_PATH: helperAppPath
|
||||
}
|
||||
})
|
||||
child.on('error', (error) => errors.push(error.stack ?? error.message))
|
||||
child.stderr?.on('data', (chunk) => errors.push(String(chunk)))
|
||||
return { child, errors }
|
||||
}
|
||||
|
||||
function requestSidecar(sidecar, id, method) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Sidecar ${method} request timed out: ${sidecar.errors.join('')}`))
|
||||
}, 10_000)
|
||||
const onMessage = (message) => {
|
||||
if (message?.id !== id) {
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
if (message.ok) {
|
||||
resolve(message.result)
|
||||
} else {
|
||||
reject(new Error(`Sidecar ${method} failed: ${JSON.stringify(message.error)}`))
|
||||
}
|
||||
}
|
||||
const onExit = (code, signal) => {
|
||||
cleanup()
|
||||
reject(
|
||||
new Error(
|
||||
`Sidecar exited during ${method}: ${JSON.stringify({ code, signal, stderr: sidecar.errors.join('') })}`
|
||||
)
|
||||
)
|
||||
}
|
||||
const onError = (error) => {
|
||||
cleanup()
|
||||
reject(new Error(`Sidecar ${method} process error: ${error.message}`))
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
sidecar.child.off('message', onMessage)
|
||||
sidecar.child.off('exit', onExit)
|
||||
sidecar.child.off('error', onError)
|
||||
}
|
||||
sidecar.child.on('message', onMessage)
|
||||
sidecar.child.once('exit', onExit)
|
||||
sidecar.child.once('error', onError)
|
||||
try {
|
||||
sidecar.child.send({ id, method, params: {} })
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHelper(sidecarPid) {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (Date.now() < deadline) {
|
||||
const output = execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,command='], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
for (const line of output.split('\n')) {
|
||||
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)
|
||||
if (
|
||||
match &&
|
||||
Number(match[2]) === sidecarPid &&
|
||||
Number(match[3]) === Number(match[1]) &&
|
||||
match[4].includes(helperPath) &&
|
||||
match[4].includes(' --agent ')
|
||||
) {
|
||||
return { pid: Number(match[1]), pgid: Number(match[3]), command: match[4] }
|
||||
}
|
||||
}
|
||||
await sleep(50)
|
||||
}
|
||||
throw new Error(`Could not find helper owned by sidecar ${sidecarPid}`)
|
||||
}
|
||||
|
||||
function socketPathFromCommand(command) {
|
||||
const match = command.match(/ --agent (.+?) --token-file /)
|
||||
if (!match) {
|
||||
throw new Error(`Could not read helper socket path from command: ${command}`)
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function connectInvalidPeer(socketPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection(socketPath)
|
||||
let accepted = false
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy()
|
||||
reject(new Error('Invalid-peer connection timed out'))
|
||||
}, 5_000)
|
||||
let buffer = ''
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.off('error', onError)
|
||||
socket.off('data', onData)
|
||||
}
|
||||
const onError = (error) => {
|
||||
if (accepted) {
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const onData = (chunk) => {
|
||||
buffer += chunk
|
||||
const newline = buffer.indexOf('\n')
|
||||
if (newline < 0) {
|
||||
return
|
||||
}
|
||||
let response
|
||||
try {
|
||||
response = JSON.parse(buffer.slice(0, newline))
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
if (response.ok !== false || response.error?.code !== 'permission_denied') {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
reject(new Error(`Invalid peer was not rejected: ${JSON.stringify(response)}`))
|
||||
return
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
socket.off('data', onData)
|
||||
accepted = true
|
||||
resolve(socket)
|
||||
}
|
||||
socket.setEncoding('utf8')
|
||||
socket.on('error', onError)
|
||||
socket.on('data', onData)
|
||||
socket.once('connect', () => {
|
||||
socket.write(
|
||||
`${JSON.stringify({ id: 991, method: 'handshake', params: {}, token: 'invalid' })}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForChildExit(child, timeoutMs) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return true
|
||||
}
|
||||
return await Promise.race([
|
||||
new Promise((resolve) => child.once('exit', () => resolve(true))),
|
||||
sleep(timeoutMs).then(() => false)
|
||||
])
|
||||
}
|
||||
|
||||
async function startAuthenticatedSession() {
|
||||
const sidecar = startSidecar()
|
||||
let helper
|
||||
try {
|
||||
const capabilitiesResult = requestSidecar(sidecar, 1, 'capabilities').then(
|
||||
(capabilities) => ({ capabilities }),
|
||||
(error) => ({ error })
|
||||
)
|
||||
helper = await waitForHelper(sidecar.child.pid)
|
||||
const helperRecordPath = process.env[HELPER_RECORD_PATH_ENV]
|
||||
if (!helperRecordPath) {
|
||||
throw new Error('Missing helper process record path')
|
||||
}
|
||||
writeProcessRecord(helperRecordPath, helper)
|
||||
const { capabilities, error } = await capabilitiesResult
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
if (capabilities?.protocolVersion !== 1) {
|
||||
throw new Error(`Unexpected helper handshake: ${JSON.stringify(capabilities)}`)
|
||||
}
|
||||
return { authenticated: capabilities.protocolVersion === 1, sidecar, helper }
|
||||
} catch (error) {
|
||||
sidecar.child.kill('SIGKILL')
|
||||
await stopProcess(helper)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function exerciseActiveRequests(sidecar) {
|
||||
const latencies = []
|
||||
const startedAt = performance.now()
|
||||
for (let index = 0; index < ACTIVE_REQUEST_COUNT; index += 1) {
|
||||
const requestStartedAt = performance.now()
|
||||
const result = await requestSidecar(sidecar, 10_000 + index, 'listApps')
|
||||
if (!Array.isArray(result?.apps)) {
|
||||
throw new Error(`Unexpected listApps response: ${JSON.stringify(result)}`)
|
||||
}
|
||||
latencies.push(performance.now() - requestStartedAt)
|
||||
}
|
||||
const totalMs = performance.now() - startedAt
|
||||
return {
|
||||
totalMs,
|
||||
requestsPerSecond: (ACTIVE_REQUEST_COUNT * 1_000) / totalMs,
|
||||
medianLatencyMs: median(latencies),
|
||||
p95LatencyMs: percentile(latencies, 0.95),
|
||||
maxLatencyMs: Math.max(...latencies)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyGracefulClose() {
|
||||
const { sidecar, helper } = await startAuthenticatedSession()
|
||||
try {
|
||||
const startedAt = performance.now()
|
||||
sidecar.child.disconnect()
|
||||
if (!(await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS))) {
|
||||
throw new Error('Sidecar did not exit after graceful IPC close')
|
||||
}
|
||||
const helperExitMs = await waitForProcessExit(helper, PROCESS_EXIT_TIMEOUT_MS)
|
||||
if (helperExitMs === null) {
|
||||
throw new Error('Helper did not exit after graceful owner close')
|
||||
}
|
||||
return Math.round(performance.now() - startedAt)
|
||||
} finally {
|
||||
sidecar.child.kill('SIGKILL')
|
||||
await stopProcess(helper)
|
||||
}
|
||||
}
|
||||
|
||||
async function runInternalTrial(expectation) {
|
||||
let sidecar
|
||||
let helper
|
||||
let invalidPeer
|
||||
let invalidPeerRejected = false
|
||||
try {
|
||||
const session = await startAuthenticatedSession()
|
||||
sidecar = session.sidecar
|
||||
helper = session.helper
|
||||
const authenticatedAt = performance.now()
|
||||
const initial = await sampleProcess(helper.pid)
|
||||
const activeRequests = await exerciseActiveRequests(sidecar)
|
||||
const remainingHoldMs = Math.max(0, OWNER_HOLD_MS - (performance.now() - authenticatedAt))
|
||||
await sleep(remainingHoldMs)
|
||||
const connected = await sampleProcess(helper.pid)
|
||||
const invalidSocketPath = socketPathFromCommand(helper.command)
|
||||
invalidPeer = await connectInvalidPeer(invalidSocketPath)
|
||||
invalidPeerRejected = true
|
||||
const survivedClaimDeadline =
|
||||
performance.now() - authenticatedAt >= OWNER_HOLD_MS && isProcessAlive(helper.pid)
|
||||
|
||||
sidecar.child.kill('SIGKILL')
|
||||
await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS)
|
||||
const abruptExitMs = await waitForProcessExit(
|
||||
helper,
|
||||
expectation === 'reaped' ? PROCESS_EXIT_TIMEOUT_MS : RETAIN_PROOF_MS
|
||||
)
|
||||
const helperExitedAfterAbruptLoss = abruptExitMs !== null
|
||||
if (expectation === 'reaped' && !helperExitedAfterAbruptLoss) {
|
||||
throw new Error('Expected helper to exit after abrupt authenticated owner loss')
|
||||
}
|
||||
if (expectation === 'retained' && helperExitedAfterAbruptLoss) {
|
||||
throw new Error('Expected baseline helper to remain after abrupt owner loss')
|
||||
}
|
||||
const postLossRssBytes = helperExitedAfterAbruptLoss ? 0 : processSnapshot(helper.pid).rssBytes
|
||||
await stopProcess(helper)
|
||||
helper = null
|
||||
invalidPeer.destroy()
|
||||
invalidPeer = null
|
||||
|
||||
const gracefulExitMs = await verifyGracefulClose()
|
||||
return {
|
||||
authenticated: session.authenticated,
|
||||
survivedClaimDeadline,
|
||||
invalidPeerRejectedAndDidNotRetain: invalidPeerRejected && helperExitedAfterAbruptLoss,
|
||||
connectedRssBytes: connected.rssBytes,
|
||||
connectedCpuMilliseconds: Math.max(
|
||||
0,
|
||||
Math.round((connected.cpuTimeSeconds - initial.cpuTimeSeconds) * 1_000)
|
||||
),
|
||||
activeRequests,
|
||||
cpuSampleMs: Math.round(performance.now() - authenticatedAt),
|
||||
helperExitedAfterAbruptLoss,
|
||||
abruptExitMs: abruptExitMs === null ? null : Math.round(abruptExitMs),
|
||||
postLossRssBytes,
|
||||
gracefulExitMs
|
||||
}
|
||||
} finally {
|
||||
invalidPeer?.destroy()
|
||||
sidecar?.child.kill('SIGKILL')
|
||||
await stopProcess(helper)
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { expect: '', trials: DEFAULT_TRIALS, output: '' }
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (arg === '--expect' || arg === '--trials' || arg === '--output') {
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for ${arg}`)
|
||||
}
|
||||
options[arg.slice(2)] = arg === '--trials' ? Number(value) : value
|
||||
index += 1
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
if (!['retained', 'reaped'].includes(options.expect)) {
|
||||
throw new Error('--expect must be retained or reaped')
|
||||
}
|
||||
if (!Number.isInteger(options.trials) || options.trials < 1) {
|
||||
throw new Error('--trials must be a positive integer')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function electronPath() {
|
||||
const electronModulePath = fileURLToPath(import.meta.resolve('electron'))
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
}
|
||||
|
||||
function buildArtifacts() {
|
||||
execFileSync('pnpm', ['exec', 'electron-vite', 'build'], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
execFileSync('pnpm', ['build:computer-macos'], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
}
|
||||
|
||||
function artifactSha256(artifactPath) {
|
||||
return createHash('sha256').update(readFileSync(artifactPath)).digest('hex')
|
||||
}
|
||||
|
||||
function runTrial(executable, expectation) {
|
||||
let launcherDir
|
||||
let trialTempDir
|
||||
let helperRecordPath
|
||||
let resultPath
|
||||
let stderrPath
|
||||
let stdoutPath
|
||||
let result
|
||||
let serializedResult
|
||||
let parsedResult
|
||||
let parsedResultAvailable = false
|
||||
let trialError
|
||||
let cleanupError
|
||||
let stderrDescriptor
|
||||
let stdoutDescriptor
|
||||
let trialOutput = ''
|
||||
try {
|
||||
launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-helper-owner-bench-launcher-'))
|
||||
trialTempDir = mkdtempSync(path.join(path.sep, 'tmp', 'orca-owner-bench-'))
|
||||
helperRecordPath = path.join(launcherDir, 'helper.json')
|
||||
resultPath = path.join(launcherDir, 'result.json')
|
||||
stderrPath = path.join(launcherDir, 'stderr.log')
|
||||
stdoutPath = path.join(launcherDir, 'stdout.log')
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'package.json'),
|
||||
JSON.stringify({ name: 'orca-helper-owner-benchmark', main: 'main.cjs' })
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'main.cjs'),
|
||||
`import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})\n`
|
||||
)
|
||||
const env = {
|
||||
...process.env,
|
||||
TMPDIR: trialTempDir,
|
||||
[INTERNAL_ENV]: '1',
|
||||
[EXPECTATION_ENV]: expectation,
|
||||
[HELPER_RECORD_PATH_ENV]: helperRecordPath,
|
||||
[RESULT_PATH_ENV]: resultPath
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
stderrDescriptor = openSync(stderrPath, 'w')
|
||||
stdoutDescriptor = openSync(stdoutPath, 'w')
|
||||
result = spawnBenchmarkProcess(executable, [launcherDir], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: ['ignore', stdoutDescriptor, stderrDescriptor],
|
||||
timeout: TRIAL_TIMEOUT_MS
|
||||
})
|
||||
if (result.status === 0 && existsSync(resultPath)) {
|
||||
serializedResult = readFileSync(resultPath, 'utf8')
|
||||
parsedResult = parseBenchmarkTrialResult(serializedResult)
|
||||
parsedResultAvailable = true
|
||||
}
|
||||
} catch (error) {
|
||||
trialError = error
|
||||
} finally {
|
||||
const failedTrial = benchmarkTrialNeedsCleanup(result, parsedResultAvailable)
|
||||
const trialMarker = trialTempDir ? `TMPDIR=${trialTempDir}` : undefined
|
||||
const cleanup = cleanupOwnerLossTrial({
|
||||
failed: failedTrial,
|
||||
pid: result?.pid,
|
||||
marker: trialMarker,
|
||||
recordPath: helperRecordPath,
|
||||
helperPath,
|
||||
tempDir: trialTempDir,
|
||||
stderrDescriptor,
|
||||
stdoutDescriptor,
|
||||
outputPaths: [stderrPath, stdoutPath],
|
||||
launcherDir
|
||||
})
|
||||
cleanupError = cleanup.error
|
||||
trialOutput = cleanup.output
|
||||
}
|
||||
if (!trialError && result?.status !== 0) {
|
||||
trialError = new Error(
|
||||
`Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n${trialOutput}`
|
||||
)
|
||||
}
|
||||
if (!trialError && !serializedResult) {
|
||||
trialError = new Error(`Electron trial did not write a result:\n${trialOutput}`)
|
||||
}
|
||||
throwBenchmarkTrialFailures(trialError, cleanupError)
|
||||
return parsedResult
|
||||
}
|
||||
|
||||
function runBenchmark() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('The computer-use helper owner benchmark is macOS-only')
|
||||
}
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const dirty = execFileSync('git', ['status', '--porcelain'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
if (dirty) {
|
||||
throw new Error('Commit or stash changes before running the provenance-bound benchmark')
|
||||
}
|
||||
buildArtifacts()
|
||||
if (!existsSync(sidecarPath) || !existsSync(helperPath)) {
|
||||
throw new Error('Fresh production sidecar/helper build did not produce the expected artifacts')
|
||||
}
|
||||
const executable = electronPath()
|
||||
const results = Array.from({ length: options.trials }, () => runTrial(executable, options.expect))
|
||||
const rssBytes = results.map((result) => result.connectedRssBytes)
|
||||
const cpuMilliseconds = results.map((result) => result.connectedCpuMilliseconds)
|
||||
const activeRequestTotals = results.map((result) => result.activeRequests.totalMs)
|
||||
const activeRequestRates = results.map((result) => result.activeRequests.requestsPerSecond)
|
||||
const activeRequestMedians = results.map((result) => result.activeRequests.medianLatencyMs)
|
||||
const activeRequestP95s = results.map((result) => result.activeRequests.p95LatencyMs)
|
||||
const activeRequestMaxes = results.map((result) => result.activeRequests.maxLatencyMs)
|
||||
const postLossRssBytes = results.map((result) => result.postLossRssBytes)
|
||||
const report = {
|
||||
benchmark: 'macos-computer-helper-authenticated-owner-loss',
|
||||
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8'
|
||||
}).trim(),
|
||||
artifacts: {
|
||||
sidecarSha256: artifactSha256(sidecarPath),
|
||||
helperSha256: artifactSha256(helperPath)
|
||||
},
|
||||
sources: {
|
||||
benchmarkSha256: artifactSha256(scriptPath),
|
||||
metricsSha256: artifactSha256(metricsPath),
|
||||
processCleanupSha256: artifactSha256(processCleanupPath),
|
||||
trialCleanupSha256: artifactSha256(trialCleanupPath)
|
||||
},
|
||||
expectation: options.expect,
|
||||
trials: options.trials,
|
||||
ownerHoldMs: OWNER_HOLD_MS,
|
||||
activeRequestCount: ACTIVE_REQUEST_COUNT,
|
||||
authenticated: results.every((result) => result.authenticated),
|
||||
survivedClaimDeadline: results.every((result) => result.survivedClaimDeadline),
|
||||
invalidPeerRejectedAndDidNotRetain: results.every(
|
||||
(result) => result.invalidPeerRejectedAndDidNotRetain
|
||||
),
|
||||
connectedRssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))),
|
||||
medianConnectedRssMiB: Number((median(rssBytes) / MIB).toFixed(2)),
|
||||
connectedCpuMilliseconds: cpuMilliseconds,
|
||||
medianConnectedCpuMilliseconds: median(cpuMilliseconds),
|
||||
activeRequestTotalMs: activeRequestTotals.map((value) => Number(value.toFixed(3))),
|
||||
medianActiveRequestTotalMs: Number(median(activeRequestTotals).toFixed(3)),
|
||||
activeRequestsPerSecond: activeRequestRates.map((value) => Number(value.toFixed(2))),
|
||||
medianActiveRequestsPerSecond: Number(median(activeRequestRates).toFixed(2)),
|
||||
activeRequestMedianLatencyMs: activeRequestMedians.map((value) => Number(value.toFixed(3))),
|
||||
medianActiveRequestMedianLatencyMs: Number(median(activeRequestMedians).toFixed(3)),
|
||||
activeRequestP95LatencyMs: activeRequestP95s.map((value) => Number(value.toFixed(3))),
|
||||
medianActiveRequestP95LatencyMs: Number(median(activeRequestP95s).toFixed(3)),
|
||||
activeRequestMaxLatencyMs: activeRequestMaxes.map((value) => Number(value.toFixed(3))),
|
||||
helperExitedAfterAbruptLoss: results.map((result) => result.helperExitedAfterAbruptLoss),
|
||||
abruptExitMs: results.map((result) => result.abruptExitMs),
|
||||
postLossRssMiB: postLossRssBytes.map((value) => Number((value / MIB).toFixed(2))),
|
||||
medianPostLossRssMiB: Number((median(postLossRssBytes) / MIB).toFixed(2)),
|
||||
gracefulExitMs: results.map((result) => result.gracefulExitMs)
|
||||
}
|
||||
const serialized = `${JSON.stringify(report, null, 2)}\n`
|
||||
process.stdout.write(serialized)
|
||||
if (options.output) {
|
||||
writeFileSync(path.resolve(options.output), serialized)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env[INTERNAL_ENV] === '1') {
|
||||
const { app } = await import('electron')
|
||||
await app.whenReady()
|
||||
try {
|
||||
const result = await runInternalTrial(process.env[EXPECTATION_ENV])
|
||||
writeFileSync(process.env[RESULT_PATH_ENV], JSON.stringify(result))
|
||||
} finally {
|
||||
app.quit()
|
||||
}
|
||||
} else {
|
||||
runBenchmark()
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { signalValidatedProcessGroup } from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
|
||||
describe('macOS helper owner-loss benchmark group recovery', () => {
|
||||
it('retains uncertain stop state across cleanup stage failures', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
|
||||
const groupState = { stopped: false }
|
||||
let scanCount = 0
|
||||
let continueAttempts = 0
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount >= 3) {
|
||||
throw new Error(
|
||||
scanCount === 3 ? 'post-stop inspection failed' : 'final inspection failed'
|
||||
)
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
if (pid === -41 && signal === 'SIGCONT') {
|
||||
continueAttempts += 1
|
||||
if (continueAttempts === 1) {
|
||||
throw new Error('first compensation failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
|
||||
).toThrow('Benchmark process group signal recovery failed')
|
||||
expect(groupState.stopped).toBe(true)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('final inspection failed')
|
||||
expect(continueAttempts).toBe(2)
|
||||
expect(groupState.stopped).toBe(false)
|
||||
})
|
||||
|
||||
it('retains an uncertain anchor stop across cleanup stage failures', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
|
||||
const groupState = { stopped: false, anchorPid: null }
|
||||
let scanCount = 0
|
||||
let continueAttempts = 0
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount >= 2) {
|
||||
throw new Error(
|
||||
scanCount === 2 ? 'post-anchor inspection failed' : 'final inspection failed'
|
||||
)
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
if (pid === 41 && signal === 'SIGCONT') {
|
||||
continueAttempts += 1
|
||||
if (continueAttempts === 1) {
|
||||
throw new Error('first anchor compensation failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
|
||||
).toThrow('Benchmark process group signal recovery failed')
|
||||
expect(groupState.anchorPid).toBe(41)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('final inspection failed')
|
||||
expect(continueAttempts).toBe(2)
|
||||
expect(groupState.anchorPid).toBeNull()
|
||||
})
|
||||
|
||||
it('recovers a retained anchor before selecting a new one', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const firstAnchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
|
||||
const finalAnchor = { pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
const groupState = { stopped: false, anchorPid: null }
|
||||
let scanCount = 0
|
||||
let firstAnchorContinues = 0
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount === 2) {
|
||||
throw new Error('post-anchor inspection failed')
|
||||
}
|
||||
return scanCount === 1 ? [firstAnchor] : [finalAnchor]
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
if (pid === 41 && signal === 'SIGCONT') {
|
||||
firstAnchorContinues += 1
|
||||
if (firstAnchorContinues === 1) {
|
||||
throw new Error('first anchor compensation failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
|
||||
).toThrow('Benchmark process group signal recovery failed')
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(true)
|
||||
expect(firstAnchorContinues).toBe(2)
|
||||
expect(groupState).toEqual({ stopped: false, anchorPid: null })
|
||||
})
|
||||
|
||||
it('preserves group authority errors when compensation fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const ownershipError = 'Benchmark process group no longer belongs to this trial'
|
||||
let thrown
|
||||
|
||||
try {
|
||||
signalValidatedProcessGroup(
|
||||
41,
|
||||
marker,
|
||||
'SIGKILL',
|
||||
{ stopped: true },
|
||||
{
|
||||
processIdentities: () => [{ pid: 41, pgid: 41, command: '/unrelated' }],
|
||||
signalProcess: () => {
|
||||
throw new Error('resume denied')
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors.map((error) => error.message)).toEqual([ownershipError, 'resume denied'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
const SAMPLE_COUNT = 5
|
||||
const SAMPLE_INTERVAL_MS = 200
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export 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]
|
||||
}
|
||||
|
||||
export function percentile(values, fraction) {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]
|
||||
}
|
||||
|
||||
function parseCpuTimeSeconds(value) {
|
||||
const [dayOrTime, clock] = value.includes('-') ? value.split('-', 2) : [null, value]
|
||||
const days = dayOrTime === null ? 0 : Number(dayOrTime)
|
||||
const parts = clock.split(':').map(Number)
|
||||
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
|
||||
throw new Error(`Invalid process CPU time: ${value}`)
|
||||
}
|
||||
const seconds = parts.pop() ?? 0
|
||||
const minutes = parts.pop() ?? 0
|
||||
const hours = parts.pop() ?? 0
|
||||
return days * 86_400 + hours * 3_600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
export function processSnapshot(pid) {
|
||||
const raw = execFileSync(
|
||||
'ps',
|
||||
['-o', 'rss=', '-o', 'time=', '-o', 'command=', '-p', String(pid)],
|
||||
{ encoding: 'utf8' }
|
||||
).trim()
|
||||
const match = raw.match(/^(\d+)\s+(\S+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
throw new Error(`Could not inspect process ${pid}: ${raw}`)
|
||||
}
|
||||
return {
|
||||
rssBytes: Number(match[1]) * 1024,
|
||||
cpuTimeSeconds: parseCpuTimeSeconds(match[2]),
|
||||
command: match[3]
|
||||
}
|
||||
}
|
||||
|
||||
export async function sampleProcess(pid) {
|
||||
const samples = []
|
||||
for (let index = 0; index < SAMPLE_COUNT; index += 1) {
|
||||
samples.push(processSnapshot(pid))
|
||||
await sleep(SAMPLE_INTERVAL_MS)
|
||||
}
|
||||
return {
|
||||
rssBytes: median(samples.map((sample) => sample.rssBytes)),
|
||||
cpuTimeSeconds: samples.at(-1).cpuTimeSeconds,
|
||||
command: samples.at(-1).command
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 2_000
|
||||
const PROCESS_POLL_MS = 25
|
||||
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
|
||||
|
||||
const processIdentityOperations = {
|
||||
executePs: execFileSync,
|
||||
signalProcess: process.kill.bind(process)
|
||||
}
|
||||
|
||||
export function processIdentity(pid, operations = processIdentityOperations) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const output = operations
|
||||
.executePs('ps', ['-p', String(pid), '-o', 'pid=,pgid=,command='], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
.trim()
|
||||
const match = output.match(/^(\d+)\s+(\d+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse process identity for ${pid}`)
|
||||
}
|
||||
return { pid: Number(match[1]), pgid: Number(match[2]), command: match[3] }
|
||||
} catch (error) {
|
||||
try {
|
||||
operations.signalProcess(pid, 0)
|
||||
} catch (lookupError) {
|
||||
if (lookupError?.code === 'ESRCH') {
|
||||
return null
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function matchingDetachedProcesses(identities, expectedCommandFragments) {
|
||||
return identities.filter(
|
||||
(identity) =>
|
||||
identity.pgid === identity.pid &&
|
||||
expectedCommandFragments.every((fragment) => identity.command.includes(fragment))
|
||||
)
|
||||
}
|
||||
|
||||
const matchingProcessOperations = {
|
||||
processIdentities,
|
||||
signalProcessIdentity,
|
||||
waitForIdentityExit
|
||||
}
|
||||
|
||||
export function killProcessMatchingCommand(
|
||||
expectedCommandFragments,
|
||||
operations = matchingProcessOperations
|
||||
) {
|
||||
const matches = matchingDetachedProcesses(
|
||||
operations.processIdentities(),
|
||||
expectedCommandFragments
|
||||
)
|
||||
if (matches.length === 0) {
|
||||
return false
|
||||
}
|
||||
const errors = []
|
||||
for (const match of matches) {
|
||||
try {
|
||||
if (operations.signalProcessIdentity(match, expectedCommandFragments[0], 'SIGKILL')) {
|
||||
operations.waitForIdentityExit(match)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const remaining = matchingDetachedProcesses(
|
||||
operations.processIdentities(),
|
||||
expectedCommandFragments
|
||||
)
|
||||
if (remaining.length > 0) {
|
||||
errors.push(
|
||||
new Error(
|
||||
`Benchmark helper cleanup left matching processes: ${remaining
|
||||
.map((identity) => identity.pid)
|
||||
.join(', ')}`
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Benchmark exact-command cleanup failed')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function sleepSync(milliseconds) {
|
||||
Atomics.wait(sleepBuffer, 0, 0, milliseconds)
|
||||
}
|
||||
|
||||
function validateDetachedIdentity(identity, expectedCommandFragment) {
|
||||
if (
|
||||
!Number.isInteger(identity?.pid) ||
|
||||
identity.pid <= 0 ||
|
||||
identity.pgid !== identity.pid ||
|
||||
typeof identity.command !== 'string' ||
|
||||
!identity.command.includes(expectedCommandFragment)
|
||||
) {
|
||||
throw new Error('Recorded benchmark helper identity is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
function sameIdentity(left, right) {
|
||||
return left?.pid === right?.pid && left?.pgid === right?.pgid && left?.command === right?.command
|
||||
}
|
||||
|
||||
function processIdentities(includeEnvironment = false) {
|
||||
const args = includeEnvironment
|
||||
? ['eww', '-axo', 'pid=,pgid=,command=']
|
||||
: ['-axo', 'pid=,pgid=,command=']
|
||||
const output = execFileSync('ps', args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/))
|
||||
.filter(Boolean)
|
||||
.map((match) => ({
|
||||
pid: Number(match[1]),
|
||||
pgid: Number(match[2]),
|
||||
command: match[3]
|
||||
}))
|
||||
}
|
||||
|
||||
function waitForIdentityExit(identity) {
|
||||
const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS
|
||||
while (Date.now() < deadline) {
|
||||
if (!processIdentityIsCurrent(identity)) {
|
||||
return true
|
||||
}
|
||||
sleepSync(PROCESS_POLL_MS)
|
||||
}
|
||||
throw new Error(`Recorded benchmark helper ${identity.pid} did not exit`)
|
||||
}
|
||||
|
||||
export function spawnBenchmarkProcess(executable, args, options) {
|
||||
return spawnSync(executable, args, {
|
||||
...options,
|
||||
detached: true,
|
||||
killSignal: 'SIGKILL'
|
||||
})
|
||||
}
|
||||
|
||||
export function runBenchmarkCleanupStages(stages) {
|
||||
const errors = []
|
||||
for (const stage of stages) {
|
||||
try {
|
||||
stage()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Benchmark trial cleanup failed')
|
||||
}
|
||||
}
|
||||
|
||||
export function throwBenchmarkTrialFailures(trialError, cleanupError) {
|
||||
if (trialError && cleanupError) {
|
||||
throw new AggregateError([trialError, cleanupError], 'Electron trial and cleanup failed')
|
||||
}
|
||||
if (trialError) {
|
||||
throw trialError
|
||||
}
|
||||
if (cleanupError) {
|
||||
throw cleanupError
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBenchmarkTrialResult(serializedResult) {
|
||||
return JSON.parse(serializedResult)
|
||||
}
|
||||
|
||||
export function benchmarkTrialNeedsCleanup(spawnResult, parsedResultAvailable) {
|
||||
return spawnResult?.status !== 0 || !parsedResultAvailable
|
||||
}
|
||||
|
||||
const processGroupSignalOperations = {
|
||||
processIdentities,
|
||||
signalProcess: process.kill.bind(process)
|
||||
}
|
||||
|
||||
function compensateStoppedGroup(pgid, groupState, operations) {
|
||||
const errors = []
|
||||
const targets = [
|
||||
groupState.stopped ? [-pgid, 'stopped'] : null,
|
||||
groupState.anchorPid ? [groupState.anchorPid, 'anchorPid'] : null
|
||||
].filter(Boolean)
|
||||
for (const [pid, stateKey] of targets) {
|
||||
try {
|
||||
operations.signalProcess(pid, 'SIGCONT')
|
||||
groupState[stateKey] = stateKey === 'stopped' ? false : null
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') {
|
||||
groupState[stateKey] = stateKey === 'stopped' ? false : null
|
||||
} else {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function signalValidatedProcessGroup(
|
||||
pgid,
|
||||
environmentFragment,
|
||||
signal,
|
||||
groupState = { stopped: false, anchorPid: null },
|
||||
operations = processGroupSignalOperations
|
||||
) {
|
||||
if (!Number.isInteger(pgid) || pgid <= 0) {
|
||||
return false
|
||||
}
|
||||
let members
|
||||
try {
|
||||
members = operations.processIdentities(true).filter((identity) => identity.pgid === pgid)
|
||||
} catch (error) {
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...recoveryErrors],
|
||||
'Benchmark process group recovery failed before validation'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (members.length === 0) {
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(recoveryErrors, 'Benchmark missing process group recovery failed')
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (members.some((identity) => !identity.command.includes(environmentFragment))) {
|
||||
const ownershipError = new Error('Benchmark process group no longer belongs to this trial')
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[ownershipError, ...recoveryErrors],
|
||||
'Benchmark process group authority recovery failed'
|
||||
)
|
||||
}
|
||||
throw ownershipError
|
||||
}
|
||||
if (groupState.anchorPid) {
|
||||
try {
|
||||
operations.signalProcess(groupState.anchorPid, 'SIGCONT')
|
||||
groupState.anchorPid = null
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') {
|
||||
groupState.anchorPid = null
|
||||
} else {
|
||||
throw new AggregateError([error], 'Benchmark pending anchor recovery failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
const anchor = members[0]
|
||||
try {
|
||||
operations.signalProcess(anchor.pid, 'SIGSTOP')
|
||||
groupState.anchorPid = anchor.pid
|
||||
const stoppedAnchor = operations
|
||||
.processIdentities(true)
|
||||
.find((identity) => identity.pid === anchor.pid)
|
||||
if (!sameIdentity(stoppedAnchor, anchor)) {
|
||||
throw new Error('Benchmark process group anchor changed before signaling')
|
||||
}
|
||||
operations.signalProcess(-pgid, 'SIGSTOP')
|
||||
groupState.stopped = true
|
||||
groupState.anchorPid = null
|
||||
const stoppedMembers = operations
|
||||
.processIdentities(true)
|
||||
.filter((identity) => identity.pgid === pgid)
|
||||
if (
|
||||
stoppedMembers.length === 0 ||
|
||||
stoppedMembers.some((identity) => !identity.command.includes(environmentFragment))
|
||||
) {
|
||||
throw new Error('Benchmark process group changed before signaling')
|
||||
}
|
||||
if (signal !== 'SIGSTOP') {
|
||||
operations.signalProcess(-pgid, signal)
|
||||
if (signal !== 'SIGKILL') {
|
||||
operations.signalProcess(-pgid, 'SIGCONT')
|
||||
}
|
||||
groupState.stopped = false
|
||||
groupState.anchorPid = null
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...recoveryErrors],
|
||||
'Benchmark process group signal recovery failed'
|
||||
)
|
||||
}
|
||||
if (error.code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function writeProcessRecord(recordPath, processIdentity) {
|
||||
const temporaryPath = `${recordPath}.${process.pid}.tmp`
|
||||
writeFileSync(temporaryPath, JSON.stringify(processIdentity))
|
||||
renameSync(temporaryPath, recordPath)
|
||||
}
|
||||
|
||||
export function processIdentityIsCurrent(identity) {
|
||||
return sameIdentity(processIdentity(identity?.pid), identity)
|
||||
}
|
||||
|
||||
const processSignalOperations = {
|
||||
processIdentity,
|
||||
signalProcess: process.kill.bind(process)
|
||||
}
|
||||
|
||||
export function signalProcessIdentity(
|
||||
identity,
|
||||
expectedCommandFragment,
|
||||
signal,
|
||||
operations = processSignalOperations
|
||||
) {
|
||||
validateDetachedIdentity(identity, expectedCommandFragment)
|
||||
const currentIdentity = operations.processIdentity(identity.pid)
|
||||
if (!currentIdentity) {
|
||||
return false
|
||||
}
|
||||
if (!sameIdentity(currentIdentity, identity)) {
|
||||
throw new Error('Recorded benchmark helper PID now belongs to another process')
|
||||
}
|
||||
let stopped = false
|
||||
try {
|
||||
operations.signalProcess(identity.pid, 'SIGSTOP')
|
||||
stopped = true
|
||||
const stoppedIdentity = operations.processIdentity(identity.pid)
|
||||
if (!sameIdentity(stoppedIdentity, identity)) {
|
||||
throw new Error('Recorded benchmark helper PID changed before signaling')
|
||||
}
|
||||
operations.signalProcess(-identity.pgid, signal)
|
||||
if (signal !== 'SIGKILL') {
|
||||
operations.signalProcess(-identity.pgid, 'SIGCONT')
|
||||
}
|
||||
stopped = false
|
||||
return true
|
||||
} catch (error) {
|
||||
let resumeError
|
||||
if (stopped) {
|
||||
try {
|
||||
operations.signalProcess(identity.pid, 'SIGCONT')
|
||||
} catch (caught) {
|
||||
if (caught?.code !== 'ESRCH') {
|
||||
resumeError = caught
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resumeError) {
|
||||
throw new AggregateError([error, resumeError], 'Benchmark helper signal recovery failed')
|
||||
}
|
||||
if (error.code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function killRecordedProcess(recordPath, expectedCommandFragment) {
|
||||
if (!existsSync(recordPath)) {
|
||||
return false
|
||||
}
|
||||
const record = JSON.parse(readFileSync(recordPath, 'utf8'))
|
||||
if (!signalProcessIdentity(record, expectedCommandFragment, 'SIGKILL')) {
|
||||
return false
|
||||
}
|
||||
return waitForIdentityExit(record)
|
||||
}
|
||||
|
||||
export function killRecordedAndMatchingProcesses(
|
||||
recordPath,
|
||||
recordedCommandFragment,
|
||||
matchingCommandFragments
|
||||
) {
|
||||
const errors = []
|
||||
try {
|
||||
killRecordedProcess(recordPath, recordedCommandFragment)
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
try {
|
||||
killProcessMatchingCommand(matchingCommandFragments)
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Benchmark helper cleanup failed')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
benchmarkTrialNeedsCleanup,
|
||||
killProcessMatchingCommand,
|
||||
killRecordedAndMatchingProcesses,
|
||||
killRecordedProcess,
|
||||
parseBenchmarkTrialResult,
|
||||
processIdentity,
|
||||
runBenchmarkCleanupStages,
|
||||
signalProcessIdentity,
|
||||
signalValidatedProcessGroup,
|
||||
spawnBenchmarkProcess,
|
||||
throwBenchmarkTrialFailures,
|
||||
writeProcessRecord
|
||||
} from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
|
||||
const describeMacOS = process.platform === 'darwin' ? describe : describe.skip
|
||||
const spawnedPids = new Set()
|
||||
const temporaryDirectories = new Set()
|
||||
|
||||
afterEach(() => {
|
||||
for (const pid of spawnedPids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {}
|
||||
}
|
||||
spawnedPids.clear()
|
||||
for (const temporaryDirectory of temporaryDirectories) {
|
||||
rmSync(temporaryDirectory, { recursive: true, force: true })
|
||||
}
|
||||
temporaryDirectories.clear()
|
||||
})
|
||||
|
||||
describeMacOS('macOS helper owner-loss benchmark process cleanup', () => {
|
||||
it('enforces a hard timeout when the trial ignores SIGTERM', () => {
|
||||
const startedAt = Date.now()
|
||||
const result = spawnBenchmarkProcess(
|
||||
process.execPath,
|
||||
['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 1_000)"],
|
||||
{ stdio: 'ignore', timeout: 100 }
|
||||
)
|
||||
expect(result.error?.code).toBe('ETIMEDOUT')
|
||||
expect(result.signal).toBe('SIGKILL')
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000)
|
||||
expect(() => process.kill(result.pid, 0)).toThrow()
|
||||
})
|
||||
|
||||
it('runs every cleanup stage before aggregating errors', () => {
|
||||
const completed = []
|
||||
let thrown
|
||||
|
||||
try {
|
||||
runBenchmarkCleanupStages([
|
||||
() => {
|
||||
completed.push(1)
|
||||
throw new Error('first failure')
|
||||
},
|
||||
() => {
|
||||
completed.push(2)
|
||||
},
|
||||
() => {
|
||||
completed.push(3)
|
||||
throw new Error('last failure')
|
||||
}
|
||||
])
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(completed).toEqual([1, 2, 3])
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors.map((error) => error.message)).toEqual(['first failure', 'last failure'])
|
||||
})
|
||||
|
||||
it('preserves malformed-result and cleanup failures', () => {
|
||||
let trialError
|
||||
try {
|
||||
parseBenchmarkTrialResult('{malformed')
|
||||
} catch (error) {
|
||||
trialError = error
|
||||
}
|
||||
const cleanupError = new Error('cleanup failed')
|
||||
let thrown
|
||||
|
||||
try {
|
||||
throwBenchmarkTrialFailures(trialError, cleanupError)
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(trialError).toBeInstanceOf(SyntaxError)
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors).toEqual([trialError, cleanupError])
|
||||
})
|
||||
|
||||
it('cleans up a status-zero trial whose result could not be parsed', () => {
|
||||
expect(benchmarkTrialNeedsCleanup(undefined, false)).toBe(true)
|
||||
expect(benchmarkTrialNeedsCleanup({ status: 0 }, false)).toBe(true)
|
||||
expect(benchmarkTrialNeedsCleanup({ status: 0 }, true)).toBe(false)
|
||||
expect(benchmarkTrialNeedsCleanup({ status: 1 }, true)).toBe(true)
|
||||
})
|
||||
|
||||
it('removes a launcher directory after partial trial setup', () => {
|
||||
const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-owner-partial-setup-test-'))
|
||||
temporaryDirectories.add(launcherDir)
|
||||
|
||||
const cleanup = cleanupOwnerLossTrial({
|
||||
failed: true,
|
||||
launcherDir,
|
||||
outputPaths: []
|
||||
})
|
||||
|
||||
expect(cleanup.error).toBeUndefined()
|
||||
expect(existsSync(launcherDir)).toBe(false)
|
||||
temporaryDirectories.delete(launcherDir)
|
||||
})
|
||||
|
||||
it('kills a timed-out trial group only after validating its environment', async () => {
|
||||
const temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'orca-owner-benchmark-group-test-'))
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const childPidPath = path.join(temporaryDirectory, 'child.pid')
|
||||
const environmentName = `ORCA_OWNER_GROUP_${process.pid}`
|
||||
const environmentValue = `${Date.now()}`
|
||||
const fixture = `
|
||||
const { spawn } = require('node:child_process')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid))
|
||||
setInterval(() => {}, 1000)
|
||||
`
|
||||
const result = spawnBenchmarkProcess(process.execPath, ['-e', fixture], {
|
||||
env: { ...process.env, [environmentName]: environmentValue },
|
||||
stdio: 'ignore',
|
||||
timeout: 100
|
||||
})
|
||||
const childPid = Number(readFileSync(childPidPath, 'utf8'))
|
||||
spawnedPids.add(childPid)
|
||||
const environmentFragment = `${environmentName}=${environmentValue}`
|
||||
const groupState = { stopped: false }
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(result.pid, `${environmentName}=wrong`, 'SIGSTOP')
|
||||
).toThrow('Benchmark process group no longer belongs to this trial')
|
||||
expect(() => process.kill(childPid, 0)).not.toThrow()
|
||||
expect(
|
||||
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGSTOP', groupState)
|
||||
).toBe(true)
|
||||
expect(
|
||||
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGKILL', groupState)
|
||||
).toBe(true)
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
process.kill(childPid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.toBe(false)
|
||||
|
||||
spawnedPids.delete(childPid)
|
||||
})
|
||||
|
||||
it('resumes the group after post-stop revalidation fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [
|
||||
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
|
||||
{ pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
]
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(
|
||||
41,
|
||||
marker,
|
||||
'SIGKILL',
|
||||
{ stopped: false },
|
||||
{
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount === 3) {
|
||||
throw new Error('transient group inspection failure')
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
)
|
||||
).toThrow('transient group inspection failure')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[-41, 'SIGSTOP'],
|
||||
[-41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('compensates a possible stop after group anchor replacement', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
|
||||
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(
|
||||
41,
|
||||
marker,
|
||||
'SIGKILL',
|
||||
{ stopped: false },
|
||||
{
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
return scanCount === 1 ? [anchor] : [replacement]
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
)
|
||||
).toThrow('Benchmark process group anchor changed before signaling')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('resumes a previously frozen group when final inspection fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
const groupState = { stopped: false }
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount === 4) {
|
||||
throw new Error('transient final inspection failure')
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('transient final inspection failure')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[-41, 'SIGSTOP'],
|
||||
[-41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('resumes a previously frozen group when final anchor stop fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [
|
||||
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
|
||||
{ pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
]
|
||||
const signals = []
|
||||
let finalCall = false
|
||||
const groupState = { stopped: false }
|
||||
const missingProcessError = Object.assign(new Error('anchor exited'), { code: 'ESRCH' })
|
||||
const operations = {
|
||||
processIdentities: () => members,
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
if (finalCall && pid === 41 && signal === 'SIGSTOP') {
|
||||
throw missingProcessError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
|
||||
finalCall = true
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(false)
|
||||
expect(signals.at(-1)).toEqual([-41, 'SIGCONT'])
|
||||
})
|
||||
|
||||
it('resumes a previously frozen group after final anchor replacement', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
|
||||
const child = { pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
const groupState = { stopped: false }
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
return scanCount === 5 ? [replacement, child] : [anchor, child]
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('Benchmark process group anchor changed before signaling')
|
||||
expect(signals.slice(-2)).toEqual([
|
||||
[-41, 'SIGCONT'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('kills a recorded helper in a separate process group', async () => {
|
||||
const temporaryDirectory = mkdtempSync(
|
||||
path.join(tmpdir(), 'orca-owner-benchmark-cleanup-test-')
|
||||
)
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const recordPath = path.join(temporaryDirectory, 'helper.json')
|
||||
const marker = `orca-owner-cleanup-${process.pid}-${Date.now()}`
|
||||
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
const exited = new Promise((resolve) => helper.once('exit', resolve))
|
||||
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const processGroup = Number(
|
||||
execFileSync('ps', ['-p', String(helper.pid), '-o', 'pgid='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
)
|
||||
writeProcessRecord(recordPath, { pid: helper.pid, pgid: processGroup, command })
|
||||
|
||||
expect(processGroup).toBe(helper.pid)
|
||||
expect(killRecordedProcess(recordPath, marker)).toBe(true)
|
||||
await exited
|
||||
expect(() => process.kill(helper.pid, 0)).toThrow()
|
||||
|
||||
spawnedPids.delete(helper.pid)
|
||||
})
|
||||
|
||||
it('kills every unrecorded helper using its unique trial command', async () => {
|
||||
const marker = `orca-owner-unrecorded-${process.pid}-${Date.now()}`
|
||||
const helpers = Array.from({ length: 2 }, () =>
|
||||
spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
)
|
||||
for (const helper of helpers) {
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
}
|
||||
const exited = Promise.all(
|
||||
helpers.map((helper) => new Promise((resolve) => helper.once('exit', resolve)))
|
||||
)
|
||||
|
||||
expect(killProcessMatchingCommand([process.execPath, marker])).toBe(true)
|
||||
await exited
|
||||
for (const helper of helpers) {
|
||||
expect(() => process.kill(helper.pid, 0)).toThrow()
|
||||
spawnedPids.delete(helper.pid)
|
||||
}
|
||||
})
|
||||
|
||||
it('continues exact-match cleanup after an earlier match fails', () => {
|
||||
const marker = `orca-owner-multiple-${process.pid}-${Date.now()}`
|
||||
const matches = [
|
||||
{ pid: 41, pgid: 41, command: `/helper ${marker}` },
|
||||
{ pid: 42, pgid: 42, command: `/helper ${marker}` }
|
||||
]
|
||||
const attempted = []
|
||||
let scanCount = 0
|
||||
|
||||
expect(() =>
|
||||
killProcessMatchingCommand(['/helper', marker], {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
return scanCount === 1 ? matches : [matches[0]]
|
||||
},
|
||||
signalProcessIdentity: (identity) => {
|
||||
attempted.push(identity.pid)
|
||||
if (identity.pid === matches[0].pid) {
|
||||
throw new Error('identity changed')
|
||||
}
|
||||
return true
|
||||
},
|
||||
waitForIdentityExit: () => {}
|
||||
})
|
||||
).toThrow('Benchmark exact-command cleanup failed')
|
||||
expect(attempted).toEqual([41, 42])
|
||||
})
|
||||
|
||||
it('does not treat an identity query failure as process exit', () => {
|
||||
const queryError = new Error('transient ps failure')
|
||||
|
||||
expect(() =>
|
||||
processIdentity(41, {
|
||||
executePs: () => {
|
||||
throw queryError
|
||||
},
|
||||
signalProcess: () => {}
|
||||
})
|
||||
).toThrow(queryError)
|
||||
})
|
||||
|
||||
it('resumes a helper when post-stop identity inspection fails', () => {
|
||||
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
|
||||
const signals = []
|
||||
let inspectionCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
|
||||
processIdentity: () => {
|
||||
inspectionCount += 1
|
||||
if (inspectionCount === 2) {
|
||||
throw new Error('transient ps failure after stop')
|
||||
}
|
||||
return identity
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
})
|
||||
).toThrow('transient ps failure after stop')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('compensates a possible stop after helper PID replacement', () => {
|
||||
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
|
||||
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
|
||||
const signals = []
|
||||
let inspectionCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
|
||||
processIdentity: () => {
|
||||
inspectionCount += 1
|
||||
return inspectionCount === 1 ? identity : replacement
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
})
|
||||
).toThrow('Recorded benchmark helper PID changed before signaling')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves helper identity errors when compensation fails', () => {
|
||||
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
|
||||
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
|
||||
let inspectionCount = 0
|
||||
let thrown
|
||||
|
||||
try {
|
||||
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
|
||||
processIdentity: () => {
|
||||
inspectionCount += 1
|
||||
return inspectionCount === 1 ? identity : replacement
|
||||
},
|
||||
signalProcess: (_pid, signal) => {
|
||||
if (signal === 'SIGCONT') {
|
||||
throw new Error('resume denied')
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors.map((error) => error.message)).toEqual([
|
||||
'Recorded benchmark helper PID changed before signaling',
|
||||
'resume denied'
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a missing PID as process exit after an identity query failure', () => {
|
||||
const missingProcessError = Object.assign(new Error('missing process'), { code: 'ESRCH' })
|
||||
|
||||
expect(
|
||||
processIdentity(41, {
|
||||
executePs: () => {
|
||||
throw new Error('ps found no process')
|
||||
},
|
||||
signalProcess: () => {
|
||||
throw missingProcessError
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('runs unique-command cleanup after an invalid process record', async () => {
|
||||
const temporaryDirectory = mkdtempSync(
|
||||
path.join(tmpdir(), 'orca-owner-benchmark-fallback-test-')
|
||||
)
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const recordPath = path.join(temporaryDirectory, 'helper.json')
|
||||
const marker = `orca-owner-invalid-record-${process.pid}-${Date.now()}`
|
||||
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
const exited = new Promise((resolve) => helper.once('exit', resolve))
|
||||
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
|
||||
|
||||
expect(() =>
|
||||
killRecordedAndMatchingProcesses(recordPath, marker, [process.execPath, marker])
|
||||
).toThrow('Recorded benchmark helper identity is invalid')
|
||||
await exited
|
||||
expect(() => process.kill(helper.pid, 0)).toThrow()
|
||||
|
||||
spawnedPids.delete(helper.pid)
|
||||
})
|
||||
|
||||
it('rejects a record that is not a detached process-group identity', () => {
|
||||
const temporaryDirectory = mkdtempSync(
|
||||
path.join(tmpdir(), 'orca-owner-benchmark-identity-test-')
|
||||
)
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const recordPath = path.join(temporaryDirectory, 'helper.json')
|
||||
const marker = `orca-owner-invalid-identity-${process.pid}-${Date.now()}`
|
||||
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
|
||||
|
||||
expect(() => killRecordedProcess(recordPath, marker)).toThrow(
|
||||
'Recorded benchmark helper identity is invalid'
|
||||
)
|
||||
expect(() => process.kill(helper.pid, 0)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { closeSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import {
|
||||
killRecordedAndMatchingProcesses,
|
||||
runBenchmarkCleanupStages,
|
||||
signalValidatedProcessGroup
|
||||
} from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
|
||||
export function cleanupOwnerLossTrial(options) {
|
||||
const groupState = { stopped: false, anchorPid: null }
|
||||
let error
|
||||
let output = ''
|
||||
try {
|
||||
runBenchmarkCleanupStages([
|
||||
() => {
|
||||
if (options.failed && Number.isInteger(options.pid) && options.marker) {
|
||||
signalValidatedProcessGroup(options.pid, options.marker, 'SIGSTOP', groupState)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.failed && options.recordPath && options.tempDir) {
|
||||
killRecordedAndMatchingProcesses(options.recordPath, options.helperPath, [
|
||||
options.helperPath,
|
||||
options.tempDir
|
||||
])
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.failed && Number.isInteger(options.pid) && options.marker) {
|
||||
signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL', groupState)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.stderrDescriptor !== undefined) {
|
||||
closeSync(options.stderrDescriptor)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.stdoutDescriptor !== undefined) {
|
||||
closeSync(options.stdoutDescriptor)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
output = (options.outputPaths ?? [])
|
||||
.filter((outputPath) => outputPath && existsSync(outputPath))
|
||||
.map((outputPath) => readFileSync(outputPath, 'utf8'))
|
||||
.join('')
|
||||
},
|
||||
() => {
|
||||
if (options.launcherDir) {
|
||||
rmSync(options.launcherDir, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.tempDir) {
|
||||
rmSync(options.tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
])
|
||||
} catch (caught) {
|
||||
error = caught
|
||||
}
|
||||
return { error, output }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function readCreatureNames(path) {
|
||||
const source = readFileSync(path, 'utf8')
|
||||
return Array.from(source.matchAll(/^ '([^']+)',?$/gm), (match) => match[1])
|
||||
}
|
||||
|
||||
describe('marine creature corpus mirrors', () => {
|
||||
it('keeps the mobile mirror in parity with the shared corpus', () => {
|
||||
const sharedNames = readCreatureNames('src/shared/marine-creatures.ts')
|
||||
const mobileNames = readCreatureNames('mobile/src/constants/marine-creatures.ts')
|
||||
|
||||
expect(mobileNames).toEqual(sharedNames)
|
||||
})
|
||||
})
|
||||
@@ -227,12 +227,89 @@ describe('orchestration skill guidance', () => {
|
||||
|
||||
expect(agentGuidance).toContain('After sending `worker_done`, end your turn')
|
||||
expect(agentGuidance).toContain('idle at the agent prompt')
|
||||
expect(agentGuidance).toContain('Do not poll or keep calling `orca orchestration check`')
|
||||
expect(agentGuidance).toContain(
|
||||
'do not start more work, poll, or attempt to close the terminal yourself'
|
||||
)
|
||||
expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input')
|
||||
expect(skill).not.toContain('post-completion polling messages')
|
||||
expect(skill).not.toContain('every 2 minutes')
|
||||
})
|
||||
|
||||
it('makes settled worker terminal release an explicit coordinator step', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
const nextAction = getSection(skill, 'Next Action')
|
||||
|
||||
expect(workerLoop).toContain(
|
||||
'# Process every message. For each accepted worker_done that is not immediately reused:\n' +
|
||||
'orca orchestration worker-release --dispatch <dispatch_id> --json'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'Acknowledge only after every message and required release decision is handled'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca ' +
|
||||
'transfers cleanup ownership to the new Dispatch'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' +
|
||||
'explicitly asked to keep that worker live.'
|
||||
)
|
||||
expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation')
|
||||
expect(workerLoop).toContain('orca orchestration worker-retain --dispatch <dispatch_id> --json')
|
||||
expect(workerLoop).toContain(
|
||||
'the same Dispatch can be passed to `worker-release`, which clears the requested retention'
|
||||
)
|
||||
expect(agentGuidance).toContain(
|
||||
'Coordinators must account for every settled worker terminal before waiting again or ending ' +
|
||||
'the turn'
|
||||
)
|
||||
expect(agentGuidance).toContain('released workers remain readable through `worker-read`')
|
||||
expect(nextAction).toContain(
|
||||
'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' +
|
||||
'follow-up Dispatch or run `worker-release` before the next wait.'
|
||||
)
|
||||
})
|
||||
|
||||
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 aws-bedrock-opus-5 --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')
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
|
||||
// The prohibition sentence is the guard the negative patterns below rely on.
|
||||
expect(workerLoop).toContain(
|
||||
'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' +
|
||||
'escalation, or rejected/stale `worker_done`.'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'do not substitute `terminal close`; follow the exact recovery action in the receipt'
|
||||
)
|
||||
expect(skill).not.toMatch(
|
||||
/release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu
|
||||
)
|
||||
expect(skill).not.toMatch(
|
||||
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
|
||||
)
|
||||
expect(agentGuidance).toContain(
|
||||
'do not start more work, poll, or attempt to close the terminal yourself'
|
||||
)
|
||||
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
|
||||
})
|
||||
|
||||
it('documents @grok in the Messaging group address list', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
|
||||
@@ -487,7 +487,7 @@ describe('Electron runtime package contract', () => {
|
||||
expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}')
|
||||
})
|
||||
|
||||
it('keeps terminal rendering regressions in the fast golden E2E gate', () => {
|
||||
it('keeps terminal rendering regressions in the manual golden E2E workflow', () => {
|
||||
const packageScripts = packageJson.scripts
|
||||
const goldenWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8')
|
||||
@@ -511,7 +511,6 @@ describe('Electron runtime package contract', () => {
|
||||
|
||||
return steps.find((step) => step.name === `Run golden E2E tests on ${label}`)
|
||||
})
|
||||
const pullRequestPaths = goldenWorkflow.on.pull_request.paths
|
||||
const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden']
|
||||
const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence']
|
||||
const releaseBuildNeeds = releaseWorkflow.jobs.build.needs
|
||||
@@ -540,11 +539,8 @@ describe('Electron runtime package contract', () => {
|
||||
for (const runStep of goldenRunSteps) {
|
||||
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
}
|
||||
expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
|
||||
expect(pullRequestPaths).toContain('tests/e2e/terminal-webgl-atlas-budget.spec.ts')
|
||||
expect(pullRequestPaths).toContain('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
|
||||
expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
|
||||
expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
|
||||
expect(goldenWorkflow.on.pull_request).toBeUndefined()
|
||||
expect(goldenWorkflow.on.workflow_dispatch).toBeDefined()
|
||||
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
|
||||
expect(releaseBuildNeeds).not.toContain('terminal-rendering-release-evidence')
|
||||
expect(publishReleaseNeeds).toContain('terminal-rendering-golden')
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('packaged hang watchdog worker contract', () => {
|
||||
it('boots the worker from app.asar in PR checks', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const smokeSource = readFileSync(
|
||||
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
|
||||
'utf8'
|
||||
)
|
||||
const smokeStep = workflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Smoke packaged hang watchdog worker'
|
||||
)
|
||||
|
||||
expect(smokeStep.run).toBe(
|
||||
'xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked'
|
||||
)
|
||||
expect(smokeSource).toContain(
|
||||
"process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]"
|
||||
)
|
||||
})
|
||||
|
||||
// Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion.
|
||||
it('fails the smoke when the packaged worker never reports success', () => {
|
||||
const smokeSource = readFileSync(
|
||||
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(smokeSource).toContain('app.exit(1)')
|
||||
expect(smokeSource).not.toContain('app.quit()')
|
||||
expect(smokeSource).toContain('if (!result.stdout.includes(SUCCESS_LINE))')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ 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 } from '../build-plugins/plain-node-entry-guard'
|
||||
|
||||
let outputDir: string | undefined
|
||||
|
||||
@@ -85,3 +85,91 @@ describe('plain Node entry guard', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Why (#11161): Electron's module is not registered on worker threads either —
|
||||
// require("electron") throws "Cannot find module 'electron'" inside a
|
||||
// main-process worker and kills it at startup. The worker entries carried only
|
||||
// hand-written "must stay electron-free" comments, and the port-scan worker sits
|
||||
// one import away from a client that deliberately does require electron.
|
||||
describe('worker thread entry guard', () => {
|
||||
function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
|
||||
const hook = plugin.writeBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected writeBundle hook')
|
||||
}
|
||||
hook.call(
|
||||
{ meta: { watchMode: false } } as never,
|
||||
{ dir: createOutputDir() } as Rollup.NormalizedOutputOptions,
|
||||
bundle
|
||||
)
|
||||
}
|
||||
|
||||
function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
|
||||
return {
|
||||
type: 'chunk',
|
||||
code,
|
||||
dynamicImports: [],
|
||||
fileName: `${name}.js`,
|
||||
imports,
|
||||
isEntry: true,
|
||||
name
|
||||
} as Rollup.OutputChunk
|
||||
}
|
||||
|
||||
it('rejects an Electron require reachable from a worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'port-scan-command-worker-entry.js': workerChunk(
|
||||
'port-scan-command-worker-entry',
|
||||
'require("electron")'
|
||||
)
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron')
|
||||
})
|
||||
|
||||
it('names the worker-thread runtime so the failure is actionable', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'stt-worker.js': workerChunk('stt-worker', 'require("electron")')
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread')
|
||||
})
|
||||
|
||||
// The real risk is transitive: a worker entry importing a shared chunk that
|
||||
// reaches the electron-requiring client, not a direct import anyone would spot.
|
||||
it('follows shared chunks out of a worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'session-scanner-opencode-sqlite-worker-entry.js': workerChunk(
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'require("./chunks/shared.js")',
|
||||
['chunks/shared.js']
|
||||
),
|
||||
'chunks/shared.js': {
|
||||
type: 'chunk',
|
||||
code: 'require("electron")',
|
||||
dynamicImports: [],
|
||||
fileName: 'chunks/shared.js',
|
||||
imports: [],
|
||||
isEntry: false,
|
||||
name: 'shared'
|
||||
} as Rollup.OutputChunk
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
|
||||
})
|
||||
|
||||
it('passes a clean worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'warp-theme-parser-worker.js': workerChunk(
|
||||
'warp-theme-parser-worker',
|
||||
'require("node:worker_threads")'
|
||||
)
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,9 +5,10 @@ import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
|
||||
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
|
||||
(step) => step.name === 'Filter E2E-relevant paths'
|
||||
(step) => step.name === 'Filter changed E2E specs'
|
||||
)
|
||||
const verifyStep = prWorkflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
@@ -25,7 +26,7 @@ describe('PR E2E gate contract', () => {
|
||||
expect(verifyStep.run).not.toContain('$E2E')
|
||||
})
|
||||
|
||||
it('runs E2E only when the detector says the PR touches E2E paths', () => {
|
||||
it('passes only changed specs to the reusable E2E workflow', () => {
|
||||
// Why: without this the job could lose its filter and run on every PR — the
|
||||
// cost the path filter exists to avoid — while the gate assertions above
|
||||
// stay green.
|
||||
@@ -34,6 +35,10 @@ describe('PR E2E gate contract', () => {
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.should_run).toBe(
|
||||
'${{ steps.filter.outputs.should_run }}'
|
||||
)
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.test_files).toBe(
|
||||
'${{ steps.filter.outputs.test_files }}'
|
||||
)
|
||||
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.e2e-paths.outputs.test_files }}')
|
||||
})
|
||||
|
||||
it('enforces every job verify depends on', () => {
|
||||
@@ -49,16 +54,41 @@ describe('PR E2E gate contract', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the Playwright config where it actually lives', () => {
|
||||
// Why: the config is tests/playwright.config.ts, beside tests/e2e/ rather
|
||||
// than inside it. A bare `playwright.` prefix matches no tracked file, so
|
||||
// editing the runner config would silently skip E2E.
|
||||
expect(filterStep.run).toContain('tests/playwright\\.')
|
||||
expect(filterStep.run).not.toMatch(/\(\^?\|\|]tests\/e2e\/\|playwright\\\./)
|
||||
it('selects modified Playwright specs without running deleted tests', () => {
|
||||
expect(filterStep.run).toContain('--diff-filter=AMCR')
|
||||
expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'")
|
||||
expect(filterStep.run).not.toContain('tests/playwright\\.')
|
||||
})
|
||||
|
||||
it('uses one runner for changed specs and keeps full runs sharded', () => {
|
||||
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
|
||||
expect(e2eWorkflow.jobs['changed-e2e'].if).toBe("inputs.test_files != ''")
|
||||
expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined()
|
||||
expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toBe("inputs.test_files == ''")
|
||||
const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find(
|
||||
(step) => step.name === 'Run changed E2E specs'
|
||||
)
|
||||
expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}')
|
||||
expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1')
|
||||
})
|
||||
|
||||
it('keeps dedicated E2E workflows out of pull request CI', () => {
|
||||
const dedicatedWorkflows = [
|
||||
'golden-e2e-experiment.yml',
|
||||
'linux-wayland-gpu-sandbox.yml',
|
||||
'terminal-ime-e2e.yml',
|
||||
'win-crash-survival-e2e.yml',
|
||||
'windows-terminal-restart-e2e.yml'
|
||||
]
|
||||
|
||||
for (const file of dedicatedWorkflows) {
|
||||
const workflow = parse(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
|
||||
expect(workflow.on.pull_request, file).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('scopes detection to the PR range so base drift cannot false-trigger', () => {
|
||||
expect(filterStep.run).toContain('git diff --name-only --merge-base "$BASE" "$HEAD"')
|
||||
expect(filterStep.run).toContain('--merge-base "$BASE" "$HEAD"')
|
||||
expect(filterStep.run).toContain('set -euo pipefail')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ const testFilePatterns = [
|
||||
'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
|
||||
'tests/tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
|
||||
]
|
||||
const realZshUsage =
|
||||
/(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/
|
||||
@@ -173,11 +173,13 @@ describe('PR workflow parallelism', () => {
|
||||
it('keeps verify as the aggregate required check', () => {
|
||||
expect(workflow.jobs.verify.needs).toEqual([
|
||||
'static_analysis',
|
||||
'root_directory_guard',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'package'
|
||||
'package',
|
||||
'package_windows'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { build } from 'esbuild'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
@@ -9,6 +10,12 @@ import { dirname, join, resolve } from 'node:path'
|
||||
const WAIT_TIMEOUT_MS = 20_000
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
// Why: endpoint credentials are 32–256 base64url chars; openClient requires an authenticated
|
||||
// transport, and #12746 admits pty.data only after a consumer grant.
|
||||
function writeEndpointCredential(path) {
|
||||
return writeFile(path, randomBytes(32).toString('base64url'), 'utf8')
|
||||
}
|
||||
|
||||
function withTimeout(promise, label, stderr) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -68,14 +75,59 @@ async function loadProtocol(bundleDir) {
|
||||
return require(outfile)
|
||||
}
|
||||
|
||||
function attachProcessStreams(proc) {
|
||||
let stderr = ''
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
|
||||
})
|
||||
return {
|
||||
stderr: () => stderr
|
||||
}
|
||||
}
|
||||
|
||||
function waitForStdoutSentinel(proc, protocol, stderr) {
|
||||
let stdoutBuffer = Buffer.alloc(0)
|
||||
return withTimeout(
|
||||
new Promise((resolvePromise, rejectPromise) => {
|
||||
let settled = false
|
||||
const onData = (chunk) => {
|
||||
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
|
||||
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
|
||||
const index = stdoutBuffer.indexOf(sentinel)
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
proc.stdout.off('data', onData)
|
||||
proc.off('exit', onExit)
|
||||
resolvePromise(stdoutBuffer.subarray(index + sentinel.length))
|
||||
}
|
||||
const onExit = (code, signal) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
proc.stdout.off('data', onData)
|
||||
rejectPromise(
|
||||
new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`)
|
||||
)
|
||||
}
|
||||
proc.stdout.on('data', onData)
|
||||
proc.once('exit', onExit)
|
||||
}),
|
||||
'relay sentinel',
|
||||
stderr
|
||||
)
|
||||
}
|
||||
|
||||
function createRelayClient(entryPath, args, env, protocol) {
|
||||
const proc = spawn(process.execPath, [entryPath, ...args], {
|
||||
cwd: dirname(entryPath),
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
const streams = attachProcessStreams(proc)
|
||||
const messages = []
|
||||
let stderr = ''
|
||||
let nextSequence = 1
|
||||
let stdoutBuffer = Buffer.alloc(0)
|
||||
let ready = false
|
||||
@@ -88,9 +140,6 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
messages.push(protocol.parseJsonRpcMessage(frame.payload))
|
||||
}
|
||||
})
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
|
||||
})
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
if (ready) {
|
||||
decoder.feed(chunk)
|
||||
@@ -111,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
})
|
||||
|
||||
const waitForMessage = (startIndex, predicate, label) =>
|
||||
pollUntil(
|
||||
() => messages.slice(startIndex).find(predicate),
|
||||
label,
|
||||
() => stderr
|
||||
)
|
||||
pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr)
|
||||
|
||||
const request = async (method, params = {}) => {
|
||||
const id = nextSequence++
|
||||
@@ -141,7 +186,7 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
proc,
|
||||
request,
|
||||
notify,
|
||||
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr),
|
||||
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', streams.stderr),
|
||||
messageCount: () => messages.length,
|
||||
waitForNotification: (startIndex, method, predicate = () => true) =>
|
||||
waitForMessage(
|
||||
@@ -149,7 +194,20 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
(message) => message.method === method && predicate(message.params ?? {}),
|
||||
`${method} notification`
|
||||
),
|
||||
stderr: () => stderr
|
||||
stderr: streams.stderr
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcess(proc, stderr, label) {
|
||||
if (!proc || proc.exitCode !== null || proc.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
proc.kill('SIGTERM')
|
||||
try {
|
||||
await withTimeout(waitForExit(proc), `${label} shutdown`, stderr)
|
||||
} catch {
|
||||
proc.kill('SIGKILL')
|
||||
await withTimeout(waitForExit(proc), `forced ${label} shutdown`, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,24 +242,64 @@ async function main() {
|
||||
}
|
||||
|
||||
let tempRoot
|
||||
let daemon
|
||||
let daemonStreams
|
||||
let relay
|
||||
try {
|
||||
tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-'))
|
||||
const watchRoot = await realpath(tempRoot)
|
||||
const pidFile = join(tempRoot, 'watcher.pid')
|
||||
const credentialFile = join(tempRoot, 'endpoint.credential')
|
||||
const protocol = await loadProtocol(tempRoot)
|
||||
const socketPath =
|
||||
process.platform === 'win32'
|
||||
? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}`
|
||||
: join(tempRoot, 'relay.sock')
|
||||
await writeEndpointCredential(credentialFile)
|
||||
|
||||
// Why detached + --connect: the daemon primary stdio is unproved, so it cannot open a consumer
|
||||
// session; only an endpoint-credential socket client is admitted for pty.data after #12746.
|
||||
daemon = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
relayEntry,
|
||||
'--detached',
|
||||
'--grace-time',
|
||||
'0',
|
||||
'--sock-path',
|
||||
socketPath,
|
||||
'--endpoint-dir',
|
||||
join(tempRoot, 'agent-hooks'),
|
||||
'--credential-file',
|
||||
credentialFile
|
||||
],
|
||||
{
|
||||
cwd: dirname(relayEntry),
|
||||
env: { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
daemonStreams = attachProcessStreams(daemon)
|
||||
await waitForStdoutSentinel(daemon, protocol, daemonStreams.stderr)
|
||||
|
||||
relay = createRelayClient(
|
||||
relayEntry,
|
||||
['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')],
|
||||
{ ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
|
||||
['--connect', '--sock-path', socketPath, '--credential-file', credentialFile],
|
||||
process.env,
|
||||
protocol
|
||||
)
|
||||
await relay.sentinelReceived
|
||||
|
||||
// Why no outputFlowControl: legacy owner grant admits plain pty.data without delivery tokens.
|
||||
const grant = await relay.request('pty.openClient', {
|
||||
protocolVersion: 1,
|
||||
clientInstanceId: `relay-watcher-fault-${process.pid}`,
|
||||
requestedRole: 'session-owner'
|
||||
})
|
||||
if (grant?.role !== 'session-owner') {
|
||||
throw new Error(`expected session-owner grant, got ${JSON.stringify(grant)}`)
|
||||
}
|
||||
|
||||
const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot })
|
||||
const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}`
|
||||
let startIndex = relay.messageCount()
|
||||
@@ -213,7 +311,11 @@ async function main() {
|
||||
)
|
||||
|
||||
await relay.request('fs.watch', { rootPath: watchRoot })
|
||||
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr)
|
||||
const firstWatcherPid = await waitForWatcherPid(
|
||||
pidFile,
|
||||
undefined,
|
||||
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
)
|
||||
const beforePath = join(watchRoot, 'before.txt')
|
||||
startIndex = relay.messageCount()
|
||||
await writeFile(beforePath, 'before')
|
||||
@@ -224,7 +326,11 @@ async function main() {
|
||||
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
|
||||
startIndex = relay.messageCount()
|
||||
process.kill(firstWatcherPid, faultSignal)
|
||||
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr)
|
||||
const replacementWatcherPid = await waitForWatcherPid(
|
||||
pidFile,
|
||||
firstWatcherPid,
|
||||
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
)
|
||||
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
|
||||
Array.isArray(params.events)
|
||||
? params.events.some(
|
||||
@@ -234,7 +340,7 @@ async function main() {
|
||||
)
|
||||
|
||||
const status = await relay.request('relay.status')
|
||||
if (status.pid !== relay.proc.pid) {
|
||||
if (status.pid !== daemon.pid) {
|
||||
throw new Error('relay.status did not come from the original surviving relay process')
|
||||
}
|
||||
const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}`
|
||||
@@ -257,7 +363,7 @@ async function main() {
|
||||
await relay.request('pty.shutdown', { id: spawned.id })
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
relayPid: relay.proc.pid,
|
||||
relayPid: daemon.pid,
|
||||
killedWatcherPid: firstWatcherPid,
|
||||
replacementWatcherPid,
|
||||
faultSignal,
|
||||
@@ -268,15 +374,8 @@ async function main() {
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) {
|
||||
relay.proc.kill('SIGTERM')
|
||||
try {
|
||||
await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr)
|
||||
} catch {
|
||||
relay.proc.kill('SIGKILL')
|
||||
await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr)
|
||||
}
|
||||
}
|
||||
await stopProcess(relay?.proc, relay?.stderr ?? (() => ''), 'connect bridge')
|
||||
await stopProcess(daemon, daemonStreams?.stderr ?? (() => ''), 'relay daemon')
|
||||
if (tempRoot) {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -34,4 +34,14 @@ describe('release E2E dispatch contract', () => {
|
||||
expect(refInput.type).toBe('string')
|
||||
expect(refInput.required).toBe(false)
|
||||
})
|
||||
|
||||
it('includes the paired-runtime web client in the shared E2E build artifact', () => {
|
||||
const buildStep = e2eWorkflow.jobs.build.steps.find(
|
||||
(step) => step.name === 'Build Electron app for E2E'
|
||||
)
|
||||
|
||||
expect(buildStep.run).toContain('electron-vite build --mode e2e')
|
||||
expect(buildStep.env.VITE_EXPOSE_STORE).toBe('true')
|
||||
expect(buildStep.run).toContain('pnpm run build:web-from-renderer')
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user