mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/mobile-host-card-actions-menu
# Conflicts: # mobile/src/components/MobileHostCard.tsx
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,343 @@
|
||||
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`.
|
||||
|
||||
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 (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'
|
||||
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:
|
||||
- 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: ${{ inputs.ref || github.ref_name }}
|
||||
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 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why this check: the version script is read from the branch being built,
|
||||
# not from main, so a branch cut before the adhoc channel landed has no
|
||||
# copy of it. Say that plainly instead of failing with a module-not-found.
|
||||
if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then
|
||||
echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel."
|
||||
exit 1
|
||||
fi
|
||||
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
|
||||
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,6 +9,12 @@ 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'
|
||||
@@ -59,6 +65,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 +93,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
|
||||
@@ -130,15 +140,31 @@ jobs:
|
||||
- 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,395 @@
|
||||
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 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why read the highest number off existing titles rather than counting
|
||||
# releases: the prune step trims to HOURLY_RETAIN_COUNT, so a count would
|
||||
# roll backwards after three days and reissue numbers already in use. The
|
||||
# maximum is always among the retained builds, because pruning drops the
|
||||
# oldest. `capture(...)?` swallows the non-match on legacy titles, which
|
||||
# were the raw tag, so the series simply starts at 01.
|
||||
#
|
||||
# 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.
|
||||
last_number="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name,isDraft \
|
||||
--jq '[.[] | (.name // "") | capture(" • (?<n>[0-9]+) • ")? | .n | tonumber] | max // 0')"
|
||||
build_number=$(( last_number + 1 ))
|
||||
echo "Hourly build number $build_number (previous high: $last_number)"
|
||||
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
|
||||
>"$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
|
||||
|
||||
+90
-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
|
||||
|
||||
@@ -273,11 +290,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 +353,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 +361,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 +387,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 +415,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:
|
||||
@@ -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:
|
||||
|
||||
+3
-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,7 @@ 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
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
@@ -132,4 +133,4 @@ 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})();
|
||||
`
|
||||
}
|
||||
@@ -21,7 +21,6 @@ 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'
|
||||
|
||||
@@ -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,41 @@
|
||||
{
|
||||
"$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/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}",
|
||||
"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}", "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
+1231
-77
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { formatReleaseTitleTimestamp } from './release-title-timestamp.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 • 08-01 14:25 • 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 = '') {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const version = createAdhocBuildVersion(packageJson.version, 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 ?? '')
|
||||
// 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 • 07-31 13:54 • 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(' 01-14 18:30 ')
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' 07-31 00:00 ')
|
||||
})
|
||||
|
||||
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 • 07-31 13:54 • e698241'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an invalid timestamp', () => {
|
||||
expect(() => formatAdhocReleaseName('1.4.163', 'x', 'abcdefg', new Date('nope'))).toThrow(
|
||||
/invalid/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
@@ -163,20 +225,15 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -40,12 +99,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 +128,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,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,64 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { formatReleaseTitleTimestamp } from './release-title-timestamp.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}`
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • 01 • 07-31 13:54 • 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(' • ')
|
||||
}
|
||||
|
||||
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const version = createHourlyBuildVersion(packageJson.version, now)
|
||||
return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
|
||||
const identity = getHourlyBuildIdentity(new Date(), buildNumber)
|
||||
// Consumed by the workflow via $GITHUB_OUTPUT.
|
||||
process.stdout.write(
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHourlyBuildVersion, formatHourlyReleaseName } 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 • 07-31 13:54 • 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 • 01-14 18:30 • e698241')
|
||||
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • 07-31 00:00 • e698241')
|
||||
})
|
||||
|
||||
// Why: hour12: false renders midnight as "24" on some ICU builds, which would
|
||||
// read as an hour that does not exist and sort oddly beside 00:xx.
|
||||
it('renders midnight as 00, never 24', () => {
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' 00:00 ')
|
||||
})
|
||||
|
||||
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/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -5038,5 +5038,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": "선택된 에이전트가 없습니다"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '전체 디스크 접근 권한',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
|
||||
|
||||
/**
|
||||
* `07-31 13:54` — the timestamp segment of a dev build's release title, shown
|
||||
* verbatim in both the GitHub releases list and the in-app build picker.
|
||||
*
|
||||
* Why Pacific while the tag's own stamp stays UTC: that stamp is a sort key, and
|
||||
* a local one would repeat an hour at every DST fall-back, making two distinct
|
||||
* builds compare equal. A title is only ever read, so it uses the timezone the
|
||||
* people reading it are in. The two therefore disagree by the current offset.
|
||||
*/
|
||||
export function formatReleaseTitleTimestamp(date) {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
throw new Error('Release title timestamp is invalid.')
|
||||
}
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: RELEASE_NAME_TIME_ZONE,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
|
||||
hourCycle: 'h23'
|
||||
})
|
||||
.formatToParts(date)
|
||||
.map((part) => [part.type, part.value])
|
||||
)
|
||||
return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { getDefaultUserDataPath } from '../../src/cli/runtime/metadata'
|
||||
import type { PairingOffer } from '../../src/shared/pairing'
|
||||
import { RemoteRuntimeSharedControlConnection } from '../../src/shared/remote-runtime-shared-control-connection'
|
||||
import {
|
||||
resolveEnvironment,
|
||||
resolveEnvironmentPairingOffer
|
||||
} from '../../src/shared/runtime-environment-store'
|
||||
import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME
|
||||
if (!environmentName) {
|
||||
throw new Error('ORCA_PROBE_ENVIRONMENT_NAME is required')
|
||||
}
|
||||
const userDataPath = getDefaultUserDataPath()
|
||||
const environment = resolveEnvironment(userDataPath, environmentName)
|
||||
const pairing = resolveEnvironmentPairingOffer(userDataPath, environment.id)
|
||||
const cycles = readProbeInteger('ORCA_PROBE_CYCLES', 10, 100)
|
||||
const concurrency = readProbeInteger('ORCA_PROBE_CONCURRENCY', 25, 200)
|
||||
const settleMs = readProbeInteger('ORCA_PROBE_SETTLE_MS', 250, 5_000)
|
||||
const cleanupTimeoutMs = readProbeInteger('ORCA_PROBE_CLEANUP_TIMEOUT_MS', 10_000, 30_000)
|
||||
const unknownResponses = new Map<string, number>()
|
||||
const originalWarn = console.warn
|
||||
console.warn = (message?: unknown, details?: unknown): void => {
|
||||
if (
|
||||
message === '[remote-runtime.shared-control] unknown response id' &&
|
||||
typeof details === 'object' &&
|
||||
details !== null
|
||||
) {
|
||||
const responseId = String((details as { responseId?: unknown }).responseId ?? 'unknown')
|
||||
unknownResponses.set(responseId, (unknownResponses.get(responseId) ?? 0) + 1)
|
||||
return
|
||||
}
|
||||
originalWarn(message, details)
|
||||
}
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const before = await requestMemorySnapshot(pairing, environment.id)
|
||||
let ok = 0
|
||||
let subscriptionResponses = 0
|
||||
let runtimeStatus: RuntimeStatus | null = null
|
||||
const cleanupDurationsMs: number[] = []
|
||||
for (let cycle = 0; cycle < cycles; cycle += 1) {
|
||||
const result = await runCycle({
|
||||
pairing,
|
||||
environmentId: environment.id,
|
||||
concurrency,
|
||||
settleMs,
|
||||
cleanupTimeoutMs
|
||||
})
|
||||
ok += result.ok
|
||||
subscriptionResponses += result.subscriptionResponses
|
||||
runtimeStatus ??= result.runtimeStatus
|
||||
cleanupDurationsMs.push(result.cleanupDurationMs)
|
||||
}
|
||||
await wait(settleMs)
|
||||
const after = await requestMemorySnapshot(pairing, environment.id)
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
environment: { id: environment.id, name: environment.name },
|
||||
runtime: runtimeStatus
|
||||
? {
|
||||
appVersion: runtimeStatus.appVersion ?? null,
|
||||
capabilities: runtimeStatus.capabilities ?? [],
|
||||
hostPlatform: runtimeStatus.hostPlatform ?? null
|
||||
}
|
||||
: null,
|
||||
cycles,
|
||||
concurrency,
|
||||
requests: cycles * concurrency,
|
||||
ok,
|
||||
subscriptionResponses,
|
||||
cleanupDurationMs: {
|
||||
average: Math.round(
|
||||
cleanupDurationsMs.reduce((total, duration) => total + duration, 0) /
|
||||
cleanupDurationsMs.length
|
||||
),
|
||||
maximum: Math.max(...cleanupDurationsMs)
|
||||
},
|
||||
unknownResponseFrames: Array.from(unknownResponses.values()).reduce(
|
||||
(total, count) => total + count,
|
||||
0
|
||||
),
|
||||
unknownResponseIds: unknownResponses.size,
|
||||
memory: {
|
||||
before: summarizeMemory(before),
|
||||
after: summarizeMemory(after),
|
||||
appDelta: after.app.memory - before.app.memory
|
||||
},
|
||||
elapsedMs: Date.now() - startedAt
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
console.warn = originalWarn
|
||||
}
|
||||
}
|
||||
|
||||
async function runCycle(args: {
|
||||
pairing: PairingOffer
|
||||
environmentId: string
|
||||
concurrency: number
|
||||
settleMs: number
|
||||
cleanupTimeoutMs: number
|
||||
}): Promise<{
|
||||
ok: number
|
||||
subscriptionResponses: number
|
||||
cleanupDurationMs: number
|
||||
runtimeStatus: RuntimeStatus | null
|
||||
}> {
|
||||
const connection = new RemoteRuntimeSharedControlConnection(args.pairing, {
|
||||
environmentId: args.environmentId
|
||||
})
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: args.concurrency }, () =>
|
||||
connection.request<RuntimeStatus>('status.get', undefined, 10_000)
|
||||
)
|
||||
)
|
||||
const runtimeStatus = responses.find((response) => response.ok)
|
||||
let subscriptionResponses = 0
|
||||
const subscriptions = await Promise.all([
|
||||
connection.subscribe('runtime.clientEvents.subscribe', undefined, 10_000, {
|
||||
onResponse: () => {
|
||||
subscriptionResponses += 1
|
||||
},
|
||||
onError: () => {}
|
||||
}),
|
||||
connection.subscribe('session.tabs.subscribeAll', undefined, 10_000, {
|
||||
onResponse: () => {
|
||||
subscriptionResponses += 1
|
||||
},
|
||||
onError: () => {}
|
||||
})
|
||||
])
|
||||
await wait(args.settleMs)
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.close()
|
||||
}
|
||||
const cleanupDurationMs = await waitForConnectionIdle(connection, args.cleanupTimeoutMs)
|
||||
// Let cleanup replies reach the retirement cache before closing the socket.
|
||||
await wait(args.settleMs)
|
||||
return {
|
||||
ok: responses.filter((response) => response.ok).length,
|
||||
subscriptionResponses,
|
||||
cleanupDurationMs,
|
||||
runtimeStatus: runtimeStatus?.ok === true ? runtimeStatus.result : null
|
||||
}
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnectionIdle(
|
||||
connection: RemoteRuntimeSharedControlConnection,
|
||||
timeoutMs: number
|
||||
): Promise<number> {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const diagnostics = connection.getDiagnostics()
|
||||
if (diagnostics.pendingRequestCount === 0 && diagnostics.subscriptionCount === 0) {
|
||||
return Date.now() - startedAt
|
||||
}
|
||||
await wait(25)
|
||||
}
|
||||
throw new Error(`Cycle did not settle: ${JSON.stringify(connection.getDiagnostics())}`)
|
||||
}
|
||||
|
||||
async function requestMemorySnapshot(
|
||||
pairing: PairingOffer,
|
||||
environmentId: string
|
||||
): Promise<MemorySnapshot> {
|
||||
const connection = new RemoteRuntimeSharedControlConnection(pairing, { environmentId })
|
||||
try {
|
||||
const response = await connection.request<MemorySnapshot>(
|
||||
'diagnostics.memory',
|
||||
undefined,
|
||||
20_000
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Memory snapshot failed: ${response.error.message}`)
|
||||
}
|
||||
return response.result
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeMemory(snapshot: MemorySnapshot): {
|
||||
app: MemorySnapshot['app']
|
||||
host: MemorySnapshot['host']
|
||||
processMemoryMetric: MemorySnapshot['processMemoryMetric']
|
||||
totalCpu: number
|
||||
totalMemory: number
|
||||
worktreeCount: number
|
||||
sessionCount: number
|
||||
worktreeMemory: number
|
||||
topWorktrees: {
|
||||
worktreeName: string
|
||||
repoName: string
|
||||
cpu: number
|
||||
memory: number
|
||||
sessionCount: number
|
||||
topSessions: { pid: number; cpu: number; memory: number }[]
|
||||
}[]
|
||||
} {
|
||||
return {
|
||||
app: snapshot.app,
|
||||
host: snapshot.host,
|
||||
processMemoryMetric: snapshot.processMemoryMetric,
|
||||
totalCpu: snapshot.totalCpu,
|
||||
totalMemory: snapshot.totalMemory,
|
||||
worktreeCount: snapshot.worktrees.length,
|
||||
sessionCount: snapshot.worktrees.reduce(
|
||||
(total, worktree) => total + worktree.sessions.length,
|
||||
0
|
||||
),
|
||||
worktreeMemory: snapshot.worktrees.reduce((total, worktree) => total + worktree.memory, 0),
|
||||
topWorktrees: [...snapshot.worktrees]
|
||||
.sort((left, right) => right.memory - left.memory)
|
||||
.slice(0, 10)
|
||||
.map((worktree) => ({
|
||||
worktreeName: worktree.worktreeName,
|
||||
repoName: worktree.repoName,
|
||||
cpu: worktree.cpu,
|
||||
memory: worktree.memory,
|
||||
sessionCount: worktree.sessions.length,
|
||||
topSessions: [...worktree.sessions]
|
||||
.sort((left, right) => right.memory - left.memory)
|
||||
.slice(0, 5)
|
||||
.map((session) => ({
|
||||
pid: session.pid,
|
||||
cpu: session.cpu,
|
||||
memory: session.memory
|
||||
}))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function readProbeInteger(name: string, fallback: number, maximum: number): number {
|
||||
const value = process.env[name]
|
||||
if (value === undefined) {
|
||||
return fallback
|
||||
}
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) {
|
||||
throw new Error(`${name} must be an integer from 1 through ${maximum}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function wait(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -20,11 +20,27 @@ function quoteWindowsArg(value) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
// Why: under a Git Bash setup runner, Orca exports ORCA_WORKTREE_PATH in MSYS form (/c/...), which
|
||||
// cmd.exe cannot resolve. This is the migration pattern for any setup script feeding a native exe.
|
||||
function posixShellPathToNativeWindowsPath(value) {
|
||||
const driveMatch = value.match(/^\/([A-Za-z])\/(.*)$/)
|
||||
if (driveMatch) {
|
||||
return `${driveMatch[1].toUpperCase()}:\\${driveMatch[2].replace(/\//g, '\\')}`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function spawnOptionalSetup(spawn, setupPath, worktreePath, platform, env) {
|
||||
if (platform === 'win32') {
|
||||
const nativeWorktreePath = posixShellPathToNativeWindowsPath(worktreePath)
|
||||
spawn(
|
||||
env.ComSpec || 'cmd.exe',
|
||||
['/d', '/s', '/c', `call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(worktreePath)}`],
|
||||
[
|
||||
'/d',
|
||||
'/s',
|
||||
'/c',
|
||||
`call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(nativeWorktreePath)}`
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
windowsVerbatimArguments: true
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* pnpm bench:multi-workspace-typing [-- --panes 8 --rate-kbps 512 \
|
||||
* --keys 48 --cadence-ms 250 --cpu-workers 4 --label before-fix]
|
||||
*
|
||||
* Results land in tools/benchmarks/results/multi-workspace-typing-*.json.
|
||||
* Results land in tests/tools/benchmarks/results/multi-workspace-typing-*.json.
|
||||
* Run once per build/config with distinct --label values, then diff the
|
||||
* totalMs/inputHalfMs/echoHalfMs percentiles.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const extraArgs = process.argv.slice(2)
|
||||
const pnpmEntry = process.env.npm_execpath
|
||||
if (!pnpmEntry) {
|
||||
throw new Error('npm_execpath is required; run this harness through pnpm')
|
||||
}
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(process.execPath, [pnpmEntry, 'run', 'ensure:electron-runtime'], {
|
||||
stdio: 'inherit',
|
||||
env
|
||||
})
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
pnpmEntry,
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env
|
||||
}
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -0,0 +1,81 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const defaultFiles = [
|
||||
'src/main/ssh/sftp-upload.test.ts',
|
||||
'src/main/ssh/ssh-file-transfer-abort.test.ts',
|
||||
'src/main/ssh/ssh-relay-deploy-staged-upload.test.ts',
|
||||
'src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts',
|
||||
'src/main/ssh/ssh-relay-sftp-namespace-install.test.ts',
|
||||
'src/main/ssh/ssh-relay-install-namespace.test.ts',
|
||||
'src/main/ssh/ssh-relay-upload-stage-commands.test.ts',
|
||||
'src/main/ssh/sftp-namespace-resolution.test.ts',
|
||||
'src/main/ssh/ssh-connection-sftp-wire.test.ts',
|
||||
'src/main/ssh/ssh-remote-commands.test.ts',
|
||||
'src/main/ssh/ssh-relay-cross-version-isolation.test.ts'
|
||||
]
|
||||
|
||||
const cliArguments = process.argv.slice(2)
|
||||
const powerShellFlag = cliArguments.indexOf('--powershell')
|
||||
const configuredPowerShell =
|
||||
powerShellFlag >= 0 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE
|
||||
if (powerShellFlag >= 0 && !configuredPowerShell) {
|
||||
console.error('--powershell requires an executable path')
|
||||
process.exit(2)
|
||||
}
|
||||
const powerShellExecutable = [
|
||||
configuredPowerShell,
|
||||
...(process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh'])
|
||||
].find((candidate) => {
|
||||
if (!candidate) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
spawnSync(
|
||||
candidate,
|
||||
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.ToString()'],
|
||||
{ encoding: 'utf8' }
|
||||
).status === 0
|
||||
)
|
||||
})
|
||||
if (!powerShellExecutable) {
|
||||
console.error('A native PowerShell executable is required')
|
||||
process.exit(2)
|
||||
}
|
||||
const powerShellVersion = spawnSync(
|
||||
powerShellExecutable,
|
||||
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.ToString()'],
|
||||
{ encoding: 'utf8' }
|
||||
).stdout.trim()
|
||||
console.log(`SSH staged-upload reliability: PowerShell ${powerShellVersion}`)
|
||||
const requestedFiles = cliArguments.filter(
|
||||
(_argument, index) => index !== powerShellFlag && index !== powerShellFlag + 1
|
||||
)
|
||||
const files = requestedFiles.length > 0 ? requestedFiles : defaultFiles
|
||||
|
||||
const result = spawnSync(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'--config',
|
||||
'config/vitest.config.ts',
|
||||
...files,
|
||||
'--maxWorkers=1',
|
||||
'--reporter=dot'
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_POWERSHELL_EXECUTABLE: powerShellExecutable
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Creates stablyai/orca-adhoc and grants the existing release App write access to
|
||||
# it, so adhoc-mac-build.yml can publish there.
|
||||
#
|
||||
# Why a separate repo rather than reusing orca-hourly: an adhoc build is somebody's
|
||||
# unlanded branch. Sharing hourly's repo would put branch builds in the list a
|
||||
# developer riding main sees, and the two are different levels of unvetted.
|
||||
#
|
||||
# Why no secrets are set here: the adhoc workflow reuses the same GitHub App as
|
||||
# hourly — one App id, one private key, one thing to rotate. This script only has
|
||||
# to widen that App's installation to cover the new repo.
|
||||
#
|
||||
# Run once, after config/scripts/setup-hourly-release-token.sh:
|
||||
# bash config/scripts/setup-adhoc-release-repo.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ORG="stablyai"
|
||||
ADHOC_REPO="$ORG/orca-adhoc"
|
||||
MAIN_REPO="$ORG/orca"
|
||||
APP_SLUG="orca-hourly-release"
|
||||
|
||||
fail() {
|
||||
echo "error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com"
|
||||
gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login"
|
||||
|
||||
if gh api "repos/$ADHOC_REPO" --jq '.full_name' >/dev/null 2>&1; then
|
||||
echo "$ADHOC_REPO already exists."
|
||||
else
|
||||
echo "Creating $ADHOC_REPO..."
|
||||
# Why public: the in-app updater fetches release assets unauthenticated, exactly
|
||||
# as it does for orca-hourly. A private repo would 404 for every client.
|
||||
#
|
||||
# Why the features are off: this repo holds releases and nothing else. Leaving
|
||||
# issues open invites bug reports against a branch build in a repo nobody
|
||||
# watches, where they are simply lost.
|
||||
gh repo create "$ADHOC_REPO" \
|
||||
--public \
|
||||
--description "Adhoc macOS dev builds of Orca, cut from unlanded branches. Not a source repo." \
|
||||
--disable-issues \
|
||||
--disable-wiki ||
|
||||
fail "Could not create $ADHOC_REPO."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Granting $APP_SLUG access to $ADHOC_REPO..."
|
||||
|
||||
# Why attempt the API before printing instructions: an org owner can do this in
|
||||
# one call. Everyone else gets a 403 and the manual path below — GitHub does not
|
||||
# let a mere admin widen an App's repository selection.
|
||||
INSTALL_ID="$(gh api "orgs/$ORG/installations" --paginate \
|
||||
--jq ".installations[] | select(.app_slug == \"$APP_SLUG\") | .id" 2>/dev/null || true)"
|
||||
REPO_ID="$(gh api "repos/$ADHOC_REPO" --jq '.id' 2>/dev/null || true)"
|
||||
|
||||
GRANTED=false
|
||||
if [[ -n "$INSTALL_ID" && -n "$REPO_ID" ]]; then
|
||||
if gh api -X PUT "user/installations/$INSTALL_ID/repositories/$REPO_ID" >/dev/null 2>&1; then
|
||||
GRANTED=true
|
||||
echo "Done — $APP_SLUG can now write to $ADHOC_REPO."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$GRANTED" != "true" ]]; then
|
||||
# Why no automated check afterwards: the endpoints that report an App's
|
||||
# repository access (repos/*/installation, user/installations/*/repositories)
|
||||
# both reject an ordinary `gh auth login` token, so any "verified" this script
|
||||
# printed would be guesswork. The smoke test below is the real check.
|
||||
cat <<EOF
|
||||
|
||||
Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in the browser:
|
||||
|
||||
1. Open: https://github.com/organizations/$ORG/settings/installations
|
||||
2. Configure -> $APP_SLUG
|
||||
3. Repository access -> Only select repositories -> add $ADHOC_REPO
|
||||
(keep orca-hourly selected; both dev channels use this one App)
|
||||
4. Save.
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Smoke-test the pipeline (after this merges):"
|
||||
echo " gh workflow run adhoc-mac-build.yml --repo $MAIN_REPO --ref main \\"
|
||||
echo " -f ref=<your-branch> -f label=<short-name>"
|
||||
echo " gh run watch --repo $MAIN_REPO"
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Provisions the credentials hourly-mac-build.yml uses to publish into
|
||||
# stablyai/orca-hourly. GITHUB_TOKEN cannot be used: it is scoped to the repo
|
||||
# running the workflow, and hourly artifacts are published to a different one.
|
||||
#
|
||||
# A GitHub App is used rather than a PAT because its private key does not expire
|
||||
# — no yearly rotation — and it belongs to the org rather than to the person who
|
||||
# created it, so it survives that person leaving.
|
||||
#
|
||||
# The same App also serves adhoc-mac-build.yml, which reads these same two
|
||||
# secrets: one credential, one rotation, both dev channels. Widening it to cover
|
||||
# stablyai/orca-adhoc is config/scripts/setup-adhoc-release-repo.sh's job.
|
||||
#
|
||||
# The key is read from a file and piped straight into `gh secret set`. It is never
|
||||
# echoed, never passed as a command-line argument (argv is world-readable via
|
||||
# `ps`), and never copied anywhere on disk.
|
||||
#
|
||||
# Usage: bash config/scripts/setup-hourly-release-token.sh [path/to/key.pem]
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# Guard: xtrace would echo the key to stderr on every expansion. Test before
|
||||
# disabling, or the check reads the state this line just cleared and never fires.
|
||||
if [[ -o xtrace ]]; then
|
||||
echo "Refusing to run with xtrace enabled; it would echo the private key." >&2
|
||||
exit 1
|
||||
fi
|
||||
set +x
|
||||
|
||||
MAIN_REPO="stablyai/orca"
|
||||
HOURLY_REPO="stablyai/orca-hourly"
|
||||
APP_ID_SECRET="HOURLY_RELEASE_APP_ID"
|
||||
APP_KEY_SECRET="HOURLY_RELEASE_APP_PRIVATE_KEY"
|
||||
|
||||
fail() {
|
||||
echo "error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com"
|
||||
gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login"
|
||||
|
||||
# Setting repo secrets requires admin; check before asking for anything.
|
||||
if [[ "$(gh api "repos/$MAIN_REPO" --jq '.permissions.admin' 2>/dev/null)" != "true" ]]; then
|
||||
fail "You need admin on $MAIN_REPO to set repository secrets."
|
||||
fi
|
||||
gh api "repos/$HOURLY_REPO" --jq '.full_name' >/dev/null 2>&1 ||
|
||||
fail "$HOURLY_REPO does not exist or you cannot see it."
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Create a GitHub App (one time — the key never expires)
|
||||
──────────────────────────────────────────────────────
|
||||
1. Open: https://github.com/organizations/stablyai/settings/apps/new
|
||||
2. Name .................. orca-hourly-release
|
||||
Homepage URL .......... https://github.com/$HOURLY_REPO
|
||||
Webhook ............... UNCHECK "Active"
|
||||
3. Repository permissions -> Contents: Read and write
|
||||
(leave everything else alone)
|
||||
4. "Where can this app be installed?" -> Only on this account
|
||||
5. Create, then note the App ID shown at the top of the page.
|
||||
6. Generate a private key (bottom of the page) — a .pem downloads.
|
||||
7. Install App -> Only select repositories -> $HOURLY_REPO
|
||||
|
||||
EOF
|
||||
|
||||
read -rp "App ID (numeric): " APP_ID
|
||||
[[ "$APP_ID" =~ ^[0-9]+$ ]] || fail "App ID must be numeric, got: ${APP_ID:-<empty>}"
|
||||
|
||||
KEY_PATH="${1:-}"
|
||||
if [[ -z "$KEY_PATH" ]]; then
|
||||
read -rp "Path to the downloaded .pem: " KEY_PATH
|
||||
fi
|
||||
# Expand a leading ~ so a pasted path works without quoting rules.
|
||||
KEY_PATH="${KEY_PATH/#\~/$HOME}"
|
||||
[[ -r "$KEY_PATH" ]] || fail "Cannot read key file: $KEY_PATH"
|
||||
grep -q "BEGIN.*PRIVATE KEY" "$KEY_PATH" ||
|
||||
fail "$KEY_PATH does not look like a PEM private key."
|
||||
|
||||
echo "Storing $APP_ID_SECRET in $MAIN_REPO..."
|
||||
printf '%s' "$APP_ID" | gh secret set "$APP_ID_SECRET" --repo "$MAIN_REPO" ||
|
||||
fail "Could not set $APP_ID_SECRET."
|
||||
|
||||
# Piped on stdin so the key never appears in argv or in shell history.
|
||||
echo "Storing $APP_KEY_SECRET in $MAIN_REPO..."
|
||||
gh secret set "$APP_KEY_SECRET" --repo "$MAIN_REPO" <"$KEY_PATH" ||
|
||||
fail "Could not set $APP_KEY_SECRET."
|
||||
|
||||
echo
|
||||
echo "Done. Both secrets are set on $MAIN_REPO."
|
||||
echo
|
||||
echo "Delete your local copy of the key — the workflow reads it from the secret,"
|
||||
echo "and a .pem sitting in ~/Downloads is a standing credential:"
|
||||
echo " rm '$KEY_PATH'"
|
||||
echo
|
||||
echo "Smoke-test the pipeline without waiting for the hour (after this merges):"
|
||||
echo " gh workflow run hourly-mac-build.yml --repo $MAIN_REPO -f force=true"
|
||||
echo " gh run watch --repo $MAIN_REPO"
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
|
||||
describe('packaged skills CLI PR gates', () => {
|
||||
it('builds and executes the Windows packaged CLI', () => {
|
||||
const job = workflow.jobs.package_windows
|
||||
const buildStep = job.steps.find((step) => step.name === 'Build package inputs')
|
||||
const prepareStep = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
|
||||
const packageStep = job.steps.find((step) => step.name === 'Package unpacked app')
|
||||
const smokeStep = job.steps.find((step) => step.name === 'Smoke packaged CLI')
|
||||
|
||||
expect(job['runs-on']).toBe('windows-2022')
|
||||
expect(buildStep.run).toBe('pnpm run build:release')
|
||||
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(packageStep.run).toContain('electron-builder')
|
||||
expect(packageStep.run).toContain('--dir')
|
||||
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
||||
expect(smokeStep.run).toBe(
|
||||
'node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked'
|
||||
)
|
||||
|
||||
const aggregateStep = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
expect(aggregateStep.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}')
|
||||
expect(aggregateStep.run).toContain('"$PACKAGE_WINDOWS"')
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { execFile } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -34,13 +35,70 @@ const appDir = resolve(readAppDirArg(process.argv.slice(2)))
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-packaged-cli-smoke-'))
|
||||
const copiedAppDir = join(tempRoot, basename(appDir))
|
||||
|
||||
let smokeFailure = null
|
||||
try {
|
||||
await cp(appDir, copiedAppDir, { recursive: true, verbatimSymlinks: true })
|
||||
const cliPath = getPackagedCliPath(copiedAppDir)
|
||||
await execFileAsync(cliPath, ['--help'], {
|
||||
env: { ...process.env, NODE_PATH: '' }
|
||||
})
|
||||
console.log(`[packaged-cli-smoke] ${cliPath} --help succeeded outside the repo`)
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
const env = { ...process.env, NODE_PATH: '' }
|
||||
delete env.ORCA_CLI_CWD
|
||||
const run = (args) =>
|
||||
execFileAsync(cliPath, args, {
|
||||
env,
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: 30_000
|
||||
})
|
||||
|
||||
await run(['--help'])
|
||||
const list = JSON.parse((await run(['skills', 'list', '--json'])).stdout)
|
||||
assert(list.topics.some((topic) => topic.name === 'orca-cli'))
|
||||
assert.match((await run(['skills', 'get', 'orca-cli'])).stdout, /name: orca-cli/)
|
||||
assert.match((await run(['skills', 'get', 'computer-use'])).stdout, /name: computer-use/)
|
||||
const install = JSON.parse(
|
||||
(
|
||||
await run([
|
||||
'skills',
|
||||
'install',
|
||||
'--skill',
|
||||
'orca-cli',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--dry-run',
|
||||
'--json'
|
||||
])
|
||||
).stdout
|
||||
)
|
||||
const update = JSON.parse(
|
||||
(await run(['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json'])).stdout
|
||||
)
|
||||
assert.equal(install.executed, false)
|
||||
assert.equal(update.executed, false)
|
||||
console.log(`[packaged-cli-smoke] help and skills commands passed via ${cliPath}`)
|
||||
} catch (error) {
|
||||
smokeFailure = error
|
||||
}
|
||||
|
||||
// Why: on Windows the launcher above spawns the copied Orca.exe (and its crashpad/utility children)
|
||||
// once per command; those handles can outlive execFile's exit by a few ms, so this cleanup hits
|
||||
// EBUSY on our own just-exited process after every assertion already passed. Same retry treatment
|
||||
// as removeHostTree(); a lock that never clears still throws — unless the smoke run itself failed,
|
||||
// in which case surfacing EBUSY instead of the real assertion would hide the actual regression.
|
||||
const cleanupFailure = await rm(tempRoot, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 20,
|
||||
retryDelay: 250
|
||||
}).then(
|
||||
() => null,
|
||||
(error) => error
|
||||
)
|
||||
|
||||
if (smokeFailure) {
|
||||
if (cleanupFailure) {
|
||||
console.warn(`[packaged-cli-smoke] temp cleanup failed: ${cleanupFailure.message}`)
|
||||
}
|
||||
throw smokeFailure
|
||||
}
|
||||
if (cleanupFailure) {
|
||||
throw cleanupFailure
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
|
||||
const INTERNAL_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_INTERNAL'
|
||||
const ASAR_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_ASAR'
|
||||
const TIMEOUT_MS = 100
|
||||
const CHECK_INTERVAL_MS = 20
|
||||
const POLL_TIMEOUT_MS = 5_000
|
||||
const SUCCESS_LINE = '[packaged-watchdog-smoke] app.asar worker detected and recovered a stall'
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
|
||||
}
|
||||
|
||||
function readAppDirArg(argv) {
|
||||
const explicit = argv.find((arg) => arg.startsWith('--app-dir='))
|
||||
if (explicit) {
|
||||
return explicit.slice('--app-dir='.length)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return 'dist/mac-arm64/Orca.app'
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return 'dist/win-unpacked'
|
||||
}
|
||||
return 'dist/linux-unpacked'
|
||||
}
|
||||
|
||||
function getResourcesDir(appDir) {
|
||||
return process.platform === 'darwin' || appDir.endsWith('.app')
|
||||
? join(appDir, 'Contents', 'Resources')
|
||||
: join(appDir, 'resources')
|
||||
}
|
||||
|
||||
function readMarker(markerPath) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(markerPath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMarker(markerPath, predicate, workerError) {
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS
|
||||
while (Date.now() < deadline) {
|
||||
if (workerError.current) {
|
||||
throw workerError.current
|
||||
}
|
||||
const marker = readMarker(markerPath)
|
||||
if (predicate(marker)) {
|
||||
return marker
|
||||
}
|
||||
await sleep(CHECK_INTERVAL_MS)
|
||||
}
|
||||
throw new Error(`Timed out waiting for packaged watchdog marker at ${markerPath}`)
|
||||
}
|
||||
|
||||
async function waitForExit(worker, workerError) {
|
||||
const exitCode = await Promise.race([
|
||||
new Promise((resolveExit) => worker.once('exit', resolveExit)),
|
||||
sleep(POLL_TIMEOUT_MS).then(() => {
|
||||
throw new Error('Timed out waiting for packaged watchdog worker to exit')
|
||||
})
|
||||
])
|
||||
if (workerError.current) {
|
||||
throw workerError.current
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Packaged watchdog worker exited with code ${exitCode}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function runInternal() {
|
||||
const appAsar = process.env[ASAR_ENV]
|
||||
if (!appAsar) {
|
||||
throw new Error(`Missing ${ASAR_ENV}`)
|
||||
}
|
||||
const { app } = await import('electron')
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-smoke-'))
|
||||
const markerPath = join(tempRoot, 'main-thread-hang.json')
|
||||
const entryPath = join(appAsar, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
let worker
|
||||
try {
|
||||
await app.whenReady()
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`Packaged watchdog entry is missing from app.asar: ${entryPath}`)
|
||||
}
|
||||
const workerError = { current: null }
|
||||
worker = new Worker(entryPath, {
|
||||
workerData: {
|
||||
parentPid: process.pid,
|
||||
markerPath,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
checkIntervalMs: CHECK_INTERVAL_MS
|
||||
}
|
||||
})
|
||||
worker.once('error', (error) => {
|
||||
workerError.current = error
|
||||
})
|
||||
await waitForMarker(markerPath, (marker) => marker?.selfRecovered === false, workerError)
|
||||
worker.postMessage({ type: 'heartbeat' })
|
||||
await waitForMarker(markerPath, (marker) => marker?.selfRecovered === true, workerError)
|
||||
worker.postMessage({ type: 'shutdown' })
|
||||
await waitForExit(worker, workerError)
|
||||
worker = undefined
|
||||
console.log(SUCCESS_LINE)
|
||||
} finally {
|
||||
await worker?.terminate()
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function runSmoke() {
|
||||
const appDir = resolve(readAppDirArg(process.argv.slice(2)))
|
||||
const appAsar = join(getResourcesDir(appDir), 'app.asar')
|
||||
if (!existsSync(appAsar)) {
|
||||
throw new Error(`Packaged app archive is missing: ${appAsar}`)
|
||||
}
|
||||
const require = createRequire(import.meta.url)
|
||||
const executable = require('electron')
|
||||
const launcherDir = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-launcher-'))
|
||||
const launcherPath = join(launcherDir, 'main.cjs')
|
||||
writeFileSync(
|
||||
join(launcherDir, 'package.json'),
|
||||
JSON.stringify({ name: 'orca-packaged-watchdog-smoke', main: 'main.cjs' })
|
||||
)
|
||||
writeFileSync(
|
||||
launcherPath,
|
||||
`import(${JSON.stringify(pathToFileURL(import.meta.filename).href)}).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})\n`
|
||||
)
|
||||
const env = { ...process.env, [INTERNAL_ENV]: '1', [ASAR_ENV]: appAsar, NODE_PATH: '' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
try {
|
||||
const electronArgs =
|
||||
process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]
|
||||
const result = spawnSync(executable, electronArgs, {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Packaged watchdog smoke failed (${result.error?.message ?? result.signal ?? result.status}):\n` +
|
||||
`${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
// Why: Electron discards process.exitCode, so status 0 alone can't prove the worker ran.
|
||||
if (!result.stdout.includes(SUCCESS_LINE)) {
|
||||
throw new Error(
|
||||
`Packaged watchdog smoke did not report success:\n${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
process.stdout.write(result.stdout)
|
||||
} finally {
|
||||
rmSync(launcherDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env[INTERNAL_ENV] === '1') {
|
||||
// Why: a graceful quit exits 0 regardless of process.exitCode; only app.exit propagates failure.
|
||||
const { app } = await import('electron')
|
||||
try {
|
||||
await runInternal()
|
||||
app.exit(0)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
}
|
||||
} else {
|
||||
runSmoke()
|
||||
}
|
||||
@@ -10,24 +10,10 @@ describe('terminal IME e2e workflow', () => {
|
||||
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
|
||||
)
|
||||
|
||||
it('runs for xterm patch and terminal IME regression changes', () => {
|
||||
expect(workflow.on.pull_request.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
|
||||
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers.ts',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.test.ts',
|
||||
'src/renderer/src/components/terminal-pane/terminal-ime-*',
|
||||
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts',
|
||||
'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts',
|
||||
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
|
||||
'tests/e2e/terminal-ime-*.ts'
|
||||
])
|
||||
)
|
||||
it('runs only on schedule or manual dispatch', () => {
|
||||
expect(workflow.on.pull_request).toBeUndefined()
|
||||
expect(workflow.on.workflow_dispatch).toBeNull()
|
||||
expect(workflow.on.schedule).toEqual([{ cron: '30 9 * * *' }])
|
||||
})
|
||||
|
||||
it('installs native IBus Hangul and X11 input tools', () => {
|
||||
|
||||
@@ -82,13 +82,17 @@ async function extractToTemporaryCatalog(root, tempDir) {
|
||||
const outputPattern = path.join(tempDir, '{{language}}.json')
|
||||
// Why: extraction output is evidence for this check, not another committed
|
||||
// catalog that feature authors must keep synchronized.
|
||||
await execFileAsync(process.execPath, [cliPath, 'extract', '--sync-primary', '--quiet'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[cliPath, '--config', 'config/i18next.config.ts', 'extract', '--sync-primary', '--quiet'],
|
||||
{
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8'))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
const { existsSync, readFileSync, realpathSync } = require('node:fs')
|
||||
const { builtinModules, createRequire, isBuiltin } = require('node:module')
|
||||
const { dirname, isAbsolute, join, relative, resolve, sep } = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const ts = require('typescript-api')
|
||||
|
||||
const BUILTINS = new Set(builtinModules.flatMap((name) => [name, `node:${name}`]))
|
||||
const CLI_COMMAND_TIMEOUT_MS = 30_000
|
||||
|
||||
function artifactPath(outDir, file) {
|
||||
return relative(outDir, file).split(sep).join('/')
|
||||
}
|
||||
|
||||
function runtimeImportSpecifiers(source, file) {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
false,
|
||||
ts.ScriptKind.JS
|
||||
)
|
||||
const specifiers = []
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isCallExpression(node) && node.arguments.length > 0) {
|
||||
const [argument] = node.arguments
|
||||
const expression = node.expression
|
||||
const isRequire = ts.isIdentifier(expression) && expression.text === 'require'
|
||||
const isRequireResolve =
|
||||
ts.isPropertyAccessExpression(expression) &&
|
||||
ts.isIdentifier(expression.expression) &&
|
||||
expression.expression.text === 'require' &&
|
||||
expression.name.text === 'resolve'
|
||||
const isDynamicImport = expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
|
||||
if ((isRequire || isRequireResolve || isDynamicImport) && ts.isStringLiteralLike(argument)) {
|
||||
specifiers.push(argument.text)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return specifiers
|
||||
}
|
||||
|
||||
function isOutsideRoot(root, target) {
|
||||
const pathFromRoot = relative(root, target)
|
||||
return isAbsolute(pathFromRoot) || pathFromRoot === '..' || pathFromRoot.startsWith(`..${sep}`)
|
||||
}
|
||||
|
||||
function isOptionalPackageImport(artifactRoot, importer, specifier) {
|
||||
if (specifier.startsWith('.') || isAbsolute(specifier)) {
|
||||
return false
|
||||
}
|
||||
const segments = specifier.split('/')
|
||||
const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]
|
||||
let directory = realpathSync(dirname(importer))
|
||||
|
||||
while (!isOutsideRoot(artifactRoot, directory)) {
|
||||
const packageJson = join(directory, 'package.json')
|
||||
if (existsSync(packageJson)) {
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(packageJson, 'utf8'))
|
||||
return (
|
||||
Object.hasOwn(manifest.optionalDependencies ?? {}, packageName) ||
|
||||
manifest.peerDependenciesMeta?.[packageName]?.optional === true
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (directory === artifactRoot) {
|
||||
break
|
||||
}
|
||||
directory = dirname(directory)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveRuntimeImport(outDir, artifactRoot, importer, specifier) {
|
||||
if (BUILTINS.has(specifier) || isBuiltin(specifier)) {
|
||||
return null
|
||||
}
|
||||
let resolved
|
||||
try {
|
||||
resolved = createRequire(importer).resolve(specifier)
|
||||
} catch (error) {
|
||||
if (isOptionalPackageImport(artifactRoot, importer, specifier)) {
|
||||
return null
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`[verify-skills-cli-runtime] missing runtime import "${specifier}" from ` +
|
||||
`${artifactPath(outDir, importer)}: ${detail}`
|
||||
)
|
||||
}
|
||||
if (isOutsideRoot(artifactRoot, resolved)) {
|
||||
throw new Error(
|
||||
`[verify-skills-cli-runtime] import "${specifier}" from ` +
|
||||
`${artifactPath(outDir, importer)} resolved outside ${artifactRoot}: ${resolved}`
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function collectRuntimeClosure(outDir, artifactRoot = dirname(outDir)) {
|
||||
outDir = realpathSync(outDir)
|
||||
artifactRoot = realpathSync(artifactRoot)
|
||||
if (isOutsideRoot(artifactRoot, outDir)) {
|
||||
throw new Error(`[verify-skills-cli-runtime] ${outDir} is outside ${artifactRoot}`)
|
||||
}
|
||||
const entry = resolve(outDir, 'cli', 'index.js')
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error(`[verify-skills-cli-runtime] missing entry ${entry}`)
|
||||
}
|
||||
const pending = [entry]
|
||||
const visited = new Set()
|
||||
|
||||
while (pending.length > 0) {
|
||||
const file = pending.pop()
|
||||
if (!file || visited.has(file)) {
|
||||
continue
|
||||
}
|
||||
visited.add(file)
|
||||
const source = readFileSync(file, 'utf8')
|
||||
for (const specifier of runtimeImportSpecifiers(source, file)) {
|
||||
const resolved = resolveRuntimeImport(outDir, artifactRoot, file, specifier)
|
||||
if (resolved && !isOutsideRoot(artifactRoot, resolved) && /\.(?:c|m)?js$/.test(resolved)) {
|
||||
pending.push(resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...visited].sort()
|
||||
}
|
||||
|
||||
function runCli(outDir, args, timeoutMs = CLI_COMMAND_TIMEOUT_MS) {
|
||||
const entry = resolve(outDir, 'cli', 'index.js')
|
||||
const env = { ...process.env, NODE_PATH: '' }
|
||||
delete env.ORCA_CLI_CWD
|
||||
const result = spawnSync(process.execPath, [entry, ...args], {
|
||||
cwd: dirname(outDir),
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: timeoutMs
|
||||
})
|
||||
if (result.error || result.signal || result.status !== 0) {
|
||||
const detail = [
|
||||
result.error?.message,
|
||||
result.signal ? `terminated by ${result.signal}` : null,
|
||||
result.stdout,
|
||||
result.stderr
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
throw new Error(
|
||||
`[verify-skills-cli-runtime] ${args.join(' ')} exited ${String(result.status)}\n${detail}`
|
||||
)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function parseJson(label, output) {
|
||||
try {
|
||||
return JSON.parse(output)
|
||||
} catch {
|
||||
throw new Error(`[verify-skills-cli-runtime] ${label} emitted invalid JSON:\n${output}`)
|
||||
}
|
||||
}
|
||||
|
||||
function verifySkillsCliRuntime(outDir, artifactRoot = dirname(outDir), options = {}) {
|
||||
const absoluteOutDir = resolve(outDir)
|
||||
const closure = collectRuntimeClosure(absoluteOutDir, resolve(artifactRoot))
|
||||
if (options.executeCommands === false) {
|
||||
return { closureFiles: closure.length, commands: 0 }
|
||||
}
|
||||
const list = parseJson('skills list', runCli(absoluteOutDir, ['skills', 'list', '--json']))
|
||||
const topicNames = new Set(list.topics?.map((topic) => topic.name))
|
||||
for (const topic of ['orca-cli', 'computer-use']) {
|
||||
if (!topicNames.has(topic)) {
|
||||
throw new Error(`[verify-skills-cli-runtime] skills list omitted ${topic}`)
|
||||
}
|
||||
const guide = runCli(absoluteOutDir, ['skills', 'get', topic])
|
||||
if (!guide.includes(`name: ${topic}`)) {
|
||||
throw new Error(`[verify-skills-cli-runtime] skills get ${topic} returned the wrong guide`)
|
||||
}
|
||||
}
|
||||
|
||||
const install = parseJson(
|
||||
'skills install --dry-run',
|
||||
runCli(absoluteOutDir, [
|
||||
'skills',
|
||||
'install',
|
||||
'--skill',
|
||||
'orca-cli',
|
||||
'--agent',
|
||||
'codex',
|
||||
'--dry-run',
|
||||
'--json'
|
||||
])
|
||||
)
|
||||
const update = parseJson(
|
||||
'skills update --dry-run',
|
||||
runCli(absoluteOutDir, ['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json'])
|
||||
)
|
||||
if (install.executed !== false || update.executed !== false) {
|
||||
throw new Error('[verify-skills-cli-runtime] a dry-run reported execution')
|
||||
}
|
||||
|
||||
return { closureFiles: closure.length, commands: 5 }
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const result = verifySkillsCliRuntime(process.argv[2] ?? 'out')
|
||||
console.log(
|
||||
`[verify-skills-cli-runtime] ${result.closureFiles} closure files and ` +
|
||||
`${result.commands} commands passed`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { collectRuntimeClosure, runCli, verifySkillsCliRuntime }
|
||||
@@ -0,0 +1,230 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
collectRuntimeClosure,
|
||||
runCli,
|
||||
verifySkillsCliRuntime
|
||||
} = require('./verify-skills-cli-runtime.cjs')
|
||||
|
||||
async function writeSkillsCliFixture(outDir, handlerSource) {
|
||||
const cliDir = join(outDir, 'cli')
|
||||
const handlerDir = join(cliDir, 'handlers')
|
||||
await mkdir(handlerDir, { recursive: true })
|
||||
await writeFile(join(cliDir, 'index.js'), "require('./handlers/skills')\n", 'utf8')
|
||||
await writeFile(join(handlerDir, 'skills.js'), handlerSource, 'utf8')
|
||||
}
|
||||
|
||||
describe('skills CLI runtime closure', () => {
|
||||
it('runs after Electron composes the final output', async () => {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(new URL('../../package.json', import.meta.url), 'utf8')
|
||||
)
|
||||
for (const scriptName of ['build:desktop', 'build:release']) {
|
||||
const script = packageJson.scripts[scriptName]
|
||||
expect(script.indexOf('build:electron-vite')).toBeLessThan(
|
||||
script.indexOf('verify:built-skills-cli')
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports the missing final-artifact import and its owner', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
await writeSkillsCliFixture(root, "require('../../main/codex-cli/command')\n")
|
||||
|
||||
expect(() => collectRuntimeClosure(root)).toThrow(
|
||||
/missing runtime import "\.\.\/\.\.\/main\/codex-cli\/command" from cli\/handlers\/skills\.js/
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('walks static and dynamic relative imports', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
const sharedDir = join(root, 'shared')
|
||||
await mkdir(sharedDir, { recursive: true })
|
||||
await writeSkillsCliFixture(
|
||||
root,
|
||||
"require('../../shared/first.js'); import('../../shared/second.js')\n"
|
||||
)
|
||||
await writeFile(join(sharedDir, 'first.js'), '', 'utf8')
|
||||
await writeFile(join(sharedDir, 'second.js'), '', 'utf8')
|
||||
|
||||
expect(
|
||||
collectRuntimeClosure(root)
|
||||
.map((file) => relative(realpathSync(root), file))
|
||||
.sort()
|
||||
).toEqual(['cli/handlers/skills.js', 'cli/index.js', 'shared/first.js', 'shared/second.js'])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores import-shaped text in comments and strings', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
await writeSkillsCliFixture(
|
||||
root,
|
||||
[
|
||||
"// require('../../missing-comment.js')",
|
||||
'const message = "import(\'../../missing-string.js\')"',
|
||||
"const template = `require.resolve('../../missing-template.js')`"
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
expect(collectRuntimeClosure(root)).toHaveLength(2)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('can inspect a cross-arch artifact without executing it', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
await writeSkillsCliFixture(root, '')
|
||||
|
||||
expect(verifySkillsCliRuntime(root, undefined, { executeCommands: false })).toEqual({
|
||||
closureFiles: 2,
|
||||
commands: 0
|
||||
})
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds command execution time', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
await writeSkillsCliFixture(root, 'setInterval(() => {}, 1_000)\n')
|
||||
|
||||
expect(() => runCli(root, [], 50)).toThrow(/ETIMEDOUT|terminated by SIGKILL/)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects bare imports resolved outside the artifact', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
const artifactRoot = join(root, 'artifact')
|
||||
const outDir = join(artifactRoot, 'out')
|
||||
const externalPackageDir = join(root, 'node_modules', 'external-package')
|
||||
await mkdir(externalPackageDir, { recursive: true })
|
||||
await writeSkillsCliFixture(outDir, "require('external-package')\n")
|
||||
await writeFile(
|
||||
join(externalPackageDir, 'package.json'),
|
||||
JSON.stringify({ main: 'index.js' }),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
|
||||
|
||||
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
|
||||
/external-package.*resolved outside/s
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects package dependencies resolved outside the artifact', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
const artifactRoot = join(root, 'artifact')
|
||||
const outDir = join(artifactRoot, 'out')
|
||||
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
|
||||
const externalPackageDir = join(root, 'node_modules', 'ancestor-dependency')
|
||||
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
|
||||
await mkdir(packageDir, { recursive: true })
|
||||
await mkdir(externalPackageDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(packageDir, 'package.json'),
|
||||
JSON.stringify({ main: 'index.js' }),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(join(packageDir, 'index.js'), "require('ancestor-dependency')\n", 'utf8')
|
||||
await writeFile(
|
||||
join(externalPackageDir, 'package.json'),
|
||||
JSON.stringify({ main: 'index.js' }),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
|
||||
|
||||
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
|
||||
/ancestor-dependency.*resolved outside/s
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('allows absent dependencies declared optional by their package', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
const artifactRoot = join(root, 'artifact')
|
||||
const outDir = join(artifactRoot, 'out')
|
||||
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
|
||||
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
|
||||
await mkdir(packageDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(packageDir, 'package.json'),
|
||||
JSON.stringify({
|
||||
main: 'index.js',
|
||||
peerDependencies: { 'optional-native': '*' },
|
||||
peerDependenciesMeta: { 'optional-native': { optional: true } }
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(
|
||||
join(packageDir, 'index.js'),
|
||||
"try { require('optional-native') } catch {}\n",
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(collectRuntimeClosure(outDir, artifactRoot)).toHaveLength(3)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects optional dependencies resolved only outside the artifact', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
|
||||
try {
|
||||
const artifactRoot = join(root, 'artifact')
|
||||
const outDir = join(artifactRoot, 'out')
|
||||
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
|
||||
const externalPackageDir = join(root, 'node_modules', 'optional-native')
|
||||
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
|
||||
await mkdir(packageDir, { recursive: true })
|
||||
await mkdir(externalPackageDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(packageDir, 'package.json'),
|
||||
JSON.stringify({
|
||||
main: 'index.js',
|
||||
optionalDependencies: { 'optional-native': '*' }
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(join(packageDir, 'index.js'), "require('optional-native')\n", 'utf8')
|
||||
await writeFile(
|
||||
join(externalPackageDir, 'package.json'),
|
||||
JSON.stringify({ main: 'index.js' }),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
|
||||
|
||||
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
|
||||
/optional-native.*resolved outside/s
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,32 +1,23 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parseArgs } from '../../tools/win-crash-survival-e2e/cli-args.mjs'
|
||||
import { buildCrashAssertions } from '../../tools/win-crash-survival-e2e/crash-assertions.mjs'
|
||||
import { scanPwshFailFast } from '../../tools/win-crash-survival-e2e/crash-step.mjs'
|
||||
import { selectScopedDaemon } from '../../tools/win-crash-survival-e2e/daemon-identity.mjs'
|
||||
import { parseArgs } from '../../tests/tools/win-crash-survival-e2e/cli-args.mjs'
|
||||
import { buildCrashAssertions } from '../../tests/tools/win-crash-survival-e2e/crash-assertions.mjs'
|
||||
import { scanPwshFailFast } from '../../tests/tools/win-crash-survival-e2e/crash-step.mjs'
|
||||
import { selectScopedDaemon } from '../../tests/tools/win-crash-survival-e2e/daemon-identity.mjs'
|
||||
import {
|
||||
reattachSentinelMatches,
|
||||
selectCreatedTabId
|
||||
} from '../../tools/win-crash-survival-e2e/reattach-proof.mjs'
|
||||
import { quotePowerShellLiteral } from '../../tools/win-update-e2e/powershell-runner.mjs'
|
||||
import { closeApp, resolveElectronMainPid } from '../../tools/win-update-e2e/app-driver.mjs'
|
||||
import { isPidAlive } from '../../tools/win-update-e2e/daemon-processes.mjs'
|
||||
} from '../../tests/tools/win-crash-survival-e2e/reattach-proof.mjs'
|
||||
import { quotePowerShellLiteral } from '../../tests/tools/win-update-e2e/powershell-runner.mjs'
|
||||
import { closeApp, resolveElectronMainPid } from '../../tests/tools/win-update-e2e/app-driver.mjs'
|
||||
import { isPidAlive } from '../../tests/tools/win-update-e2e/daemon-processes.mjs'
|
||||
|
||||
describe('win-crash-survival-e2e proof contracts', () => {
|
||||
it('keeps the packaged proof wired as a targeted pull-request gate', () => {
|
||||
it('keeps the packaged proof manually dispatchable without a PR trigger', () => {
|
||||
const workflow = readFileSync('.github/workflows/win-crash-survival-e2e.yml', 'utf8')
|
||||
expect(workflow).toMatch(/^ pull_request:/m)
|
||||
expect(workflow).not.toMatch(/^ pull_request:/m)
|
||||
expect(workflow).toMatch(/^ workflow_dispatch:/m)
|
||||
expect(workflow).not.toMatch(/^ push:/m)
|
||||
expect(workflow).toContain("- 'src/main/daemon/**'")
|
||||
expect(workflow).toContain("- 'src/main/index.ts'")
|
||||
expect(workflow).toContain("- 'src/main/ipc/pty*.ts'")
|
||||
expect(workflow).toContain("- 'src/main/startup/first-window-startup-services.ts'")
|
||||
expect(workflow).toContain("- 'src/main/window/attach-main-window-services.ts'")
|
||||
expect(workflow).toContain("- 'src/preload/**'")
|
||||
expect(workflow).toContain("- 'src/renderer/src/components/terminal-pane/**'")
|
||||
expect(workflow).toContain("- 'src/renderer/src/store/slices/terminals.ts'")
|
||||
expect(workflow).toContain("- '!src/**/*.test.*'")
|
||||
expect(workflow).toContain("- '!src/**/*.bench.*'")
|
||||
expect(workflow).toContain('--expect "$env:EXPECT"')
|
||||
expect(workflow).toContain('exit $LASTEXITCODE')
|
||||
expect(workflow).toContain("'!config/**/*.test.*'")
|
||||
@@ -68,7 +59,7 @@ describe('win-crash-survival-e2e proof contracts', () => {
|
||||
})
|
||||
|
||||
it('scans for FailFast only after the post-crash input probe', () => {
|
||||
const harness = readFileSync('tools/win-crash-survival-e2e/run.mjs', 'utf8')
|
||||
const harness = readFileSync('tests/tools/win-crash-survival-e2e/run.mjs', 'utf8')
|
||||
const scanIndex = harness.indexOf('const { events: failFastEvents }')
|
||||
const probeIndex = harness.indexOf('reattachProven = await proveReattachedShell')
|
||||
expect(scanIndex).not.toBe(-1)
|
||||
@@ -172,7 +163,7 @@ describe('win-crash-survival-e2e proof contracts', () => {
|
||||
})
|
||||
|
||||
it('requires the real packaged main for the crash proof but permits fallback cleanup', async () => {
|
||||
const harness = readFileSync('tools/win-crash-survival-e2e/run.mjs', 'utf8')
|
||||
const harness = readFileSync('tests/tools/win-crash-survival-e2e/run.mjs', 'utf8')
|
||||
expect(harness).toContain(
|
||||
'resolveElectronMainPid(session.app, { allowLauncherFallback: false })'
|
||||
)
|
||||
|
||||
@@ -104,6 +104,41 @@ describe('Windows signing workflow contract', () => {
|
||||
expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module')
|
||||
})
|
||||
|
||||
it('still installs SignPath when the cut ref predates the composite action', () => {
|
||||
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
|
||||
const steps = parsedWorkflow.jobs.build.steps
|
||||
const stepNames = steps.map((step) => step.name)
|
||||
const checkoutIndex = stepNames.indexOf('Checkout')
|
||||
const restoreIndex = stepNames.indexOf('Restore composite actions from the workflow ref')
|
||||
const installIndex = stepNames.indexOf('Install SignPath PowerShell module')
|
||||
|
||||
// Why: the build job checks out the cut tag, which for a hotfix cut from an
|
||||
// older ref can predate `.github/actions/install-signpath-module`; without
|
||||
// this restore the `uses: ./…` step dies on a missing action.yml.
|
||||
expect(restoreIndex).toBeGreaterThan(checkoutIndex)
|
||||
expect(restoreIndex).toBeLessThan(installIndex)
|
||||
|
||||
const restoreStep = steps[restoreIndex]
|
||||
const restoreRun = restoreStep.run
|
||||
|
||||
expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restoreRun).toContain('.github/actions/install-signpath-module/action.yml')
|
||||
expect(restoreRun).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restoreRun).toContain('git checkout "$WORKFLOW_SHA" -- .github/actions')
|
||||
|
||||
// Why: restoring the action must not turn signing into a soft dependency —
|
||||
// a missing module still has to fail the Windows job, and the CDN fallback
|
||||
// still has to reject an unexpected payload.
|
||||
expect(steps[installIndex]['continue-on-error']).toBeUndefined()
|
||||
expect(restoreStep['continue-on-error']).toBeUndefined()
|
||||
|
||||
const installRun = readWorkflow('.github/actions/install-signpath-module/action.yml').runs
|
||||
.steps[0].run
|
||||
|
||||
expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()')
|
||||
expect(installRun).toContain('throw "SHA-256 mismatch for $source')
|
||||
})
|
||||
|
||||
it('shares one SignPath module install path between release and rehearsal', () => {
|
||||
const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml')
|
||||
const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name)
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
"../src/main/agent-hooks/local-agent-cli-presence.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/ipc/local-agent-install-dir-detection.ts",
|
||||
"../src/main/ipc/tui-agent-detection-commands.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"../src/renderer/src/**/*.tsx",
|
||||
"../src/preload/api-types.ts",
|
||||
"../src/shared/**/*",
|
||||
"../src/main/gitlab/mappers.ts",
|
||||
"../src/main/ipc/worktree-branch-name.ts",
|
||||
"../src/main/ipc/worktree-logic.ts",
|
||||
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
'src/**/*.test.tsx',
|
||||
'config/scripts/**/*.test.ts',
|
||||
'config/scripts/**/*.test.mjs',
|
||||
'tools/**/*.test.mjs',
|
||||
'tests/tools/**/*.test.mjs',
|
||||
'tests/e2e/**/*.unit.test.ts'
|
||||
],
|
||||
// Why: the full suite runs heavy TS transforms plus real git/http fixtures;
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
# Agent Status over WSL (STA-1515)
|
||||
|
||||
Status: implemented and rig-validated (2026-07-09 round 3 + 2026-07-10 round-4 re-run
|
||||
pinned to the hardened build, Windows 11 + WSL2 NAT): Claude end-to-end live —
|
||||
provisioning, working→done in the store, completion toast, loopback-only posture, and
|
||||
restart resume over a daemon-surviving PTY with the instance-keyed endpoint dir reused
|
||||
across restarts. The round-4 re-run also proved the `--exec` spawn form live (host
|
||||
process table) and the stale-exit reinstall upgrading the guest to the new bundle
|
||||
version in place. Residual: Codex's done/Stop leg unproven live — env-blocked on the
|
||||
rig (no dev-profile Codex credentials AND the model backend is unreachable from the
|
||||
guest under NAT); everything that fired behaved correctly. Confirm on a credentialed
|
||||
rig with a guest-reachable backend, ideally on a fresh distro to also observe the
|
||||
deferred Codex trust entries landing after config.toml is seeded.
|
||||
Owner: brennanb2025. Linear: STA-1515.
|
||||
Precedent this mirrors: the SSH agent-hook relay (`src/relay/agent-hook-server.ts`,
|
||||
`src/shared/agent-hook-relay.ts`, ingest at `agentHookServer.ingestRemote` in
|
||||
`src/main/agent-hooks/server.ts`).
|
||||
|
||||
## Background — how we got here
|
||||
|
||||
GitHub issue `7565` reported OMP agents in WSL worktrees disappearing from the worktree
|
||||
sidebar after v1.4.124. Diagnosis split it into a regression and a pre-existing class gap:
|
||||
|
||||
- The **regression** was a title-normalization change (PR `7447`) that stopped idle OMP
|
||||
titles from producing the sidebar's title-derived fallback row. Decision: the sidebar is
|
||||
moving to **hook-driven rows only** (fallback removal in flight, separate PR), so the
|
||||
fallback was not restored.
|
||||
- The **class gap** is that agent hooks have never worked from inside WSL for any agent.
|
||||
Two scoped PRs fixed it for OMP alone (merged, live-validated on a Windows+WSL2-NAT rig):
|
||||
- PR `7642` — Orca-managed WSL shells wrap interactive `omp` invocations with
|
||||
`--extension "$ORCA_OMP_STATUS_EXTENSION"` (the env var is WSLENV `/p`-translated so
|
||||
the WSL process reads the extension out of the Windows filesystem via `/mnt/c`).
|
||||
- PR `7641` — when the extension's loopback POST cannot connect, it delivers via
|
||||
Windows-side `/mnt/c/Windows/System32/curl.exe` (a Windows process, so *its*
|
||||
`127.0.0.1` is the loopback Orca actually binds). Fire-and-forget spawn,
|
||||
`--noproxy 127.0.0.1`, memoized WSL/curl probes, load-tolerant timeouts
|
||||
(`--connect-timeout 3 --max-time 10`; 0.5s dropped events under load).
|
||||
|
||||
This document is the full context for the general fix: every other hook client is still
|
||||
dead from WSL, and the hooks-only sidebar change makes this work the gate for the
|
||||
Windows+WSL story.
|
||||
|
||||
## Why hooks don't work on Windows+WSL — two independent gaps
|
||||
|
||||
### Gap A — transport
|
||||
|
||||
The hook listener binds `127.0.0.1` only, deliberately (`src/main/agent-hooks/server.ts`,
|
||||
`listen(0, '127.0.0.1')`; auth via `X-Orca-Agent-Hook-Token`, 403 otherwise). Every hook
|
||||
client POSTs to a hardcoded `http://127.0.0.1:$ORCA_AGENT_HOOK_PORT/hook/<source>`.
|
||||
|
||||
WSL2 under default **NAT** networking is a VM with its own network namespace. Microsoft's
|
||||
localhost forwarding is **one-way (Windows→WSL only)**: `127.0.0.1` inside WSL is WSL's
|
||||
own loopback, so every POST dies `ECONNREFUSED` — silently, because hook clients are
|
||||
deliberately fail-open. Reaching Windows from WSL would require the host vNIC IP (changes
|
||||
per boot) + a non-loopback listener bind + a firewall rule — all three conflict with the
|
||||
loopback-only security posture.
|
||||
|
||||
The env coordinates DO cross correctly (`src/main/pty/wsl-orca-env.ts`
|
||||
`addOrcaWslInteropEnv`: WSLENV `PORT/u TOKEN/u ENV/u VERSION/u` plus
|
||||
`ORCA_AGENT_HOOK_ENDPOINT/p` path-translated; called from `src/main/ipc/pty.ts` and
|
||||
`src/main/daemon/pty-subprocess.ts`). The address is simply unreachable.
|
||||
|
||||
Opt-in **mirrored** networking (Win11, `.wslconfig`) shares loopback and makes plain fetch
|
||||
work — the fix must not fight it. No `wslinfo` probing is needed: under mirrored mode the
|
||||
relay's preferred-port bind collides with the Windows listener and the `EADDRINUSE`
|
||||
fallback (below) handles it, while clients that go straight to the shared loopback reach
|
||||
the Windows listener directly. Both delivery paths stay valid.
|
||||
|
||||
### Gap B — installation
|
||||
|
||||
Hook configs and scripts are written to the **Windows** home by every hook service:
|
||||
Claude `settings.json` + managed scripts, Codex config, Gemini/Cursor/Droid/Devin/Grok/
|
||||
Copilot scripts, Amp/OpenCode plugin files, the Pi/OMP extension file. An agent inside WSL
|
||||
reads the **WSL-side** `$HOME` and sees none of it. There is zero WSL-targeted install
|
||||
code in `src/main/agent-hooks/` or any hook service. SSH remotes have the exact precedent
|
||||
needed: dedicated remote installers (`src/main/ssh/ssh-relay-session.ts` remote
|
||||
settings.json handling; PR `7744` installed Droid/Copilot hooks over SSH).
|
||||
|
||||
Consequence: even mirrored-networking users get no hooks — transport fine, configs absent.
|
||||
OMP escapes Gap B by *pointing across* the boundary (`/mnt/c` path via `/p` translation)
|
||||
rather than installing WSL-side; that trick can carry file *content* for some clients, but
|
||||
shell hooks still execute inside WSL and then hit Gap A regardless.
|
||||
|
||||
## Transport map — every client, how it posts
|
||||
|
||||
Endpoint file contract: `writeEndpointFile` (`src/shared/agent-hook-listener.ts`) emits
|
||||
exactly four keys (`ORCA_AGENT_HOOK_PORT/TOKEN/ENV/VERSION`) to `endpoint.env` (POSIX) /
|
||||
`endpoint.cmd` (Windows) — **no host field**. Shell clients source it to refresh stale
|
||||
coords after an Orca restart; node clients parse it. It is never executed as a delivery
|
||||
script. Clients prefer endpoint-FILE coords over env (restart re-coordination) — any
|
||||
transport change must preserve that property.
|
||||
|
||||
| Client | Mechanism | Runtime that POSTs |
|
||||
| --- | --- | --- |
|
||||
| Claude, Codex, Gemini, Cursor, Droid, Devin, Grok | managed shell script | `curl` (POSIX) / `curl.exe` (Windows), built in `src/main/agent-hooks/installer-utils.ts` |
|
||||
| Copilot | managed script | `curl` (POSIX) / PowerShell `Invoke-WebRequest` (Windows) |
|
||||
| command-code | managed script (parse-not-source hardened) | `curl` |
|
||||
| Amp, OpenCode | in-process node plugin | `fetch` |
|
||||
| Pi / OMP | bundled in-process extension (`src/main/pi/agent-status-extension-source.ts`) | `fetch`, now with the WSL curl.exe fallback |
|
||||
|
||||
All of them target `127.0.0.1`.
|
||||
|
||||
## Status quo + why this gates the hooks-only sidebar
|
||||
|
||||
Today the worktree-card rows still have a title-derived fallback producer
|
||||
(`src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts`), so WSL users
|
||||
DO currently see rows for title-rich agents (Claude `✳`/spinner titles, Gemini glyphs) —
|
||||
degraded (generic text, no prompt/last-message preview, no notifications) but present.
|
||||
Title-poor agents are dark (Codex — hence GH `6907`). When the hooks-only change removes
|
||||
that producer, **every non-OMP agent in a WSL worktree loses its card row entirely until
|
||||
this work ships**. Hook fidelity adds: prompt + last-assistant-message previews,
|
||||
waiting/blocked precision, completion notifications, AI Vault / native chat session
|
||||
integration.
|
||||
|
||||
## Solution design — WSL relay + WSL-side installation
|
||||
|
||||
### Transport (Gap A): guest-resident relay, host-owned stdio
|
||||
|
||||
Run a small receiver **inside WSL on WSL's own loopback, listening on the very port the
|
||||
clients were already given** (`$ORCA_AGENT_HOOK_PORT` — free inside WSL, since that port
|
||||
only exists on the Windows side). Unmodified clients then deliver successfully with
|
||||
**zero client changes**; the reporter's diagnostic relay in GH `7565` proved this shape
|
||||
live. Forward each parsed envelope to the Windows host over the relay's **own stdio**
|
||||
(Orca spawns it via `wsl.exe`, so it owns that pipe). Ingest through the existing trust
|
||||
boundary: `agentHookServer.ingestRemote` (`src/main/agent-hooks/server.ts`), envelope
|
||||
shape `src/shared/agent-hook-relay.ts` — identical to the SSH relay, which runs a
|
||||
loopback-only receiver on the remote box and forwards over the SSH control channel.
|
||||
|
||||
**Port binding.** Bind the inherited `$ORCA_AGENT_HOOK_PORT` first — it keeps every
|
||||
already-crossed coordinate (env and the `/p`-translated endpoint file) truthful with zero
|
||||
divergence. On `EADDRINUSE` inside the guest, fall back to the SSH relay's own pattern:
|
||||
bind `127.0.0.1:0`, write a **WSL-side endpoint file**, and point WSL PTYs'
|
||||
`ORCA_AGENT_HOOK_ENDPOINT` at it (clients already prefer endpoint-file coords over env).
|
||||
The relay writes that WSL-side endpoint file in **both** modes so restart re-coordination
|
||||
never depends on `/mnt/c` translation being readable.
|
||||
|
||||
Lifecycle: one relay per distro **per Orca instance** (concurrent instances have distinct
|
||||
ports, so guest listeners never collide); **ensure** — not just start — whenever a WSL PTY
|
||||
exists: first spawn *and* daemon-PTY reattach after an Orca restart (WSL PTYs survive in
|
||||
the daemon; the new instance has a new port + token and must respawn the relay before
|
||||
surviving agents re-coordinate). The relay **exits when its stdin closes**: a lingering
|
||||
guest listener would let WSL's own Windows→WSL forwarder grab the freed Windows-side port
|
||||
and blackhole stale Windows-side hook posts. Restart if WSL restarts; token still
|
||||
validated at the relay's HTTP receiver; harmless under mirrored networking (bind
|
||||
fallback) and inert on non-WSL platforms (ensure only fires from WSL PTY spawns).
|
||||
|
||||
Reliability contract (invariant class `agent-session.hook-transport`): hook clients are
|
||||
fail-open silent, so the relay must not be — spawn failures, `EADDRINUSE` fallback, and
|
||||
forward errors each leave a diagnosable breadcrumb; the `wsl.exe` "Catastrophic failure
|
||||
(E_UNEXPECTED)" retry is **bounded** with backoff, never a spawn loop. Oracle: provider-
|
||||
contract tests with fault injection (stdin close → exit, occupied port → fallback + file
|
||||
rewrite, envelope round-trip to `ingestRemote`) rather than end-to-end flows only.
|
||||
|
||||
Design notes from a survey of comparable WSL-capable tools (kept nameless per policy):
|
||||
- Guest-resident component + host-owned channel + guest-side installation, explicitly
|
||||
reusing the tool's SSH-remote machinery, **is the established pattern**. No surveyed
|
||||
tool makes guest processes dial back to a Windows-localhost listener — the merged OMP
|
||||
curl.exe stopgap is the outlier as a primary path (but see the round-4 revised
|
||||
stance below: it survives as the no-node fallback).
|
||||
- Prefer host-owned **stdio** over Windows→WSL localhost port forwarding (wslhost
|
||||
forwarding is known-flaky under load; one surveyed tool dials the distro vNIC IP just to
|
||||
avoid it — stdio sidesteps the question entirely).
|
||||
- WSL offers no persistent control channel between separate `wsl.exe` invocations —
|
||||
collapse the relay's ensure-installed + launch into **one idempotent script per spawn**.
|
||||
- Install into the guest from inside the guest (download/extract in WSL) or stream the
|
||||
binary through `wsl.exe` stdin — not by copying through `/mnt/c`.
|
||||
|
||||
### Installation (Gap B): WSL-side hook installers
|
||||
|
||||
Write agent hook configs/scripts into the WSL-side home, per agent, analogous to the SSH
|
||||
remote installers — via `wsl.exe`-executed scripts (preferred, mirrors SSH most closely)
|
||||
or `\\wsl.localhost\<distro>\...` writes. Without this half, the relay receives nothing:
|
||||
the hook clients themselves are absent from the WSL filesystem.
|
||||
|
||||
## Alternatives considered (and why not)
|
||||
|
||||
1. **Endpoint-file host/URL field** — the file already crosses into WSL (`/p`-translated),
|
||||
but clients read only PORT/TOKEN and hardcode the host, so all ~13 still need edits;
|
||||
and a WSL-reachable listener bind breaks the loopback-only posture (LAN exposure,
|
||||
firewall prompts).
|
||||
2. **Replicate the curl.exe bridge per client** — the shell clients share ~2 generated
|
||||
builders so it is cheaper than it sounds, but it is N point fixes, requires WSL interop
|
||||
enabled (`/etc/wsl.conf` can disable it), pays a per-event process spawn (load-sensitive,
|
||||
see validation facts), and keeps the ecosystem-outlier direction.
|
||||
3. **Listener-side bind changes / rely on mirrored networking** — posture conflict /
|
||||
opt-in only.
|
||||
4. **OSC 9999 in-band status** (`src/shared/agent-status-osc.ts`, parsed per-pane in
|
||||
`pty-transport.ts` and `orca-runtime.ts`) — zero-network and pane-attributed, but only
|
||||
viable for in-process clients and carries status payloads, not the full hook event
|
||||
vocabulary (prompts, tools, completion) — cannot replace the pipeline.
|
||||
|
||||
## Facts + gotchas from the 2026-07-08 Windows-rig validation
|
||||
|
||||
- curl.exe interop delivery works under NAT (shipped for OMP), but per-event process spawn
|
||||
is load-sensitive: `--connect-timeout 0.5` dropped 3/3 events to a *healthy* listener
|
||||
under load; fine at 3s. A resident relay avoids per-event spawns entirely.
|
||||
- `wslinfo --networking-mode` distinguishes NAT vs mirrored.
|
||||
- Clients prefer endpoint-FILE coords over env. Testing gotcha: unset
|
||||
`ORCA_AGENT_HOOK_ENDPOINT` in synthetic tests or events go to the real running app.
|
||||
- Server ingest silently drops paneKeys that are not `uuid:uuid`-shaped — use real-shaped
|
||||
keys in synthetic validation.
|
||||
- OMP is a Bun single-file binary; Bun's `node:child_process`/`fetch` compat held. Other
|
||||
in-process clients run inside their agents' runtimes — verify per runtime.
|
||||
- Environmental: fresh WSL 2.7.10 intermittently threw "Catastrophic failure
|
||||
(E_UNEXPECTED)" from `wsl.exe -d <distro> -- bash -lc` under concurrent spawn load
|
||||
(cleared by `wsl --terminate`). The relay spawn path should tolerate/retry this.
|
||||
- Fork-PR CI runs sit in `action_required` until approved:
|
||||
`gh api repos/stablyai/orca/actions/runs/<id>/approve -X POST`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
On a default-config Windows 11 + WSL2 **NAT** machine: launch **Codex or Claude**
|
||||
(explicitly not OMP) in a WSL worktree → live hook-driven worktree-card row with status
|
||||
transitions and a completion notification; hook listener still bound to Windows loopback
|
||||
only; zero per-client transport changes; hooks installed WSL-side automatically (no manual
|
||||
config); harmless under mirrored networking and inert on non-WSL platforms. After an Orca
|
||||
restart with the WSL agent still running (daemon-surviving PTY), status events resume
|
||||
without relaunching the agent.
|
||||
|
||||
## 2026-07-09 Windows-rig validation follow-ups
|
||||
|
||||
The first live GUI run proved every mechanism in isolation but failed end-to-end, yielding
|
||||
two fixes:
|
||||
|
||||
1. **Link death must be handled, not just child death.** A mux protocol error or timeout
|
||||
can kill the host↔guest link while the guest process (and its 204-returning receiver)
|
||||
stays alive — the exact observed signature: hooks POST 204, store never populates.
|
||||
`wsl-hook-relay-link.ts` now guarantees exactly-once death handling from either signal;
|
||||
the manager breadcrumbs it, kills the child, and self-restarts after a short cooldown
|
||||
(a live agent session produces no new PTY spawns to re-trigger ensure).
|
||||
`ORCA_WSL_HOOK_RELAY_DEBUG=1` traces every received envelope pre-ingest so a live rig
|
||||
can pinpoint any residual drop. The full host chain is pinned by a live integration
|
||||
test (real bundle over real child stdio through the real manager into a real
|
||||
`ingestRemote`).
|
||||
2. **(Round 2) The renderer's SSH-era ownership gate dropped `wsl:*` events.** With the
|
||||
link fixed, envelopes reached `ingestRemote` and the durable cache, but
|
||||
`useIpcEvents.applyAgentStatus` compares the stamped connectionId against the owning
|
||||
repo's — `"wsl:<distro>" !== null` for a local repo, so every WSL-relayed status died
|
||||
before `setAgentStatus`/notifications. Fix: `wsl:*` ids are transport provenance, not
|
||||
ownership — the gate normalizes them to local (null) via
|
||||
`isWslHookRelayConnectionId`, while still rejecting WSL-stamped events against
|
||||
SSH-owned repos. Provenance stays stamped (it made the drop diagnosable in the first
|
||||
place).
|
||||
3. **Codex reads a redirected home.** Orca launches WSL Codex with `CODEX_HOME` pointed at
|
||||
the managed runtime home (`~/.local/share/orca/codex-runtime-home/home`), so installing
|
||||
hooks to `~/.codex` left Codex dark. The installers now accept an explicit codex home;
|
||||
the trust write into `config.toml` is deferred while that file doesn't exist (the
|
||||
launch path seeds it only-if-absent — creating it first would cancel the seed), and the
|
||||
manager re-runs the idempotent installers on later ensures (throttled) to upsert trust
|
||||
once the seed lands. Consequence: the very first WSL Codex session after a cold relay
|
||||
may miss hooks; the next one has them.
|
||||
|
||||
## 2026-07-09 adversarial-review hardening (pre-rig round 3)
|
||||
|
||||
Four independent review lenses over the full diff; confirmed findings fixed:
|
||||
|
||||
- **Endpoint identity (all 4 reviewers)**: the guest endpoint dir was keyed by the
|
||||
ephemeral Windows hook port, so a daemon-surviving agent kept sourcing the DEAD
|
||||
`port-P1` file after an Orca restart — breaking the restart-resume acceptance criterion
|
||||
and regressing shipped OMP recovery. Now keyed by a restart-stable instance key
|
||||
(hash of the Windows endpoint file path = userData + namespace, crossed via
|
||||
`ORCA_WSL_HOOK_INSTANCE`): the restarted instance's relay REWRITES the same file, which
|
||||
is exactly what re-coordinates survivors.
|
||||
- **Restart policy**: every failure now arms the restart timer (one failed relaunch no
|
||||
longer ends self-recovery), and the timer probes `wsl --list --running` first — `wsl -d`
|
||||
BOOTS a stopped distro, so recovery must never resurrect a VM the user shut down; a
|
||||
stopped distro's state is dropped instead (next WSL terminal re-ensures). Failure
|
||||
counters only reset after 2 min of stable uptime, so connect-then-die loops escalate to
|
||||
the 10-min cap instead of cycling every 10s.
|
||||
- **Install-dir versioning**: the guest install dir is namespaced by bundle version, so
|
||||
concurrent Orca instances with different bundles (dev + prod) never reinstall over each
|
||||
other; tmp files carry the guest PID. The install spawn also gained the 30s timeout it
|
||||
was missing (a wedged wsl.exe could previously pin the state machine at 'starting'
|
||||
forever).
|
||||
- **Guest node resolution**: candidates (PATH, nvm glob, fixed paths) are each
|
||||
version-probed, first pass wins — an apt node 12 on PATH no longer masks an nvm node 20
|
||||
into a false "no node >= 18" 10-minute cooldown.
|
||||
- **wsl.exe text handling**: `WSL_UTF8=1` on all spawns + NUL-stripping on stderr, so the
|
||||
"Catastrophic failure" transient-retry matcher and breadcrumbs survive UTF-16LE output.
|
||||
- Smaller: ordered post-sentinel chunk handoff (frame-decoder desync race), port-fallback
|
||||
breadcrumb now reaches host logs via the home handshake, bad home reply fails the
|
||||
connect (was: silently 'running' without installs), missing-bundle warn-once, distro
|
||||
map keys case-normalized, `disposeAll` wired to app `will-quit`, single-spawn Codex
|
||||
trust catch-up via a one-shot 60s reinstall timer.
|
||||
|
||||
Accepted gaps (reviewed, deliberately not addressed here): old version-namespaced
|
||||
install dirs accrete across upgrades (~200KB each); an outdated running daemon
|
||||
/p-translates the guest endpoint path until it restarts (hook scripts fall back to env
|
||||
coords, which same-port binding keeps correct); default-distro resolution caches the
|
||||
first non-empty answer for the app run.
|
||||
|
||||
`wslDistroCache` no longer caches a transient empty list for the app run — an empty
|
||||
result is re-probed after a short window, so a distro registered mid-session is picked
|
||||
up. `isWslAvailable` likewise re-probes instead of latching a failure.
|
||||
|
||||
## 2026-07-09 round-4 external adversarial review
|
||||
|
||||
A second adversarial sweep (five independent lenses: guest relay + fs bridge, host
|
||||
lifecycle state machine, app integration + renderer gate, design-vs-alternatives, and a
|
||||
platform fact-check of every WSL claim). Design verdict: the guest-resident relay over
|
||||
host-owned stdio is the right architecture — the zero-per-client-change chokepoint is
|
||||
what the curl.exe alternative cannot match, and the lifecycle weight is inherent to any
|
||||
guest-resident helper. Confirmed findings, all fixed on this branch:
|
||||
|
||||
- **`dropState` identity race (major)**: the recovery timer re-checked state identity
|
||||
only BEFORE the async `wsl --list --running` probe; an ensure() landing during the
|
||||
probe could get its fresh state deleted by key — orphaning a live relay child outside
|
||||
the map (unkillable by `disposeAll`, duplicate relay on next ensure). Fixed: identity
|
||||
re-check after the probe await + identity-guarded delete in the manager.
|
||||
- **Distro-running probe failed OPEN**: any probe error (including its 10s timeout)
|
||||
reported "running", so recovery could `wsl -d` — and thereby BOOT — a distro the user
|
||||
shut down, in exactly the wedged-wsl.exe failure mode where the probe errors. Now
|
||||
fails closed: drop the state; the next WSL PTY spawn re-ensures.
|
||||
- **Spawn form hardened to `--exec`**: `wsl.exe -- <cmd>` routes through the distro's
|
||||
default login shell (Microsoft docs: only `--exec` runs "without using the default
|
||||
Linux shell"), so a fish/nushell chsh could mangle the launch; `--exec sh -c`/-`s`
|
||||
bypasses it and passes argv verbatim (no `$`-preprocessing, escaping shim dropped) —
|
||||
same form as the Codex WSL login spawn.
|
||||
- **Post-sentinel handoff microtask**: pending chunks flushed synchronously inside the
|
||||
mux constructor, before the manager could register notification handlers — an
|
||||
envelope arriving in the trailing bytes dispatched to zero handlers (recovered only
|
||||
by the later replay request). Flush now rides a microtask: after the caller's
|
||||
synchronous wiring, still ahead of any subsequent stdout IO event.
|
||||
- **Relay process posture**: the guest relay now mirrors the SSH relay's
|
||||
`uncaughtException` (log + exit → manager respawns) / `unhandledRejection` (log +
|
||||
survive) handlers.
|
||||
- **Replay cache recency cap**: the WSL relay has no per-pane teardown signal, so the
|
||||
per-pane replay cache grew for the relay's lifetime; now capped at 256 panes,
|
||||
evicting longest-idle first (meta map kept in lockstep). Backstop for SSH too.
|
||||
- Smaller: guest launch script derives the stale-exit code from the shared contract
|
||||
constant (was a hardcoded 42 twin); one-shot reinstall timer refuses to arm after
|
||||
dispose; fs-bridge scope comment states the lexical (symlink-following) bound
|
||||
honestly. New oracles: sentinel unit suite (chunk splits, overflow kill, timeout,
|
||||
microtask handoff), fs-bridge scoping suite, 403 + fallback endpoint-file rewrite,
|
||||
cache-cap eviction, and the recovery/manager race regressions.
|
||||
|
||||
**Revised stance on the OMP curl.exe bridge — keep it, do not retire.** The relay
|
||||
requires node ≥ 18 in the distro; a fresh WSL Ubuntu ships none, Codex CLI is a native
|
||||
binary that brings none, and Claude Code's native installer no longer implies a system
|
||||
node. A distro running only Codex would hit the no-node cooldown and stay dark — the
|
||||
exact GH `6907` shape. The interop bridge is the one delivery path with no guest
|
||||
runtime requirement, so it stays as the documented no-node fallback (currently wired
|
||||
for OMP; extending it to the shared shell-script builders is the tracked follow-up if
|
||||
no-node distros show up in telemetry). The relay remains the primary path: resident
|
||||
(no per-event spawn cost) and interop-independent.
|
||||
|
||||
## Implementation map
|
||||
|
||||
- Guest: `src/relay/wsl-agent-hook-relay.ts` (entry; exits on stdin close),
|
||||
`src/relay/wsl-hook-fs-bridge.ts` (home-scoped fs RPCs for installs),
|
||||
`src/relay/agent-hook-server.ts` (`token`/`preferredPort` options + `EADDRINUSE`
|
||||
fallback). Bundled by `config/scripts/build-relay.mjs` → `out/relay/wsl/`.
|
||||
- Host: `src/main/agent-hooks/wsl-hook-relay-manager.ts` (per-distro state machine),
|
||||
`wsl-hook-relay-launch.ts` (bundle resolve, guest launch/install scripts, spawn env,
|
||||
sentinel wait), `wsl-hook-relay-link.ts` (envelope forward + exactly-once link-death
|
||||
handling), `wsl-hook-relay-deps.ts` (DI seam), `wsl-hook-fs-adapter.ts` (SFTP-shaped
|
||||
adapter + `installWslGuestHooks`, which targets Codex's managed runtime home).
|
||||
- Wiring: `buildPtyHostEnv` (`src/main/ipc/pty.ts`) ensures the relay on every WSL spawn
|
||||
and repoints `ORCA_AGENT_HOOK_ENDPOINT` at the guest endpoint file once known;
|
||||
`src/main/pty/wsl-orca-env.ts` picks `/u` vs `/p` by value shape.
|
||||
- Contract shared by both sides: `src/shared/wsl-hook-relay-contract.ts`.
|
||||
- Oracles: `src/relay/wsl-agent-hook-relay.test.ts`,
|
||||
`src/main/agent-hooks/wsl-hook-relay-manager.test.ts` (fault injection: stale-42
|
||||
reinstall, no-node-43 cooldown, bounded E_UNEXPECTED retry, exit re-ensure gating, and a
|
||||
full installer run against an in-memory guest).
|
||||
|
||||
## References
|
||||
|
||||
- GitHub: issues `6907` (Codex/WSL), `7091` + `7565` (OMP, fixed), `7563` (WSL CLI
|
||||
detection, adjacent); PRs `7642` + `7641` (OMP fixes), `7744` (SSH hook installers
|
||||
precedent), `7447` (title-collapse regression).
|
||||
- Linear: STA-1515 (this work; ticket comments carry the same context).
|
||||
- Key files: `src/main/agent-hooks/server.ts`, `src/shared/agent-hook-listener.ts`,
|
||||
`src/shared/agent-hook-relay.ts`, `src/relay/agent-hook-server.ts`, `src/relay/relay.ts`,
|
||||
`src/main/pty/wsl-orca-env.ts`, `src/main/agent-hooks/installer-utils.ts`,
|
||||
`src/main/pi/agent-status-extension-source.ts`, `src/main/ssh/ssh-relay-session.ts`,
|
||||
`src/main/providers/windows-shell-args.ts`, `src/shared/wsl-login-shell-command.ts`.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Android Emulation — Live Pane Streaming
|
||||
|
||||
The Android **control** path (device list, boot, tap/type/buttons/rotate/exec,
|
||||
install/launch/permissions/ax/logcat) is complete and unit-tested. This document
|
||||
covers the **live H.264 video pane** (scrcpy + WebCodecs), which requires the
|
||||
Android SDK, a running AVD, the bundled `scrcpy-server.jar`, and Electron's
|
||||
WebCodecs runtime for end-to-end validation.
|
||||
|
||||
## What is built (committed)
|
||||
|
||||
| Module | Tested | Notes |
|
||||
|---|---|---|
|
||||
| `android/scrcpy-control-protocol.ts` | ✅ unit | Byte-exact control encoders (touch/key/text/back). |
|
||||
| `android/scrcpy-video-frame-parser.ts` | ✅ unit | scrcpy v2.4 codec-meta + frame-header parsing. |
|
||||
| `android/scrcpy-server-deploy.ts` | ✅ unit | push / forward / server-start arg builders. |
|
||||
| `android/scrcpy-stream-session.ts` | live-validated | Owns the server process + video/control sockets. |
|
||||
| `emulator/scrcpy-video-registry.ts` | ✅ unit | Pub/sub bridging a session to renderer subscribers. |
|
||||
| `ipc/emulator-video-stream.ts` | registration tested | `emulator:videoStream*` IPC; registered in `register-core-handlers`. |
|
||||
| `emulator-pane/use-emulator-video-stream.ts` | live-validated | WebCodecs `VideoDecoder` → `<canvas>`. |
|
||||
|
||||
## Current wiring
|
||||
|
||||
### 1. `scrcpy-server.jar`
|
||||
|
||||
The version-pinned server jar must match `SCRCPY_SERVER_VERSION` in
|
||||
`scrcpy-server-deploy.ts`. Runtime resolution checks the packaged resource and a
|
||||
development fallback.
|
||||
|
||||
### 2. `AndroidEmulatorBackend.startSession`
|
||||
|
||||
`startSession` ensures the target is booted, resolves the bundled server jar,
|
||||
starts `ScrcpyStreamSession`, and feeds `scrcpyVideoRegistry`. A
|
||||
`createStreamSession` option lets unit tests inject a fake because the real
|
||||
session does socket I/O.
|
||||
|
||||
```ts
|
||||
async startSession(deviceId: string): Promise<EmulatorSessionInfo> {
|
||||
const serial = await this.ensureBooted(deviceId)
|
||||
const jar = resolveScrcpyServerJar()
|
||||
if (!jar) {
|
||||
throw new EmulatorError('emulator_helper_failed', 'scrcpy-server.jar not bundled (see docs/android-emulation-streaming.md).')
|
||||
}
|
||||
const session = await (this.createStreamSession ?? ScrcpyStreamSession.start)(
|
||||
{ runner: this.runner, sdk: this.requireSdk(), serial, localJarPath: jar, maxSize: 1024 },
|
||||
{
|
||||
onMeta: (meta) => scrcpyVideoRegistry.pushMeta(serial, meta),
|
||||
onFrame: (f) => scrcpyVideoRegistry.pushFrame(serial, { config: f.config, keyFrame: f.keyFrame, pts: String(f.pts), bytes: toArrayBuffer(f.data) }),
|
||||
onError: () => scrcpyVideoRegistry.stop(serial),
|
||||
onClose: () => scrcpyVideoRegistry.stop(serial)
|
||||
}
|
||||
)
|
||||
scrcpyVideoRegistry.register(serial, () => session.close())
|
||||
this.streamSessions.set(serial, session)
|
||||
return { deviceUdid: serial, streamUrl: `scrcpy://${serial}`, wsUrl: '', streamCodec: 'h264' }
|
||||
}
|
||||
```
|
||||
|
||||
`stopHelperForDevice(serial)` stops the registry and closes the stored session.
|
||||
|
||||
### 3. Low-latency input via the scrcpy control socket (optional refinement)
|
||||
|
||||
Input already works via `adb shell input`. For smooth multi-touch, when a live
|
||||
session exists, route `tap`/`gesture` through `session.sendControl(...)` using the
|
||||
encoders in `scrcpy-control-protocol.ts` (convert normalized coords →
|
||||
device pixels with `android-input-mapping`, then `encodeInjectTouchEvent`).
|
||||
|
||||
### 4. Preload + pane
|
||||
|
||||
- **Preload** (`src/preload/...` emulator API): exposes `startVideoStream`,
|
||||
`stopVideoStream`, `onVideoStreamMeta`, `onVideoStreamFrame` wrapping the
|
||||
`emulator:videoStream*` channels (mirror the existing `startFrameStream` etc.).
|
||||
- **Pane** (`emulator-screen-stream-content.tsx`): when
|
||||
`session.streamCodec === 'h264'`, renders the `<canvas>` from
|
||||
`useEmulatorVideoStream(deviceId, enabled)` instead of the MJPEG `<img>`.
|
||||
- **Hardware buttons** (`emulator-phone-hardware-buttons.tsx`): includes Android
|
||||
variants (Back, Home, Recents, Power, Volume) selected by backend kind, per
|
||||
`docs/STYLEGUIDE.md`.
|
||||
|
||||
### 5. Validate on hardware
|
||||
|
||||
```sh
|
||||
pnpm build:cli
|
||||
# boot an AVD (Android Studio or `emulator @<avd>`), then:
|
||||
orca-dev emulator devices --json # see the device
|
||||
orca-dev emulator tap 0.5 0.8 --device <serial> # control works today
|
||||
# after wiring §2/§4: open the emulator pane and confirm the live frame + taps.
|
||||
```
|
||||
|
||||
## Risks to validate first
|
||||
|
||||
- **WebCodecs H.264 in Electron**: confirm `VideoDecoder.isConfigSupported({ codec: 'avc1.640028' })`. If unsupported, fall back to a wasm decoder (Broadway/tinyh264) or a main-process H.264→JPEG transcode into the existing MJPEG channel — neither changes the backend interface (it advertises the codec).
|
||||
- **scrcpy server protocol/version**: the option set + handshake in
|
||||
`scrcpy-server-deploy.ts` / `scrcpy-stream-session.ts` are pinned to v2.4 and
|
||||
must match the bundled jar.
|
||||
- **Annex-B vs avcC**: the renderer configures the decoder without a description
|
||||
(Annex-B). If frames don't decode, extract the avcC from the config packet.
|
||||
@@ -1,376 +0,0 @@
|
||||
# Android Emulation
|
||||
|
||||
## Problem
|
||||
|
||||
Orca ships a built-in mobile emulator surface (live pane + `orca emulator` CLI +
|
||||
agent skill), but it is **iOS Simulator only and macOS only**:
|
||||
|
||||
- `src/main/emulator/emulator-availability.ts:32` hard-returns "unavailable" for
|
||||
any `platform() !== 'darwin'`, so Windows and Linux users get nothing.
|
||||
- The backend (`src/main/emulator/emulator-bridge.ts`) is wired directly to
|
||||
`serve-sim` (`serve-sim-*.ts`) and `xcrun simctl`
|
||||
(`simctl-simulator-devices.ts`), both Apple-only tooling.
|
||||
|
||||
Android emulators run on Windows, Linux, and macOS via the Android SDK that
|
||||
Android Studio installs. We want Android emulation as a first-class peer of the
|
||||
iOS feature: full AVD lifecycle management, a live ~60fps pane, the full
|
||||
tap/gesture/type/button/rotate control surface, accessibility tree, app
|
||||
install/launch, runtime permissions, logcat, plus a dedicated
|
||||
`orca-emulator-android` agent skill.
|
||||
|
||||
## Current architecture (what we reuse vs. replace)
|
||||
|
||||
The existing stack already separates a backend from everything above it. The
|
||||
renderer pane, session registry, RPC/CLI shape, and tab system are effectively
|
||||
**backend-agnostic** and are reused unchanged:
|
||||
|
||||
- **Frame transport is main-owned.** `src/main/ipc/emulator-frame-stream.ts:40`
|
||||
runs the MJPEG socket in the main process and forwards raw JPEG bytes to the
|
||||
renderer over `emulator:frameStreamFrame`. The renderer
|
||||
(`src/renderer/src/components/emulator-pane/use-emulator-frame-stream.ts:74`)
|
||||
just wraps each frame in a `Blob`/`<img>`. The renderer is a **frame
|
||||
consumer**, decoupled from the source.
|
||||
- **Per-worktree "active emulator"** lives in
|
||||
`src/main/emulator/emulator-session-registry.ts` (like the active browser
|
||||
tab). Backend-agnostic.
|
||||
- **RPC** is declared in `src/main/runtime/rpc/methods/emulator.ts` and
|
||||
implemented in `src/main/runtime/orca-runtime-emulator.ts`.
|
||||
- **CLI** is `src/cli/specs/emulator.ts` + `src/cli/handlers/emulator.ts`.
|
||||
- **Pane** is `src/renderer/src/components/emulator-pane/**` (~50 files).
|
||||
|
||||
What is iOS-bound and needs an Android sibling:
|
||||
|
||||
- Device management: `xcrun simctl` → `adb` / `emulator` / `avdmanager`.
|
||||
- Streaming helper: `serve-sim` (MJPEG/H.264 over HTTP+WS) → `scrcpy-server.jar`
|
||||
(H.264 + control over adb-forwarded sockets).
|
||||
- Input: serve-sim normalized-coord WS → adb-backed Android input commands.
|
||||
- Availability gate: darwin-only → SDK-present on any OS.
|
||||
|
||||
## Goals
|
||||
|
||||
- Android emulation on **Windows, Linux, and macOS** (macOS users choose iOS or
|
||||
Android in the same pane).
|
||||
- **Full AVD lifecycle**: discover installed AVDs via the SDK, boot/shutdown
|
||||
them from Orca, and attach to already-running emulators + physical `adb`
|
||||
devices.
|
||||
- **Live ~60fps pane** via scrcpy H.264 decoded in the renderer with WebCodecs.
|
||||
- Control parity: tap, swipe/gesture, type, hardware buttons (Back, Home,
|
||||
Recents, Power, Volume), rotate.
|
||||
- Extra capabilities: accessibility tree (`uiautomator dump`), app
|
||||
install/launch (`adb install` / `am start`), runtime permissions
|
||||
(`pm grant/revoke/reset`), logcat capture.
|
||||
- A dedicated `skills/orca-emulator-android/SKILL.md`.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- Camera/sensor injection (the Android emulator's virtual-scene path is a much
|
||||
larger problem than serve-sim's iOS camera injection; defer).
|
||||
- Remote/SSH device control (matches the current iOS limitation; emulator
|
||||
hardware is local).
|
||||
- Wear OS / Android TV / Automotive form factors.
|
||||
- Migrating the iOS backend to H.264 (the interface allows it later; not done
|
||||
now).
|
||||
|
||||
## Design
|
||||
|
||||
### Extract an `EmulatorBackend` interface; make the bridge a router
|
||||
|
||||
Today `EmulatorBridge` *is* the iOS implementation. Refactor it into a thin
|
||||
router over a backend interface so iOS and Android share the session registry,
|
||||
RPC/CLI shape, frame IPC, and tab system. This is the only change to existing
|
||||
iOS behavior, and it is a pure extraction (no semantic change).
|
||||
|
||||
New module `src/main/emulator/backends/emulator-backend.ts`:
|
||||
|
||||
```ts
|
||||
export type EmulatorBackendKind = 'ios' | 'android'
|
||||
export type EmulatorStreamCodec = 'mjpeg' | 'h264'
|
||||
|
||||
export type EmulatorDevice = {
|
||||
backend: EmulatorBackendKind
|
||||
id: string // opaque: simulator UDID, adb serial, or AVD name
|
||||
name: string
|
||||
state: 'shutdown' | 'booting' | 'booted'
|
||||
kind?: string // form factor / api level, display only
|
||||
isAvailable: boolean
|
||||
}
|
||||
|
||||
export type EmulatorBackendCapabilities = {
|
||||
install: boolean
|
||||
launch: boolean
|
||||
permissions: boolean
|
||||
accessibilityTree: boolean
|
||||
logcat: boolean
|
||||
}
|
||||
|
||||
export interface EmulatorBackend {
|
||||
readonly kind: EmulatorBackendKind
|
||||
readonly capabilities: EmulatorBackendCapabilities
|
||||
isSupportedOnHost(): boolean
|
||||
checkAvailability(): Promise<BackendAvailability>
|
||||
listDevices(): Promise<EmulatorDevice[]>
|
||||
bootDevice(id: string): Promise<EmulatorDevice>
|
||||
startSession(id: string): Promise<EmulatorSessionInfo> // includes streamCodec
|
||||
tap(id, x, y): Promise<void>
|
||||
gesture(id, points): Promise<void>
|
||||
type(id, text): Promise<void>
|
||||
button(id, name): Promise<void>
|
||||
rotate(id, orientation): Promise<void>
|
||||
exec(id, command): Promise<unknown>
|
||||
// capability-gated:
|
||||
installApp?(id, path): Promise<void>
|
||||
launchApp?(id, pkg, activity?): Promise<void>
|
||||
setPermission?(id, op): Promise<void>
|
||||
accessibilityTree?(id): Promise<unknown>
|
||||
logcat?(id, opts): Promise<...>
|
||||
stopSession(id): Promise<void>
|
||||
kill(id): Promise<void>
|
||||
shutdown(id): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- `src/main/emulator/backends/ios-emulator-backend.ts` — the existing serve-sim
|
||||
+ simctl logic extracted from `EmulatorBridge`, implementing the interface
|
||||
with `kind: 'ios'`, `streamCodec: 'mjpeg'`, and capabilities mapped to what
|
||||
serve-sim already supports.
|
||||
- `src/main/emulator/backends/android-emulator-backend.ts` — new, orchestrates
|
||||
the Android modules below with `kind: 'android'`, `streamCodec: 'h264'`.
|
||||
- `EmulatorBridge` keeps its public method names (so RPC/runtime callers don't
|
||||
churn) but becomes a router: it holds the available backends, resolves which
|
||||
backend owns a given device/session (via the session registry's recorded
|
||||
`backend` tag, or by probing `listDevices()` for an unknown id), and
|
||||
delegates. The session registry record gains a `backend: EmulatorBackendKind`
|
||||
field; `EmulatorSessionInfo` gains `streamCodec`. The existing `deviceUdid`
|
||||
field is retained as the opaque `id` to preserve wire-compat across the
|
||||
renderer and CLI.
|
||||
|
||||
### New Android modules — `src/main/emulator/android/`
|
||||
|
||||
Each module is small and single-purpose with a co-located `.test.ts`, matching
|
||||
the existing `serve-sim-*` / `simctl-*` granularity (no file approaches the
|
||||
`max-lines` limit):
|
||||
|
||||
- `android-sdk-discovery.ts` — resolve the SDK root and the `adb` / `emulator` /
|
||||
`avdmanager` binaries from `ANDROID_HOME`, then `ANDROID_SDK_ROOT`, then per-OS
|
||||
defaults: `%LOCALAPPDATA%\Android\Sdk` (Windows), `~/Library/Android/sdk`
|
||||
(macOS), `~/Android/Sdk` (Linux). All paths via `path.join`.
|
||||
- `adb-devices.ts` — `adb devices -l`, resolve serial, `wait-for-device` +
|
||||
`getprop sys.boot_completed` poll, and `wm size` for the device resolution.
|
||||
- `avd-manager.ts` — `emulator -list-avds`, boot an AVD via a detached
|
||||
`emulator @<name>` spawn, shut down via `adb -s <serial> emu kill`.
|
||||
- `scrcpy-server-deploy.ts` — push the version-pinned `scrcpy-server.jar`, start
|
||||
it via `app_process`, and set up the `adb forward` tunnel(s).
|
||||
- `scrcpy-stream-session.ts` — owns the server process, adb tunnel, and video
|
||||
socket lifecycle.
|
||||
- `scrcpy-video-frame-parser.ts` — read the video socket, parse scrcpy frame headers
|
||||
(PTS + length), and emit H.264 access units plus the codec config (SPS/PPS).
|
||||
- `scrcpy-control-protocol.ts` — encode scrcpy control messages (touch
|
||||
down/move/up with pointer id + pressure, inject keycode, inject UTF-8 text,
|
||||
scroll, set screen power, rotate, clipboard) for a future low-latency input path.
|
||||
- `android-input-mapping.ts` — convert normalized 0–1 ↔ device pixels using the
|
||||
live frame size; map button names → Android keycodes (BACK=4, HOME=3,
|
||||
APP_SWITCH=187, POWER=26, VOLUME_UP=24, VOLUME_DOWN=25).
|
||||
- `android-input-commands.ts` — current adb-backed tap/type/button/rotate/gesture
|
||||
command construction.
|
||||
- `uiautomator-tree.ts` — `adb shell uiautomator dump` → parsed XML tree.
|
||||
- `android-app-control.ts` — `adb install <apk>`, `am start` package/activity.
|
||||
- `android-permissions.ts` — `pm grant` / `revoke` / `reset`.
|
||||
- `android-logcat.ts` — tail/filter `adb logcat` with bounded buffering.
|
||||
- `android-availability.ts` — SDK present? AVDs + connected devices list, with
|
||||
clear, surfaced messages (mirroring the iOS availability message style).
|
||||
|
||||
### Streaming & control data flow
|
||||
|
||||
**Control currently uses `adb shell input` commands.** Coordinates stay
|
||||
normalized 0–1 at every public boundary (CLI, RPC, renderer); the Android backend
|
||||
maps them to device pixels before issuing adb-backed tap/gesture/button commands.
|
||||
The scrcpy control protocol encoders are present for a future low-latency input
|
||||
path, but video streaming does not require that path.
|
||||
|
||||
**Video path (H.264 → renderer WebCodecs):**
|
||||
|
||||
1. `android-emulator-backend.startSession()` deploys + starts scrcpy-server,
|
||||
opens the video + control sockets, and returns `EmulatorSessionInfo` with
|
||||
`streamCodec: 'h264'`.
|
||||
2. `scrcpy-video-stream.ts` reads access units and the SPS/PPS config and pushes
|
||||
them over a **new IPC channel** `emulator:videoStream{Start,Config,Frame,Stop}`
|
||||
(a sibling of the existing `emulator:frameStream*`), keyed by stream id.
|
||||
3. New renderer hook
|
||||
`src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts`
|
||||
feeds the access units to a WebCodecs `VideoDecoder`, drawing decoded
|
||||
`VideoFrame`s to a `<canvas>`.
|
||||
4. `src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx`
|
||||
branches on `session.streamCodec`: `mjpeg` keeps today's `<img>` path
|
||||
untouched; `h264` uses the canvas path. No iOS behavior changes.
|
||||
|
||||
## Coordinate & input mapping
|
||||
|
||||
- Public API (CLI/RPC/pane gestures) stays normalized 0–1, top-left origin, as
|
||||
the iOS path already mandates.
|
||||
- `android-input-mapping.ts` multiplies by the current device display size before
|
||||
adb input commands are built. Rotation changes the effective frame size; the
|
||||
mapper reads the current size each gesture rather than caching.
|
||||
- Hardware buttons: adb keyevents inject keycodes. Android adds
|
||||
**Back** and **Recents** (no iOS equivalent); the button name → keycode map
|
||||
and the renderer's hardware-button row gain Android variants.
|
||||
|
||||
## RPC + CLI surface
|
||||
|
||||
Extend `src/main/runtime/rpc/methods/emulator.ts`,
|
||||
`src/main/runtime/orca-runtime-emulator.ts`, `src/cli/specs/emulator.ts`, and
|
||||
`src/cli/handlers/emulator.ts`:
|
||||
|
||||
- `orca emulator list` gains a **platform column** and shows iOS + Android
|
||||
devices/AVDs together; device selection resolves the backend automatically
|
||||
(by recorded session tag, else by which backend's `listDevices()` owns the id).
|
||||
- Existing verbs (`attach`, `tap`, `gesture`, `type`, `button`, `rotate`,
|
||||
`exec`, `kill`, `shutdown`) route unchanged to the resolved backend.
|
||||
- New capability-gated verbs: `install`, `launch`, `permissions`, `ax`,
|
||||
`logcat`. On a backend lacking the capability they fail with a clear
|
||||
`emulator_unsupported` error rather than silently no-op.
|
||||
- Existing `--worktree` / `--device` targeting is unchanged.
|
||||
|
||||
## Renderer pane
|
||||
|
||||
- `src/renderer/src/components/emulator-pane/emulator-phone-hardware-buttons.tsx`
|
||||
→ Android variant (Back, Home, Recents, Power, Volume) selected by backend
|
||||
kind.
|
||||
- Android device bezel/frame + Android entries (and a "boot AVD" affordance) in
|
||||
the attach/list UI.
|
||||
- `MobileEmulatorAgentSetupGuide*` → Android prerequisites step (install Android
|
||||
Studio / SDK, set `ANDROID_HOME`).
|
||||
- Codec-aware stream content (the canvas path above).
|
||||
- All UI follows `docs/STYLEGUIDE.md`: existing tokens from
|
||||
`src/renderer/src/assets/main.css` and shadcn primitives in
|
||||
`src/renderer/src/components/ui/`; no new color/size/shadow values.
|
||||
- Shortcut labels and any new accelerators use the platform checks required by
|
||||
`AGENTS.md` (`CmdOrCtrl`, `⌘`/`Ctrl+`).
|
||||
|
||||
## Packaging & dependencies
|
||||
|
||||
- Bundle the single, version-pinned `scrcpy-server.jar` (~80 KB) as an app
|
||||
resource; wire it into `config/electron-builder.config.cjs` and
|
||||
`config/packaged-runtime-node-modules.cjs`. Pin the scrcpy version — the
|
||||
server protocol is coupled to the jar.
|
||||
- Do **not** bundle `adb` / `emulator` / `avdmanager` (large; Android Studio
|
||||
installs them). Discover them at runtime and surface a clear setup message if
|
||||
the SDK is absent.
|
||||
- No new runtime npm dependency is required for decode (WebCodecs is built into
|
||||
Electron's Chromium). The wasm-decoder fallback (see Risks) would add a dep
|
||||
only if the WebCodecs spike fails.
|
||||
|
||||
## Skill
|
||||
|
||||
New `skills/orca-emulator-android/SKILL.md`, mirroring
|
||||
`skills/orca-emulator/SKILL.md`:
|
||||
|
||||
- Prerequisites: Android Studio / SDK installed, `ANDROID_HOME` (or
|
||||
`ANDROID_SDK_ROOT`) set, at least one AVD or a connected device.
|
||||
- The `orca emulator ...` command table (shared CLI; Android examples).
|
||||
- Gotchas: Orca handles pixel ↔ normalized conversion (agents always pass 0–1);
|
||||
adb device/serial targeting; no camera injection in v1; scrcpy version
|
||||
coupling.
|
||||
- Cross-reference from the iOS skill's "When NOT to use" (which already
|
||||
anticipates an Android backend under the same namespace).
|
||||
- Register it the same way `orca-emulator` is registered.
|
||||
|
||||
## Availability & platform gating
|
||||
|
||||
`src/main/emulator/emulator-availability.ts` becomes an aggregator that asks
|
||||
each backend `isSupportedOnHost()` + `checkAvailability()`:
|
||||
|
||||
- iOS backend: supported only on `darwin` (unchanged behavior/messages).
|
||||
- Android backend: supported on any OS where the SDK is discoverable.
|
||||
- The combined result drives the pane's availability UI; Windows/Linux report an
|
||||
available mobile backend for the first time.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- adb device in `offline` / `unauthorized` state → clear surfaced error, not a
|
||||
hang.
|
||||
- AVD boot timeout (cold boot can take minutes) → bounded wait with a
|
||||
cancel/error path; pane shows "booting".
|
||||
- SDK present but no AVDs and no devices → availability message points to "create
|
||||
an AVD in Android Studio".
|
||||
- Device rotates while a gesture is mid-flight → mapper re-reads frame size per
|
||||
event; no cached dimensions.
|
||||
- Multiple Android devices in one worktree → same "one active per worktree"
|
||||
model as iOS; explicit `--device <serial>` for the rest.
|
||||
- WebCodecs decoder error / key-frame loss → request a new keyframe from scrcpy
|
||||
and surface a transient "reconnecting" state (parity with the MJPEG reconnect
|
||||
in `mjpeg-frame-stream.ts`).
|
||||
- Windows path handling for the SDK and the pushed jar uses `path.join` only;
|
||||
never assume `/` or `\`.
|
||||
- App quit / pane close cleans up scrcpy-server, the adb tunnel, and (for managed
|
||||
AVDs) the emulator, mirroring `EmulatorBridge.onAppQuit()` /
|
||||
`destroyAllSessions()`.
|
||||
|
||||
## Test plan
|
||||
|
||||
Unit tests (co-located, node Vitest, matching the module's existing test
|
||||
density):
|
||||
|
||||
- `android-sdk-discovery` — env precedence + per-OS default paths (mock env/fs;
|
||||
assert Windows/macOS/Linux branches).
|
||||
- `adb-devices` — parse `adb devices -l` (booted, offline, unauthorized,
|
||||
physical), boot-complete polling.
|
||||
- `avd-manager` — parse `emulator -list-avds`, boot command construction.
|
||||
- `scrcpy-control-channel` — exact byte encoding of touch/key/text/scroll
|
||||
messages.
|
||||
- `android-input-mapping` — normalized↔pixel round-trips, rotation, keycode map.
|
||||
- `uiautomator-tree` — XML → tree parsing, including malformed input.
|
||||
- `android-availability` + `emulator-availability` aggregation — iOS-only,
|
||||
Android-only, both, neither.
|
||||
- backend router resolution in `emulator-bridge` — id → backend, unknown id,
|
||||
cross-backend isolation.
|
||||
|
||||
Integration tests mock `adb` / `emulator` and the scrcpy sockets the same way
|
||||
the iOS tests mock serve-sim (`serve-sim-*.test.ts`, `emulator-bridge.test.ts`).
|
||||
|
||||
Electron validation (manual, on a machine with the Android SDK):
|
||||
|
||||
- Boot an AVD from Orca; confirm the live pane streams and is responsive.
|
||||
- tap / swipe / type / Back / Home / Recents / rotate.
|
||||
- `ax`, `install` + `launch`, `permissions grant`, `logcat`.
|
||||
- Cross-platform smoke on Windows (primary driver) and macOS (iOS + Android
|
||||
coexistence).
|
||||
|
||||
## Risks / verify-first
|
||||
|
||||
- **Electron H.264 WebCodecs decode** — verify in a step-0 spike that an Electron
|
||||
renderer `VideoDecoder` decodes scrcpy's H.264. Electron ships proprietary
|
||||
codec decode, so this is expected to pass. Fallback if not: a wasm H.264
|
||||
decoder (Broadway / tinyh264) or dropping to a main-process H.264→JPEG
|
||||
transcode — **neither changes the backend interface**, since the session
|
||||
advertises its codec.
|
||||
- **scrcpy-server protocol is version-coupled** to the bundled jar (same class of
|
||||
risk as serve-sim's private SimulatorKit APIs). Pin the version; record it next
|
||||
to the bundled jar.
|
||||
- **adb/emulator environment variance** — offline/unauthorized devices, cold-boot
|
||||
timeouts, missing SDK. All handled via explicit, surfaced errors.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Step-0 spike: confirm WebCodecs H.264 decode in the Electron renderer.
|
||||
2. Extract the `EmulatorBackend` interface + `IosEmulatorBackend` (pure
|
||||
refactor); keep all iOS tests green.
|
||||
3. Android device management (`android-sdk-discovery`, `adb-devices`,
|
||||
`avd-manager`, `android-availability`) + availability aggregation; surface
|
||||
Android devices in `orca emulator list`.
|
||||
4. scrcpy streaming (`scrcpy-server-deploy`, `scrcpy-video-stream`) + the video
|
||||
IPC channel + the renderer WebCodecs canvas path; live pane renders.
|
||||
5. scrcpy control (`scrcpy-control-channel`, `android-input-mapping`) +
|
||||
tap/gesture/type/button/rotate end-to-end.
|
||||
6. Extra capabilities: `ax`, `install`/`launch`, `permissions`, `logcat`.
|
||||
7. Renderer polish: Android hardware buttons, bezel, setup guide.
|
||||
8. Packaging (`scrcpy-server.jar` resource) + the `orca-emulator-android` skill.
|
||||
9. Tests at each step; typecheck + lint; Electron validation on Windows + macOS.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- Whether `orca emulator install`/`launch`/`logcat` should also be exposed for
|
||||
iOS later (iOS install is `xcrun simctl install`); v1 leaves them
|
||||
Android-only via capability flags.
|
||||
- Whether to expose an explicit `orca emulator boot <avd>` verb vs. folding boot
|
||||
into `attach`; initial version folds boot into `attach` (parity with iOS,
|
||||
which boots on attach) and adds a `--no-boot` opt-out.
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 12m">
|
||||
<title>downloads: 12m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 13m">
|
||||
<title>downloads: 13m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">12m</text>
|
||||
<text x="90" y="14">12m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">13m</text>
|
||||
<text x="90" y="14">13m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
Binary file not shown.
|
Before Width: | Height: | Size: 380 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 399 KiB |
@@ -1,75 +0,0 @@
|
||||
# Automations Navigation Stack
|
||||
|
||||
## Problem
|
||||
|
||||
- `worktree-nav-history.ts` models view entries as `'tasks'` only; `'automations'` cannot be recorded or replayed.
|
||||
- `openTaskPage` records a view visit before switching view; `openAutomationsPage` does not.
|
||||
- `closeTaskPage` rewinds history index when closing from a `'tasks'` history node; `closeAutomationsPage` does not.
|
||||
- Keyboard history navigation already works on Automations (`Cmd/Ctrl+Alt+Arrow`), but titlebar Back/Forward is hidden there.
|
||||
- `setWorktreeNavViewActivator` is currently Tasks-sentinel oriented in types/comments and must be widened for Automations.
|
||||
|
||||
## Goal
|
||||
|
||||
Make Automations a first-class entry in the existing mixed worktree/page navigation stack, matching Tasks behavior for open, back/forward traversal, close-page rewind, and titlebar controls.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not add persistence for navigation history; the existing stack is session-only and renderer-local.
|
||||
- Do not preserve per-automation detail selection through Back/Forward beyond existing `selectedAutomationId` state.
|
||||
- Do not change Activity, Settings, Space, Skills, or terminal navigation behavior.
|
||||
- Do not add new shortcuts; reuse existing cross-platform `Cmd/Ctrl+Alt+Arrow` handling.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add explicit view-entry type.
|
||||
- `type WorktreeNavHistoryViewEntry = 'tasks' | 'automations'`.
|
||||
- `type WorktreeNavHistoryEntry = string | WorktreeNavHistoryViewEntry`.
|
||||
- Update `recordViewVisit`, `ViewActivateFn`, and `setWorktreeNavViewActivator` signatures accordingly.
|
||||
- Update `isLiveEntry` to treat both page sentinels as live.
|
||||
|
||||
2. Generalize history replay branch.
|
||||
- In `navigateToIndex`, dispatch page sentinels through `viewActivator(entry)` and worktree ids through `activator(id)`.
|
||||
- Keep page replay on `setActiveView(entry)` (not `openTaskPage`/`openAutomationsPage`) to avoid mutating `previousViewBefore*` and avoid appending history during replay.
|
||||
- Keep existing index semantics: update index only after successful activation path.
|
||||
|
||||
3. Record and close Automations like Tasks.
|
||||
- `openAutomationsPage`: call `recordViewVisit('automations')` before switching `activeView`.
|
||||
- `closeAutomationsPage`: if current history node is `'automations'`, rewind to `findPrevLiveWorktreeHistoryIndex(state)` when available; otherwise keep index unchanged.
|
||||
- This rewind must apply regardless of close trigger (Esc / header X / any direct `closeAutomationsPage` call site).
|
||||
|
||||
4. Align titlebar controls with shortcut scope.
|
||||
- Show titlebar Back/Forward when `activeView` is `terminal`, `tasks`, or `automations`.
|
||||
- Keep shortcut logic unchanged; it already includes Automations.
|
||||
|
||||
5. Tests.
|
||||
- `worktree-nav-history.test.ts`: add Automations sentinel coverage for replay path, adjacent dedupe, dead-worktree skip, and rewind/forward behavior.
|
||||
- `ui.test.ts`: add Automations open/close history-index parity tests with Tasks, including “only automations in history” no-op rewind.
|
||||
- `App.tsx`: assert Back/Forward controls render on Automations (not optional; this is where current behavior regressed from shortcut scope).
|
||||
- `worktree-activation` wiring test coverage (or equivalent integration assertion) should verify `setWorktreeNavViewActivator` accepts/replays both sentinels.
|
||||
|
||||
## Known residual quirks
|
||||
|
||||
- Replay uses `setActiveView(...)`, so `previousViewBeforeTasks/Automations` is not recomputed on back/forward landing. Close from a replayed page can return to stale `previousViewBefore*`; this is existing Tasks behavior.
|
||||
- History is capped at 50 entries. Long sessions may evict older entries, including page sentinels; this is existing behavior.
|
||||
- History is renderer-local and session-local (no persistence, no cross-window reconciliation).
|
||||
- Liveness is evaluated against current store state at navigation time. If a target worktree becomes invalid between target selection and activation, `activateAndRevealWorktree` may fail and index stays put.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- `A -> Automations -> B`, Back lands on Automations, Back again lands on A.
|
||||
- `A -> Automations -> Automations` records only one Automations entry.
|
||||
- `A -> Automations`, close rewinds index to A; Forward reopens Automations.
|
||||
- `Automations` as the only history entry: close leaves index at `0` (do not force `-1`, or Forward target is lost).
|
||||
- If the prior worktree was deleted while Automations is open, Back/Close rewind skips it and lands on the next live prior entry.
|
||||
- Back-to-Automations must not call `openAutomationsPage`, or it would overwrite `previousViewBeforeAutomations` and append duplicate history.
|
||||
- Shortcut labels and handling remain cross-platform (`⌘⌥` on Mac, `Ctrl+Alt` elsewhere).
|
||||
- Multi-window: each renderer has an independent history stack; no cross-window reconciliation is attempted.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Update `worktree-nav-history.ts` types, live-entry predicate, and replay branch.
|
||||
2. Update `ui.ts` to record and rewind Automations visits.
|
||||
3. Update `worktree-activation.ts` comments/types for generalized view activator.
|
||||
4. Update `App.tsx` titlebar visibility and comments.
|
||||
5. Add/adjust unit tests for history slice, UI slice, and titlebar visibility.
|
||||
6. Run `worktree-nav-history.test.ts` and `ui.test.ts`, then `pnpm typecheck` and `pnpm lint`.
|
||||
@@ -1,183 +0,0 @@
|
||||
# Browser Normal Download Behavior
|
||||
|
||||
## Problem
|
||||
|
||||
The built-in browser does not behave like a normal desktop browser when a page downloads a file.
|
||||
|
||||
- `src/main/browser/browser-session-registry.ts:553` installs a `will-download` handler for browser sessions.
|
||||
- `src/main/browser/browser-manager.ts:952` pauses every download before Orca has a save path.
|
||||
- `src/renderer/src/components/browser-pane/BrowserPane.tsx:4915` shows a `Save` / `Cancel` prompt instead of starting the download.
|
||||
- `src/main/ipc/browser.ts:342` opens a native save dialog after the renderer clicks `Save`.
|
||||
- `src/main/browser/browser-manager.ts:1026` calls `DownloadItem.setSavePath()` only after that renderer + dialog round trip.
|
||||
- Electron's installed type contract says `setSavePath()` is only available during the session `will-download` callback (`node_modules/electron/electron.d.ts:8271`).
|
||||
|
||||
That late path assignment explains the "clicked Save, still did not save" failure mode and makes the flow higher-friction than Chrome/Safari/Edge defaults.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Orca treats downloads as renderer-approved actions. Electron treats the destination as a main-process `will-download` decision. The current flow crosses that boundary too late: it pauses the download, asks the renderer to approve it, asks the OS where to save it, and only then sets the save path.
|
||||
|
||||
## Goal
|
||||
|
||||
Make built-in browser downloads feel like a normal browser:
|
||||
|
||||
- Download starts automatically.
|
||||
- File saves to the OS Downloads directory by default.
|
||||
- Name collisions are resolved without overwriting.
|
||||
- Browser chrome shows progress and completion.
|
||||
- Completed downloads offer familiar actions such as opening or revealing the file.
|
||||
- Cancel remains available while downloading.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- A full persistent download manager/history.
|
||||
- A settings UI for "ask where to save every file."
|
||||
- Cross-device transfer of downloads from a remote browser host to a local client.
|
||||
- Changing generic filesystem downloads outside the browser.
|
||||
|
||||
## Design
|
||||
|
||||
1. **Resolve the save path synchronously in main**
|
||||
|
||||
Add a small browser-specific destination module, e.g. `src/main/browser/browser-download-destination.ts`, that:
|
||||
|
||||
- uses `app.getPath('downloads')`;
|
||||
- normalizes the filename to a safe basename;
|
||||
- uses `path.join` for cross-platform paths;
|
||||
- avoids overwrites with browser-style suffixes (`report.csv`, `report (1).csv`);
|
||||
- checks existing files synchronously because Electron requires `setSavePath()` during `will-download`;
|
||||
- also checks a main-process reservation set for active browser downloads, so two same-name downloads that start before either file exists still get distinct paths;
|
||||
- keys reservations by a normalized absolute path, with platform-aware case folding where the target filesystem is conventionally case-insensitive, so `Report.csv` and `report.csv` cannot collide on Windows;
|
||||
- caps suffix attempts and fails the download with a clear error instead of spinning forever in a crowded Downloads directory.
|
||||
|
||||
Do not create a placeholder file just to reserve the path: Electron may treat an existing target as an overwrite. The in-memory reservation prevents Orca-internal concurrent collisions; an external process can still create the same path after the check, so this remains best-effort at the filesystem boundary.
|
||||
|
||||
2. **Set the destination during `will-download`**
|
||||
|
||||
In `BrowserManager.handleGuestWillDownload`, compute and reserve the path, then call `DownloadItem.setSavePath()` immediately while still in the `will-download` call stack. Do not pause for renderer approval, do not open `dialog.showSaveDialog`, and do not use `setSaveDialogOptions()` for this flow because that still preserves dialog behavior. If destination resolution or `setSavePath()` fails, release the reservation, cancel the item, and send or queue a failed terminal event with a specific, non-secret error.
|
||||
|
||||
3. **Track downloads from start to finish in main**
|
||||
|
||||
Replace the approval-centric state with browser download state:
|
||||
|
||||
- `downloading`: save path is already assigned; item is progressing, waiting on network, or temporarily interrupted.
|
||||
- `completed` / `failed` / `canceled`: terminal states sent to the renderer.
|
||||
|
||||
Register `updated` and `done` listeners immediately in `will-download`, store latest received-byte count plus any terminal result, and keep the existing guest-to-tab queue so downloads that start before registration still surface when the tab binds. Queue state snapshots, not every progress event: if a download finishes before the tab registers, flush a started row followed by its terminal state so the renderer does not drop an orphan progress/finish event. If an unregistered guest is destroyed or retired before the tab binds, cancel its queued active downloads and release their reservations instead of only dropping the pending queue entry. Send exactly one terminal event per download, detach only this feature's listener references during cleanup, and release the reserved path on every terminal path and on explicit cancel.
|
||||
|
||||
4. **Update IPC shape for browser-like status**
|
||||
|
||||
Keep the existing renderer event channels if that keeps the diff smaller, but change the payload contract from "approval requested" to "download started" semantics. Include `browserPageId` on start/progress/finish when the tab is known, include `savePath` on start and terminal data, and keep progress payloads to `downloadId`, byte counts, and transient state. Remove the renderer `acceptDownload` path for normal browser downloads and delete the save-dialog IPC test; `cancelDownload` remains useful while an item is active.
|
||||
|
||||
5. **Render browser chrome, not an approval prompt**
|
||||
|
||||
In `BrowserPane`, replace the `Save` / `Cancel` prompt with a compact download row/list under the toolbar:
|
||||
|
||||
- active row: filename, origin, progress label, `Cancel`;
|
||||
- completed row: filename, "Downloaded" status, `Open`, `Show`, dismiss;
|
||||
- failed/canceled row: filename, concise failure/cancel reason, dismiss.
|
||||
|
||||
Track more than one download per pane so a second download does not hide the first. Cap visible recent completed rows to a small number, such as three, to avoid growing the browser surface. This is a per-pane transient list, not persisted history.
|
||||
|
||||
6. **Use existing shell bridges for actions**
|
||||
|
||||
Use `window.api.shell.openFilePath(savePath)` for `Open` and `window.api.shell.openInFileManager(savePath)` for `Show`. `openPath` is a legacy reveal wrapper whose `void` return type prevents the preload bridge from reporting success or failure back to the renderer. If either action fails or the file has been moved/deleted, show an inline or toast failure that does not claim the file opened.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
Browser session will-download
|
||||
-> BrowserManager computes and reserves ~/Downloads/name.ext
|
||||
-> DownloadItem.setSavePath(path) during will-download
|
||||
-> BrowserManager tracks item and sends/queues browser download-started
|
||||
-> BrowserPane renders progress row
|
||||
-> DownloadItem updated/done
|
||||
-> BrowserManager sends progress/finished
|
||||
-> BrowserPane shows completed row with Open / Show
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Duplicate filenames must not overwrite existing files.
|
||||
- Simultaneous same-name downloads in the same Orca process must not choose the same path before either file exists.
|
||||
- Same-name reservation checks must respect platform path identity, including case-insensitive collisions on Windows.
|
||||
- External filesystem races between path selection and Chromium's file creation are unavoidable with `setSavePath()`; treat them as residual risk, not a guarantee.
|
||||
- Path traversal or separator-like filenames must collapse to a safe basename.
|
||||
- Empty filenames fall back to `download`.
|
||||
- Unknown total bytes should show received bytes or a generic "Downloading" state without broken math.
|
||||
- Multiple simultaneous downloads from the same tab must render independently.
|
||||
- A download that starts, progresses, or finishes before the webview registers must still appear once the tab binds.
|
||||
- A queued download whose guest is destroyed or retired before registration must be canceled and release its reserved path.
|
||||
- Since this design intentionally avoids a global persistent download manager, closing the owning browser tab must cancel active downloads rather than leaving hidden file writes with no chrome.
|
||||
- `setSavePath()` failure should not leave a hidden, still-running download.
|
||||
- `updated` events with `interrupted` state should keep the row honest without treating them as terminal; `done` is the terminal source of truth.
|
||||
- Explicit cancel and Electron's eventual `done` callback must not produce duplicate terminal UI events.
|
||||
- Completed file actions may fail if the file was moved/deleted after download; UI must report that honestly.
|
||||
- On Windows, Linux, and macOS, all paths are built with Node/Electron path APIs.
|
||||
- In SSH/remote workflows, the file is saved on the machine running the browser runtime; UI copy must not imply cross-machine transfer.
|
||||
- Web clients keep their existing "handled by server browser" behavior unless the server-side browser implements equivalent events.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: destination builder sanitizes names, preserves extensions, picks collision-free paths, respects active path reservations including platform path identity, releases reservations, and stops at a bounded suffix limit.
|
||||
- Unit: `BrowserManager.handleGuestWillDownload` calls `setSavePath()` during handling, does not pause/resume for approval, registers listeners immediately, sends started/progress/finished events exactly once, and cancels cleanly on destination failure.
|
||||
- Unit: queued download-started/progress/finished state flushes after guest registration with the latest state.
|
||||
- Unit: multiple download events for one browser page are tracked independently.
|
||||
- Unit: guest cleanup cancels queued-but-unbound active downloads and releases reserved paths.
|
||||
- Unit: tab unregister cancels every active download for that tab and releases reserved paths.
|
||||
- IPC: remove `browser:acceptDownload` and its native save-dialog test for normal downloads; keep `browser:cancelDownload` authorization coverage.
|
||||
- Renderer: download list state covers active, completed, failed, canceled, unknown-size, multiple same-tab downloads, bounded recent rows, and missing-file action failures.
|
||||
- Shell IPC/preload: `Open` uses `openFilePath`; `Show` uses `openInFileManager` and surfaces structured failure.
|
||||
- Electron validation: from a local test page, click a download link and verify a file appears in the OS Downloads folder without a save dialog.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
The changed browser chrome should be quiet and dense, matching the toolbar area around it. Use existing tokens (`background`, `foreground`, `muted-foreground`, `border`, `accent`) and shadcn `Button` variants/sizes. Do not use amber warning color for ordinary downloads. The active state should be readable without looking like an error. Buttons must not wrap, overlap, or resize the toolbar/pane unexpectedly. Copy must describe real state only: "Downloading", "Downloaded", "Canceled", or the specific failure.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
Stage 5 must capture:
|
||||
|
||||
1. Active download in progress, with no save prompt visible.
|
||||
2. Completed download row with file actions visible.
|
||||
3. A repeat download of the same filename showing successful completion without overwrite.
|
||||
4. Multiple browser downloads visible at once or in the recent-download cap.
|
||||
5. Adjacent browser toolbar smoke state after the download row is dismissed.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add the destination builder and focused tests.
|
||||
2. Move BrowserManager download handling to immediate save-path assignment and progress tracking.
|
||||
3. Update browser IPC/preload/API types to remove approval semantics and carry save-path/status data.
|
||||
4. Refresh BrowserPane state and UI for active/recent downloads.
|
||||
5. Add renderer notices, tests, and localization catalog entries as required.
|
||||
6. Run targeted unit tests, typecheck, lint, then Electron validation with screenshots.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: Kept to local built-in browser download behavior. Reduced by excluding a preferences UI, persistent download history, and remote-to-local transfer. The smallest useful normal-browser behavior is automatic save to Downloads plus transient progress/completion chrome.
|
||||
- Architecture/data flow: Main owns destination choice and file writes because Electron requires `setSavePath()` during `will-download`. Renderer owns only display and user actions (`Cancel`, `Open`, `Show`, dismiss). Existing session policy installation remains the entry point; existing shell bridges handle file actions. Browser web clients remain unchanged.
|
||||
- Failure modes covered:
|
||||
- Late `setSavePath()` is removed by assigning the path synchronously in `will-download`.
|
||||
- Duplicate filenames use existing-file checks plus active path reservations instead of overwriting each other inside Orca.
|
||||
- Unsafe or empty filenames are normalized before `path.join`.
|
||||
- Downloads starting or finishing before tab registration are snapshotted and flushed in order.
|
||||
- Queued downloads are canceled if their guest is destroyed or retired before registration.
|
||||
- Multiple downloads no longer overwrite a single pane state.
|
||||
- Tab close/app shutdown cancels active items rather than leaving hidden downloads without chrome.
|
||||
- File action failures after external deletion are surfaced without overclaiming.
|
||||
- Test coverage required:
|
||||
- Unit: `src/main/browser/browser-download-destination.test.ts` for basename/suffix/collision/reservation behavior and bounded failure.
|
||||
- Unit: `src/main/browser/browser-manager.test.ts` for immediate `setSavePath`, listener registration, queued snapshots, progress/finish, cancellation, path-reservation release, and failure.
|
||||
- Unit/IPC: `src/main/ipc/browser.test.ts` removes the native save-dialog accept path and keeps cancel authorization.
|
||||
- Renderer: focused tests around download notice/list state if an existing seam is available; otherwise cover formatting helpers and rely on Electron validation for BrowserPane DOM behavior.
|
||||
- Electron: local server download verifies file creation in Downloads without save dialog.
|
||||
- Performance/blast radius: No startup cost. Per-download synchronous filesystem checks are bounded by the collision limit and happen only during `will-download`; this is acceptable for one user action but must not walk unbounded directories. Renderer IPC volume stays proportional to Electron download progress events already emitted. No migration.
|
||||
- UI quality bar: Validation should judge a neutral browser-toolbar-adjacent download row/list against `docs/STYLEGUIDE.md`: no amber error styling for ordinary progress, compact button sizes, no overlap/wrapping, honest copy, clear active/completed/failed hierarchy, and stable layout while progress changes.
|
||||
- Required review screenshots:
|
||||
1. Active download in progress with no save prompt.
|
||||
2. Completed download row with `Open` / `Show` actions.
|
||||
3. Repeat same-name download completed with distinct filename.
|
||||
4. Multiple downloads visible or capped as designed.
|
||||
5. Browser toolbar after dismissing download chrome.
|
||||
- Residual risks: Headless Electron may not reliably prove absence of a native save dialog; validation should instead verify no Orca `Save` prompt appears and the file lands in Downloads. If the OS Downloads path is redirected or unavailable, behavior depends on Electron's `app.getPath('downloads')` result.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user