merge origin/main after #15212 landed

Both conflicts are independent map fields added to the same listener state — this
branch's `claudeConsumedCompactPromptIdByPaneKey` and #15212's
`claudeSessionOwnerByPaneKey`. Taking either side whole would drop the other's
work, so both are kept in the type and the initializer.
This commit is contained in:
Brennan Benson
2026-08-24 11:17:09 -07:00
1615 changed files with 99028 additions and 26057 deletions
+4
View File
@@ -18,3 +18,7 @@
# source patch under xterm-src/ instead. The sibling patches stay diffable.
/config/patches/@xterm__xterm@*.patch -diff
/config/patches/xterm-src/*.patch text eol=lf
# Generated wrapper fixtures: collapse them in the PR diff so they stop drowning
# the reviewable change, and pin LF because they are compared byte-for-byte.
# Not -diff: the shell diff is the review surface when a wrapper does change.
/src/main/__fixtures__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf
@@ -73,7 +73,8 @@ runs:
shell: bash
run: |
pnpm install --frozen-lockfile --ignore-scripts
git diff --exit-code package.json pnpm-lock.yaml
# Job containers can run composite steps from a source mirror without .git.
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
-5
View File
@@ -52,8 +52,3 @@ Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Re
- [ ] Self-reviewed for correctness, security, and performance
- [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred)
## Author
- X / Twitter: @your_handle
<!-- Optional but appreciated -->
+4 -1
View File
@@ -3,7 +3,10 @@ export const LOC_BLOCK_END = '<!-- /orca-pr-loc -->'
export const LOC_HANDS_OFF_COMMENT =
'<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->'
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|e2e|tests)(?:\/|$)/i
// Why the __-wrapped names: they are unambiguous test-data markers, so a dir
// cannot be one by accident. Bare `fixtures/` is deliberately absent — it reads
// as a plausible prod module name, and this classifier bills every PR.
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|__fixtures__|__snapshots__|e2e|tests)(?:\/|$)/i
const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i
export function isTestPath(path) {
+40 -4
View File
@@ -92,6 +92,11 @@ jobs:
# HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch
# policy to main so only main's copy of this file can read them.
environment: adhoc-mac-build
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.adhoc.outputs.version }}
head_sha: ${{ steps.adhoc.outputs.head_sha }}
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
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
@@ -290,16 +295,23 @@ jobs:
--repo "$ADHOC_REPO" \
--title "$NAME" \
--draft \
--notes "Adhoc macOS dev build of \`$REF\` at commit \`$short_sha\`.
--notes "Adhoc macOS and Windows 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."
ran. The macOS build is 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.
**Windows builds are unsigned.** They install and update normally once you
are on one, but a signed Stable or RC build cannot install one through the
in-app updater — download \`orca-windows-setup.exe\` below and run it once
(SmartScreen will warn about an unknown publisher). Every later switch,
including back to Stable, works in-app from there."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish adhoc macOS artifacts
uses: nick-fields/retry@v4
with:
@@ -416,3 +428,27 @@ jobs:
gh release delete "$tag" --repo "$ADHOC_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
# Why this runs after the mac leg rather than beside it: the tag and version
# are computed inside that job, so until it has run nothing else can name the
# release to upload into. The cost is small enough not to matter — the Windows
# leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a
# ~9.5 min mac run, which keeps a adhoc run far inside its interval.
#
# Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this
# file itself came from, which for an ordinary dispatch is main. Someone who
# deliberately points the Actions "Use workflow from" picker at a branch
# already gets that branch's copy of this entire file, so this follows the same
# rule instead of inventing a second one.
build-adhoc-win:
needs: build-adhoc-mac
# Only once the mac release is actually live: there is no release to upload
# into otherwise, and the Windows workflow refuses to create one.
if: needs.build-adhoc-mac.outputs.published == 'true'
uses: ./.github/workflows/dev-channel-win-build.yml
secrets: inherit
with:
channel: adhoc
tag: ${{ needs.build-adhoc-mac.outputs.tag }}
ref: ${{ needs.build-adhoc-mac.outputs.head_sha }}
version: ${{ needs.build-adhoc-mac.outputs.version }}
+37 -4
View File
@@ -73,6 +73,8 @@ jobs:
build-daily-mac:
if: github.repository == 'stablyai/orca'
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.daily.outputs.version }}
head_sha: ${{ steps.freshness.outputs.head_sha }}
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
runs-on: blacksmith-6vcpu-macos-15
@@ -274,13 +276,19 @@ jobs:
# exactly. One source, written once, read twice.
notes_file="$RUNNER_TEMP/daily-release-notes.md"
cat >"$notes_file" <<EOF
Automated daily macOS dev build from commit \`$short_sha\`.
Automated daily macOS and Windows 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.
**Unvetted.** No tests ran. The macOS build is 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.
**Windows builds are unsigned.** They install and update normally once you
are on one, but a signed Stable or RC build cannot install one through the
in-app updater — download \`orca-windows-setup.exe\` below and run it once
(SmartScreen will warn about an unknown publisher). Every later switch,
including back to Stable, works in-app from there.
EOF
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
@@ -300,6 +308,7 @@ jobs:
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT"
- name: Publish daily macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
@@ -505,3 +514,27 @@ jobs:
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
done
echo "::warning::Failed to dispatch post-daily E2E for $SHA after 3 attempts."
# Why this runs after the mac leg rather than beside it: the tag and version
# are computed inside that job, so until it has run nothing else can name the
# release to upload into. The cost is small enough not to matter — the Windows
# leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a
# ~9.5 min mac run, which keeps a daily run far inside its interval.
#
# Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this
# file itself came from, which for an ordinary dispatch is main. Someone who
# deliberately points the Actions "Use workflow from" picker at a branch
# already gets that branch's copy of this entire file, so this follows the same
# rule instead of inventing a second one.
build-daily-win:
needs: build-daily-mac
# Only once the mac release is actually live: there is no release to upload
# into otherwise, and the Windows workflow refuses to create one.
if: needs.build-daily-mac.outputs.published == 'true'
uses: ./.github/workflows/dev-channel-win-build.yml
secrets: inherit
with:
channel: daily
tag: ${{ needs.build-daily-mac.outputs.tag }}
ref: ${{ needs.build-daily-mac.outputs.head_sha }}
version: ${{ needs.build-daily-mac.outputs.version }}
+304
View File
@@ -0,0 +1,304 @@
name: Dev Channel Windows Build
# Why its own file rather than steps inlined into hourly/daily/adhoc: one copy of
# the Windows leg instead of three, and it stays dispatchable on its own so a
# Windows artifact can be rebuilt for an existing tag without paying for the mac
# leg's packaging and notarization again.
#
# Each mac workflow calls this as a `needs:`-gated job once its release is live,
# passing the tag it created. Both legs land in that one release, so a tag
# carries every platform it managed to build. Measured cost of the Windows leg is
# ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a mac run of ~9.5
# min, so running it after the mac job keeps an hourly well inside its cron.
#
# Why unsigned: Windows release installers are signed by SignPath *after*
# packaging, and release-cut budgets 1h + 4h for those approval waits. That does
# not fit an hourly cadence and it does not fit "dispatch an adhoc build and go
# get coffee". So dev-channel Windows builds ship unsigned, which has one real
# consequence, handled in `src/shared/release-channel.ts`:
#
# electron-updater Authenticode-verifies every installer it downloads against
# the publisherName baked into the *installed* app's app-update.yml. Stable and
# RC carry 'SignPath Foundation', so they reject an unsigned dev installer and
# no future build can fix the copies already installed. Dev builds omit the
# name (config/electron-builder.config.cjs), so verification is skipped there.
#
# Net effect: the way *into* a dev channel on Windows is a one-time manual
# installer run. Every way out — to another dev build, or back to Stable — works
# through the in-app updater. The picker offers a download for exactly that jump.
#
# Called as a job by each mac workflow once its release is live, and separately
# dispatchable by hand to rebuild a Windows artifact for an existing tag without
# re-running the mac leg's twenty minutes of packaging and notarization:
#
# gh workflow run dev-channel-win-build.yml --ref main \
# -f channel=adhoc -f tag=v1.4.178-adhoc.20260819010203 \
# -f ref=<sha> -f version=1.4.178-adhoc.20260819010203
on:
# Why the inputs are duplicated: workflow_call does not accept `type: choice`,
# and workflow_dispatch wants it so the Actions UI offers a menu instead of a
# free-text box. The channel allowlist below is what actually enforces the set,
# since a workflow_call caller can pass any string.
workflow_call:
inputs:
channel:
description: Dev channel whose release this build uploads into
required: true
type: string
tag:
description: Existing release tag in the channel repo
required: true
type: string
ref:
description: Commit SHA to build — must be the exact commit the mac leg built
required: true
type: string
version:
description: Version to package, without the leading v
required: true
type: string
workflow_dispatch:
inputs:
channel:
description: Dev channel whose release this build uploads into
required: true
type: choice
options:
- hourly
- daily
- adhoc
tag:
description: Existing release tag in the channel repo (e.g. v1.4.178-adhoc.20260819010203)
required: true
type: string
ref:
description: Commit SHA to build — must be the exact commit the mac leg built
required: true
type: string
version:
description: Version to package, without the leading v
required: true
type: string
permissions:
contents: read
concurrency:
# Keyed on the tag: re-dispatching the same tag must not race two uploads into
# one release, but two different channels (or two adhoc branches) are the
# ordinary case and must not wait on each other.
group: dev-channel-win-build-${{ inputs.tag }}
cancel-in-progress: false
jobs:
build-win:
if: github.repository == 'stablyai/orca'
# Why the same environment as the mac workflows: this needs the App token
# that can write to the dev-channel repos, and it should be reachable from
# exactly the same place those secrets already live.
environment: adhoc-mac-build
# Why windows-2022 and not windows-latest: windows-latest moved to the
# Windows 2025 / VS 2026 image before node-gyp could detect VS 18, breaking
# native dependency install. release-cut pins the same image.
runs-on: windows-2022
timeout-minutes: 90
env:
NODE_OPTIONS: --max-old-space-size=4096
CHANNEL: ${{ inputs.channel }}
TAG: ${{ inputs.tag }}
VERSION: ${{ inputs.version }}
steps:
# Why vet before checkout: everything after this runs the checked-out code
# with a token that can write to a release repo. The mac leg already vetted
# the ref it resolved, but this workflow is dispatchable on its own, so it
# re-derives the same guarantee rather than trusting its caller.
- name: Vet the requested inputs
id: vetted
shell: bash
env:
REQUESTED_SHA: ${{ inputs.ref }}
REPO_URL: https://github.com/${{ github.repository }}
run: |
set -euo pipefail
# workflow_call takes channel as a free-text string, so the set is
# enforced here rather than by the input type.
case "$CHANNEL" in
hourly|daily|adhoc) ;;
*)
echo "::error::Unknown dev channel '$CHANNEL'; expected hourly, daily, or adhoc."
exit 1
;;
esac
if [[ ! "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::ref must be a full 40-character commit SHA, got '$REQUESTED_SHA'. The dispatching workflow passes the commit it resolved."
exit 1
fi
# The tag must name the version being packaged, or the artifacts would
# land in a release describing a different build.
if [[ "$TAG" != "v$VERSION" ]]; then
echo "::error::tag '$TAG' does not match version '$VERSION'."
exit 1
fi
# And the version must carry the channel's own prerelease identifier,
# so an hourly artifact can never be uploaded into an adhoc release.
if [[ "$VERSION" != *"-$CHANNEL."* ]]; then
echo "::error::version '$VERSION' is not a $CHANNEL version."
exit 1
fi
# Reachability is the trust test: GitHub serves PR-only commits by SHA,
# so resolving the object is not proof a branch or tag of this repo
# reaches it. Bare + tree:0 keeps this to the commit graph.
scratch="$RUNNER_TEMP/vet-requested-ref"
git init -q --bare "$scratch"
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
if ! git -C "$scratch" rev-parse --verify --quiet "$REQUESTED_SHA^{commit}" >/dev/null; then
echo "::error::Commit $REQUESTED_SHA is not in stablyai/orca."
exit 1
fi
if [[ -z "$(git -C "$scratch" for-each-ref --contains "$REQUESTED_SHA" refs/heads refs/tags | head -1)" ]]; then
echo "::error::Commit $REQUESTED_SHA is not reachable from any branch or tag of stablyai/orca; refusing to build it."
exit 1
fi
echo "Vetted $CHANNEL $TAG at $REQUESTED_SHA"
- name: Checkout the built commit
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
# This job only reads stablyai/orca and never pushes; every write goes
# to the dev-channel repo through a minted App token passed by env
# (zizmor: artipacked).
persist-credentials: false
# Why a guard and not just a build: the workflow file comes from the
# dispatch ref, but the packaging config comes from the *built* commit. A
# branch cut before Windows dev builds landed has a config that ignores
# ORCA_WIN_*, which would resolve publish.repo to the main repo. Say that
# in one sentence here rather than failing deep inside electron-builder.
- name: Resolve the dev-channel packaging identity
shell: bash
run: |
set -euo pipefail
upper="$(printf '%s' "$CHANNEL" | tr '[:lower:]' '[:upper:]')"
echo "ORCA_WIN_${upper}=1" >>"$GITHUB_ENV"
echo "ORCA_${upper}_BUILD_VERSION=${VERSION}" >>"$GITHUB_ENV"
if [[ ! -f config/scripts/verify-dev-channel-packaging.mjs ]]; then
echo "::error::$TAG was built from a commit with no config/scripts/verify-dev-channel-packaging.mjs; that commit predates Windows dev builds, so it cannot produce one."
exit 1
fi
# pnpm must be on PATH before setup-node so setup-node can locate the store.
- 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
# Caches the Electron binary and electron-builder's tool downloads (nsis,
# winCodeSign). Same key shape as release-cut's Windows leg.
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-win-
# Why retried: pnpm install triggers electron's postinstall, which pulls the
# Electron binary from GitHub release assets, and that CDN returns transient
# 504s often enough to lose a build to it.
- 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 the packaging check runs before the 20-minute build: it only needs
# node_modules, and a stale config should cost seconds rather than a build.
- name: Verify dev-channel packaging identity
shell: bash
run: node config/scripts/verify-dev-channel-packaging.mjs --channel="$CHANNEL" --platform=win32
- name: Build app
shell: bash
run: pnpm build:release
env:
# Why unset ORCA_BUILD_IDENTITY: telemetry's transport gate accepts only
# 'stable' or 'rc', so leaving it unset keeps dev builds silent — which
# is correct for unvetted artifacts. Same as the mac dev channels.
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 and nothing before this point writes anything.
- name: Mint dev channel 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-${{ inputs.channel }}
# Why: electron-builder's publisher creates a release when it cannot find
# the tag ("publish: always"). If the mac leg failed and discarded its draft
# while this was building, that would mint a fresh, untitled, Windows-only
# release. Check first and fail instead.
- name: Confirm the target release still exists
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
if ! gh release view "$TAG" --repo "stablyai/orca-$CHANNEL" --json tagName >/dev/null 2>&1; then
echo "::error::Release $TAG no longer exists in stablyai/orca-$CHANNEL; the mac leg most likely failed and discarded it. Not creating a Windows-only release."
exit 1
fi
- name: Publish Windows artifacts
uses: nick-fields/retry@v4
with:
# 30 is the pack + upload budget; there is no notary queue on this leg.
timeout_minutes: 30
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
ORCA_BUILD_COMMIT: ${{ inputs.ref }}
# Why: electron-publish refuses to upload into a release published more
# than two hours ago (gitHubPublisher.getOrCreateRelease). The mac leg
# publishes the draft live as soon as *it* finishes, so a slow notary
# queue plus a slow Windows build can cross that line and silently drop
# every Windows asset. This is the documented escape hatch.
EP_GH_IGNORE_TIME: 'true'
# Why: the updater resolves a tag, then fetches latest.yml from it. A
# release carrying the installer but not the manifest is one the picker
# offers and the update 404s on, so assert both.
- name: Verify Windows update manifest published
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "stablyai/orca-$CHANNEL" --json assets --jq '.assets[].name')"
echo "Assets on $TAG:"
echo "$assets"
for required in latest.yml orca-windows-setup.exe; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::$TAG is missing $required; Windows could not install this build."
exit 1
fi
done
echo "Windows artifacts verified on $TAG."
+9 -3
View File
@@ -86,6 +86,10 @@ jobs:
with:
name: e2e-build-out
path: out/
# Why: build-relay.mjs writes each relay's marker as `out/relay/<platform>/.version`,
# and upload-artifact drops dotfiles by default — consumers then fail SSH specs with
# "local relay build is missing its version marker".
include-hidden-files: true
retention-days: 1
if-no-files-found: error
@@ -130,7 +134,8 @@ jobs:
# scripts still need the Linux toolchain even though this shard reuses
# the prebuilt Electron output.
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 zsh
# Why: paired Quick Open coverage exercises the resource-bounded host search.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
# Why: Electron on Linux needs an X display even when the app
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
@@ -211,9 +216,11 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
# Why ripgrep: Quick Open's bounded host-side search requires rg instead of an
# unbounded inventory fallback; the paired fixture exercises that real boundary.
# Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this
# lane now receives those specs from pr.yml's SSH source mapping.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
@@ -221,7 +228,6 @@ jobs:
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
@@ -74,6 +74,7 @@ jobs:
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
- name: Run golden E2E tests on macOS
@@ -87,6 +88,7 @@ jobs:
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
- name: Run golden E2E tests on Windows
@@ -97,6 +99,7 @@ jobs:
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
pnpm run --if-present test:e2e:workspace-session-golden
pnpm run --if-present test:e2e:windows-fresh-startup-golden
pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) {
pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
}
+40 -4
View File
@@ -62,6 +62,11 @@ env:
jobs:
build-hourly-mac:
if: github.repository == 'stablyai/orca'
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.hourly.outputs.version }}
head_sha: ${{ steps.freshness.outputs.head_sha }}
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
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
@@ -270,15 +275,22 @@ jobs:
--repo "$HOURLY_REPO" \
--title "$NAME" \
--draft \
--notes "Automated hourly macOS dev build from commit \`$short_sha\`.
--notes "Automated hourly macOS and Windows 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."
**Unvetted.** No tests ran. The macOS build is 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.
**Windows builds are unsigned.** They install and update normally once you
are on one, but a signed Stable or RC build cannot install one through the
in-app updater — download \`orca-windows-setup.exe\` below and run it once
(SmartScreen will warn about an unknown publisher). Every later switch,
including back to Stable, works in-app from there."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish hourly macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
@@ -430,3 +442,27 @@ jobs:
gh release delete "$tag" --repo "$HOURLY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
# Why this runs after the mac leg rather than beside it: the tag and version
# are computed inside that job, so until it has run nothing else can name the
# release to upload into. The cost is small enough not to matter — the Windows
# leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a
# ~9.5 min mac run, which keeps a hourly run far inside its interval.
#
# Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this
# file itself came from, which for an ordinary dispatch is main. Someone who
# deliberately points the Actions "Use workflow from" picker at a branch
# already gets that branch's copy of this entire file, so this follows the same
# rule instead of inventing a second one.
build-hourly-win:
needs: build-hourly-mac
# Only once the mac release is actually live: there is no release to upload
# into otherwise, and the Windows workflow refuses to create one.
if: needs.build-hourly-mac.outputs.published == 'true'
uses: ./.github/workflows/dev-channel-win-build.yml
secrets: inherit
with:
channel: hourly
tag: ${{ needs.build-hourly-mac.outputs.tag }}
ref: ${{ needs.build-hourly-mac.outputs.head_sha }}
version: ${{ needs.build-hourly-mac.outputs.version }}
+163 -41
View File
@@ -16,8 +16,46 @@ permissions:
contents: read
jobs:
# Why: a README/docs-only PR used to start the full matrix (32 test shards,
# two package jobs, typecheck, git compat, xterm, shell contracts). Path
# filters on `on.pull_request` would drop the `verify` check entirely; this
# detector keeps verify as the required aggregate and skips the expensive jobs.
code_paths:
name: detect code-relevant changes
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why blob:none: full history is needed for the merge-base diff, but historical
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
# few this job actually reads on demand.
fetch-depth: 0
filter: blob:none
persist-credentials: false
- name: Classify changed paths
id: filter
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# Why --no-renames: name-only rename detection can report only the destination.
# A code file moved under docs/ must still expose its code-side deletion.
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
echo "Changed paths:"
printf '%s\n' "$CHANGED"
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)"
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "should_run=$SHOULD_RUN"
static_analysis:
name: static analysis
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
runs-on: ubuntu-latest
steps:
@@ -78,6 +116,15 @@ jobs:
- name: Enforce max-lines ratchet
run: pnpm run check:max-lines-ratchet
- name: Enforce runtime Electron-import ratchet
run: pnpm run check:runtime-electron-ratchet
# Why both: the ratchet proves nothing reachable from the runtime imports electron,
# which is a property of the import graph. This proves the Node artifact it enables
# actually boots, pairs, creates a worktree and round-trips a real PTY.
- name: Boot orcad and round-trip a terminal
run: pnpm run smoke:orcad-terminal
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
@@ -140,6 +187,8 @@ jobs:
run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA"
typecheck:
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
runs-on: ubuntu-latest
steps:
@@ -166,6 +215,8 @@ jobs:
git_compatibility:
name: Git compatibility
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
runs-on: ubuntu-latest
steps:
@@ -231,6 +282,8 @@ jobs:
xterm_patch_sync:
name: xterm patch sync
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
runs-on: ubuntu-latest
steps:
@@ -259,7 +312,14 @@ jobs:
shell_contracts:
name: shell contracts
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
runs-on: ubuntu-latest
# Why: this job's cost is almost entirely package download, and a stalled mirror has
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
# is generous; it exists so a wedge fails the job instead of holding the whole run
# open for the 6h GitHub default — which also blocks `gh run rerun --failed`.
timeout-minutes: 15
env:
# Why: the suites below gate their live fish tests on the binary, which is
# right on a developer machine and wrong here — this job is a required check
@@ -294,16 +354,39 @@ jobs:
# an update on each side of it this step refreshed them three times over, and
# the Azure archive mirror alone costs ~15-30s a pass. The PPA index is the
# only thing the repo list gains here, and the single update below fetches it.
# Why bound acquisition: measured on a *passing* run, this step spent 40s
# fetching 11.4 MB of index and then 2m17s fetching 8.9 MB of packages at
# 65 kB/s — it is dominated by download throughput, not by work. apt applies
# no wall-clock bound to a stalled mirror, so a slow Launchpad or archive
# host wedges the step for tens of minutes. This job is a required check, so
# a wedge holds the entire run open and blocks `gh run rerun --failed`.
# Bounded timeouts plus retries turn an unbounded hang into a fast, legible
# failure. Set in apt.conf.d rather than on each command line so the two
# invocations below stay exactly as pr-workflow-parallelism.test.mjs parses
# them. Retries are 1, not 3: a first attempt at these bounds already multiplied
# 30s x 3 retries across every index file into a ~15 minute stall on a dead
# mirror, which is worse than failing once and moving on.
sudo tee /etc/apt/apt.conf.d/99-orca-shell-contracts >/dev/null <<'APTCONF'
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
Acquire::Retries "1";
APTCONF
for attempt in 1 2 3; do
sudo add-apt-repository -y -n ppa:fish-shell/release-4 && break
echo "add-apt-repository attempt ${attempt} failed; retrying" >&2
sudo add-apt-repository -y -n -r ppa:fish-shell/release-4 || true
sleep 5
done
sudo apt-get update || true
# Why a wall-clock bound on each command: apt's Acquire timeouts are per-connection,
# so a dead mirror costs timeout x retries x every index file. Measured: the archive
# mirror stalled with zero bytes and the step burned 14m26s before the job bound
# killed it. `timeout` is the only thing that bounds the command as a whole.
# The update is already tolerant by design (see above), so bounding it just caps
# what a dead mirror can cost before the install runs against whatever index exists.
timeout 120 sudo apt-get update || true
# Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the
# first install command in this step to prove the lane really installs them.
sudo apt-get install -y zsh fish
timeout 300 sudo apt-get install -y zsh fish
# Separate from the install so the failure names the contract, not an apt error.
# ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds
@@ -331,19 +414,24 @@ jobs:
src/main/daemon/shell-ready.test.ts \
src/main/daemon/node-pty-fd-leak.test.ts \
src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \
src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \
src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/codex-shell-launch-preflight.test.ts \
src/main/pty/omp-shell-wrapper-alias-safety.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/main/shell-startup-feature-channel.test.ts \
src/main/terminal-history-fish-session.node-pty.test.ts \
src/main/zsh-scoped-histfile.live-shell.test.ts \
src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
src/shared/startup-shell-portability.live-shell.test.ts \
src/shared/posix-command-path-lookup.test.ts
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -373,14 +461,15 @@ jobs:
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/main/shell-startup-feature-channel.test.ts \
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
@@ -388,6 +477,8 @@ jobs:
cross-version-wire:
name: cross-version wire compatibility
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
runs-on: ubuntu-latest
steps:
@@ -415,6 +506,8 @@ jobs:
managed_hook_node18:
name: managed hooks on Node 18
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
runs-on: ubuntu-latest
steps:
@@ -438,6 +531,8 @@ jobs:
package:
name: package
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
runs-on: ubuntu-latest
steps:
@@ -499,6 +594,8 @@ jobs:
package_windows:
name: package (windows)
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
runs-on: windows-2022
timeout-minutes: 30
@@ -532,9 +629,30 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: node-pty prefers its upstream prebuild, which does not contain
# Orca's Windows patch, so the job-object exports would be absent and the
# suite below would test an unpatched binary. build_from_source removes
# the prebuild, and the package's postinstall restores the ConPTY runtime
# files that a bare node-gyp rebuild would miss.
- name: Rebuild node-pty from patched source
env:
npm_config_build_from_source: 'true'
run: pnpm rebuild node-pty
- name: Test Windows-specific boundaries
run: >-
pnpm exec vitest run --config config/vitest.config.ts
src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts
src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts
src/shared/child-process/windows-command-line.win32.test.ts
src/main/windows/windows-pty-job.win32.test.ts
src/main/windows/windows-host-job.win32.test.ts
src/main/wsl/wsl-runner.test.ts
src/main/wsl/wsl-guest-environment.test.ts
src/main/wsl/wsl-invocation-boundary.test.ts
src/main/wsl/wsl-executable-path.win32.test.ts
src/main/wsl/wsl-w1-w3-contract.test.ts
src/shared/source-scan/source-tree-scan.test.ts
src/main/cli/wsl-cli-powershell-boundary.test.ts
src/main/orca-profiles/profile-index-store.test.ts
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
@@ -555,6 +673,9 @@ jobs:
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
- name: Smoke packaged Windows PTY native capability
run: pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked
@@ -562,8 +683,9 @@ jobs:
# release runs retain full-suite coverage.
e2e-paths:
name: detect changed e2e specs
needs: [code_paths]
runs-on: ubuntu-latest
if: github.event.pull_request.draft != true
if: github.event.pull_request.draft != true && needs.code_paths.outputs.should_run == 'true'
# Why: detector only needs to read the checkout; do not inherit repo defaults.
permissions:
contents: read
@@ -588,38 +710,9 @@ jobs:
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
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)"
if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then
TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)"
fi
# Why: the Docker-SSH specs only ever ran when someone edited a spec, so four
# pane-restore regressions shipped from SSH source edits that touched no test.
# Unit tests are excluded because they prove the same source without Docker.
# ssh-startup-exec-readiness.spec.ts is listed to route e2e.yml's dedicated
# ssh-docker-watcher-isolation lane, which filters it out of changed-e2e.
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(src/main/ssh/|src/main/providers/ssh-|src/main/ipc/(ssh-|pty)|src/relay/|src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty))'; then
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
'tests/e2e/pty-input-write-queue-ssh.spec.ts' \
'tests/e2e/ssh-cold-activation-restore.spec.ts' \
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' \
'tests/e2e/ssh-startup-exec-readiness.spec.ts' \
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' | sort -u)"
fi
# Why: same lesson as the SSH specs above. The IME e2e suite asserts real pty bytes and
# real overlay geometry, but only ran when someone edited a spec, so every IME fix in the
# 1.4.18x window shipped without it - including one whose diagnosis hardware later refuted.
# config/patches is listed because the terminal's input handling lives in the xterm patch.
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(config/patches/|src/renderer/src/components/terminal-pane/(terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))'; then
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
'tests/e2e/terminal-cjk-ime-committed-text.spec.ts' \
'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts' \
'tests/e2e/terminal-ime-exact-byte.spec.ts' \
'tests/e2e/terminal-korean-composing-chord-order.spec.ts' \
'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts' \
'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts' \
'tests/e2e/terminal-korean-preedit-visibility.spec.ts' | sort -u)"
fi
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
# Source routes are executable contracts so a test can prove exact
# authorities, exclusions, and sentinels without evaluating workflow shell.
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
if [ "$TEST_FILES_JSON" != '[]' ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
@@ -643,6 +736,7 @@ jobs:
verify:
if: always()
needs:
- code_paths
- static_analysis
- root_directory_guard
- typecheck
@@ -666,6 +760,8 @@ jobs:
# checked outside the loop or it would excuse the jobs above.
- name: Require successful checks
env:
CODE_PATHS: ${{ needs.code_paths.result }}
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
@@ -677,7 +773,33 @@ jobs:
PACKAGE: ${{ needs.package.result }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
run: |
if [ "$CODE_PATHS" != "success" ]; then
exit 1
fi
if [ "$SHOULD_RUN" != "true" ]; then
echo "Docs-only change; expensive PR checks skipped."
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
exit 1
fi
for result in \
"$STATIC_ANALYSIS" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$MANAGED_HOOK_NODE18" \
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
if [ "$result" != "skipped" ]; then
exit 1
fi
done
exit 0
fi
# Require success when the PR has code-relevant changes
for result in \
"$CODE_PATHS" \
"$STATIC_ANALYSIS" \
"$ROOT_DIRECTORY_GUARD" \
"$TYPECHECK" \
+3 -1
View File
@@ -53,7 +53,7 @@ on:
default: ''
permissions:
contents: write
contents: read
concurrency:
group: release-cut
@@ -68,6 +68,8 @@ jobs:
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
outputs:
tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
+9 -1
View File
@@ -1,18 +1,26 @@
name: Skill update round trip
# Why the same paths on push as pull_request: GitHub evaluates each event's
# filters independently. The PR already skipped README-only changes, but the
# unfiltered `push` to main still started the 13-job matrix. Cancelling that
# run marks the default-branch tip failed, so the repo shows a red X.
on:
pull_request:
paths:
paths: &skill-roundtrip-paths
- 'skills/**'
- 'resources/skills/**'
- 'config/scripts/verify-skill-update-roundtrip.mjs'
- 'config/scripts/skill-update-roundtrip-workflow.test.mjs'
- 'src/main/skills/skill-freshness-eligibility.ts'
- 'src/shared/skill-freshness.ts'
- '.github/workflows/skill-update-roundtrip.yml'
# Why unfiltered: GitHub ignores `paths` on merge_group. We do not run a merge
# queue today; if one is added, gate the matrix on a path job first.
merge_group:
push:
branches:
- main
paths: *skill-roundtrip-paths
jobs:
roundtrip:
+3
View File
@@ -103,8 +103,11 @@ docs/**
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/windows-process-enumeration.md
!docs/reference/wsl-runner-verification.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/renderer-agent-status-performance.md
!docs/reference/ssh-execution-boundary.md
!docs/reference/ssh-host-key-verification.md
!docs/reference/ssh-reconnect-source-recovery.md
!docs/reference/windows-setup-shell.md
+3 -1
View File
@@ -34,12 +34,14 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import.
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
## SSH Use Case
All changes must consider the SSH use case. Don't assume local-only execution.
All changes must consider the SSH use case. Don't assume local-only execution. Before changing anything that reports on, stops, or lists remote work, follow [`docs/reference/ssh-execution-boundary.md`](./docs/reference/ssh-execution-boundary.md): the execution host owns everything that touches execution, and loss of contact is never evidence of process death — the verdict vocabulary is `live` / `unverifiable` / `exited`, with no synonyms.
## Folder Workspace Use Case
+5 -4
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
- **Android:** [Download APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
---
@@ -238,9 +238,10 @@ 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:** Scan to join the Orca community WeChat group 7.
- **WeChat:** Scan to join the Orca community WeChat group 7. If it is full, use group 8.
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 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" />&nbsp;&nbsp;
<img src="docs/assets/wechat-qr-group8.jpg" alt="WeChat group 8 QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
@@ -17,12 +17,13 @@ type OutputChunk = Rollup.OutputChunk
// graph still resolves.
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
// forked daemon, parcel-watcher and computer sidecars, and the CLI-run
// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'computer-sidecar',
'wsl-transcript-fs-process-entry',
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
] as const
+36 -10
View File
@@ -22,14 +22,27 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
const isMacDaily = process.env.ORCA_MAC_DAILY === '1'
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
// Why a second set of variables rather than making the mac ones platform-neutral:
// the mac ones gate `isMacRelease` below, which turns on hardened runtime,
// notarization, and root-level `forceCodeSigning`. A Windows dev build that
// reused them would fail packaging outright for want of a cert it is
// deliberately not using.
const isWinHourly = process.env.ORCA_WIN_HOURLY === '1'
const isWinDaily = process.env.ORCA_WIN_DAILY === '1'
const isWinAdhoc = process.env.ORCA_WIN_ADHOC === '1'
const isWinDevChannel = isWinHourly || isWinDaily || isWinAdhoc
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const devChannelBuildVersion = isMacHourly
const localBuildVersion =
isMacRelease || isWinDevChannel ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const isHourlyChannel = isMacHourly || isWinHourly
const isDailyChannel = isMacDaily || isWinDaily
const isAdhocChannel = isMacAdhoc || isWinAdhoc
const devChannelBuildVersion = isHourlyChannel
? process.env.ORCA_HOURLY_BUILD_VERSION
: isMacDaily
: isDailyChannel
? process.env.ORCA_DAILY_BUILD_VERSION
: isMacAdhoc
: isAdhocChannel
? process.env.ORCA_ADHOC_BUILD_VERSION
: undefined
// Why each dev channel gets its own repo rather than tagging into the main one:
@@ -38,11 +51,11 @@ const devChannelBuildVersion = isMacHourly
// to install. Keeping adhoc/daily separate from hourly too means a branch build
// or a once-a-day cut cannot be picked up by someone who only meant to ride
// main's hourlies.
const devChannelRepo = isMacHourly
const devChannelRepo = isHourlyChannel
? 'orca-hourly'
: isMacDaily
: isDailyChannel
? 'orca-daily'
: isMacAdhoc
: isAdhocChannel
? 'orca-adhoc'
: null
const appId = 'com.stablyai.orca'
@@ -129,6 +142,9 @@ module.exports = {
'!out/**/*.test.js',
// Why: Vite's manifest is only used to project the paired web client.
'!out/renderer/.vite{,/**/*}',
// Why: out/electron-dev caches `pnpm dev`'s per-branch Electron.app copies (~270MB each).
// CI never creates it, but packaging on a machine that has run dev would pack them all.
'!out/electron-dev{,/**/*}',
'!electron.vite.config.{js,ts,mjs,cjs}',
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
@@ -189,6 +205,7 @@ module.exports = {
'out/main/hermes/**',
'out/main/daemon-entry.js',
'out/main/session-scanner-service-entry.js',
'out/main/wsl-transcript-fs-process-entry.js',
'out/main/session-scanner-opencode-sqlite-worker-entry.js',
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
@@ -300,9 +317,18 @@ module.exports = {
executableName: 'Orca',
// Why: Windows installers are signed after electron-builder packaging by
// SignPath, so the packager cannot infer the updater publisherName.
signtoolOptions: {
publisherName: 'SignPath Foundation'
},
//
// Why dev channels drop it instead: they ship unsigned, because SignPath's
// approval waits are budgeted in hours and cannot fit an hourly cadence.
// electron-updater Authenticode-verifies every installer it downloads
// against the publisherName baked into the *installed* app's app-update.yml
// (NsisUpdater.verifySignature), and skips verification entirely when that
// name is absent. An unsigned build that still claimed 'SignPath Foundation'
// would therefore reject its own channel's next build — and its way back to
// stable with it. Dropping it is what makes dev→dev and dev→stable work.
...(isWinDevChannel
? { verifyUpdateCodeSignature: false }
: { signtoolOptions: { publisherName: 'SignPath Foundation' } }),
extraResources: [
...commonExtraResources,
...createPackagedRuntimeNodeModuleResources('win32'),
-1
View File
@@ -128,7 +128,6 @@ inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
inline src/renderer/src/components/terminal-pane/pty-connection.ts
inline src/renderer/src/components/terminal-pane/pty-transport.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+4 -1
View File
@@ -31,7 +31,10 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'yaml',
'zod'
]
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['windows-native-registry']
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'@vscode/windows-process-tree',
'windows-native-registry'
]
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
darwin: 'darwin-',
@@ -0,0 +1,27 @@
diff --git a/binding.gyp b/binding.gyp
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -16,9 +16,6 @@
],
"include_dirs": [],
"libraries": [ 'psapi.lib' ],
- "msvs_configuration_attributes": {
- "SpectreMitigation": "Spectre"
- },
"msvs_settings": {
"VCCLCompilerTool": {
"AdditionalOptions": [
diff --git a/src/process.cc b/src/process.cc
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
--- a/src/process.cc
+++ b/src/process.cc
@@ -37,7 +37,7 @@ uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
process_info.push_back(std::move(pinfo));
process_count++;
}
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
+ } while (Process32Next(snapshot_handle, &process_entry));
}
CloseHandle(snapshot_handle);
+125
View File
@@ -0,0 +1,125 @@
diff --git a/lib/gitWorkflow.js b/lib/gitWorkflow.js
index 3a32d6a9cd65c7ecb6b3cbccfce5a15c96530435..363513128bf7302eab73ea775102bb8a633a3b8b 100644
--- a/lib/gitWorkflow.js
+++ b/lib/gitWorkflow.js
@@ -50,6 +50,9 @@ export const STASH = 'lint-staged automatic backup'
const PATCH_UNSTAGED = 'lint-staged_unstaged.patch'
+const BACKUP_REF_PREFIX = 'refs/worktree/lint-staged-backups'
+const ZERO_OID = '0000000000000000000000000000000000000000'
+
const GIT_DIFF_ARGS = [
'--binary', // support binary files
'--unified=0', // do not add lines around diff for consistent behaviour
@@ -100,6 +103,8 @@ export class GitWorkflow {
/** @type {import('./getStagedFiles.js').StagedFile[][]} */
this.matchedFileChunks = matchedFileChunks
this.topLevelDir = topLevelDir
+ this.backupOid = null
+ this.backupRef = null
/**
* These three files hold state about an ongoing git merge
@@ -122,6 +127,17 @@ export class GitWorkflow {
* Get name of backup stash
*/
async getBackupStash(ctx) {
+ if (this.backupRef) {
+ const backupOid = await this.execGit(['rev-parse', '--verify', this.backupRef])
+
+ if (backupOid !== this.backupOid) {
+ ctx.errors.add(GetBackupStashError)
+ throw new Error('lint-staged automatic backup is missing!')
+ }
+
+ return this.backupRef
+ }
+
/** Print stash list with short hash and subject */
const stashes = await this.execGit(['stash', 'list', '--format="%h %s"', '-z'])
.then(parseGitZOutput)
@@ -270,11 +286,14 @@ export class GitWorkflow {
} else {
/** Save stash of all changes, keeping all files as-is */
const stashHash = await this.execGit(['stash', 'create'])
+ this.backupOid = stashHash
+ this.backupRef = `${BACKUP_REF_PREFIX}/${crypto.randomUUID()}`
ctx.backupHash = await this.execGit(['rev-parse', '--short', stashHash])
- await this.execGit(['stash', 'store', '--quiet', '--message', STASH, ctx.backupHash])
+ await this.execGit(['update-ref', this.backupRef, this.backupOid, ZERO_OID])
+ ctx.backupRef = this.backupRef
}
- task.title = `Backed up original state in git stash (${ctx.backupHash})`
+ task.title = `Backed up original state (${ctx.backupHash})`
debugLog(task.title)
}
} catch (error) {
@@ -425,7 +444,11 @@ export class GitWorkflow {
async cleanup(ctx) {
try {
debugLog('Dropping backup stash...')
- await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)])
+ if (this.backupRef) {
+ await this.execGit(['update-ref', '-d', this.backupRef, this.backupOid])
+ } else {
+ await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)])
+ }
debugLog('Done dropping backup stash!')
} catch (error) {
handleError(error, ctx)
diff --git a/lib/index.js b/lib/index.js
index 75eeacee48759ef90249df5524639d61bce10918..24b4852eba9b14e05e10c3df8a2b2dae75af19f3 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -157,7 +157,7 @@ const lintStaged = async (
logger.warn(PREVENTED_EMPTY_COMMIT)
} else if (ctx.errors.has(FailOnChangesError)) {
logger.warn(PREVENTED_TASK_MODIFICATIONS + '\n')
- logger.warn(restoreStashExample(ctx.backupHash))
+ logger.warn(restoreStashExample(ctx.backupHash, ctx.backupRef))
} else if (ctx.errors.has(RestoreUnstagedChangesError)) {
logger.warn(UNSTAGED_CHANGES_BACKUP_STASH_LOCATION)
logger.warn(ctx.unstagedPatch)
@@ -168,7 +168,7 @@ const lintStaged = async (
logger.error(GIT_ERROR)
if (ctx.shouldBackup) {
// No sense to show this if the backup stash itself is missing.
- logger.error(restoreStashExample(ctx.backupHash) + '\n')
+ logger.error(restoreStashExample(ctx.backupHash, ctx.backupRef) + '\n')
}
}
diff --git a/lib/messages.js b/lib/messages.js
index 993f8d81cac48132b5ae2ebdc8ca452c7217caad..2dc2291bc82451eacc5babfc8ce408051e1101e3 100644
--- a/lib/messages.js
+++ b/lib/messages.js
@@ -66,9 +66,12 @@ export const PREVENTED_EMPTY_COMMIT = `
Use the --allow-empty option to continue, or check your task configuration`)}
`
-export const restoreStashExample = (
- hash = 'h0a0s0h0'
-) => `Any lost modifications can be restored from a git stash:
+export const restoreStashExample = (hash = 'h0a0s0h0', backupRef) =>
+ backupRef
+ ? `Any lost modifications can be restored from the worktree backup:
+
+ > git stash apply --index ${backupRef}`
+ : `Any lost modifications can be restored from a git stash:
> git stash list --format="%h %s"
${hash} On main: lint-staged automatic backup
diff --git a/lib/state.js b/lib/state.js
index da30e6f639d31307ebf503691aa991bb93948f8a..4a38799fa7773a8e8629c7c3a6b72287e6b22f4b 100644
--- a/lib/state.js
+++ b/lib/state.js
@@ -15,6 +15,7 @@ export const getInitialState = ({
revert = true,
} = {}) => ({
backupHash: null,
+ backupRef: null,
errors: new Set([]),
shouldFailOnChanges: failOnChanges,
hasFilesToHide: null,
+334 -20
View File
@@ -35,7 +35,7 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
+++ b/deps/winpty/src/winpty.gyp
@@ -10,7 +10,7 @@
# make -j4 CXX=i686-w64-mingw32-g++ LDFLAGS="-static -static-libgcc -static-libstdc++"
'variables': {
- 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && GetCommitHash.bat")',
+ 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && .\\GetCommitHash.bat")',
@@ -116,7 +116,7 @@ index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd
--- a/src/conpty_console_list_agent.ts
+++ b/src/conpty_console_list_agent.ts
@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils';
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
const shellPid = parseInt(process.argv[2], 10);
-const consoleProcessList = getConsoleProcessList(shellPid);
@@ -141,12 +141,12 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
#include <unistd.h>
+#include <string>
#include <thread>
#include <sys/types.h>
@@ -47,6 +49,25 @@
#include <termios.h>
#endif
+/* Orca: glibc 2.32-2.34 relocated pthread_sigmask/openpty/forkpty into libc
+ * under new symbol versions, so building on a newer glibc produces references
+ * (GLIBC_2.32/2.34) absent on Ubuntu 20.04 (glibc 2.31) and the app fails to
@@ -171,7 +171,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
#define VWERASE VWERSE
@@ -237,13 +258,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
+struct pty_spawn_error {
+ const char* step;
@@ -192,12 +192,12 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
- int* err);
+ pty_spawn_error* err);
#endif
struct DelBuf {
@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
argv[i + 3] = strdup(arg.c_str());
}
- int err = -1;
- pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err);
- if (err != 0) {
@@ -212,7 +212,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
+static const char*
+pty_errno_name(int errnum) {
@@ -283,7 +283,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ bool acts_initialized = false;
+ posix_spawnattr_t attrs;
+ bool attrs_initialized = false;
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env,
@@ -294,7 +294,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "posix_openpt", errno);
+ goto done;
}
- int res = grantpt(*master) || unlockpt(*master);
+ res = grantpt(*master);
if (res == -1) {
@@ -308,7 +308,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "unlockpt", errno);
+ goto done;
}
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
- int slave;
char slave_pty_name[128];
@@ -318,14 +318,14 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "ioctl_TIOCPTYGNAME", errno);
+ goto done;
}
slave = open(slave_pty_name, O_RDWR | O_NOCTTY);
if (slave == -1) {
- return;
+ pty_set_spawn_error(err, "open_slave", errno, "slave", slave_pty_name);
+ goto done;
}
if (termp) {
res = tcsetattr(slave, TCSANOW, termp);
if (res == -1) {
@@ -334,7 +334,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ goto done;
};
}
if (winp) {
res = ioctl(slave, TIOCSWINSZ, winp);
if (res == -1) {
@@ -343,7 +343,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ goto done;
}
}
- posix_spawn_file_actions_t acts;
- posix_spawn_file_actions_init(&acts);
+ res = posix_spawn_file_actions_init(&acts);
@@ -357,7 +357,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO);
posix_spawn_file_actions_addclose(&acts, slave);
posix_spawn_file_actions_addclose(&acts, *master);
- posix_spawnattr_t attrs;
- posix_spawnattr_init(&attrs);
- *err = posix_spawnattr_setflags(&attrs, flags);
@@ -373,7 +373,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "posix_spawnattr_setflags", res);
goto done;
}
sigset_t signal_set;
/* Reset all signal the child to their default behavior */
sigfillset(&signal_set);
@@ -384,7 +384,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "posix_spawnattr_setsigdefault", res);
goto done;
}
/* Reset the signal mask for all signals */
sigemptyset(&signal_set);
- *err = posix_spawnattr_setsigmask(&attrs, &signal_set);
@@ -394,7 +394,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ pty_set_spawn_error(err, "posix_spawnattr_setsigmask", res);
goto done;
}
do
- *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
- while (*err == EINTR);
@@ -419,7 +419,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
+ close(*master);
+ *master = -1;
+ }
- for (; count > 0; count--) {
- close(low_fds[count]);
+ for (size_t i = 0; i <= count && i < 3; i++) {
@@ -429,3 +429,317 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
}
}
#endif
diff --git a/src/win/conpty.cc b/src/win/conpty.cc
index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a6a4082ce 100644
--- a/src/win/conpty.cc
+++ b/src/win/conpty.cc
@@ -18,6 +18,7 @@
#include <iostream>
#include <string>
#include <thread>
+#include <mutex>
#include <vector>
#include <Windows.h>
#include <strsafe.h>
@@ -44,12 +45,29 @@ struct pty_baton {
HANDLE hOut;
HPCON hpc;
- HANDLE hShell;
+ HANDLE hShell = nullptr;
+ // Orca: the shell's pid, captured at spawn. The ownership guard compares
+ // against this rather than calling GetProcessId(hShell), because the exit
+ // watcher closes hShell on another thread -- reading it there is an
+ // invalid-handle operation, and under strict handle checks that is fatal.
+ DWORD shellPid = 0;
+
+ // Orca: job object owning this pty's whole process tree. Null when the OS
+ // refused to create or assign one (an outer job without breakaway rights),
+ // in which case callers fall back to their pre-job behaviour.
+ HANDLE hJob = nullptr;
pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : id(_id), hIn(_hIn), hOut(_hOut), hpc(_hpc) {};
};
static std::vector<std::unique_ptr<pty_baton>> ptyHandles;
+// Orca: guards the job accessors below against the exit watcher thread. It does
+// NOT make the whole table safe -- PtyResize/PtyClear/PtyKill read it unlocked,
+// as they always have -- but it closes the window this patch opened, where the
+// watcher can close hShell/hJob and free the baton between a lookup and its use.
+// Handle VALUES are recycled aggressively, so an unguarded read could pass the
+// shell-pid check against an unrelated process and terminate the wrong job.
+static std::mutex ptyJobMutex;
static volatile LONG ptyCounter;
static pty_baton* get_pty_baton(int id) {
@@ -102,8 +120,27 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
// Get process exit code.
GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code));
// Clean up handles
- CloseHandle(baton->hShell);
- assert(remove_pty_baton(baton->id));
+ // Orca: release the job once the shell is gone. Without kill-on-close this
+ // only frees the handle -- anything the user backgrounded is orphaned, as
+ // it was before this patch.
+ {
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ CloseHandle(baton->hShell);
+ baton->hShell = nullptr;
+ if (baton->hJob != nullptr) {
+ CloseHandle(baton->hJob);
+ baton->hJob = nullptr;
+ }
+ // Why inside the lock: erasing frees the baton the job accessors hold a
+ // pointer to. Note remove_pty_baton must not be an assert() argument --
+ // NDEBUG would compile the call away and leak every baton.
+ const bool removed = remove_pty_baton(baton->id);
+ assert(removed);
+ (void)removed;
+ }
+ // Why the lock ends here: BlockingCall below waits on the JS thread, and the
+ // JS thread can be waiting on ptyJobMutex inside PtyTerminateJob. Holding
+ // the lock across it deadlocks. Do not widen this scope.
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
switch (status) {
@@ -409,6 +446,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "UpdateProcThreadAttribute failed");
}
+ // Orca: resolve the DLL BEFORE creating anything. It throws when conpty.dll
+ // is missing -- a real state, and one this branch hit during development --
+ // and every throw between CreateProcessW and SetupExitCallback leaks the job,
+ // process and thread handles AND leaves an untracked shell tree running,
+ // once per attempt. Validating first means the only throw after creation is
+ // the resume failure, which cleans up after itself.
+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
+ bool fLoadedDll = hLibrary != nullptr;
+
PROCESS_INFORMATION piClient{};
fSuccess = !!CreateProcessW(
nullptr,
@@ -416,7 +462,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
nullptr, // lpProcessAttributes
nullptr, // lpThreadAttributes
false, // bInheritHandles VERY IMPORTANT that this is false
- EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, // dwCreationFlags
+ // Orca: CREATE_SUSPENDED so the shell is inside its job before it can
+ // spawn anything. Assigning after the fact leaves a window in which a
+ // fast child escapes the job and outlives the pane.
+ EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED, // dwCreationFlags
envArg, // lpEnvironment
mutableCwd.get(), // lpCurrentDirectory
&siEx.StartupInfo, // lpStartupInfo
@@ -426,8 +475,47 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "Cannot create process");
}
- HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
- bool fLoadedDll = hLibrary != nullptr;
+ // Orca: own the tree with a handle instead of inferring it later from a
+ // parent-pid walk. A pid walk cannot survive pid reuse and cannot see a
+ // descendant that reparented, which is why detached agent children outlived
+ // their pane and held the worktree directory open.
+ //
+ // Deliberately WITHOUT JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Measured on
+ // Windows 11: with that flag, closing the handle when the shell exits also
+ // kills whatever the user left running, so typing `exit` in a pane reaped a
+ // `start /b` server that used to survive. This job exists to make an
+ // EXPLICIT teardown exact, not to redefine what a clean exit means.
+ HANDLE hJob = CreateJobObjectW(nullptr, nullptr);
+ if (hJob != nullptr) {
+ // Why BREAKAWAY_OK and not a bare job: with no limits set, a child asking
+ // for CREATE_BREAKAWAY_FROM_JOB is refused with ERROR_ACCESS_DENIED.
+ // Installers, msiexec and some updater and service-control paths spawn that
+ // way deliberately, so a bare job breaks them ONLY inside an Orca terminal.
+ // With this flag a child has to ask, so ordinary descendants stay owned.
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits{};
+ jobLimits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_BREAKAWAY_OK;
+ if (!SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jobLimits, sizeof(jobLimits)) ||
+ !AssignProcessToJobObject(hJob, piClient.hProcess)) {
+ // Why tolerate failure: an outer job without JOB_OBJECT_LIMIT_BREAKAWAY_OK
+ // (some EDR and container hosts) refuses the assignment. The pty must
+ // still start; ownership just degrades to the older best-effort path.
+ CloseHandle(hJob);
+ hJob = nullptr;
+ }
+ }
+ // Safe to run now: either it is in the job, or we accepted that it is not.
+ if (ResumeThread(piClient.hThread) == static_cast<DWORD>(-1)) {
+ // Why fatal: a shell left suspended produces a pane that never prints and
+ // never exits, which is far harder to diagnose than a failed spawn.
+ if (hJob != nullptr) {
+ CloseHandle(hJob);
+ }
+ TerminateProcess(piClient.hProcess, 1);
+ CloseHandle(piClient.hProcess);
+ CloseHandle(piClient.hThread);
+ throw errorWithCode(info, "Cannot resume process");
+ }
+
if (useConptyDll && fLoadedDll)
{
PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress(
@@ -440,6 +528,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
// Update handle
handle->hShell = piClient.hProcess;
+ handle->shellPid = piClient.dwProcessId;
+ handle->hJob = hJob;
// Close the thread handle to avoid resource leak
CloseHandle(piClient.hThread);
@@ -567,6 +657,143 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
return env.Undefined();
}
+/**
+ * Orca: confirm a baton really is the pty the caller means.
+ *
+ * The winpty backend mints its own `pty` ids from a separate counter, and the
+ * JS layer stores both in the same field -- so a winpty terminal's id can
+ * collide with a live ConPTY baton here and terminate an unrelated pane's whole
+ * process tree. Matching the shell pid makes the id unforgeable.
+ */
+static bool ownsShell(const pty_baton* handle, DWORD expectedShellPid) {
+ return handle != nullptr && handle->hJob != nullptr && expectedShellPid != 0 &&
+ handle->shellPid == expectedShellPid;
+}
+
+/**
+ * Orca: kill this pty's entire tree in one syscall.
+ *
+ * Replaces "scrape the process table, walk parent pids, hope none were
+ * recycled, then taskkill /T /F". Returns false when no job was assigned so
+ * the caller knows to fall back rather than assume the tree is gone.
+ */
+static Napi::Value PtyTerminateJob(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ if (info.Length() != 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
+ throw Napi::Error::New(env, "Usage: pty.terminateJob(id, shellPid)");
+ }
+
+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise
+ // close these handles and free the baton in between.
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton(info[0].As<Napi::Number>().Int32Value());
+ if (!ownsShell(handle, info[1].As<Napi::Number>().Uint32Value())) {
+ return Napi::Boolean::New(env, false);
+ }
+ return Napi::Boolean::New(env, !!TerminateJobObject(handle->hJob, 1));
+}
+
+/**
+ * Orca: the pids still alive in this pty's tree, straight from the kernel.
+ *
+ * Descendant liveness for a tree that is still tracked, including children that
+ * detached from the console. Once the shell exits the baton is gone, so this
+ * returns null rather than an empty list -- null means "no answer", never
+ * "they died". Also returns null when no job was assigned.
+ *
+ * Does not include the ConPTY console host: CreatePseudoConsole spawns it
+ * before this job exists, so it is not a member and ClosePseudoConsole is what
+ * reaps it.
+ */
+static Napi::Value PtyListJobProcessIds(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ if (info.Length() != 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
+ throw Napi::Error::New(env, "Usage: pty.listJobProcessIds(id, shellPid)");
+ }
+
+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise
+ // close these handles and free the baton in between.
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton(info[0].As<Napi::Number>().Int32Value());
+ if (!ownsShell(handle, info[1].As<Napi::Number>().Uint32Value())) {
+ return env.Null();
+ }
+
+ // Grow until the buffer holds every pid: the count can change between calls,
+ // and a truncated list would read as "these children are gone".
+ DWORD capacity = 64;
+ for (int attempt = 0; attempt < 8; attempt++) {
+ const size_t bytes = sizeof(JOBOBJECT_BASIC_PROCESS_ID_LIST) + sizeof(ULONG_PTR) * capacity;
+ std::vector<char> buffer(bytes, 0);
+ auto* list = reinterpret_cast<JOBOBJECT_BASIC_PROCESS_ID_LIST*>(buffer.data());
+ if (QueryInformationJobObject(handle->hJob, JobObjectBasicProcessIdList, list, static_cast<DWORD>(bytes), nullptr)) {
+ auto pids = Napi::Array::New(env, list->NumberOfProcessIdsInList);
+ for (DWORD i = 0; i < list->NumberOfProcessIdsInList; i++) {
+ pids.Set(i, Napi::Number::New(env, static_cast<double>(list->ProcessIdList[i])));
+ }
+ return pids;
+ }
+ if (GetLastError() != ERROR_MORE_DATA) {
+ return env.Null();
+ }
+ capacity *= 4;
+ }
+ return env.Null();
+}
+
+/**
+ * Orca: put THIS process in a kill-on-close job, so its whole descendant tree
+ * dies with it.
+ *
+ * Why here and not per-pty: a per-pty job cannot carry KILL_ON_JOB_CLOSE,
+ * because its handle is released when the shell exits and that would reap
+ * whatever the user had backgrounded. This job's handle is released only when
+ * the process itself dies, so it reaps a crashed host without changing what a
+ * clean shell exit means. Children inherit job membership, so every pty the
+ * caller later spawns is covered without further work, and the per-pty jobs
+ * simply nest inside this one.
+ *
+ * The handle is deliberately never closed: it must outlive every caller, and
+ * process teardown is what releases it.
+ */
+static Napi::Value PtyAssignCurrentProcessToJob(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ // Why locked: two callers racing here would each create a job, put the
+ // process in both, and leak the first handle -- and since the handle is what
+ // keeps a kill-on-close job alive, a leaked one is never released. A worker
+ // thread with its own N-API env shares these statics, so "only JS calls it"
+ // is not a guarantee.
+ static HANDLE hHostJob = nullptr;
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ if (hHostJob != nullptr) {
+ return Napi::Boolean::New(env, true);
+ }
+
+ HANDLE job = CreateJobObjectW(nullptr, nullptr);
+ if (job == nullptr) {
+ return Napi::Boolean::New(env, false);
+ }
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
+ // BREAKAWAY_OK for the same reason as the per-pty job: without it a child
+ // asking for CREATE_BREAKAWAY_FROM_JOB is refused outright.
+ limits.BasicLimitInformation.LimitFlags =
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
+ if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) ||
+ !AssignProcessToJobObject(job, GetCurrentProcess())) {
+ // An outer job that forbids nesting refuses this; the caller degrades.
+ CloseHandle(job);
+ return Napi::Boolean::New(env, false);
+ }
+ hHostJob = job;
+ return Napi::Boolean::New(env, true);
+}
+
/**
* Init
*/
@@ -577,6 +804,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("clear", Napi::Function::New(env, PtyClear));
exports.Set("kill", Napi::Function::New(env, PtyKill));
+ exports.Set("terminateJob", Napi::Function::New(env, PtyTerminateJob));
+ exports.Set("listJobProcessIds", Napi::Function::New(env, PtyListJobProcessIds));
+ exports.Set("assignCurrentProcessToJob", Napi::Function::New(env, PtyAssignCurrentProcessToJob));
return exports;
};
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
# Modules reachable from the Orca runtime that import `electron`.
# Generated by config/scripts/check-runtime-electron-ratchet.mjs.
# This list is EMPTY and must stay that way: the runtime boots on plain Node
# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;
# migrate the module behind a host port instead (src/main/host/).
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* Bundle `orcad` — the Orca runtime served from plain Node, no Electron.
*
* Variant B (see docs/design/node-only-runtime-backend.html): the browser-pane and
* speech clusters are excluded. That is not a size optimisation — those modules are
* the only ones that statically import `node:sqlite`, so dropping them is what keeps
* the host Node floor at 18 instead of 22.5+.
*/
import { build } from 'esbuild'
import { mkdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import process from 'node:process'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = join(ROOT, 'out', 'orcad')
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
// Native addons must exist on the host; they cannot be bundled.
// `electron` is external so a residual import fails loudly at require() time rather
// than silently bundling the npm package's installer shim, which is what happened the
// first time and made the bundle look clean while it was not.
// Why only these: measured, not guessed. `node-pty` is a hard `require.resolve` — orcad
// exits at startup without it. `@parcel/watcher` is a guarded dynamic import, so the
// server boots without it but every watch install fails. `fsevents` is macOS-only and
// optional upstream. better-sqlite3 / keytar / cpu-features were externalized here
// defensively and appear nowhere in the graph; listing them implied a shipping burden
// that does not exist.
const EXTERNAL = ['electron', 'node-pty', '@parcel/watcher', 'fsevents']
/** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */
const jsoncParserEsm = {
name: 'jsonc-parser-esm',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /^jsonc-parser$/ }, () => ({
path: join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
}))
}
}
/** Why: optional native deps reference prebuilt .node files that may not exist here. */
const externalNativeAddons = {
name: 'external-native-addons',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }))
}
}
rmSync(OUT_DIR, { recursive: true, force: true })
mkdirSync(OUT_DIR, { recursive: true })
const result = await build({
entryPoints: [ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(OUT_DIR, 'orcad.js'),
external: EXTERNAL,
plugins: [jsoncParserEsm, externalNativeAddons],
metafile: true,
minify: true,
sourcemap: false,
define: { 'process.env.NODE_ENV': '"production"' },
logLevel: 'error'
})
const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint)
// Why check `original` and not just `path`: when electron is bundleable, esbuild
// rewrites `path` to the resolved file under node_modules and the naive check passes
// while the package is very much in the bundle.
const electronImporters = new Set()
for (const [file, info] of Object.entries(result.metafile.inputs)) {
for (const imported of info.imports ?? []) {
const specifier = imported.original ?? imported.path
if (specifier === 'electron' || specifier.startsWith('electron/')) {
electronImporters.add(file)
}
}
}
if (electronImporters.size > 0) {
console.error(
`[build-orcad] ${electronImporters.size} module(s) in the bundle import electron:
${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
)
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
// install the PTY controller. Once orcad ships, it should become a ratchet entry
// point so the two numbers cannot drift.
process.exitCode = 1
} else {
console.log(
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.`
)
}
+74 -28
View File
@@ -10,8 +10,22 @@
*/
import { build } from 'esbuild'
import { createHash } from 'node:crypto'
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import {
RELAY_BUILD_PLATFORMS,
RELAY_VERSION_FILENAME,
isWindowsRelayPlatform,
relayArtifactFilenames
} from '../../src/shared/relay-artifacts.ts'
const __dirname = import.meta.dirname
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
@@ -19,6 +33,13 @@ const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
const WSL_TRANSCRIPT_FS_PROCESS_ENTRY = join(
ROOT,
'src',
'main',
'native-chat',
'wsl-transcript-fs-process-entry.ts'
)
const MANAGED_HOOK_RUNTIME_ENTRY = join(
ROOT,
'src',
@@ -35,19 +56,17 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
)
const PLATFORMS = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64'
]
// Why: lets the packaging contract test build into a temp tree instead of
// clobbering a developer's out/relay or racing tests that read it.
const OUT_ROOT = process.env.ORCA_RELAY_OUT_ROOT ?? join(ROOT, 'out', 'relay')
const RELAY_VERSION = '0.1.0'
for (const platform of PLATFORMS) {
const outDir = join(ROOT, 'out', 'relay', platform)
for (const platform of RELAY_BUILD_PLATFORMS) {
const outDir = join(OUT_ROOT, platform)
// Why: a stale companion left by an earlier build would otherwise satisfy the
// manifest check and be hashed into .version, shipping mixed-generation bytes.
rmSync(outDir, { recursive: true, force: true })
mkdirSync(outDir, { recursive: true })
await build({
@@ -67,7 +86,7 @@ for (const platform of PLATFORMS) {
}
})
if (platform.startsWith('win32-')) {
if (isWindowsRelayPlatform(platform)) {
copyFileSync(
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
@@ -104,6 +123,23 @@ for (const platform of PLATFORMS) {
}
})
// Why beside the service: the spawn resolves this child next to its own
// bundle, and a relay host has no desktop out/main to fall back to.
await build({
entryPoints: [WSL_TRANSCRIPT_FS_PROCESS_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'wsl-transcript-fs-process-entry.js'),
external: ['electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
bundle: true,
@@ -121,24 +157,34 @@ for (const platform of PLATFORMS) {
}
})
// Why: include a content hash so the deploy check detects code changes
// even when RELAY_VERSION hasn't been bumped. Hash every executable module
// so a companion-only change always deploys beside the matching relay host.
const relayContent = readFileSync(join(outDir, 'relay.js'))
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js'))
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
// Why: include a content hash so the deploy check detects code changes even
// when RELAY_VERSION hasn't been bumped. Hashing the whole manifest means a
// companion-only change still selects a fresh immutable relay directory.
const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform))
const hash = createHash('sha256')
.update(relayContent)
.update(watcherContent)
.update(aiVaultServiceContent)
.update(managedHookRuntimeContent)
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
if (platform.startsWith('win32-')) {
hash.update(readFileSync(join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)))
for (const filename of expected) {
const artifactPath = join(outDir, filename)
if (!existsSync(artifactPath)) {
throw new Error(
`Relay ${platform} declares ${filename} in RELAY_ARTIFACTS but never emitted it. ` +
'Add the build step, or drop it from src/shared/relay-artifacts.ts.'
)
}
hash.update(readFileSync(artifactPath))
}
const contentHash = hash.digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`)
// Close the loop: an artifact emitted here but absent from the manifest would
// ship unhashed and unprobed — exactly how the WSL helper went missing.
const emitted = readdirSync(outDir).filter((name) => name !== RELAY_VERSION_FILENAME)
const undeclared = emitted.filter((name) => !expected.includes(name))
if (undeclared.length > 0) {
throw new Error(
`Relay ${platform} emitted undeclared artifacts: ${undeclared.join(', ')}. ` +
'Add them to RELAY_ARTIFACTS in src/shared/relay-artifacts.ts.'
)
}
writeFileSync(join(outDir, RELAY_VERSION_FILENAME), `${RELAY_VERSION}+${contentHash}`)
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
}
@@ -149,7 +195,7 @@ for (const platform of PLATFORMS) {
// Windows app via the same out/relay extraResources mapping.
{
const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
const outDir = join(ROOT, 'out', 'relay', 'wsl')
const outDir = join(OUT_ROOT, 'wsl')
mkdirSync(outDir, { recursive: true })
await build({
entryPoints: [wslEntry],
@@ -0,0 +1,170 @@
#!/usr/bin/env node
/**
* Ratchet gate for Electron imports reachable from the Orca runtime.
*
* The runtime is meant to become host-agnostic so it can also run on plain Node
* (see docs/design/node-only-runtime-backend.html). Nothing enforces that today:
* `orca-runtime.ts` reaches ~50 modules that import `electron`, and the number
* silently grows whenever someone adds an import several hops away, because no
* single reviewer sees the transitive edge.
*
* This bundles the runtime with esbuild, reads the metafile for every module that
* imports `electron`, and compares that set to a checked-in baseline. A NEW module
* fails the build; a removed one must be dropped from the baseline. The baseline
* may only shrink, so the migration is measurable and cannot regress.
*
* This is a reachability check, not a lint rule: the point is precisely the edges
* that no per-file rule can see.
*
* Usage: node config/scripts/check-runtime-electron-ratchet.mjs [--write]
*/
import { build } from 'esbuild'
import { readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
// Why absolute, not cwd-relative: `pnpm lint` runs from the repo root but CI steps and
// editors do not always, and a cwd-relative miss surfaced as an unhandled ENOENT stack
// instead of a usable message.
const ROOT = path.join(import.meta.dirname, '..', '..')
const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt')
// The two module graphs a Node backend would have to boot: the runtime service
// itself and the RPC server that fronts it.
const ENTRY_POINTS = [
path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'),
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts')
]
// Native addons and electron cannot be bundled; externalising them is what the
// relay build already does (config/scripts/build-relay.mjs).
const EXTERNAL = [
'electron',
'node-pty',
'@parcel/watcher',
'better-sqlite3',
'keytar',
'fsevents',
'cpu-features'
]
/**
* Why: some optional native deps (ssh2's cpu-features) reference a prebuilt `.node`
* that only exists where a build toolchain has run. Resolving them made this gate
* pass on a developer machine and hard-fail on CI. Nothing here needs the addon —
* only the import graph — so mark every `.node` external instead.
*/
const externalNativeAddons = {
name: 'external-native-addons',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }))
}
}
export async function collectElectronImporters(entryPoints = ENTRY_POINTS) {
const result = await build({
entryPoints,
bundle: true,
write: false,
// Why outdir with write:false: esbuild refuses multiple entry points without one,
// even though nothing is emitted — the metafile is all this reads.
outdir: path.join(ROOT, 'runtime-electron-ratchet-metafile-only'),
platform: 'node',
target: 'node20',
format: 'cjs',
external: EXTERNAL,
metafile: true,
absWorkingDir: ROOT,
logLevel: 'silent',
plugins: [externalNativeAddons]
})
const importers = new Set()
for (const [file, info] of Object.entries(result.metafile.inputs)) {
for (const imported of info.imports ?? []) {
// Subpaths (electron/main) are as unavailable under plain Node as the bare module.
if (imported.path === 'electron' || imported.path.startsWith('electron/')) {
importers.add(path.relative(ROOT, path.resolve(ROOT, file)).split(path.sep).join('/'))
}
}
}
return [...importers].sort()
}
export function readBaseline(text) {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.sort()
}
export function diffAgainstBaseline(current, baseline) {
const baselineSet = new Set(baseline)
const currentSet = new Set(current)
return {
added: current.filter((file) => !baselineSet.has(file)),
removed: baseline.filter((file) => !currentSet.has(file))
}
}
function renderBaseline(files) {
return [
'# Modules reachable from the Orca runtime that import `electron`.',
'# Generated by config/scripts/check-runtime-electron-ratchet.mjs.',
'# This list is EMPTY and must stay that way: the runtime boots on plain Node',
'# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;',
'# migrate the module behind a host port instead (src/main/host/).',
'',
...files
].join('\n')
}
async function main() {
const write = process.argv.includes('--write')
const current = await collectElectronImporters()
if (write) {
writeFileSync(BASELINE_PATH, `${renderBaseline(current)}\n`)
console.log(`[runtime-electron-ratchet] wrote ${current.length} entries to ${BASELINE_PATH}`)
return
}
const baseline = readBaseline(readFileSync(BASELINE_PATH, 'utf8'))
const { added, removed } = diffAgainstBaseline(current, baseline)
if (added.length > 0) {
console.error(
`[runtime-electron-ratchet] ${added.length} new module(s) reachable from the Orca runtime now import electron:
${added.map((file) => ` + ${file}`).join('\n')}
The runtime must stay bootable on plain Node. Put the Electron facility behind a port in
src/main/host/ and depend on the port, or move the code out of the runtime's import graph.
See docs/design/node-only-runtime-backend.html.`
)
process.exitCode = 1
return
}
if (removed.length > 0) {
console.error(
`[runtime-electron-ratchet] ${removed.length} module(s) no longer import electron — nice.
Refresh the baseline so the gate keeps its new, tighter floor:
${removed.map((file) => ` - ${file}`).join('\n')}
node config/scripts/check-runtime-electron-ratchet.mjs --write`
)
process.exitCode = 1
return
}
console.log(`[runtime-electron-ratchet] ok — ${current.length} entries, unchanged.`)
}
// Why pathToFileURL and not a `file://` template: on Windows process.argv[1] is a
// native path (C:\repo\...) while import.meta.url is file:///C:/repo/..., so the
// template never matches and the gate would exit 0 without checking anything — a
// lint gate that fails open. Same idiom as check-max-lines-ratchet.mjs:225.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main()
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import {
collectElectronImporters,
diffAgainstBaseline,
readBaseline
} from './check-runtime-electron-ratchet.mjs'
describe('readBaseline', () => {
it('drops comments and blank lines and sorts, so baseline formatting cannot cause a false diff', () => {
expect(readBaseline('# header\n\n b/second.ts \na/first.ts\n')).toEqual([
'a/first.ts',
'b/second.ts'
])
})
})
describe('diffAgainstBaseline', () => {
it('reports a module that started importing electron', () => {
expect(diffAgainstBaseline(['a.ts', 'b.ts'], ['a.ts'])).toEqual({
added: ['b.ts'],
removed: []
})
})
it('reports a module that stopped, so the baseline is forced to tighten rather than drift', () => {
expect(diffAgainstBaseline(['a.ts'], ['a.ts', 'b.ts'])).toEqual({
added: [],
removed: ['b.ts']
})
})
it('is quiet when the set is unchanged', () => {
expect(diffAgainstBaseline(['a.ts'], ['a.ts'])).toEqual({ added: [], removed: [] })
})
})
describe('the checked-in baseline', () => {
// Why real: the value of this gate is the transitive edges, which a fixture cannot model.
// If this is slow enough to hurt, it is still cheaper than shipping a runtime that
// cannot boot on Node.
it('matches what the runtime actually reaches today', async () => {
const current = await collectElectronImporters()
const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8'))
expect(diffAgainstBaseline(current, baseline)).toEqual({ added: [], removed: [] })
}, 120_000)
// Why an exact-empty assertion now: the reachable set reached zero, so "may only
// shrink" has no room left and any entry at all is a regression. This is strictly
// stronger than the old under-src/ check, which only stopped a node_modules path from
// padding a non-empty count.
it('stays empty, so nothing reachable from the runtime imports electron', () => {
const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8'))
expect(baseline).toEqual([])
})
})
@@ -0,0 +1,176 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8'))
const MAC_WORKFLOWS = [
['hourly', '.github/workflows/hourly-mac-build.yml', 'build-hourly-mac'],
['daily', '.github/workflows/daily-mac-build.yml', 'build-daily-mac'],
['adhoc', '.github/workflows/adhoc-mac-build.yml', 'build-adhoc-mac']
]
const winWorkflow = () => readWorkflow('.github/workflows/dev-channel-win-build.yml')
const winSteps = () => winWorkflow().jobs['build-win'].steps
const stepNamed = (steps, name) => steps.find((step) => step.name === name)
describe('dev-channel Windows build wiring', () => {
it.each(MAC_WORKFLOWS)(
'calls the shared Windows workflow from %s with its own channel',
(channel, path, jobName) => {
const jobs = readWorkflow(path).jobs
const winJob = jobs[`build-${channel}-win`]
expect(winJob).toBeDefined()
expect(winJob.uses).toBe('./.github/workflows/dev-channel-win-build.yml')
expect(winJob.with.channel).toBe(channel)
// Why `./` and not a pinned @main: `uses:` resolves against the ref this
// workflow file came from, which for an ordinary dispatch is main. A branch
// deliberately selected in the Actions picker already supplies its own copy
// of the whole file, so this follows that rule rather than adding a second.
expect(winJob.uses.startsWith('./')).toBe(true)
expect(winJob.needs).toBe(jobName)
expect(winJob.secrets).toBe('inherit')
}
)
// Why gated on published: the Windows leg uploads into a release the mac leg
// created, and refuses to create one itself. Without this it would run for a
// tag that was never published — or, for hourly, for a run that skipped.
it.each(MAC_WORKFLOWS)(
'only runs the %s Windows leg once the release is live',
(channel, path, jobName) => {
expect(readWorkflow(path).jobs[`build-${channel}-win`].if).toBe(
`needs.${jobName}.outputs.published == 'true'`
)
}
)
// The Windows job names the release by consuming these; a renamed step id
// would silently resolve them to empty strings and upload nowhere.
it.each(MAC_WORKFLOWS)(
'exposes the %s tag, version and commit as job outputs',
(_c, path, jobName) => {
const job = readWorkflow(path).jobs[jobName]
const stepIds = job.steps.map((step) => step.id).filter(Boolean)
for (const key of ['tag', 'version', 'head_sha', 'published']) {
expect(job.outputs[key]).toBeTruthy()
}
// Every referenced step id must actually exist in the job.
for (const expression of Object.values(job.outputs)) {
const referenced = [...expression.matchAll(/steps\.([A-Za-z0-9_-]+)\./g)].map((m) => m[1])
for (const id of referenced) {
expect(stepIds).toContain(id)
}
}
}
)
// Why this is asserted: the previous design dispatched a sibling run, which
// needed actions:write on a job holding macOS signing credentials. Calling the
// workflow directly removes that, and it must not creep back.
it.each(MAC_WORKFLOWS)(
'does not widen the %s signing job to actions:write',
(_c, path, jobName) => {
expect(readWorkflow(path).jobs[jobName].permissions?.actions).toBeUndefined()
}
)
})
describe('dev-channel Windows build workflow', () => {
it('is both callable by the mac workflows and dispatchable on its own', () => {
const triggers = winWorkflow().on ?? winWorkflow()[true]
expect(Object.keys(triggers)).toEqual(
expect.arrayContaining(['workflow_call', 'workflow_dispatch'])
)
// workflow_call cannot express `type: choice`, so the channel set has to be
// enforced in the job itself.
expect(stepNamed(winSteps(), 'Vet the requested inputs').run).toContain('hourly|daily|adhoc')
})
// Why windows-2022: windows-latest moved to the Windows 2025 / VS 2026 image
// before node-gyp could detect VS 18, breaking native dependency install.
it('pins the same Windows image release-cut builds on', () => {
expect(winWorkflow().jobs['build-win']['runs-on']).toBe('windows-2022')
})
// The guard against a branch whose electron-builder config predates Windows
// dev builds: without it, publish.repo resolves to the main repo.
it('verifies the dev-channel packaging identity before building', () => {
const steps = winSteps()
const names = steps.map((step) => step.name)
const verify = names.indexOf('Verify dev-channel packaging identity')
const build = names.indexOf('Build app')
expect(verify).toBeGreaterThanOrEqual(0)
expect(build).toBeGreaterThan(verify)
expect(stepNamed(steps, 'Verify dev-channel packaging identity').run).toContain(
'verify-dev-channel-packaging.mjs'
)
})
// Why: electron-publish creates a release when it cannot find the tag under
// `--publish always`. If the mac leg discarded its draft while this was
// building, that would mint an untitled Windows-only release.
it('confirms the target release exists before publishing into it', () => {
const names = winSteps().map((step) => step.name)
const confirm = names.indexOf('Confirm the target release still exists')
const publish = names.indexOf('Publish Windows artifacts')
expect(confirm).toBeGreaterThanOrEqual(0)
expect(publish).toBeGreaterThan(confirm)
})
// electron-publish refuses to upload into a release published more than two
// hours ago. The mac leg publishes as soon as it finishes, so a slow notary
// queue plus a slow Windows build crosses that line and drops every asset.
it('opts out of the publisher two-hour upload window', () => {
expect(stepNamed(winSteps(), 'Publish Windows artifacts').env.EP_GH_IGNORE_TIME).toBe('true')
})
it('packages Windows unsigned through the shared electron-builder config', () => {
const publish = stepNamed(winSteps(), 'Publish Windows artifacts')
expect(publish.with.command).toContain(
'electron-builder --config config/electron-builder.config.cjs --win --publish always'
)
// No signing env: SignPath's approval waits cannot fit a dev cadence, which
// is the entire reason these builds are unsigned.
expect(Object.keys(publish.env)).not.toContain('SIGNPATH_API_TOKEN')
})
// Why both: a release carrying the installer but not the manifest is a row the
// picker offers and the in-app update 404s on.
it('requires the manifest and the installer before calling the build good', () => {
const verify = stepNamed(winSteps(), 'Verify Windows update manifest published')
expect(verify.run).toContain('latest.yml')
expect(verify.run).toContain('orca-windows-setup.exe')
})
// Why: this is callable and separately dispatchable, and workflow_call takes
// its inputs as free text, so it re-derives the ref guarantee rather than
// trusting whoever called it.
it('vets the requested commit before checking it out', () => {
const names = winSteps().map((step) => step.name)
const vet = names.indexOf('Vet the requested inputs')
const checkout = names.indexOf('Checkout the built commit')
expect(vet).toBe(0)
expect(checkout).toBeGreaterThan(vet)
expect(stepNamed(winSteps(), 'Vet the requested inputs').run).toContain('--contains')
})
// Telemetry's transport gate accepts only 'stable' or 'rc'; leaving the build
// identity unset is what keeps unvetted artifacts silent.
it('never stamps an official telemetry build identity', () => {
const build = stepNamed(winSteps(), 'Build app')
expect(Object.keys(build.env ?? {})).not.toContain('ORCA_BUILD_IDENTITY')
})
})
@@ -0,0 +1,49 @@
// Why this module exists: `out/electron-dev` accumulates one ~270MB copy of Electron.app per
// (branch title x Electron version x bundle layout). The runner only ever clears the directory it is
// about to rebuild, so siblings from renamed branches and past upgrades are never reclaimed --
// measured at 143 directories / 38GB across one developer's worktrees.
// Measured from BUILD START, not from last activity: a directory's mtime only changes when a
// top-level entry is created, so it freezes once `<app>.app` appears and the 276MB copy, helper
// compiles and signing that follow never refresh it. So this is a hard cliff -- a build still
// running after this long becomes eligible for deletion by a concurrent instance. Full builds
// measured at 130-200s, so the margin is 5-10x.
export const IN_PROGRESS_WINDOW_MS = 15 * 60 * 1000
/**
* Which cached bundle directories are safe to reclaim.
*
* Three things are protected, and every one of them is a directory some other process still needs:
*
* - the bundle this run is about to use;
* - a bundle a live dev instance is running from. Deleting it can crash that instance mid-session,
* and developers routinely run several at once;
* - a bundle still being copied. Between `mkdirSync` and the marker write it exists, has no marker,
* and cannot appear in the process table because its instance has not launched yet -- so a
* concurrently starting instance would otherwise delete it mid-copy.
*
* `processTable` is raw `ps` output, searched for each candidate rather than parsed into paths.
* Parsing was tried twice and failed twice in the same dangerous direction -- a regex that missed a
* live process yielded "nothing is running" and deleted its bundle. Searching for a known absolute
* directory is immune to spaces and shell-significant characters in the path, and the trailing
* slash keeps `<dir>2` from being mistaken for `<dir>`.
*/
export function isDevBundleInUse(dir, processTable) {
// Both spellings: macOS realpaths /tmp to /private/tmp, and `ps` preserves whatever spelling the
// process was launched with. A mismatch would read as "not running" and delete a live bundle.
// Checking both can only ever protect more, which is the safe direction.
const alternate = dir.startsWith('/private/') ? dir.slice('/private'.length) : `/private${dir}`
return processTable.includes(`${dir}/`) || processTable.includes(`${alternate}/`)
}
export function selectStaleDevBundleDirs({ bundles, currentDir, processTable, nowMs }) {
return bundles
.filter(({ dir, hasMarker, mtimeMs }) => {
if (dir === currentDir || isDevBundleInUse(dir, processTable)) {
return false
}
const buildInFlight = !hasMarker && nowMs - mtimeMs < IN_PROGRESS_WINDOW_MS
return !buildInFlight
})
.map(({ dir }) => dir)
}
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest'
import {
IN_PROGRESS_WINDOW_MS,
isDevBundleInUse,
selectStaleDevBundleDirs
} from './dev-electron-bundle-cache.mjs'
const NOW = 1_800_000_000_000
const ROOT = '/repo/out/electron-dev'
const A = `${ROOT}/aaaaaaaaaaaa`
const B = `${ROOT}/bbbbbbbbbbbb`
const C = `${ROOT}/cccccccccccc`
/** A finished bundle: marker present, copied long ago. */
function settled(dir: string) {
return { dir, hasMarker: true, mtimeMs: NOW - IN_PROGRESS_WINDOW_MS * 2 }
}
/** A `ps -Awwo command=` line for a dev instance running out of `dir`. */
function psLine(dir: string, appName = 'Orca: some-branch') {
return `${dir}/${appName}.app/Contents/MacOS/Electron --remote-debugging-port=9333`
}
describe('dev-electron-bundle-cache', () => {
it('reclaims siblings while keeping the current bundle', () => {
expect(
selectStaleDevBundleDirs({
bundles: [settled(A), settled(B), settled(C)],
currentDir: B,
processTable: '',
nowMs: NOW
})
).toEqual([A, C])
})
it('never reclaims a bundle a live process is running from', () => {
// Deleting a bundle out from under a live Electron process can crash it mid-session, and
// developers routinely run several dev instances at once.
expect(
selectStaleDevBundleDirs({
bundles: [settled(A), settled(B), settled(C)],
currentDir: B,
processTable: psLine(A),
nowMs: NOW
})
).toEqual([C])
})
it('protects a live bundle whose path contains spaces', () => {
// Regression: an extraction regex using \S* could not cross a space, so any developer with a
// space in their checkout path got "nothing is live" and had the running bundle deleted.
const spaced = '/Users/me/My Projects/orca/out/electron-dev/aaaaaaaaaaaa'
const other = '/Users/me/My Projects/orca/out/electron-dev/bbbbbbbbbbbb'
expect(
selectStaleDevBundleDirs({
bundles: [settled(spaced), settled(other)],
currentDir: C,
processTable: psLine(spaced, 'Orca: my branch'),
nowMs: NOW
})
).toEqual([other])
})
it('does not treat a sibling as live just because it shares a prefix', () => {
// Boundary: `<dir>2` being live must not protect `<dir>`. Without the trailing slash in the
// needle, `A` would be found inside `A2`'s path and wrongly spared.
const a2 = `${A}2`
expect(
selectStaleDevBundleDirs({
bundles: [settled(A), settled(a2)],
currentDir: C,
processTable: psLine(a2),
nowMs: NOW
})
).toEqual([A])
})
it('never deletes a bundle that is still being copied', () => {
// Between mkdirSync and the marker write there is a ~270MB copy taking tens of seconds, during
// which the directory exists, has no marker, and cannot appear in `ps` because its instance has
// not launched yet. A concurrently starting instance would otherwise delete it mid-copy.
const inFlight = { dir: A, hasMarker: false, mtimeMs: NOW - 30_000 }
expect(
selectStaleDevBundleDirs({
bundles: [inFlight, settled(B)],
currentDir: C,
processTable: '',
nowMs: NOW
})
).toEqual([B])
})
it('reclaims a marker-less bundle once it is too old to be in flight', () => {
// A build that crashed partway leaves the same signature; only age separates it from the case
// above, otherwise abandoned debris would be protected forever.
const abandoned = { dir: A, hasMarker: false, mtimeMs: NOW - IN_PROGRESS_WINDOW_MS - 1 }
expect(
selectStaleDevBundleDirs({
bundles: [abandoned],
currentDir: C,
processTable: '',
nowMs: NOW
})
).toEqual([A])
})
it('matches a live bundle across the /tmp and /private/tmp spellings', () => {
// macOS realpaths /tmp to /private/tmp, and `ps` preserves whatever spelling the process was
// launched with. A mismatch would read as "not running" and delete a live bundle.
const priv = '/private/tmp/wt/out/electron-dev/aaaaaaaaaaaa'
const plain = '/tmp/wt/out/electron-dev/aaaaaaaaaaaa'
expect(isDevBundleInUse(priv, psLine(plain))).toBe(true)
expect(isDevBundleInUse(plain, psLine(priv))).toBe(true)
expect(isDevBundleInUse(priv, psLine('/private/tmp/wt/out/electron-dev/bbbbbbbbbbbb'))).toBe(
false
)
})
it('still honours the path boundary across spellings', () => {
const priv = '/private/tmp/wt/out/electron-dev/aaaaaaaaaaaa'
expect(isDevBundleInUse(priv, psLine('/tmp/wt/out/electron-dev/aaaaaaaaaaaa2'))).toBe(false)
})
it('reclaims nothing when every directory is current or live', () => {
expect(
selectStaleDevBundleDirs({
bundles: [settled(A), settled(B)],
currentDir: A,
processTable: psLine(B),
nowMs: NOW
})
).toEqual([])
expect(
selectStaleDevBundleDirs({ bundles: [], currentDir: A, processTable: '', nowMs: NOW })
).toEqual([])
})
})
@@ -75,6 +75,25 @@ describe('electron-builder config', () => {
expect(packs('out/main/examples/index.js')).toBe(true)
})
// Why: out/electron-dev holds `pnpm dev`'s cached Electron.app copies (~270MB per branch).
// CI never creates it, so only a local package would have hit this -- silently, as bulk.
it('keeps cached dev Electron bundles out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
for (const devBundlePath of [
'out/electron-dev/1a2b3c4d5e6f/Orca: dev.app/Contents/MacOS/Electron',
'out/electron-dev/1a2b3c4d5e6f/orca-dev-electron-app.json'
]) {
expect(packs(devBundlePath)).toBe(false)
}
// The real build outputs sit beside it under out/ and must still ship.
expect(packs('out/main/index.js')).toBe(true)
expect(packs('out/renderer/index.html')).toBe(true)
})
it('keeps runtime resources available through extraResources', () => {
const bundledPluginResources = expect.objectContaining({
from: 'resources/plugins/launch',
@@ -188,6 +207,14 @@ describe('electron-builder config', () => {
)
})
it('unpacks the replaceable WSL transcript filesystem process entry', async () => {
const entryFilename = 'wsl-transcript-fs-process-entry.js'
expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`)
const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8')
expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`))
})
// Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is
// invisible to it and a packed worker entry fails closed — dropping every
// OpenCode session in packaged builds while dev stays green. Three legs must
+11 -1
View File
@@ -13,7 +13,9 @@ const runtime = readRuntimeArg()
const NATIVE_MODULES = [
'node-pty',
...(process.platform === 'win32' ? ['windows-native-registry'] : [])
...(process.platform === 'win32'
? ['windows-native-registry', '@vscode/windows-process-tree']
: [])
]
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
const CHILD_CHECK_FLAG = '--check-only'
@@ -244,6 +246,14 @@ function collectNativeModuleFailures() {
}
function loadNativeModule(moduleName) {
if (moduleName === '@vscode/windows-process-tree') {
// A bare require already loads the .node addon on win32, so it catches an
// ABI mismatch on its own. What it cannot catch is a snapshot that comes
// back empty -- the shape a blocked CreateToolhelp32Snapshot produces --
// so check the addon actually enumerates before calling the runtime healthy.
require(moduleName)
return
}
if (moduleName === 'windows-native-registry') {
const registry = require(moduleName)
// Why: the package defers loading its .node addon until the first registry call.
@@ -169,6 +169,12 @@ async function assertStubSourcesMatchTopics(repoRoot) {
}
}
// Why: path.relative yields `\` on Windows, but these paths are asserted in tests and pasted into commands.
// split(sep) rather than replaceAll('\\', '/') so a POSIX filename containing a backslash survives intact.
function toPosixRelativePath(repoRoot, filePath, pathModule = path) {
return pathModule.relative(repoRoot, filePath).split(pathModule.sep).join('/')
}
async function buildArtifacts(repoRoot = REPO_ROOT) {
const guideRoot = path.join(repoRoot, 'skill-guides')
const sourceFiles = (await readdir(guideRoot, { withFileTypes: true }))
@@ -193,7 +199,7 @@ async function buildArtifacts(repoRoot = REPO_ROOT) {
// Why: Git may render text with native EOLs despite repository policy; the
// embedded guide and generated projection must have one platform-neutral identity.
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
const frontmatter = parseFrontmatter(markdown, path.relative(repoRoot, sourcePath))
const frontmatter = parseFrontmatter(markdown, toPosixRelativePath(repoRoot, sourcePath))
if (frontmatter.name !== name) {
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
}
@@ -243,7 +249,7 @@ async function verifyArtifacts(artifacts, repoRoot = REPO_ROOT) {
if (stale.length > 0) {
throw new Error(
`Generated bundled skill guides are stale:\n${stale
.map((filePath) => path.relative(repoRoot, filePath))
.map((filePath) => toPosixRelativePath(repoRoot, filePath))
.join('\n')}\nRun node config/scripts/generate-bundled-skill-guides.mjs --write.`
)
}
@@ -272,6 +278,7 @@ export {
normalizeMarkdown,
parseFrontmatter,
serializeEmbeddedModule,
toPosixRelativePath,
verifyArtifacts,
writeArtifacts
}
@@ -14,6 +14,7 @@ import {
frontmatterBlock,
normalizeMarkdown,
parseFrontmatter,
toPosixRelativePath,
verifyArtifacts,
writeArtifacts
} from './generate-bundled-skill-guides.mjs'
@@ -232,6 +233,20 @@ describe('bundled skill guide generator', () => {
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow('skills/computer-use/SKILL.md')
})
// Why: the stale-artifact assertions above only hit the Windows separator when the host is
// Windows; injecting path.win32 makes the Linux/macOS shards catch the regression too.
it('formats contributor-facing paths with forward slashes on every platform', () => {
expect(
toPosixRelativePath('C:\\repo', 'C:\\repo\\src\\cli\\bundled-skill-guides.ts', path.win32)
).toBe('src/cli/bundled-skill-guides.ts')
expect(
toPosixRelativePath('C:\\repo', 'C:\\repo\\skills\\computer-use\\SKILL.md', path.win32)
).toBe('skills/computer-use/SKILL.md')
expect(toPosixRelativePath('/repo', '/repo/skills/computer-use/SKILL.md', path.posix)).toBe(
'skills/computer-use/SKILL.md'
)
})
it('rejects mismatched source names and ambiguous aliases', async () => {
const root = await createFixture()
await writeFile(
@@ -0,0 +1,69 @@
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8'))
const installScript = action.runs.steps.find((step) => step.name === 'Install dependencies').run
function run(command, args, options = {}) {
return spawnSync(command, args, { encoding: 'utf8', ...options })
}
function createFixture() {
const root = mkdtempSync(join(tmpdir(), 'orca-install-node-action-'))
const workspace = join(root, 'checkout')
const detachedCwd = join(root, 'action cwd')
const bin = join(root, 'bin')
mkdirSync(workspace)
mkdirSync(detachedCwd)
mkdirSync(bin)
for (const directory of [workspace, detachedCwd]) {
writeFileSync(join(directory, 'package.json'), '{"name":"fixture"}\n')
writeFileSync(join(directory, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
}
expect(run('git', ['init', '-q'], { cwd: workspace }).status).toBe(0)
expect(run('git', ['add', 'package.json', 'pnpm-lock.yaml'], { cwd: workspace }).status).toBe(0)
const pnpm = join(bin, 'pnpm')
writeFileSync(pnpm, '#!/bin/sh\nexit 0\n')
chmodSync(pnpm, 0o755)
return { bin, detachedCwd, root, workspace }
}
function executeInstallScript(fixture) {
return run('bash', ['-c', installScript], {
cwd: fixture.detachedCwd,
env: {
...process.env,
GITHUB_WORKSPACE: fixture.workspace,
PATH: `${fixture.bin}${delimiter}${process.env.PATH}`
}
})
}
describe('install-node-dependencies action', () => {
it.each([
['package.json', '{"name":"changed"}\n'],
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
])('rejects a changed %s when the composite step cwd is detached', (file, contents) => {
const fixture = createFixture()
try {
const clean = executeInstallScript(fixture)
expect(clean.status, clean.stderr || clean.stdout).toBe(0)
writeFileSync(join(fixture.workspace, file), contents)
const dirty = executeInstallScript(fixture)
const output = `${dirty.stdout}\n${dirty.stderr}`
expect(dirty.status).toBe(1)
expect(output).toContain(`diff --git a/${file} b/${file}`)
expect(output).not.toContain('diff --git a/package.json b/pnpm-lock.yaml')
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,97 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import lintStaged from 'lint-staged'
import { expect, it } from 'vitest'
const BACKUP_REFS = 'refs/worktree/lint-staged-backups'
const silentLogger = { error() {}, log() {}, warn() {} }
it('keeps lint-staged backups isolated to the current worktree', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-lint-staged-worktree-'))
try {
const repo = join(root, 'repo')
const worktree = join(root, 'linked worktree')
const trackedFile = join(worktree, 'tracked.txt')
mkdirSync(repo)
initializeRepo(repo)
writeFileSync(join(repo, 'tracked.txt'), 'base-one\nbase-two\n')
git(repo, ['add', 'tracked.txt'])
git(repo, ['commit', '--quiet', '-m', 'initial'])
writeFileSync(join(repo, 'tracked.txt'), 'user stash\nbase-two\n')
git(repo, ['stash', 'push', '--quiet', '--message', 'user backup'])
git(repo, ['worktree', 'add', '--quiet', '-b', 'linked', worktree])
writeFileSync(trackedFile, 'staged-change\nbase-two\n')
git(worktree, ['add', 'tracked.txt'])
writeFileSync(trackedFile, 'staged-change\nunstaged-change\n')
const expectedStash = gitTrim(worktree, ['rev-parse', 'refs/stash'])
const stashBefore = git(worktree, ['stash', 'list', '--format=%H%x00%gs'])
const stagedBefore = git(worktree, ['diff', '--cached', '--binary'])
const unstagedBefore = git(worktree, ['diff', '--binary'])
const contentBefore = readFileSync(trackedFile, 'utf8')
const observation = join(root, 'task-observation.json')
const probe = join(root, 'failing-task.cjs')
writeProbe(probe)
const task = [process.execPath, probe, expectedStash, observation].map(quote).join(' ')
const passed = await lintStaged(
{ config: { '*.txt': task }, cwd: worktree, quiet: true },
silentLogger
)
expect(passed).toBe(false)
expect(JSON.parse(readFileSync(observation, 'utf8'))).toEqual({
backupRefs: [expect.stringMatching(`^${BACKUP_REFS}/`)],
sharedStash: expectedStash
})
expect(git(worktree, ['for-each-ref', '--format=%(refname)', BACKUP_REFS])).toBe('')
expect(git(worktree, ['stash', 'list', '--format=%H%x00%gs'])).toBe(stashBefore)
expect(git(worktree, ['diff', '--cached', '--binary'])).toBe(stagedBefore)
expect(git(worktree, ['diff', '--binary'])).toBe(unstagedBefore)
expect(readFileSync(trackedFile, 'utf8')).toBe(contentBefore)
expect(git(worktree, ['ls-files', '--unmerged'])).toBe('')
} finally {
rmSync(root, { force: true, recursive: true })
}
})
function initializeRepo(repo) {
git(repo, ['init', '--quiet'])
git(repo, ['config', 'user.email', 'test@example.invalid'])
git(repo, ['config', 'user.name', 'Test'])
git(repo, ['config', 'core.autocrlf', 'false'])
git(repo, ['config', 'core.hooksPath', join(repo, '.git', 'no-hooks')])
git(repo, ['config', 'commit.gpgsign', 'false'])
}
function git(cwd, args) {
return execFileSync('git', args, { cwd, encoding: 'utf8' })
}
function gitTrim(cwd, args) {
return git(cwd, args).trim()
}
function quote(value) {
return JSON.stringify(value)
}
function writeProbe(path) {
writeFileSync(
path,
[
"const { execFileSync } = require('node:child_process')",
"const { writeFileSync } = require('node:fs')",
"const git = (args) => execFileSync('git', args, { encoding: 'utf8' }).trim()",
"const backupRefs = git(['for-each-ref', '--format=%(refname)', 'refs/worktree/lint-staged-backups'])",
"writeFileSync(process.argv[3], JSON.stringify({ backupRefs: backupRefs.split('\\n').filter(Boolean), sharedStash: git(['rev-parse', 'refs/stash']) }))",
"writeFileSync(process.argv[4], 'task-output\\n')",
'process.exit(1)'
].join('\n')
)
}
@@ -0,0 +1,60 @@
import fs from 'node:fs'
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
// The frozen-terminal recovery entry is searchable only if both halves of the pair use the
// UI's "stopped" sense; MT rendered `unfreeze` as 녹이다 (thawing ice), splitting them.
const PAIR = [
{ key: 'auto.components.settings.terminal.search.88561b3499', enValue: 'frozen', ko: '정지됨' },
{
key: 'auto.components.settings.terminal.search.d4daf4f612',
enValue: 'unfreeze',
ko: '정지 해제'
}
]
function readCatalog(locale) {
return JSON.parse(
fs.readFileSync(
new URL(`../../src/renderer/src/i18n/locales/${locale}.json`, import.meta.url),
'utf8'
)
)
}
function getValue(catalog, key) {
return key.split('.').reduce((value, part) => value[part], catalog)
}
describe('Korean frozen-terminal settings search keywords', () => {
it('repairs the machine-translated unfreeze keyword', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.terminal.search.d4daf4f612',
enValue: 'unfreeze',
localeValue: '녹이다',
locale: 'ko'
})
).toBe('정지 해제')
})
it('ships the pair in the Korean catalog', () => {
const catalog = readCatalog('ko')
for (const { key, ko } of PAIR) {
expect(getValue(catalog, key), key).toBe(ko)
}
})
it('survives the canonical catalog repair policy', () => {
const english = readCatalog('en')
const catalog = readCatalog('ko')
for (const { key, enValue } of PAIR) {
expect(getValue(english, key), key).toBe(enValue)
const localeValue = getValue(catalog, key)
expect(repairTranslatedValue({ key, enValue, localeValue, locale: 'ko' }), key).toBe(
localeValue
)
}
})
})
@@ -848,6 +848,9 @@
"auto.components.editor.CheckRunDetailsPanel.fd46a70f1a": {
"ko": "시작됨"
},
"auto.components.editor.CheckRunJobs.1c0a4d7e02": {
"ko": "성공"
},
"auto.components.editor.CombinedDiffFileTree.d5ac717d65": {
"ko": "commit 안 됨"
},
@@ -2210,6 +2213,9 @@
"auto.components.settings.DeveloperPermissionsPane.e119f0d66b": {
"ko": "자동화"
},
"auto.components.settings.DeveloperPermissionsPane.f903bf20b5": {
"ko": "터미널과 개발 도구가 로컬 네트워크의 서비스에 연결할 수 있도록 허용합니다. macOS는 이 권한의 현재 상태를 Orca에 보고하지 않습니다."
},
"auto.components.settings.ExperimentalPane.agentHibernation.idleMinutesDescription": {
"ko": "완료된 백그라운드 agent를 Orca가 최대 절전 모드로 전환하기 전에 기다릴 유휴 시간(분)입니다."
},
@@ -4088,6 +4094,9 @@
"auto.components.shared.useDaemonActions.fe2ab66d45": {
"ko": "세션 {{value1}}개 중 {{value0}}개를 종료했습니다. {{value2}}개가 종료를 거부했습니다."
},
"auto.components.sidebar.AddRemoteHostDialog.sshImportSynced": {
"ko": "{{value0}} 호스트{{value1}}을(를) Orca에 추가했습니다."
},
"auto.components.sidebar.AddRepoCreateStep.0ae45b8238": {
"ko": "my-project"
},
@@ -5003,6 +5012,12 @@
"auto.web.WebConnect.4a4c017be1": {
"ko": "엔드포인트:"
},
"components.native-chat.approval.allow": {
"ko": "허용"
},
"components.native-chat.approval.deny": {
"ko": "거부"
},
"settings.appearance.statusBar.resourceUsageToggleDescription": {
"ko": "리소스 관리자를 표시합니다. 클릭하면 CPU, 메모리, 세션, 데몬 제어, 워크스페이스 디스크 스캔을 볼 수 있습니다."
},
@@ -37,6 +37,10 @@ export const SEARCH_KEYWORD_OVERRIDES = {
prompt: '프롬프트',
rename: '이름 변경',
session: '세션',
// Why: MT read 'unfreeze' as thawing ice (녹이다). Pin it to the 정지됨 pair so
// frozen-terminal recovery stays reachable from the term the UI actually shows.
frozen: '정지됨',
unfreeze: '정지 해제',
location: '위치',
detect: '감지',
path: '경로',
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { relayArtifactFilenames } from '../../src/shared/relay-artifacts.ts'
const projectDir = resolve(import.meta.dirname, '../..')
const require = createRequire(import.meta.url)
@@ -41,12 +42,12 @@ describe('Electron runtime package contract', () => {
// Why: pnpm installs optional target architectures on every host; the root
// Windows-only rebuild owns this addon so macOS/Linux never run node-gyp for it.
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('windows-native-registry')
expect(rebuildScript).toContain(
"rebuildPlatform === 'win32' ? ['windows-native-registry'] : []"
)
expect(ensureScript).toContain(
"process.platform === 'win32' ? ['windows-native-registry'] : []"
)
// Why assert the guard and the member separately: the list now carries more
// than one addon, so pinning the whole literal only tested its formatting.
expect(rebuildScript).toContain("rebuildPlatform === 'win32'")
expect(rebuildScript).toContain("'windows-native-registry'")
expect(ensureScript).toContain("process.platform === 'win32'")
expect(ensureScript).toContain("'windows-native-registry'")
const packageTargets = {
win32: createPackagedRuntimeNodeModuleResources('win32'),
darwin: createPackagedRuntimeNodeModuleResources('darwin'),
@@ -67,6 +68,47 @@ describe('Electron runtime package contract', () => {
}
})
it('keeps the native Windows process-table addon optional and platform-gated', () => {
const rebuildScript = readFileSync(
join(projectDir, 'config/scripts/rebuild-native-deps.mjs'),
'utf8'
)
const ensureScript = readFileSync(
join(projectDir, 'config/scripts/ensure-native-runtime.mjs'),
'utf8'
)
expect(packageJson.optionalDependencies['@vscode/windows-process-tree']).toBe('0.8.0')
// Why: same rule as the registry addon -- pnpm installs optional deps on
// every host, so macOS/Linux must never run node-gyp for a Windows addon.
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('@vscode/windows-process-tree')
expect(rebuildScript).toContain("'@vscode/windows-process-tree'")
expect(ensureScript).toContain("'@vscode/windows-process-tree'")
// Why pin the patch: the upstream binding.gyp requires Spectre-mitigated
// libraries our build agents do not carry, and the enumeration stops after
// 1024 processes -- on a busy host that silently hides the very descendants
// teardown is looking for.
expect(packageJson.pnpm.patchedDependencies['@vscode/windows-process-tree@0.8.0']).toBe(
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
)
const packageTargets = {
win32: createPackagedRuntimeNodeModuleResources('win32'),
darwin: createPackagedRuntimeNodeModuleResources('darwin'),
linux: createPackagedRuntimeNodeModuleResources('linux')
}
expect(packageTargets.win32).toEqual(
expect.arrayContaining([
expect.objectContaining({ to: join('node_modules', '@vscode', 'windows-process-tree') })
])
)
for (const platform of ['darwin', 'linux']) {
expect(packageTargets[platform]).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ to: join('node_modules', '@vscode', 'windows-process-tree') })
])
)
}
})
it('guards package scripts that launch Electron tooling', () => {
const scripts = packageJson.scripts
const guardedScripts = [
@@ -201,14 +243,15 @@ describe('Electron runtime package contract', () => {
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))")
expect(builderConfig).toContain("from: 'out/relay'")
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
expect(remoteCommands).toContain(
"joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')"
)
// Hashing and remote install probing are manifest-driven, so the contract
// is that both companions are declared once and that both sites read it.
expect(relayArtifactFilenames(true)).toContain('relay-watcher.js')
expect(relayArtifactFilenames(true)).toContain('relay-ai-vault-service.js')
expect(relayBuild).toContain('relayArtifactFilenames(')
expect(remoteCommands).toContain('relayArtifactFilenames(')
const assertRelayGate = (steps, publishStepName) => {
const names = steps.map((step) => step.name)
@@ -19,6 +19,8 @@ describe('packaged hang watchdog worker contract', () => {
expect(smokeSource).toContain(
"process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]"
)
expect(smokeSource).toContain('const LAUNCH_TIMEOUT_MS = 30_000')
expect(smokeSource).toContain('timeout: LAUNCH_TIMEOUT_MS')
})
// Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion.
+41
View File
@@ -0,0 +1,41 @@
import { readFileSync } from 'node:fs'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
const DOCS_ONLY_FILES = new Set([
'README.md',
'LICENSE',
'AGENTS.md',
'CLAUDE.md',
'Agents.md',
'Claude.md',
'.github/CONTRIBUTING.md',
'.github/pull_request_template.md',
'.github/CODEOWNERS'
])
const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/']
export function isDocsOnlyPath(file) {
if (DOCS_ONLY_FILES.has(file)) {
return true
}
if (DOCS_ONLY_PREFIXES.some((prefix) => file.startsWith(prefix))) {
return true
}
return /^README\.[^/]+\.md$/.test(file)
}
export function shouldRunPrChecks(changedFiles) {
// Why empty-run: a silent empty diff is more likely a detector bug than a
// genuine no-op PR, so fail closed and keep the expensive jobs.
if (changedFiles.length === 0) {
return true
}
return changedFiles.some((file) => !isDocsOnlyPath(file))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n')
}
@@ -0,0 +1,107 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const gatedIf = "needs.code_paths.outputs.should_run == 'true'"
const expensiveJobs = [
'static_analysis',
'typecheck',
'git_compatibility',
'xterm_patch_sync',
'shell_contracts',
'test',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
]
describe('docs-only path classification', () => {
it('treats the WeChat README PR files as docs-only', () => {
expect(
shouldRunPrChecks([
'README.md',
'docs/assets/wechat-qr-group8.jpg',
'docs/readme/README.zh-CN.md'
])
).toBe(false)
})
it('skips root instruction files and GitHub markdown templates', () => {
expect(isDocsOnlyPath('AGENTS.md')).toBe(true)
expect(isDocsOnlyPath('CLAUDE.md')).toBe(true)
expect(isDocsOnlyPath('LICENSE')).toBe(true)
expect(isDocsOnlyPath('.github/CONTRIBUTING.md')).toBe(true)
expect(isDocsOnlyPath('.github/pull_request_template.md')).toBe(true)
expect(isDocsOnlyPath('.github/ISSUE_TEMPLATE/bug_report.yml')).toBe(true)
expect(isDocsOnlyPath('.github/CODEOWNERS')).toBe(true)
})
it('still runs PR Checks for product markdown and CI', () => {
expect(isDocsOnlyPath('skills/computer-use/SKILL.md')).toBe(false)
expect(isDocsOnlyPath('skill-guides/orca-cli.md')).toBe(false)
expect(isDocsOnlyPath('.github/workflows/pr.yml')).toBe(false)
expect(isDocsOnlyPath('src/main/index.ts')).toBe(false)
expect(isDocsOnlyPath('config/scripts/pr-code-change-scope.mjs')).toBe(false)
expect(shouldRunPrChecks(['README.md', 'src/main/index.ts'])).toBe(true)
})
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
expect(shouldRunPrChecks([])).toBe(true)
})
})
describe('PR Checks docs-only skip wiring', () => {
it('classifies the PR range with a tested script and expands renames', () => {
const classify = prWorkflow.jobs.code_paths.steps.find(
(step) => step.name === 'Classify changed paths'
)
expect(classify.run).toContain('--diff-filter=ACDMR')
expect(classify.run).toContain('--no-renames')
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
'${{ steps.filter.outputs.should_run }}'
)
})
it('keeps the cheap root-directory guard on docs-only PRs', () => {
expect(prWorkflow.jobs.root_directory_guard.if).toBeUndefined()
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
})
it('skips expensive jobs unless the detector says the PR has code', () => {
for (const jobName of expensiveJobs) {
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf)
}
})
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
expect(prWorkflow.jobs['e2e-paths'].needs).toEqual(['code_paths'])
expect(prWorkflow.jobs['e2e-paths'].if).toBe(
"github.event.pull_request.draft != true && needs.code_paths.outputs.should_run == 'true'"
)
})
it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => {
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"')
const docsOnlyBranch = verifyStep.run.slice(
0,
verifyStep.run.indexOf('# Require success when the PR has code-relevant changes')
)
expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]')
expect(docsOnlyBranch).not.toContain('"$result" != "success"')
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
})
})
+128 -14
View File
@@ -1,11 +1,17 @@
import { existsSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parse as parseJsonc } from 'jsonc-parser'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { parse as parseYaml } from 'yaml'
import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
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 prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
const reliabilityManifest = parseJsonc(
readFileSync(join(projectDir, 'config/reliability-gates.jsonc'), 'utf8')
)
const playwrightConfig = readFileSync(join(projectDir, 'tests/playwright.config.ts'), 'utf8')
const sshDockerRunner = readFileSync(
join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'),
'utf8'
@@ -53,18 +59,31 @@ describe('PR E2E gate contract', () => {
// job without adding it to the strict loop fails here instead of silently
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
const strictLoop = verifyStep.run.slice(0, verifyStep.run.indexOf('done'))
// Why lastIndexOf: the docs-only branch has its own loop that allows skipped.
const successMarker = '# Require success when the PR has code-relevant changes'
const successLoop = verifyStep.run.slice(
verifyStep.run.indexOf(successMarker),
verifyStep.run.lastIndexOf('done')
)
expect(successLoop.length).toBeGreaterThan(0)
for (const job of prWorkflow.jobs.verify.needs) {
const envVar = job.toUpperCase()
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
expect(strictLoop).toContain(`"$${envVar}"`)
expect(successLoop).toContain(`"$${envVar}"`)
}
})
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).toContain('config/scripts/pr-e2e-source-routing.mjs')
expect(filterStep.run).not.toContain('tests/playwright\\.')
expect(
selectPrE2eSpecs([
'tests/e2e/active-view-restart-restore.spec.ts',
'tests/e2e/deleted.spec.ts.bak',
'tests/e2e/global-teardown.unit.test.ts'
])
).toEqual(['tests/e2e/active-view-restart-restore.spec.ts'])
})
it('uses one runner for changed specs and keeps full runs sharded', () => {
@@ -83,6 +102,7 @@ describe('PR E2E gate contract', () => {
expect(changedRun.run).toContain(
'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"'
)
expect(playwrightConfig).toContain('retries: 0')
})
it('keeps startup-exec live parity in the isolated SSH lane', () => {
@@ -115,7 +135,7 @@ describe('PR E2E gate contract', () => {
]
for (const file of dedicatedWorkflows) {
const workflow = parse(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
const workflow = parseYaml(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
expect(workflow.on.pull_request, file).toBeUndefined()
}
})
@@ -132,12 +152,14 @@ describe('PR E2E gate contract', () => {
const sshSourceAuthorities = [
'src/main/ssh/',
'src/main/providers/ssh-',
'src/main/ipc/(ssh-|pty)',
'src/main/ipc/pty',
'src/relay/',
'src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty)'
'src/renderer/src/components/terminal-pane/remote-runtime-'
]
for (const authority of sshSourceAuthorities) {
expect(filterStep.run).toContain(authority)
expect(selectPrE2eSpecs([`${authority}routing.ts`])).toContain(
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
)
}
const mappedSpecs = [
@@ -148,7 +170,7 @@ describe('PR E2E gate contract', () => {
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
]
for (const spec of mappedSpecs) {
expect(filterStep.run).toContain(`'${spec}'`)
expect(selectPrE2eSpecs(['src/main/ssh/connection.ts'])).toContain(spec)
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
// Why: a spec that stops reading the flag would silently run without Docker.
if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') {
@@ -156,8 +178,7 @@ describe('PR E2E gate contract', () => {
}
}
// Why: unit tests next to the mapped sources prove the same code without Docker.
expect(filterStep.run).toContain("grep -Ev '\\.test\\.tsx?$'")
expect(selectPrE2eSpecs(['src/main/ssh/connection.test.ts'])).toEqual([])
// Why: startup readiness is filtered out of changed-e2e, so listing it is only
// meaningful while it still routes the dedicated Docker lane.
@@ -177,6 +198,99 @@ describe('PR E2E gate contract', () => {
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts')
expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types')
expect(selectPrE2eSpecs(['src/shared/ephemeral-vm-recipes.ts'])).toEqual([
'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'
])
})
it('routes P0 sentinels from their causal sources', () => {
const cases = [
[
'src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx',
'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'
],
['src/main/runtime/orca-runtime-files.ts', 'tests/e2e/paired-quick-open-large-tree.spec.ts'],
[
'src/renderer/src/runtime/sync-runtime-graph.ts',
'tests/e2e/host-parked-pane-remote-viewer.spec.ts'
],
[
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts',
'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'
],
[
'src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts',
'tests/e2e/paired-remote-pane-layout-retry.spec.ts'
]
]
for (const [source, spec] of cases) {
expect(selectPrE2eSpecs([source]), source).toEqual([spec])
expect(selectPrE2eSpecs([source.replace(/\.tsx?$/, '.test.ts')]), source).toEqual([])
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
}
const quickCommandSpec = 'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'
for (const source of [
'src/renderer/src/components/terminal-pane/pty-connection.ts',
'src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts',
'src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts',
'src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts',
'src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts'
]) {
expect(selectPrE2eSpecs([source]), source).toContain(quickCommandSpec)
expect(selectPrE2eSpecs([source.replace(/\.ts$/, '.test.ts')]), source).not.toContain(
quickCommandSpec
)
}
for (const source of [
'src/main/ipc/rg-availability.ts',
'src/shared/ripgrep-process-availability.ts'
]) {
expect(selectPrE2eSpecs([source]), source).toEqual([
'tests/e2e/paired-quick-open-large-tree.spec.ts'
])
}
expect(
selectPrE2eSpecs([
'src/main/runtime/orca-runtime-files.ts',
'tests/e2e/paired-quick-open-large-tree.spec.ts'
])
).toEqual(['tests/e2e/paired-quick-open-large-tree.spec.ts'])
expect(selectPrE2eSpecs(['src/renderer/src/components/FileExplorer.tsx'])).toEqual([])
expect(
selectPrE2eSpecs([
'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts'
])
).toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts')
expect(
selectPrE2eSpecs([
'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts'
])
).not.toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts')
expect(selectPrE2eSpecs(['src/main/ipc/pty.ts'])).not.toContain(
'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'
)
})
it('keeps source-routed sentinels registered to their reliability gates', () => {
const routedGateIds = [
'terminal-startup.quick-command-pre-bind-recovery',
'quick-open.paired-host-path-search',
'terminal-session.host-cold-park-stream-continuity',
'terminal-provider.ssh-remote-reattach-contract',
'terminal-session.remote-pane-layout-retry'
]
for (const gateId of routedGateIds) {
const route = PR_E2E_SOURCE_ROUTES.find((candidate) => candidate.id === gateId)
const gate = reliabilityManifest.gates.find((candidate) => candidate.id === gateId)
expect(route, gateId).toBeDefined()
expect(gate, gateId).toMatchObject({ maturity: 'experimental', protection: 'partial' })
for (const spec of route.specs) {
expect(gate.testFiles, gateId).toContain(spec)
expect(
gate.commands.some((command) => command.includes(spec)),
gateId
).toBe(true)
}
}
})
})
+117
View File
@@ -0,0 +1,117 @@
import process from 'node:process'
import { pathToFileURL } from 'node:url'
const isProductSource = (file) => !/\.test\.tsx?$/.test(file)
export const PR_E2E_SOURCE_ROUTES = [
{
id: 'ephemeral-vm-runtime.rollback-readable-sidecar',
specs: ['tests/e2e/ephemeral-vm-provisioned-root.spec.ts'],
matches: (file) =>
/^(?:src\/main\/ephemeral-vm-(?:runtime-(?:service|provisioning-persistence)|failed-start-cleanup)|src\/shared\/(?:ephemeral-vm-runtime-(?:store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$/.test(
file
)
},
{
id: 'ssh-terminal-source',
specs: [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
],
matches: (file) =>
isProductSource(file) &&
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
file
)
},
{
id: 'terminal-input.ime-and-synthetic-forwarding',
specs: [
'tests/e2e/terminal-cjk-ime-committed-text.spec.ts',
'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts',
'tests/e2e/terminal-ime-exact-byte.spec.ts',
'tests/e2e/terminal-korean-composing-chord-order.spec.ts',
'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts',
'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts',
'tests/e2e/terminal-korean-preedit-visibility.spec.ts'
],
matches: (file) =>
isProductSource(file) &&
/^(?:config\/patches\/|src\/renderer\/src\/components\/terminal-pane\/(?:terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))/.test(
file
)
},
{
id: 'terminal-startup.quick-command-pre-bind-recovery',
specs: ['tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^(?:src\/renderer\/src\/components\/tab-bar\/TabBarQuickCommandsMenu\.tsx|src\/renderer\/src\/hooks\/use-terminal-quick-command-hosts\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:pty-connection|pty-transport|terminal-pty-pre-spawn-e2e-barrier)\.ts|src\/renderer\/src\/components\/terminal-pane\/pty-connection\/(?:connect-pane-pty|fresh-spawn-start|pane-pty-visibility-bind|pty-input-recovery)\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:TerminalPane|use-terminal-pane-lifecycle)\.tsx?|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test(
file
)
},
{
id: 'quick-open.paired-host-path-search',
specs: ['tests/e2e/paired-quick-open-large-tree.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^(?:src\/main\/ipc\/(?:filesystem-(?:list-files|search-file-paths)|rg-availability)\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/relay\/(?:fs-handler(?:-install-rg|-list-files|-ripgrep-fallback)?|fs-list-files-fallback-chain)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability)\.ts)$/.test(
file
)
},
{
id: 'terminal-session.host-cold-park-stream-continuity',
specs: ['tests/e2e/host-parked-pane-remote-viewer.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^(?:src\/renderer\/src\/components\/terminal-pane\/(?:terminal-hidden-view-parking|terminal-tab-park-candidates|terminal-tab-activation-order|terminal-parked-pty-watcher|terminal-parked-tab-watchers|terminal-parked-watcher-registry)\.ts|src\/renderer\/src\/runtime\/sync-runtime-graph\.ts)$/.test(
file
)
},
{
id: 'terminal-provider.ssh-remote-reattach-contract',
specs: ['tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'],
matches: (file) =>
isProductSource(file) &&
!file.endsWith('-test-harness.ts') &&
/^(?:src\/renderer\/src\/components\/terminal-pane\/remote-runtime-pty-transport(?:-[a-z0-9-]+)?\.ts|src\/renderer\/src\/runtime\/remote-runtime-terminal-multiplexer\.ts)$/.test(
file
)
},
{
id: 'terminal-session.remote-pane-layout-retry',
specs: ['tests/e2e/paired-remote-pane-layout-retry.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^(?:src\/renderer\/src\/components\/terminal-pane\/(?:remote-pane-layout-push|TerminalPane)\.tsx?|src\/renderer\/src\/lib\/terminal-layout-equality\.ts|src\/renderer\/src\/runtime\/web-session-tabs-sync\.ts|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test(
file
)
}
]
export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
const specs = new Set(changedPaths.filter((file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file)))
for (const route of PR_E2E_SOURCE_ROUTES) {
const matchedFiles = changedPaths.filter(route.matches)
if (matchedFiles.length === 0) {
continue
}
route.specs.forEach((spec) => specs.add(spec))
reportRoute(`[pr-e2e] ${route.id}: ${route.specs.join(', ')}`)
}
return [...specs].sort((left, right) => left.localeCompare(right))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
let input = ''
process.stdin.setEncoding('utf8')
for await (const chunk of process.stdin) {
input += chunk
}
const changedPaths = input.split(/\r?\n/).filter(Boolean)
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
process.stdout.write(`${JSON.stringify(specs)}\n`)
}
@@ -50,6 +50,17 @@ describe('PR test LoC summary', () => {
expect(isTestPath('src/main/foo.ts')).toBe(false)
})
it('classifies __fixtures__ and __snapshots__ data of any extension', () => {
expect(isTestPath('src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt')).toBe(
true
)
expect(isTestPath('src/shared/__fixtures__/trace.json')).toBe(true)
expect(isTestPath('src/main/runtime/orchestration/__snapshots__/run.test.ts.snap')).toBe(true)
// Why: only the __-wrapped names count; a bare `fixtures` dir stays prod.
expect(isTestPath('src/main/daemon/fixtures/ratatui-tui.py')).toBe(false)
expect(isTestPath('src/main/my__fixtures__helper.ts')).toBe(false)
})
it('sums GitHub pull-file additions and deletions', () => {
const totals = sumChangedFiles([
{ filename: 'src/app.ts', additions: 4, deletions: 1 },
@@ -11,16 +11,17 @@ const shellContractFiles = [
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
'src/main/daemon/shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
'src/main/shell-startup-feature-channel.test.ts',
'src/main/zsh-scoped-histfile.live-shell.test.ts',
'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts',
'src/shared/posix-command-path-lookup.test.ts'
]
const patchedNodePtyContractFiles = [
'src/main/daemon/node-pty-fd-leak.test.ts',
'src/main/pty/omp-shell-wrapper.node-pty.test.ts'
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
'src/shared/fish-query-reply-child-stdin.node-pty.test.ts'
]
const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles]
const testFilePatterns = [
@@ -29,8 +30,12 @@ const testFilePatterns = [
'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'tests/tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
]
// Why the harness import counts: the zsh startup hook runs from a `precmd`, so
// its tests drive a real zsh through a PTY in zsh-startup-hook-pty-harness
// rather than calling spawnSync('zsh') themselves. Without this branch the rule
// silently stops noticing the very tests that need the lane's zsh install.
const realZshUsage =
/(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/
/(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/
describe('PR workflow parallelism', () => {
it('cancels superseded runs for the same pull request', () => {
@@ -127,8 +132,34 @@ describe('PR workflow parallelism', () => {
)
})
it('bounds the shell lane so a stalled apt mirror cannot hold the run open', () => {
const job = workflow.jobs.shell_contracts
// A passing run of this job takes ~4.5 minutes, almost all of it package download.
// Without a job bound a stalled mirror runs to GitHub's 6h default, and because this
// is a required check it holds the whole run open and blocks `gh run rerun --failed`.
expect(job['timeout-minutes']).toBeGreaterThan(0)
expect(job['timeout-minutes']).toBeLessThanOrEqual(30)
const installStep = job.steps.find((step) => step.name === 'Install zsh and fish')
// apt applies no wall-clock bound to a stalled mirror on its own. These turn an
// unbounded hang into a bounded, retried, legible failure.
expect(installStep.run).toMatch(/Acquire::http::Timeout/)
expect(installStep.run).toMatch(/Acquire::https::Timeout/)
expect(installStep.run).toMatch(/Acquire::Retries/)
// Retries multiply: a first attempt at 30s x 3 retries across every index file turned
// a dead mirror into a ~15 minute stall. One attempt, then move on.
expect(installStep.run).toMatch(/Acquire::Retries "1"/)
// Acquire timeouts are per-connection, so they cannot bound the command as a whole.
// Only a wall-clock bound can, and both apt invocations need one.
expect(installStep.run).toMatch(/timeout \d+ sudo apt-get update/)
expect(installStep.run).toMatch(/timeout \d+ sudo apt-get install/)
})
it('keeps every real-zsh test in the dedicated shell lane', () => {
const discoveredFiles = globSync(testFilePatterns)
// Why this file is excluded: it carries the detector pattern as a literal
// and would otherwise match itself.
.filter((testFile) => testFile !== 'config/scripts/pr-workflow-parallelism.test.mjs')
.filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8')))
.sort()
@@ -225,7 +256,9 @@ describe('PR workflow parallelism', () => {
// resolution can never legitimately change anything.
expect(dependencyInstall.run).toContain('--frozen-lockfile')
expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile')
expect(dependencyInstall.run).toContain('git diff --exit-code package.json pnpm-lock.yaml')
expect(dependencyInstall.run).toContain(
'git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml'
)
expect(dependencyInstall.run).toContain('--ignore-scripts')
expect(dependencyInstall.run).not.toContain('--os=')
expect(dependencyInstall.run).not.toContain('--cpu=')
@@ -304,6 +337,7 @@ describe('PR workflow parallelism', () => {
it('keeps verify as the aggregate required check', () => {
expect(workflow.jobs.verify.needs).toEqual([
'code_paths',
'static_analysis',
'root_directory_guard',
'typecheck',
+3 -1
View File
@@ -62,7 +62,9 @@ if (ignoreModules.length > 0) {
const NATIVE_MODULES = [
'node-pty',
'cpu-features',
...(rebuildPlatform === 'win32' ? ['windows-native-registry'] : [])
...(rebuildPlatform === 'win32'
? ['windows-native-registry', '@vscode/windows-process-tree']
: [])
]
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
const forceRebuild =
@@ -0,0 +1,81 @@
// Packaged-relay contract: what `build:relay` actually writes to disk.
//
// Asserts against a real build, not the source tree — the unit suites cannot see
// this gap, because the WSL transcript dispatcher runs in-process under vitest
// and never forks.
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
RELAY_BUILD_PLATFORMS,
RELAY_VERSION_FILENAME,
isWindowsRelayPlatform,
relayArtifactFilenames
} from '../../src/shared/relay-artifacts.ts'
const projectDir = resolve(import.meta.dirname, '../..')
// Its own tree: building into out/relay would clobber a developer's build and
// race the suites that read it.
const relayOutDir = mkdtempSync(join(tmpdir(), 'orca-relay-contract-'))
beforeAll(() => {
execFileSync('node', [join(projectDir, 'config', 'scripts', 'build-relay.mjs')], {
cwd: projectDir,
stdio: 'pipe',
env: { ...process.env, ORCA_RELAY_OUT_ROOT: relayOutDir }
})
}, 120_000)
afterAll(() => {
rmSync(relayOutDir, { recursive: true, force: true })
})
describe('packaged relay artifact manifest', () => {
it.each([...RELAY_BUILD_PLATFORMS])('emits exactly the declared artifacts for %s', (platform) => {
const outDir = join(relayOutDir, platform)
const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform))
for (const filename of expected) {
expect(existsSync(join(outDir, filename)), `${platform}/${filename} missing`).toBe(true)
}
// Exactly, not merely at least: an undeclared artifact ships unhashed and
// unprobed, which is the same gap in the other direction.
const emitted = readdirSync(outDir)
.filter((name) => name !== RELAY_VERSION_FILENAME)
.sort()
expect(emitted).toEqual([...expected].sort())
})
it.each([...RELAY_BUILD_PLATFORMS])('hashes every declared artifact for %s', (platform) => {
const outDir = join(relayOutDir, platform)
const hash = createHash('sha256')
for (const filename of relayArtifactFilenames(isWindowsRelayPlatform(platform))) {
hash.update(readFileSync(join(outDir, filename)))
}
const version = readFileSync(join(outDir, RELAY_VERSION_FILENAME), 'utf8')
// A companion left out of the hash lets a changed relay reuse an existing
// immutable remote directory, serving a mixed-generation install forever.
expect(version.split('+')[1]).toBe(hash.digest('hex').slice(0, 12))
})
it('ships the WSL transcript helper beside the service that forks it', () => {
for (const platform of RELAY_BUILD_PLATFORMS) {
const outDir = join(relayOutDir, platform)
const service = readFileSync(join(outDir, 'relay-ai-vault-service.js'), 'utf8')
// The bundled service reaches the fork, so the entry must sit beside it:
// the spawn resolves the child relative to its own bundle directory.
expect(service, `${platform} service no longer forks the helper`).toContain(
'wsl-transcript-fs-process-entry.js'
)
expect(
existsSync(join(outDir, 'wsl-transcript-fs-process-entry.js')),
`${platform} forks a helper it does not ship`
).toBe(true)
}
})
})
@@ -0,0 +1,210 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const RELEASE_WORKFLOW = '.github/workflows/release-cut.yml'
const EXPECTED_MATRIX = {
'.github/workflows/e2e.yml#build': { contents: 'read' },
'.github/workflows/e2e.yml#changed-e2e': { contents: 'read' },
'.github/workflows/e2e.yml#e2e': { contents: 'read' },
'.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' },
'.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' },
'.github/workflows/release-mac-build.yml#build-mac': { contents: 'write' },
[`${RELEASE_WORKFLOW}#build`]: { actions: 'read', contents: 'write' },
[`${RELEASE_WORKFLOW}#build-mac`]: { actions: 'write', contents: 'read' },
[`${RELEASE_WORKFLOW}#create-release`]: { contents: 'write' },
[`${RELEASE_WORKFLOW}#cut`]: { contents: 'write' },
[`${RELEASE_WORKFLOW}#homebrew-bump`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#homebrew-bump -> .github/workflows/homebrew-bump.yml#bump-cask`]: {
contents: 'read'
},
[`${RELEASE_WORKFLOW}#homebrew-bump-published-rc-draft`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#homebrew-bump-published-rc-draft -> .github/workflows/homebrew-bump.yml#bump-cask`]:
{
contents: 'read'
},
[`${RELEASE_WORKFLOW}#post-release-e2e`]: { actions: 'write' },
[`${RELEASE_WORKFLOW}#publish-release`]: { contents: 'write' },
[`${RELEASE_WORKFLOW}#skill-sharing-linux-floor-release-gate`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#skill-sharing-release-gate`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#terminal-rendering-golden`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#terminal-rendering-release-evidence`]: { contents: 'read' }
}
const PUBLISH_TAG_JOBS = new Set(['build', 'create-release'])
const RELEASE_TAG_EXECUTION_JOBS = [
'build',
'create-release',
'skill-sharing-linux-floor-release-gate',
'skill-sharing-release-gate',
'terminal-rendering-golden',
'terminal-rendering-release-evidence'
]
const REUSABLE_CALL_JOBS = ['homebrew-bump', 'homebrew-bump-published-rc-draft']
function readWorkflow(relativePath) {
const ref = process.env.RELEASE_CUT_WORKFLOW_REF
const source = ref
? execFileSync('git', ['show', `${ref}:${relativePath}`], { encoding: 'utf8' })
: readFileSync(relativePath, 'utf8')
return parse(source)
}
function normalizePermissions(permissions) {
if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions)) {
throw new Error(`Expected an explicit permission map, received ${String(permissions)}`)
}
return Object.fromEntries(
Object.entries(permissions).sort(([left], [right]) => left.localeCompare(right))
)
}
function intersectPermissions(granted, requested) {
const rank = { none: 0, read: 1, write: 2 }
const scopes = new Set([...Object.keys(granted), ...Object.keys(requested)])
return Object.fromEntries(
[...scopes]
.map((scope) => {
const grantedLevel = granted[scope] ?? 'none'
const requestedLevel = requested[scope] ?? 'none'
return [scope, rank[grantedLevel] < rank[requestedLevel] ? grantedLevel : requestedLevel]
})
.filter(([, level]) => level !== 'none')
.sort(([left], [right]) => left.localeCompare(right))
)
}
function resolveWorkflowMatrix(workflow, workflowPath, inheritedPermissions, prefix = '') {
const workflowPermissions = workflow.permissions
? normalizePermissions(workflow.permissions)
: inheritedPermissions
return Object.entries(workflow.jobs).reduce((matrix, [jobName, job]) => {
const requested = job.permissions ? normalizePermissions(job.permissions) : workflowPermissions
if (!requested) {
throw new Error(`${workflowPath}#${jobName} has no resolvable permissions`)
}
const effective = inheritedPermissions
? intersectPermissions(inheritedPermissions, requested)
: requested
const identity = prefix
? `${prefix} -> ${workflowPath}#${jobName}`
: `${workflowPath}#${jobName}`
matrix[identity] = effective
if (job.uses?.startsWith('./.github/workflows/')) {
const calledPath = job.uses.slice(2)
Object.assign(
matrix,
resolveWorkflowMatrix(readWorkflow(calledPath), calledPath, effective, identity)
)
}
return matrix
}, {})
}
function applyFault(workflow) {
if (process.env.RELEASE_CUT_PERMISSION_FAULT !== 'inherit-write') {
return workflow
}
workflow.permissions = { contents: 'write' }
delete workflow.jobs.cut.permissions
return workflow
}
function checkoutRef(job) {
return job.steps?.find((step) => step.uses === 'actions/checkout@v6')?.with?.ref
}
function discoverDispatchedWorkflowPaths(workflow) {
const names = new Set()
for (const job of Object.values(workflow.jobs)) {
for (const step of job.steps ?? []) {
for (const [key, value] of Object.entries(step.env ?? {})) {
if (key.endsWith('_WORKFLOW') && typeof value === 'string' && /\.ya?ml$/.test(value)) {
names.add(value)
}
}
for (const match of step.run?.matchAll(/\bgh workflow run ([\w.-]+\.ya?ml)\b/g) ?? []) {
names.add(match[1])
}
}
}
return [...names].sort().map((name) => `.github/workflows/${name}`)
}
function discoverStandaloneReusablePaths(workflow) {
return [
...new Set(
Object.values(workflow.jobs)
.map((job) => job.uses)
.filter((uses) => uses?.startsWith('./.github/workflows/'))
.map((uses) => uses.slice(2))
.filter((path) => readWorkflow(path).on?.workflow_dispatch)
)
].sort()
}
function releaseTagExecutionJobs(workflow) {
return Object.entries(workflow.jobs).filter(([, job]) => {
const checkoutIndex = job.steps?.findIndex((step) => step.uses === 'actions/checkout@v6') ?? -1
return (
checkoutRef(job) === 'refs/tags/${{ needs.cut.outputs.tag }}' &&
job.steps.slice(checkoutIndex + 1).some((step) => step.run || step.uses?.startsWith('./'))
)
})
}
describe('release-cut token permissions', () => {
const workflow = applyFault(readWorkflow(RELEASE_WORKFLOW))
const reachedWorkflowPaths = [
...discoverDispatchedWorkflowPaths(workflow),
...discoverStandaloneReusablePaths(workflow)
]
const matrix = reachedWorkflowPaths.reduce(
(result, workflowPath) =>
Object.assign(result, resolveWorkflowMatrix(readWorkflow(workflowPath), workflowPath)),
resolveWorkflowMatrix(workflow, RELEASE_WORKFLOW)
)
it('keeps the complete effective job and reusable-workflow matrix least-privileged', () => {
expect(matrix).toEqual(EXPECTED_MATRIX)
})
it('runs release-tagged non-publishing code with read-only contents access', () => {
const tagJobs = releaseTagExecutionJobs(workflow)
expect(tagJobs.map(([jobName]) => jobName).sort()).toEqual(RELEASE_TAG_EXECUTION_JOBS)
for (const [jobName] of tagJobs) {
if (!PUBLISH_TAG_JOBS.has(jobName)) {
expect(matrix[`${RELEASE_WORKFLOW}#${jobName}`]).toEqual({ contents: 'read' })
}
}
})
it('keeps fork, tag, and reusable-workflow boundaries explicit', () => {
expect(workflow.jobs.cut.if).toBe("github.repository == 'stablyai/orca'")
expect(checkoutRef(workflow.jobs.cut)).toBe(
"${{ github.event_name == 'schedule' && 'main' || inputs.ref }}"
)
for (const [jobName] of releaseTagExecutionJobs(workflow)) {
if (!PUBLISH_TAG_JOBS.has(jobName)) {
expect(workflow.jobs[jobName].needs).toBe('cut')
expect(workflow.jobs[jobName].if).toBe("needs.cut.outputs.should_release == 'true'")
}
}
for (const jobName of REUSABLE_CALL_JOBS) {
expect(workflow.jobs[jobName].uses).toBe('./.github/workflows/homebrew-bump.yml')
expect(matrix[`${RELEASE_WORKFLOW}#${jobName}`]).toEqual({ contents: 'read' })
}
const macWorkflow = readWorkflow('.github/workflows/release-mac-build.yml')
expect(macWorkflow.jobs['build-mac'].if).toBe("github.repository == 'stablyai/orca'")
expect(checkoutRef(macWorkflow.jobs['build-mac'])).toBe('refs/tags/${{ inputs.tag }}')
const e2eWorkflow = readWorkflow('.github/workflows/e2e.yml')
for (const jobName of Object.keys(e2eWorkflow.jobs)) {
expect(matrix[`.github/workflows/e2e.yml#${jobName}`]).toEqual({ contents: 'read' })
}
})
})
+102 -9
View File
@@ -17,6 +17,7 @@ import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
import { isDevBundleInUse, selectStaleDevBundleDirs } from './dev-electron-bundle-cache.mjs'
import {
DEV_BUNDLE_ID,
getDevBundlePlistPatches,
@@ -115,6 +116,60 @@ function sanitizeMacAppBundleName(value) {
)
}
function getDevBundleProcessTable() {
// Not pgrep: macOS pgrep has no -a (a Linux procps extension) and silently prints bare PIDs,
// which reads as "nothing is running" and deletes a live bundle. -ww keeps the command column
// from being truncated. The raw text is searched directly; see dev-electron-bundle-cache.mjs
// for why it is deliberately not parsed into paths.
try {
return execFileSync('/bin/ps', ['-Awwo', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000
})
} catch {
// Treating a failure as "nothing live" would risk deleting a running bundle, so skip pruning.
return null
}
}
function pruneStaleDevBundles(distDir) {
const root = path.dirname(distDir)
let bundles
try {
bundles = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const dir = path.join(root, entry.name)
return {
dir,
hasMarker: existsSync(path.join(dir, 'orca-dev-electron-app.json')),
mtimeMs: getMtimeMs(dir)
}
})
} catch {
return
}
if (bundles.length <= 1) {
return
}
const processTable = getDevBundleProcessTable()
if (processTable === null) {
return
}
const stale = selectStaleDevBundleDirs({
bundles,
currentDir: distDir,
processTable,
nowMs: Date.now()
})
for (const dir of stale) {
try {
rmSync(dir, { recursive: true, force: true })
} catch {}
}
}
function prepareMacDevElectronApp() {
if (process.platform !== 'darwin') {
return
@@ -177,15 +232,19 @@ function prepareMacDevElectronApp() {
2
)
const executablePath = path.join(appPath, 'Contents', 'MacOS', 'Electron')
// Split by consequence: without this Chromium blank-crashes, so it gates whether the bundle can
// run at all. The keyboard-layout helper below is optional -- swiftc builds it non-fatally, so on
// a Mac without full Xcode it is simply absent and only a keyboard feature degrades.
const chromiumResourcePath = path.join(
appPath,
'Contents',
'Frameworks',
'Electron Framework.framework',
'Resources',
'icudtl.dat'
)
const requiredResourcePaths = [
path.join(
appPath,
'Contents',
'Frameworks',
'Electron Framework.framework',
'Resources',
'icudtl.dat'
),
chromiumResourcePath,
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout')
]
@@ -209,6 +268,30 @@ function prepareMacDevElectronApp() {
}
if (copiedAppIsUsable()) {
pruneStaleDevBundles(distDir)
process.env.ELECTRON_EXEC_PATH = executablePath
return
}
// Why this guard: a rebuild replaces this exact directory, and making the marker conditional on a
// successful sign means an unsigned bundle is a permanent cache miss -- so every later run would
// reach this rmSync while another instance is still running from it, deleting its app mid-session.
// Reusing what is there matches what the runner did before the marker became conditional.
const rebuildProcessTable = getDevBundleProcessTable()
if (
rebuildProcessTable !== null &&
isDevBundleInUse(distDir, rebuildProcessTable) &&
// Why a runnability check: a bundle missing Chromium's resources blank-crashes, and its
// orphaned crashpad helper still matches the process table -- so without this the runner would
// reuse a broken bundle forever and never rebuild itself out. Deliberately narrower than
// copiedAppIsUsable, which also demands the optional keyboard-layout helper: gating on that
// would strand every Mac without swiftc back on the rmSync path this guard exists to avoid.
existsSync(executablePath) &&
existsSync(chromiumResourcePath)
) {
console.warn(
`[orca-dev] Another dev instance is running from this bundle; reusing it instead of rebuilding. Quit the other instance (or delete ${distDir}) to force a rebuild.`
)
process.env.ELECTRON_EXEC_PATH = executablePath
return
}
@@ -290,14 +373,24 @@ function prepareMacDevElectronApp() {
// An ad-hoc re-sign restores delivery, the permission prompt, and the
// notification-settings deep link for dev builds. Non-fatal: a signing
// failure should not block `pnpm dev`.
let signed = true
try {
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath])
} catch (error) {
signed = false
console.warn(
`[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}`
)
}
writeFileSync(markerPath, expectedMarker, 'utf8')
// Why only when signed: the marker is what marks this bundle reusable. Writing it after a failed
// sign cached an unsigned bundle permanently -- the warning above scrolled past once and every
// later launch silently reused it, losing notification delivery and the stable cdhash that keeps
// safeStorage from re-prompting. Leaving it unwritten costs a re-copy per launch until signing
// works, and still does not block `pnpm dev`.
if (signed) {
writeFileSync(markerPath, expectedMarker, 'utf8')
}
pruneStaleDevBundles(distDir)
process.env.ELECTRON_EXEC_PATH = executablePath
}
@@ -0,0 +1,362 @@
/**
* Boots the BUILT headless runtime server (`out/main/index.js --serve`), pairs a real
* client to it over the advertised endpoint, creates a terminal, runs a command in it,
* and asserts the output comes back — then shuts down.
*
* Why this exists: "the server started" proves almost nothing. The runtime dispatches
* terminal creation into OrcaRuntimeService, and without an installed headless PTY
* controller that path falls through to a renderer reply that never arrives and times
* out after ten seconds. A boot probe, a port bind, and a `host.platform` call all pass
* against a server whose terminals are dead. Only a PTY round trip catches it.
*
* This is also the acceptance gate for a future Node-only backend
* (docs/design/node-only-runtime-backend.html): the same script should pass against
* `orcad` unchanged, because it drives nothing but the public pairing + RPC surface.
*
* Hard assertions (fail the job):
* - the server emits its ready payload with a pairing offer,
* - a paired client can list worktrees and create a terminal,
* - a command run in that terminal produces its output,
* - the server exits when asked.
*/
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
import process from 'node:process'
const projectDir = resolve(import.meta.dirname, '../..')
const serveEntry = join(projectDir, 'out', 'main', 'index.js')
const ORCAD_ENTRY = join(projectDir, 'out', 'orcad', 'orcad.js')
const READY_TIMEOUT_MS = 120_000
const OUTPUT_TIMEOUT_MS = 30_000
const SHUTDOWN_TIMEOUT_MS = 15_000
// Why a random high port: a fixed one collides with a developer's own `orca serve`.
const PORT = 6800 + Math.floor(Number(process.env.ORCA_SMOKE_PORT_OFFSET ?? '0'))
function log(message) {
process.stdout.write(`[serve-terminal-smoke] ${message}\n`)
}
function fail(message) {
process.stderr.write(`[serve-terminal-smoke] FAIL: ${message}\n`)
process.exitCode = 1
}
/**
* Prefer the CLI built from this checkout over whatever `orca` is on PATH: it is the
* version under test, and a CI runner has no installed Orca app to fall back on.
*/
function resolveCli() {
const built = join(projectDir, 'out', 'cli', 'index.js')
return existsSync(built)
? { command: process.execPath, prefix: [built] }
: { command: 'orca', prefix: [] }
}
/** The `orca` CLI, driven with an explicit pairing code so it targets this server only. */
function orca(pairingCode, args) {
const cli = resolveCli()
const result = spawnSync(
cli.command,
[...cli.prefix, ...args, '--pairing-code', pairingCode, '--json'],
{
encoding: 'utf8',
// Why not shell:true — argument encoding is handled by spawnSync; a shell would
// re-split the pairing code, which is base64url and can contain '='.
shell: false
}
)
if (result.error) {
throw new Error(`orca ${args[0]} failed to spawn: ${result.error.message}`)
}
const line = (result.stdout ?? '').trim()
if (!line.startsWith('{')) {
throw new Error(`orca ${args.join(' ')} produced no JSON:\n${result.stdout}\n${result.stderr}`)
}
const parsed = JSON.parse(line)
if (parsed.ok === false) {
throw new Error(
`orca ${args.join(' ')} returned ${parsed.error?.code}: ${parsed.error?.message}`
)
}
return parsed.result
}
function waitForReady(child) {
return new Promise((resolvePromise, rejectPromise) => {
let buffered = ''
let serverErr = ''
const timer = setTimeout(
() => rejectPromise(new Error(`no ready payload within ${READY_TIMEOUT_MS}ms`)),
READY_TIMEOUT_MS
)
// Why read stderr at all: an unread pipe can fill and block the child, and without it
// a boot failure surfaces only as "exited with 1", which says nothing actionable.
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk) => {
serverErr += chunk
})
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
buffered += chunk
for (const line of buffered.split('\n')) {
if (!line.startsWith('{')) {
continue
}
try {
const payload = JSON.parse(line)
if (payload.type === 'orca_server_ready') {
clearTimeout(timer)
resolvePromise(payload)
return
}
} catch {
// Partial line; wait for the rest.
}
}
})
child.on('exit', (code) => {
clearTimeout(timer)
rejectPromise(
new Error(
`server exited with ${code} before signalling ready${
serverErr.trim() ? `:\n${serverErr.trim()}` : ' (no stderr)'
}`
)
)
})
})
}
function pairingCodeFrom(payload) {
const url = payload?.pairing?.url
if (!url) {
throw new Error('ready payload carried no pairing offer')
}
const code = new URL(url).searchParams.get('code')
if (!code) {
throw new Error(`pairing url had no code: ${url}`)
}
return code
}
async function waitForNonce(pairingCode, terminalHandle, nonce) {
const deadline = Date.now() + OUTPUT_TIMEOUT_MS
while (Date.now() < deadline) {
const read = orca(pairingCode, ['terminal', 'read', '--terminal', terminalHandle])
const tail = (read?.terminal?.tail ?? []).map((entry) => String(entry)).join('\n')
if (tail.includes(nonce)) {
return true
}
await new Promise((r) => setTimeout(r, 1_000))
}
return false
}
/**
* The two hosts this acceptance drives.
*
* Everything after boot — pairing, worktree list, terminal create, the nonce round trip —
* is identical for both. That is the point: the Node artifact has to satisfy the same
* contract as the Electron server, proven by the same code rather than a parallel test
* that could drift into asserting less.
*/
function resolveLaunch(userDataDir) {
// Why a flag and not just an env var: package scripts have to set this on Windows too,
// and `FOO=bar cmd` is not portable there.
const flagIndex = process.argv.indexOf('--target')
const target =
flagIndex !== -1 ? process.argv[flagIndex + 1] : (process.env.ORCA_SMOKE_TARGET ?? 'electron')
if (target === 'orcad') {
return {
label: `orcad (${ORCAD_ENTRY})`,
command: process.execPath,
args: [ORCAD_ENTRY, '--port', String(PORT), '--json'],
env: { ORCA_USER_DATA: userDataDir }
}
}
if (target !== 'electron') {
throw new Error(
`--target (or ORCA_SMOKE_TARGET) must be 'electron' or 'orcad', got '${target}'`
)
}
const serveArgs = [
serveEntry,
'--serve',
'--serve-port',
String(PORT),
'--serve-json',
`--user-data-dir=${userDataDir}`
]
const override = process.env.ORCA_SMOKE_ELECTRON
return {
label: `electron (${serveEntry})`,
command: override ?? 'npx',
args: override ? serveArgs : ['electron', ...serveArgs],
env: {}
}
}
/** A throwaway git repo with one commit, so `repo add` has something real to register. */
function seedGitRepo() {
const dir = mkdtempSync(join(tmpdir(), 'orca-smoke-repo-'))
writeFileSync(join(dir, 'README.md'), '# orca smoke\n')
const git = (...args) => {
const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' })
if (result.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`)
}
}
git('init', '-b', 'main')
git('config', 'user.email', 'smoke@orca.test')
git('config', 'user.name', 'Orca Smoke')
git('add', '-A')
git('commit', '-m', 'seed')
return dir
}
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-serve-smoke-'))
const launch = resolveLaunch(userDataDir)
log(`booting ${launch.label} on port ${PORT} with userData ${userDataDir}`)
// Why tracked out here: the worktree lands in the real workspaces root, not the temp
// profile, so the finally block has to remove it explicitly or every run leaks one.
let seeded = null
let pairing = null
const child = spawn(launch.command, launch.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...launch.env }
})
try {
const ready = await waitForReady(child)
log(`ready: ${ready.advertisedEndpoint}`)
const pairingCode = pairingCodeFrom(ready)
pairing = pairingCode
// Why seed instead of using whatever the profile already holds: a hermetic repo makes
// this runnable on a clean CI box, keeps the assertion deterministic, and exercises
// repo.add + worktree.create rather than assuming someone else registered a worktree.
const repoPath = seedGitRepo()
seeded = { repoPath }
log(`seeded repo at ${repoPath}`)
const repo = orca(pairingCode, ['repo', 'add', '--path', repoPath])?.repo
if (!repo?.id) {
throw new Error('repo.add returned no repo id')
}
const worktreeName = `smoke-${randomBytes(4).toString('hex')}`
const created = orca(pairingCode, [
'worktree',
'create',
'--repo',
`id:${repo.id}`,
'--name',
worktreeName,
'--setup',
'skip'
])?.worktree
if (!created?.id) {
throw new Error('worktree.create returned no worktree id')
}
seeded.worktreeId = created.id
log(`created worktree ${created.id}`)
// Why `show` and not membership in `list`: list is capped, and the Electron target
// reads a shared dev profile that can already hold more worktrees than the cap. The
// point is that the server persisted and can resolve THIS worktree.
const shown = orca(pairingCode, ['worktree', 'show', '--worktree', created.id])?.worktree
if (shown?.id !== created.id) {
throw new Error('worktree.create succeeded but worktree.show cannot resolve it')
}
log(`server resolves ${shown.id}`)
const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal
if (!terminal?.handle) {
throw new Error('terminal.create returned no handle')
}
log(`created ${terminal.handle}`)
// Why invoke node rather than `echo`: the shell differs per platform, node does not.
const nonce = `ORCA_SMOKE_${randomBytes(8).toString('hex')}`
orca(pairingCode, [
'terminal',
'send',
'--terminal',
terminal.handle,
'--text',
`"${process.execPath}" -e "console.log('${nonce}')"`,
'--enter'
])
if (!(await waitForNonce(pairingCode, terminal.handle, nonce))) {
throw new Error(
`terminal produced no output containing ${nonce} within ${OUTPUT_TIMEOUT_MS}ms — ` +
`the server started and answered RPC, but its PTY path is dead`
)
}
log('terminal round trip OK')
} catch (error) {
fail(error instanceof Error ? error.message : String(error))
} finally {
// Why before SIGTERM: worktree removal is a server operation, so it needs the server.
if (seeded?.worktreeId && pairing) {
const cleanupCli = resolveCli()
const removed = spawnSync(
cleanupCli.command,
[
...cleanupCli.prefix,
'worktree',
'rm',
'--worktree',
seeded.worktreeId,
'--pairing-code',
pairing,
'--force',
'--json'
],
{ encoding: 'utf8' }
)
// Why the parent too: `worktree rm` removes the worktree directory, leaving the
// empty `<workspaces>/<repo-name>/` container behind. Every run would leak one.
const worktreePath = seeded.worktreeId.split('::')[1]
if (removed.status === 0 && worktreePath) {
rmSync(dirname(worktreePath), { recursive: true, force: true })
}
if (removed.status !== 0) {
log(
`WARN: could not remove seeded worktree ${seeded.worktreeId}: ` +
`${(removed.stderr?.trim() || removed.stdout?.trim() || '').replace(/\s+/g, ' ')} (exit ${removed.status})`
)
}
}
// Why the exitCode guard: a server that died during boot has already exited, and
// waiting for a second 'exit' that will never fire reported a bogus shutdown failure
// stacked on top of the real error.
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGTERM')
const exited = await Promise.race([
new Promise((r) => child.on('exit', () => r(true))),
new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS))
])
if (!exited) {
child.kill('SIGKILL')
fail(`server did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)
}
}
rmSync(userDataDir, { recursive: true, force: true })
if (seeded?.repoPath) {
rmSync(seeded.repoPath, { recursive: true, force: true })
}
}
if (!process.exitCode) {
log('PASS')
}
}
await main()
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/skill-update-roundtrip.yml'), 'utf8')
)
const expectedPaths = [
'skills/**',
'resources/skills/**',
'config/scripts/verify-skill-update-roundtrip.mjs',
'config/scripts/skill-update-roundtrip-workflow.test.mjs',
'src/main/skills/skill-freshness-eligibility.ts',
'src/shared/skill-freshness.ts',
'.github/workflows/skill-update-roundtrip.yml'
]
describe('skill-update-roundtrip workflow triggers', () => {
it('uses the same path filter on push to main as on pull_request', () => {
// Why: GitHub evaluates each event independently. An unfiltered `push` to
// main started the 13-job matrix on README-only merges; cancelling that
// run paints the default-branch tip red.
expect(workflow.on.pull_request.paths).toEqual(expectedPaths)
expect(workflow.on.push.branches).toEqual(['main'])
expect(workflow.on.push.paths).toEqual(workflow.on.pull_request.paths)
})
it('does not claim a path filter on merge_group that GitHub would ignore', () => {
expect(workflow.on.merge_group).toBeDefined()
expect(workflow.on.merge_group?.paths).toBeUndefined()
})
})
@@ -11,6 +11,7 @@ const ASAR_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_ASAR'
const TIMEOUT_MS = 100
const CHECK_INTERVAL_MS = 20
const POLL_TIMEOUT_MS = 5_000
const LAUNCH_TIMEOUT_MS = 30_000
const SUCCESS_LINE = '[packaged-watchdog-smoke] app.asar worker detected and recovered a stall'
function sleep(ms) {
@@ -144,7 +145,7 @@ function runSmoke() {
const result = spawnSync(executable, electronArgs, {
env,
encoding: 'utf8',
timeout: 15_000
timeout: LAUNCH_TIMEOUT_MS
})
if (result.status !== 0) {
throw new Error(
@@ -0,0 +1,125 @@
#!/usr/bin/env node
// Why this exists: the dev-channel workflows run from main, but they build (and
// therefore read `config/electron-builder.config.cjs` from) whatever ref was
// asked for. A branch cut before Windows dev builds landed has a config that
// ignores ORCA_WIN_*, which would resolve `publish.repo` to the *main* repo and
// leave the release identity signed-looking. Publishing would then fail deep
// inside electron-builder with a 404 from a token scoped to the dev repo — or,
// worse, succeed against a repo it was never meant to touch.
//
// So: load the config exactly as electron-builder will, and assert the identity
// it produced matches the channel and platform the workflow believes it is
// building. Runs before packaging, fails with a sentence someone can act on.
import { createRequire } from 'node:module'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const CHANNEL_REPOS = {
hourly: 'orca-hourly',
daily: 'orca-daily',
adhoc: 'orca-adhoc'
}
const CHANNEL_VERSION_ENV = {
hourly: 'ORCA_HOURLY_BUILD_VERSION',
daily: 'ORCA_DAILY_BUILD_VERSION',
adhoc: 'ORCA_ADHOC_BUILD_VERSION'
}
export function collectDevChannelPackagingProblems({ channel, platform, config, env }) {
const problems = []
const expectedRepo = CHANNEL_REPOS[channel]
if (!expectedRepo) {
return [
`Unknown dev channel "${channel}"; expected one of ${Object.keys(CHANNEL_REPOS).join(', ')}.`
]
}
if (config.publish?.repo !== expectedRepo) {
problems.push(
`publish.repo is "${config.publish?.repo}" but this ${channel} build must publish to "${expectedRepo}". ` +
`The checked-out ref's electron-builder config does not understand this channel on ${platform} — rebase it onto a main that does.`
)
}
if (config.publish?.releaseType !== 'prerelease') {
problems.push(
`publish.releaseType is "${config.publish?.releaseType}" but dev-channel builds must publish as "prerelease".`
)
}
// Why version too: `extraMetadata.version` is what stamps the tag the workflow
// already created. A config that dropped it would package package.json's
// version and upload into the wrong release entirely.
const expectedVersion = env[CHANNEL_VERSION_ENV[channel]]
if (expectedVersion && config.extraMetadata?.version !== expectedVersion) {
problems.push(
`extraMetadata.version is "${config.extraMetadata?.version}" but the workflow computed "${expectedVersion}".`
)
}
if (platform === 'win32') {
// The one that silently breaks updates rather than failing the build: a dev
// build that advertises a publisherName can never install its own channel's
// next build, because electron-updater verifies against the name baked into
// the installed app.
if (config.win?.verifyUpdateCodeSignature !== false) {
problems.push(
'win.verifyUpdateCodeSignature must be false for unsigned dev builds, or electron-updater will Authenticode-verify every installer this build downloads and reject all of them.'
)
}
if (config.win?.signtoolOptions?.publisherName != null) {
problems.push(
`win.signtoolOptions.publisherName is set to "${config.win.signtoolOptions.publisherName}" on an unsigned dev build; it must be absent.`
)
}
}
return problems
}
function parseArgs(argv) {
const args = {}
for (const entry of argv) {
const match = /^--([^=]+)=(.*)$/.exec(entry)
if (match) {
args[match[1]] = match[2]
}
}
return args
}
function main() {
const { channel, platform = process.platform } = parseArgs(process.argv.slice(2))
if (!channel) {
console.error(
'Usage: verify-dev-channel-packaging.mjs --channel=<hourly|daily|adhoc> [--platform=win32]'
)
process.exit(1)
}
const require = createRequire(import.meta.url)
const config = require(resolve(import.meta.dirname, '../electron-builder.config.cjs'))
const problems = collectDevChannelPackagingProblems({
channel,
platform,
config,
env: process.env
})
if (problems.length > 0) {
for (const problem of problems) {
console.error(`::error::${problem}`)
}
process.exit(1)
}
console.log(
`Dev-channel packaging verified: ${channel} on ${platform} → stablyai/${CHANNEL_REPOS[channel]} @ ${config.extraMetadata?.version}`
)
}
// Why the guard: the test imports the pure collector without running the CLI.
// pathToFileURL, not a `file://` template: this also runs on Windows, where a
// drive-letter path does not concatenate into a valid URL.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}
@@ -0,0 +1,174 @@
import { createRequire } from 'node:module'
import { resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { collectDevChannelPackagingProblems } from './verify-dev-channel-packaging.mjs'
const require = createRequire(import.meta.url)
const CONFIG_PATH = resolve(import.meta.dirname, '../electron-builder.config.cjs')
/** The config reads process.env at require time, so each identity needs a fresh load. */
function loadConfigWithEnv(env) {
const saved = { ...process.env }
for (const key of Object.keys(process.env)) {
if (key.startsWith('ORCA_')) {
delete process.env[key]
}
}
Object.assign(process.env, env)
try {
delete require.cache[require.resolve(CONFIG_PATH)]
return require(CONFIG_PATH)
} finally {
process.env = saved
delete require.cache[require.resolve(CONFIG_PATH)]
}
}
const WIN_ADHOC_ENV = {
ORCA_WIN_ADHOC: '1',
ORCA_ADHOC_BUILD_VERSION: '1.4.178-adhoc.20260819010203'
}
afterEach(() => {
delete require.cache[require.resolve(CONFIG_PATH)]
})
describe('electron-builder dev-channel identity', () => {
it('keeps the SignPath publisherName on stable Windows builds', () => {
const config = loadConfigWithEnv({})
expect(config.win.signtoolOptions.publisherName).toBe('SignPath Foundation')
expect(config.win.verifyUpdateCodeSignature).toBeUndefined()
expect(config.publish.repo).toBe('orca')
expect(config.publish.releaseType).toBe('release')
})
// The whole point of the change: an unsigned build that advertised a
// publisherName would Authenticode-verify — and reject — every installer it
// ever downloaded, including its own way back to stable.
it('drops the publisherName and disables update signature checks on Windows dev builds', () => {
const config = loadConfigWithEnv(WIN_ADHOC_ENV)
expect(config.win.signtoolOptions?.publisherName).toBeUndefined()
expect(config.win.verifyUpdateCodeSignature).toBe(false)
})
it.each([
['hourly', { ORCA_WIN_HOURLY: '1' }, 'orca-hourly'],
['daily', { ORCA_WIN_DAILY: '1' }, 'orca-daily'],
['adhoc', { ORCA_WIN_ADHOC: '1' }, 'orca-adhoc']
])('publishes %s Windows builds to its own repo as a prerelease', (_channel, env, repo) => {
const config = loadConfigWithEnv(env)
expect(config.publish.repo).toBe(repo)
expect(config.publish.releaseType).toBe('prerelease')
})
// Why: ORCA_MAC_* gates hardened runtime, notarization, and root-level
// forceCodeSigning. If the Windows variables leaked into that, the Windows job
// would fail packaging for want of a cert it deliberately does not use.
it('leaves mac release signing off for Windows dev builds', () => {
const config = loadConfigWithEnv(WIN_ADHOC_ENV)
expect(config.forceCodeSigning).toBe(false)
expect(config.mac.notarize).toBe(false)
expect(config.mac.hardenedRuntime).toBe(false)
})
it('still notarizes mac dev builds', () => {
const config = loadConfigWithEnv({
ORCA_MAC_ADHOC: '1',
ORCA_ADHOC_BUILD_VERSION: '1.4.178-adhoc.20260819010203'
})
expect(config.mac.notarize).toBe(true)
expect(config.publish.repo).toBe('orca-adhoc')
})
})
describe('collectDevChannelPackagingProblems', () => {
const goodWinConfig = {
publish: { repo: 'orca-adhoc', releaseType: 'prerelease' },
extraMetadata: { version: '1.4.178-adhoc.20260819010203' },
win: { verifyUpdateCodeSignature: false }
}
const env = { ORCA_ADHOC_BUILD_VERSION: '1.4.178-adhoc.20260819010203' }
it('accepts a correctly configured Windows dev build', () => {
expect(
collectDevChannelPackagingProblems({
channel: 'adhoc',
platform: 'win32',
config: goodWinConfig,
env
})
).toEqual([])
})
// The failure this script exists for: a branch predating Windows dev builds
// resolves publish.repo to the main repo.
it('rejects a config that resolved the main repo', () => {
const problems = collectDevChannelPackagingProblems({
channel: 'adhoc',
platform: 'win32',
config: { ...goodWinConfig, publish: { repo: 'orca', releaseType: 'release' } },
env
})
expect(problems.join('\n')).toContain('must publish to "orca-adhoc"')
expect(problems.join('\n')).toContain('rebase it onto a main that does')
})
it('rejects a Windows dev build that still advertises a publisherName', () => {
const problems = collectDevChannelPackagingProblems({
channel: 'adhoc',
platform: 'win32',
config: {
...goodWinConfig,
win: { signtoolOptions: { publisherName: 'SignPath Foundation' } }
},
env
})
expect(problems.join('\n')).toContain('verifyUpdateCodeSignature must be false')
expect(problems.join('\n')).toContain('publisherName is set to "SignPath Foundation"')
})
it('rejects a build packaging a version other than the tag the workflow created', () => {
const problems = collectDevChannelPackagingProblems({
channel: 'adhoc',
platform: 'win32',
config: { ...goodWinConfig, extraMetadata: { version: '1.4.178' } },
env
})
expect(problems.join('\n')).toContain('but the workflow computed')
})
// macOS builds are signed, so the Windows-only assertions must not fire there.
it('does not apply Windows signature rules to a mac dev build', () => {
expect(
collectDevChannelPackagingProblems({
channel: 'adhoc',
platform: 'darwin',
config: {
publish: { repo: 'orca-adhoc', releaseType: 'prerelease' },
extraMetadata: { version: '1.4.178-adhoc.20260819010203' },
win: { signtoolOptions: { publisherName: 'SignPath Foundation' } }
},
env
})
).toEqual([])
})
it('rejects an unknown channel', () => {
expect(
collectDevChannelPackagingProblems({
channel: 'nightly',
platform: 'win32',
config: goodWinConfig,
env
})
).toEqual(['Unknown dev channel "nightly"; expected one of hourly, daily, adhoc.'])
})
})
+67
View File
@@ -0,0 +1,67 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeEach } from 'vitest'
import { setAppEnvironment, type AppEnvironment } from '../../src/shared/app-environment'
import { setSecretStore } from '../../src/shared/secret-store'
/**
* Why: both host ports throw until an entrypoint installs them, which is the right
* production behaviour but would fail ~70 suites that only ever needed *some* host.
* Install benign defaults before every test so those suites stay unchanged; a suite
* that asserts on host behaviour installs its own and wins, because this runs first.
*/
// One directory per test environment, not per test: suites that write a file in one
// step and read it back in the next would otherwise lose it between them. Removed on
// teardown — vitest builds one environment per test file, so without this a full run
// leaves thousands of directories behind.
const userData = mkdtempSync(join(tmpdir(), 'orca-vitest-userdata-'))
afterAll(() => {
rmSync(userData, { recursive: true, force: true })
})
// Deliberately not a plaintext passthrough: encryptString must return something a
// test can tell apart from its input, or a test that forgot to seal would pass.
const SEAL_PREFIX = 'vitest-sealed:'
/**
* Build a fake environment from just the members a suite cares about. Exported so
* suites that need a specific path or metrics fixture state only that, instead of
* restating all seven members — which is boilerplate, and pushed one suite past
* the max-lines budget.
*/
export function fakeAppEnvironment(overrides: Partial<AppEnvironment> = {}): AppEnvironment {
return {
getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userData),
getAppPath: () => process.cwd(),
getVersion: () => '0.0.0-test',
isPackaged: () => false,
onWillQuit: () => {},
exit: () => {},
getAppMetrics: () => [],
...overrides
}
}
/** Install a fake in one call — the common shape in suites that need one specific member. */
export function installFakeAppEnvironment(overrides: Partial<AppEnvironment> = {}): void {
setAppEnvironment(fakeAppEnvironment(overrides))
}
beforeEach(() => {
setAppEnvironment(fakeAppEnvironment())
setSecretStore({
isEncryptionAvailable: () => true,
encryptString: (plainText) => Buffer.from(`${SEAL_PREFIX}${plainText}`),
decryptString: (cipher) => {
const text = cipher.toString()
if (!text.startsWith(SEAL_PREFIX)) {
throw new Error('vitest secret store: ciphertext was not produced by this store')
}
return text.slice(SEAL_PREFIX.length)
},
describeProtectionGap: () => null
})
})
@@ -0,0 +1,70 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
describe('packaged Windows PTY native capability routing', () => {
it('runs the packaged executable immediately after the unpacked app is built', () => {
const job = workflow.jobs.package_windows
const packageIndex = job.steps.findIndex((step) => step.name === 'Package unpacked app')
const smokeIndex = job.steps.findIndex(
(step) => step.name === 'Smoke packaged Windows PTY native capability'
)
const smoke = job.steps[smokeIndex]
expect(job['runs-on']).toBe('windows-2022')
expect(smokeIndex).toBe(packageIndex + 1)
expect(smoke.run).toBe(
'pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe'
)
expect(smoke.if).toBeUndefined()
expect(smoke['continue-on-error']).toBeUndefined()
expect(packageJson.scripts['smoke:windows-pty-native-capability']).toBe(
'node tests/tools/windows-pty-native-capability-smoke/run.mjs'
)
})
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
const job = workflow.jobs.package_windows
const sourceRebuild = job.steps.find(
(step) => step.name === 'Rebuild node-pty from patched source'
)
const build = job.steps.find((step) => step.name === 'Build package inputs')
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
const packageStep = job.steps.find((step) => step.name === 'Package unpacked app')
const verify = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
expect(build.run).toBe('pnpm run build:release:parallel')
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
expect(workflow.jobs.verify.needs).toContain('package_windows')
expect(verify.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}')
expect(verify.run).toContain('"$PACKAGE_WINDOWS"')
})
it('keeps the native probe event-based, scoped, and runnable in packaged Node mode', () => {
const driver = readFileSync('tests/tools/windows-pty-native-capability-smoke/run.mjs', 'utf8')
const probe = readFileSync(
'tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs',
'utf8'
)
const source = `${driver}\n${probe}`
expect(driver).toContain("ELECTRON_RUN_AS_NODE: '1'")
expect(source).not.toMatch(/\b(?:sleep|tasklist|taskkill)\b/i)
expect(source).not.toContain('maxRetries')
expect(source).not.toContain('retryDelay')
expect(source).not.toContain("from 'node:child_process'")
expect(source).not.toContain("require('node:child_process')")
expect(probe).toContain("'System32', 'wscript.exe'")
expect(probe).toContain('real-orca-detached-launcher.vbs')
expect(probe).not.toMatch(/cmd\.exe|start "" \/b/i)
expect(probe).toContain('native.terminateJob(target._pty, target.pid)')
})
})
+2 -8
View File
@@ -142,10 +142,7 @@ async function main() {
const loginProbe = buildWslLoginShellCommand(
`printf '\\n__ORCA_PATH__%s\\n__ORCA_GIT__%s\\n__ORCA_HOME__%s\\n' "$PATH" "$(command -v git)" "$HOME"`
)
const probe = await run(
'wsl.exe',
wslArgs(distro, ['/bin/sh', '-lc', loginProbe])
)
const probe = await run('wsl.exe', wslArgs(distro, ['/bin/sh', '-lc', loginProbe]))
const probeText = probe.stdout.toString('utf8')
const loginPath = /__ORCA_PATH__(.*)/.exec(probeText)?.[1]?.trim()
const gitPath = /__ORCA_GIT__(.*)/.exec(probeText)?.[1]?.trim()
@@ -167,10 +164,7 @@ async function main() {
].join(' ')
const delay = options.loginDelayMs > 0 ? `sleep ${options.loginDelayMs / 1_000}; ` : ''
const script = `${delay}${buildWslLoginShellCommand(command)}`
result = await run(
'wsl.exe',
wslArgs(distro, ['/bin/sh', '-lc', script])
)
result = await run('wsl.exe', wslArgs(distro, ['/bin/sh', '-lc', script]))
const markerOffset = result.stdout.indexOf(outputMarker)
if (markerOffset === -1) {
throw new Error('Login shell did not emit the Git output marker')
+2
View File
@@ -40,6 +40,7 @@
"../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-managed-home-resource-copy-marker.ts",
"../src/main/codex/codex-path-observation.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
@@ -55,6 +56,7 @@
"../src/main/codex/config-settings-baseline.ts",
"../src/main/codex/config-settings-conflict-resolution.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-settings-promotion-write-target.ts",
"../src/main/codex/config-sync-stall.ts",
"../src/main/codex/config-toml-deprecated-hook-flag.ts",
"../src/main/codex/config-toml-key-path.ts",
+1
View File
@@ -3,6 +3,7 @@
"include": [
"../electron.vite.config.*",
"./build-plugins/**/*",
"./scripts/vitest-host-ports-setup.ts",
"../src/main/**/*",
"../src/renderer/src/lib/skill-freshness-display-status.ts",
"../src/preload/**/*",
+4 -1
View File
@@ -19,7 +19,10 @@ export default defineConfig({
// Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds.
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
setupFiles: [
resolve('config/scripts/happy-dom-mutation-observer-retention.ts'),
resolve('config/scripts/vitest-host-ports-setup.ts')
],
include: [
'src/**/*.test.ts',
'src/**/*.test.tsx',
+1 -1
View File
@@ -102,7 +102,7 @@ Orca uses shadows sparingly. Three levels in practice:
1. **Inset hairline** — `border` + `border` token. The default. Almost everything sits at this level.
2. **Subtle lift** — `shadow-xs` + a single-token border. Outline buttons, embedded cards.
3. **Floating** — `0 10px 24px rgba(0, 0, 0, 0.18)`. Popovers, popups that escape the editor surface. Reserved.
3. **Floating** — `shadow-floating` (`0 10px 24px rgba(0, 0, 0, 0.18)`). Popovers, popups that escape the editor surface. Reserved.
Don't add a fourth level. If something needs more emphasis than "floating," you're probably reaching for the focus `ring` instead.
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 22m">
<title>downloads: 22m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 27m">
<title>downloads: 27m</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">22m</text>
<text x="90" y="14">22m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">27m</text>
<text x="90" y="14">27m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
---
+2 -2
View File
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android :** [Télécharger l'APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
- **Android :** [Télécharger l'APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [APK 0.0.43 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
- **Android:** [APK 0.0.44 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
- **Android:** [Baixar APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
---
+5 -4
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
---
@@ -235,9 +235,10 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **微信:** 扫码加入 Orca 社区微信第 7 群。
- **微信:** 扫码加入 Orca 社区微信第 7 群。如果第 7 群已满,请使用第 8 群。
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 社区微信第 7 群二维码" width="160" />
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 社区微信第 7 群二维码" width="160" />&nbsp;&nbsp;
<img src="../assets/wechat-qr-group8.jpg" alt="Orca 社区微信第 8 群二维码" width="160" />
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
@@ -100,6 +100,27 @@ sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing,
the relay transport. A change on those paths still needs its own reasoning against
the three rules above.
## Worked example: `agentWait` on terminal and worker reads
`terminal.show`, `orchestration.workerShow` and `orchestration.federationShow` carry an
optional `agentWait` naming a pane parked on a prompt only a human can answer. It is Rule 1 —
a new optional field — but it has a second state that Rule 1 alone does not describe, and
getting that wrong turns a skew into a false "nothing is blocked".
- **present object** — this pane is waiting, with the evidence that proved it.
- **present `null`** — the host evaluated this pane and nothing proves a wait.
- **absent** — the host never evaluated it: it predates the field, the worker identity was
unverifiable, the pane was unreadable, or the agent probe did not answer in time.
A new client against an old host sees the field absent, which is why absence must read as
*unknown* and never as *not waiting*. Collapsing absent into `null` at any hop — including a
convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable
from a healthy idle worker, which is the exact failure the field exists to remove.
An old client against a new host ignores the key, as Rule 1 allows. New members added to
`RuntimeTerminalWaitBlockedReason` are also Rule 1: no consumer switches exhaustively on it,
and both the CLI and worker-start interpolate it as an opaque string.
## Known debt: JSON-RPC errors drop Node's string code
An error raised on an SSH host crosses the relay as JSON-RPC, and
+77
View File
@@ -0,0 +1,77 @@
# SSH Execution Boundary
How Orca splits work between your machine and an SSH host, what survives a disconnect, and how to keep `unverifiable` distinct from `exited`. Nothing under `docs/` stated this before; agents and humans were inferring it from error strings and getting it wrong.
## The rule
**The execution host owns everything that touches execution** — tools, credentials, identity, environment, processes, and artifacts. The client owns the UI, transport, and Orca control-plane state, but is not authoritative for execution state.
Two consequences, both non-negotiable:
1. **No silent substitution.** An operation on a remote `repoPath` must never fall back to running on the client. A missing SSH provider is not permission to answer locally — a local run can answer for the _wrong repository_.
2. **No asserting what you cannot observe.** Loss of contact is not evidence of `exited`. Report `unverifiable`, never `exited`.
The vocabulary is fixed: **`live` / `unverifiable` / `exited`**, taken from the incumbent `UnstoppedPtyVerdict`. Do not introduce synonyms, and never collapse `unverifiable` into either neighbour. `exited` requires positive evidence of absence from the host that owns the process; a transport failure can only ever produce `unverifiable`.
Rule 1 is stated at `src/main/source-control/repo-default-branch.ts:76-78`, `src/main/repo-worktrees.ts:45-48`, `OrcaRuntimeService.probeWorktreeDrift` in `src/main/runtime/orca-runtime.ts`, and `src/renderer/src/lib/connection-context.ts:22-24`. It is enforced throughout `src/main/runtime/orca-runtime-git.ts` by the guard that throws `SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE` whenever `target.connectionId` is set and no provider is registered — grep that constant for the current call sites rather than trusting a count.
`src/main/runtime/unstopped-pty-verification.ts:12-16` is the reference implementation of rule 2: it keeps `live` / `unverifiable` / `exited` as three distinct verdicts, and treats "we could not ask" as its own answer.
## What runs where
| Concern | Executes on | Notes |
| -------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------- |
| PTYs, agent CLIs | **remote** | children of the detached relay daemon, not of the ssh channel |
| git (status, diff, log, fetch, push, commit, branch, worktree) | **remote** | via `src/relay/git-handler.ts` |
| filesystem, watching, search | **remote** | |
| repo setup hooks (`--setup`) | **remote** | identical policy to local |
| commit-message / PR-field AI generation | **remote** | uses the remote agent CLI and its auth |
| `gh` / GitHub API, `glab` / GitLab | **client** | inconsistent with the rule; PRs carry the client's identity |
| the `orca` CLI inside a remote terminal | **client runtime** | control plane only — your files and processes stay remote; see below |
## Survival: what a disconnect does _not_ do
By default, remote work survives your machine going away. The relay is a detached daemon (`nohup … </dev/null &`), its handler in `src/relay/relay.ts` ignores `SIGHUP`, the PTY is its child rather than the ssh channel's, and quitting Orca is a **detach, not a dispose** (`src/main/ssh/ssh-relay-session.ts:901-915`). Sleep additionally pushes `graceTimeSeconds: 0` to un-bound any running grace window.
Two ways remote work _can_ actually stop:
- **A bounded grace period.** The shipped default is `0` = keep alive until reset. If "keep terminals alive until reset" is unchecked, the configurable range is **60s–7d** and the form defaults to **24h**. The countdown starts when the client disconnects, after which the relay SIGKILLs every PTY. Note the asymmetry: sleep protects you, but ordinary disconnect and app quit do not. No command reports which setting is in effect for a target, so at N hours since disconnect you cannot tell "unlimited" from "24h with 7 left" — treat the remote as `unverifiable`, not `exited`.
- **Host-acknowledged explicit user action** — End Remote Terminals, Reset Relay, removing the target, or closing the tab. When the host cannot acknowledge the request, closing a tab or removing a target may clear only client state; the remote verdict remains `unverifiable`.
Reconnect re-attaches to the same live PTYs and replays a bounded buffer (`REPLAY_BUFFER_MAX`, a 102,400-code-unit tail). Output beyond that while you were away is lost to the client even though the process was never interrupted: **the transcript is truncated; the work stays `live`.**
## Control plane
On an SSH host, `orca` is a shim (`~/.orca-relay/bin/orca`) that proxies **back to the client's runtime** over the relay socket. Your repository, processes, and files remain remote — only the control plane is on the client. This is correct for an SSH target, but it has a consequence worth stating plainly:
> When the client disconnects, every `orca …` command run on the SSH host fails with `No owning Orca client is connected to the relay`. The PTY stays `live`; its control plane does not.
Orchestration state (Runs, Tasks, Dispatches, mailboxes) is client-resident for the same reason. An agent on an SSH host should not depend on `orca` for anything it must finish while you are away. **Commit and push early** — unpushed work on a remote box is unavailable to the client until it reconnects.
## Distinguishing `unverifiable` from `exited`
A verdict needs evidence from the host that owns the process. Apply these tests in order.
**Was the signal produced by the owning host, or by the client's own bookkeeping?** Absence from a client-side set, a lookup that threw, a socket that closed, a command that timed out — none of these observe the process. They are `unverifiable` by construction, whatever the field is named.
**Did every remote PTY on that target go quiet at once?** A transport drop takes them all together. Simultaneous silence across a host indicates a lost link, not simultaneous death.
**Does the termination event match the current identity?** A host-delivered exit for the live PTY incarnation and provider generation, while its siblings still report, establishes `exited`. A stale event, an event for a superseded incarnation, or one quiet terminal with no host evidence does not.
**Is a returned status actually a claim of success?** An operation that reports failure may have succeeded, and one that reports success may not have run — check the durable state it should have changed rather than trusting the return.
Anything short of positive host evidence is `unverifiable`. Reporting it as `exited` is the error this document exists to prevent: it orphans live work and can cold-start a duplicate over the same worktree.
## Reading artifacts instead of process state
Artifacts are stronger evidence than liveness signals, but they answer a narrower question than they appear to.
A matching commit from `git ls-remote --heads origin <branch>` or a PR head lookup proves **that commit reached the remote** — not that the current run pushed it, and not that the latest work was included. An absent result proves nothing was found, not that nothing was pushed: the ref may have been deleted, the PR closed, or the query may simply have failed.
A listing is only evidence about the hosts it actually covered. When a result does not name its scope, an empty answer is not evidence that nothing is running elsewhere. A clean **local** worktree says nothing at all about the remote one.
## One host, one model
An SSH host and a paired runtime (`orca environment`) imply opposite boundaries: the first is a dumb execution host driven by your client, the second is a peer that owns its own control plane. Registering the same machine both ways splits its worktrees across two identities, makes `terminal list` return different sets depending on `--environment`, and reliably confuses both humans and agents. Pick one per machine.
For work that must continue while you are offline, use the peer/headless-runtime model on the remote host instead of the direct-SSH model. Its control plane is host-local, and its daemon-backed PTYs stay `live` across a normal runtime restart so the runtime can reattach; an explicit daemon shutdown can still make them `exited`. Do not register the same machine through both models. A detached agent process outside Orca can also survive a control-plane outage, but it has no stdin, so its instructions cannot be amended mid-run.
@@ -0,0 +1,183 @@
# Reading the Windows process table
Orca needs three things from the Windows process table: who a PID's parent is
(descendant walks and teardown identity), what a process is running (agent
recognition), and how much memory/CPU it uses (Resource Manager).
Node cannot answer the first one without native code. That is why seven
independent readers existed, each forking `powershell.exe` to run
`Get-CimInstance Win32_Process`, with a `wmic` fallback that Windows 11 24H2 has
since removed.
## Use the native snapshot
`src/main/windows/windows-process-table.ts` is the only module that may read the
table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`.
```ts
import { readWindowsProcessTable, readWindowsProcessTableFresh } from '../windows/windows-process-table'
```
- `readWindowsProcessTable()` — shared TTL cache. Use for anything periodic.
- `readWindowsProcessTableFresh()` — a snapshot that starts after the call. Use
for teardown identity, where a cached row can predate the exit it is being
asked about.
Both **reject** when the table cannot be read. Do not convert that into an empty
array. An empty table is a claim that nothing is running, and callers act on
that claim by declaring a tree dead or a shell childless. "Unavailable" has to
stay distinguishable from "empty" — collapsing the two is how a PTY tree
survived its own teardown (#9045).
Measured on Windows 11 with 1050 processes (p50 / p95):
| | p50 | p95 |
| --- | --- | --- |
| pid + ppid + name | 15.9 ms | 17.5 ms |
| + memory + command line | 30.6 ms | 33.7 ms |
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
## Why the package is patched
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries two hunks.
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
libraries, which Orca's Windows build agents do not install. `node-pty` is
patched the same way for the same reason.
2. **The 1024-process cap.** `GetRawProcessList` stopped after 1024 entries.
Measured on a real host with 1051 processes, the module returned exactly
1024 and the querying process was itself among the 27 missing. A truncated
snapshot silently hides the descendants a teardown is trying to reap — the
exact failure the native path exists to remove.
The typings claim `commandLine` is truncated at 512 characters. Measured, it is
not: the longest observed on a real host was 26,059.
## Packaging
The addon is Windows-only, so it follows the same contract as
`windows-native-registry` (asserted by
`config/scripts/package-electron-runtime-contract.test.mjs`):
- an `optionalDependency`, so a macOS/Linux install tolerates its absence;
- **not** in `pnpm.onlyBuiltDependencies` — pnpm installs optional dependencies
on every host, and macOS/Linux must never run `node-gyp` for it;
- listed in the win32 branch of `rebuild-native-deps.mjs` and
`ensure-native-runtime.mjs`;
- copied into the packaged `node_modules` for win32 only.
## What the snapshot does not provide
`CreationDate` (process start time) has no equivalent. Anything using a start
time to prove a PID has not been recycled — daemon identity, managed-hook
ownership, and CPU accounting in the memory collector — still reads it through
its own query. Those callers are not migrated.
Start time is a proxy for identity, not identity. The durable answer for the
process trees Orca itself spawns is an inherited handle: a job object names the
tree Orca created, so no start-time comparison is needed. Those readers should
be resolved that way rather than by adding a start time to this module.
Do not adopt `getProcessCpuUsage()` from the package. It takes both CPU samples
inside one call with a blocking `Sleep(1000)` in the middle, which would hold a
libuv threadpool slot for a full second out of the Resource Manager's two-second
poll.
## Owning a PTY's process tree
`src/main/windows/windows-pty-job.ts` is the counterpart to reading the table:
it answers "is this tree mine, and how do I kill it?" with a handle instead of
an inference.
node-pty is patched (`config/patches/node-pty@1.1.0.patch`) to create a job
object per ConPTY and assign the shell to it under `CREATE_SUSPENDED`, before
the shell can spawn anything. Assigning after the fact leaves a window in which
a fast child escapes the job.
- `terminatePtyJob(proc)` — one `TerminateJobObject` call for the whole tree.
- `listPtyJobProcessIds(proc)` — the live pids under a tree that is still
tracked, including children that detached from the console.
Measured on Windows 11 against a shell whose grandchild was spawned `detached`:
job membership was `[shell, grandchild]` and one call killed both. Neither a
parent-pid walk nor `GetConsoleProcessList` sees that grandchild — it leaves
the console and reparents, which is what left `claude.exe`/`node.exe`/`cmd.exe`
holding worktree directories open (#9045, #10475, #10897).
The per-PTY job deliberately does **not** set
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Measured on Windows 11: with that flag,
releasing the handle when the shell exits also kills whatever the user left
running, so typing `exit` in a pane reaped a `start /b` server that used to
survive. The job exists to make an *explicit* teardown exact, not to redefine
what a clean exit means.
Reaping a dead daemon's shells (#9195, #10415) is therefore a **second, nested
job**, not this one. The terminal daemon assigns itself to a kill-on-close job
at startup (`assignHostProcessToKillOnCloseJob`); children inherit membership,
so every pty is covered and the per-PTY jobs nest inside it. Its handle is
released only when the daemon process dies, so a crashed daemon reaps its tree
without changing what a clean shell exit means.
The split is the point. One job answers "kill exactly this pane's tree, now";
the other answers "do not strand anything if the host dies". Trying to get both
from one job is what reaped users' backgrounded work on a clean `exit`.
It belongs to the daemon and never to the app: an app-main crash must still
leave sessions alive, which `.github/workflows/win-crash-survival-e2e.yml`
asserts. The app spawns the daemon `detached` and is itself in no job, so
nothing is inherited across that boundary.
The consequence is that a PTY hosted by the app rather than the daemon gets a
per-PTY job but no crash reaping. That is deliberate — the alternative is a
kill-on-close job on the app, which is exactly what the crash-survival
guarantee forbids.
Once the shell exits, node-pty drops its handle record and closes the job, so a
terminated tree reports `null` rather than `[]`. Null means *unverifiable* in
the sense of [`ssh-execution-boundary.md`](./ssh-execution-boundary.md) — no job
support, not a ConPTY, or no longer tracked. It is never evidence that
processes died.
Both functions report `unavailable` / `null` rather than a false success when a
pty has no job — an outer job without `JOB_OBJECT_LIMIT_BREAKAWAY_OK` (some EDR
and container hosts) can refuse the assignment, and a pty started before this
build has none. Callers must fall back, not conclude the tree is gone. That
conflation is the original bug.
### Known limitation: the baton table is not synchronised
node-pty keeps its per-terminal handles in a plain `std::vector` and erases from
it on a detached exit thread, while `get_pty_baton` is called from the main JS
thread. That race predates this change — `PtyResize`, `PtyClear` and `PtyKill`
all read the table the same way — but `terminatePtyJob` adds an instance of it:
the exit thread can close `hJob` between the lookup and `TerminateJobObject`.
Losing that race normally just returns `FALSE`, which surfaces as `unavailable`
and falls back. The case that would not be benign is a recycled `HANDLE` value,
where the call could reach a different job in the same process. Fixing it
properly means synchronising node-pty's handle table rather than adding a lock
around one accessor, so it is deliberately left alone here.
### The patch must actually be compiled
node-pty prefers its upstream prebuild and only builds from source when
`npm_config_build_from_source` is set or no prebuild exists for the platform.
The Windows prebuild does **not** contain this patch, so a plain `pnpm install`
on Windows yields a node-pty without the job-object exports — and
`terminatePtyJob` then reports `unavailable` on every call, which is
indistinguishable from a correctly degraded build.
Packaging is unaffected: `rebuild-native-deps.mjs` rebuilds node-pty from source
for Electron and restores the ConPTY runtime files that a bare `node-gyp
rebuild` skips. The gap is the **node-runtime test environment**, which is why
the Windows CI job rebuilds from source before running the win32 suites.
`isPtyJobOwnershipAvailable()` exists for exactly this: the win32 suite asserts
it is true before asserting anything else, so an unpatched binary fails loudly
instead of passing every case vacuously. That guard is what caught this.
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
exempts win32, on the premise that the patch is Unix-only. That premise is now
false, but lifting the exemption also needs `pnpm rebuild` to force a source
build — otherwise the assertion fires and the remedy does not fix it. Left as a
follow-up rather than changed blind.
+51
View File
@@ -0,0 +1,51 @@
# Verifying the W1–W3 Windows/WSL work
Three layers of coverage, because each catches what the others structurally cannot.
## 1. Unit — runs everywhere, every PR
| Suite | Pins |
|---|---|
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
## 2. Ratchets — the goalposts, enforced continuously
| Guard | Measures |
|---|---|
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
Each fails on a **new** offender *and* on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
## 3. Real-binary — the assertions nothing else can make
**Windows CI** (`package (windows)` job in `pr.yml`) rebuilds node-pty from patched source and runs the `win32` suites against a real ConPTY: a real detached grandchild, a real job kill, and the inverse — a clean `exit` must leave backgrounded work alone.
**Real WSL distro** — not in CI; WSL isn't available on hosted runners.
```
ORCA_REAL_WSL_RUNNER_TEST=1 ORCA_WSL_TEST_DISTRO=Ubuntu-24.04 \
pnpm vitest run src/main/wsl/wsl-runner.wsl.test.ts
```
It appends `sleep 60` to the distro's `~/.profile` and asserts the probe lane still answers inside its budget — **#14288 reproduced, not simulated** — then restores the profile. Also covers banner stripping, a script carrying quotes and `$` arriving byte-identical, WSLENV crossing, and guest cwd.
Run this before shipping a change to `src/main/wsl/`. It is the only evidence that the probe lane does what the workstream claims, and it has already gone stale once against a runner change while passing in CI, because CI skips it.
## Known gaps in the windowsHide guard
Recorded rather than implied, because a guard that looks complete is worse than one with a documented edge.
- **`fork` is not scanned.** Node forwards `windowsHide` to spawn at runtime, but `ForkOptions` does not declare it, so the two live sites — `main/daemon/daemon-init.ts` and `main/plugins/plugin-host-process.ts` — cannot be fixed without a cast. Both are console-subsystem children on Windows.
- **The allowlist is file-granular, so an allowlisted file is blind.** ~18 of its entries are false positives (`RegExp.prototype.exec`, `provider.exec`, and files the lexer desynced on), and each carries a standing pre-approval for a real regression in that file. Those entries also cannot be retired by fixing code, so the list cannot reach zero as written. Making it call-granular is the fix.
- **`stripComments` has no desync report.** The fail-closed check runs on already-stripped text, so a regex literal containing a slash-star can still swallow code silently. No occurrence in `src/` today.
### Verifying a guard change
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that *reduced* the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
+3
View File
@@ -223,6 +223,9 @@ export const electronViteConfig: UserConfig = {
'session-scanner-service-entry': resolve(
'src/main/ai-vault/session-scanner-service-entry.ts'
),
'wsl-transcript-fs-process-entry': resolve(
'src/main/native-chat/wsl-transcript-fs-process-entry.ts'
),
// Why: libuv spawns processes inline on the calling loop, so the port
// scan's probe commands run on a worker thread instead of the UI one.
'port-scan-command-worker-entry': resolve(
+22 -39
View File
@@ -30,6 +30,13 @@ import {
} from '../../../src/transport/client-context'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
import {
applyWorktreeRowDisplayState,
clearConfirmedActiveWorktreeIdentity,
getWorktreeRowIdentity,
removeWorktreeRow,
retainLiveSleptWorktreeIdentities
} from '../../../src/worktree/worktree-host-row-identity'
import {
useLastConnectedAt,
useRelayRecoveryStatus,
@@ -158,7 +165,9 @@ export function HostScreen({
// path renders as a failure instead of an empty host. Cleared on the next success.
const [catalogError, setCatalogError] = useState<string | null>(null)
// Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll.
const [optimisticActiveWorktreeId, setOptimisticActiveWorktreeId] = useState<string | null>(null)
const [optimisticActiveWorktreeIdentity, setOptimisticActiveWorktreeIdentity] = useState<
string | null
>(null)
// One tick drives every visible agent row's relative timestamp.
const now = useNow(30_000)
const [repoColorsByName, setRepoColorsByName] = useState<Map<string, string>>(new Map())
@@ -472,26 +481,12 @@ export function HostScreen({
setCachedWorktrees(hostId, confirmed, { proven: true })
}
// Drop the optimistic active override once the host reports it active, so later desktop changes win.
setOptimisticActiveWorktreeId((pending) =>
pending && confirmed.some((w) => w.worktreeId === pending && w.isActive)
? null
: pending
setOptimisticActiveWorktreeIdentity((pending) =>
clearConfirmedActiveWorktreeIdentity(pending, confirmed)
)
// Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0).
setSleptIds((prev) => {
if (prev.size === 0) {
return prev
}
const still = new Set<string>()
for (const id of prev) {
const wt = confirmed.find((w) => w.worktreeId === id)
if (wt && wt.liveTerminalCount > 0) {
still.add(id)
}
}
return still.size === prev.size ? prev : still
})
setSleptIds((prev) => retainLiveSleptWorktreeIdentities(prev, confirmed))
// Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage.
const serverPinned = new Set(confirmed.filter((w) => w.isPinned).map((w) => w.worktreeId))
@@ -610,8 +605,7 @@ export function HostScreen({
return
}
const removeFromList = (list: Worktree[]) =>
list.filter((w) => w.worktreeId !== item.worktreeId)
const removeFromList = (list: Worktree[]) => removeWorktreeRow(list, item)
setWorktrees(removeFromList)
setLastKnownWorktrees(removeFromList)
@@ -667,7 +661,7 @@ export function HostScreen({
const openWorktreeSession = useCallback(
(item: Worktree) => {
setOptimisticActiveWorktreeId(item.worktreeId)
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
if (client && connState === 'connected') {
void client
.sendRequest('worktree.activate', {
@@ -746,21 +740,8 @@ export function HostScreen({
// Why: live `worktrees` is authoritative only while connected; under the amber
// mount default, connecting/handshaking must keep the pre-reconnect list too.
const base = connState === 'connected' ? worktrees : lastKnownWorktrees
if (sleptIds.size === 0 && optimisticActiveWorktreeId === null) {
return base
}
return base.map((w) => {
const slept = sleptIds.has(w.worktreeId)
? { liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const }
: null
// Force the just-opened worktree active until the next poll confirms it, so the highlight doesn't lag.
const active =
optimisticActiveWorktreeId !== null
? { isActive: w.worktreeId === optimisticActiveWorktreeId }
: null
return slept || active ? { ...w, ...slept, ...active } : w
})
}, [connState, worktrees, lastKnownWorktrees, sleptIds, optimisticActiveWorktreeId])
return applyWorktreeRowDisplayState(base, sleptIds, optimisticActiveWorktreeIdentity)
}, [connState, worktrees, lastKnownWorktrees, sleptIds, optimisticActiveWorktreeIdentity])
const toggleCollapsed = useCallback(
(key: string) => {
@@ -773,7 +754,7 @@ export function HostScreen({
[persistViewSettings]
)
const toggleWorktreeLineage = useCallback(
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item.worktreeId)),
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item)),
[toggleCollapsed]
)
const { sections, rawSections, uniqueRepos, uniqueRepoColors } = useWorkspaceSections({
@@ -1146,7 +1127,7 @@ export function HostScreen({
<SectionList
ref={sectionListRef}
sections={sections}
keyExtractor={(w) => w.sectionListKey ?? w.worktreeId}
keyExtractor={(w) => w.sectionListKey ?? getWorktreeRowIdentity(w)}
stickySectionHeadersEnabled={false}
// Why: keep the search IME up while tapping clear / scrolling results.
keyboardShouldPersistTaps="handled"
@@ -1355,7 +1336,9 @@ export function HostScreen({
icon: Moon,
onPress: () => {
if (client) {
setSleptIds((prev) => new Set(prev).add(actionTarget.worktreeId))
setSleptIds((prev) =>
new Set(prev).add(getWorktreeRowIdentity(actionTarget))
)
void client
.sendRequest('worktree.sleep', {
worktree: `id:${actionTarget.worktreeId}`
+1 -9
View File
@@ -21,6 +21,7 @@ import { PickerListDrawer } from './PickerListDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
import { useRetiredWorktreeNames } from '../worktree/use-retired-worktree-names'
import { repoColor } from '../worktree/repo-color'
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
import {
isSetupHookTrusted,
@@ -120,15 +121,6 @@ type CreateOptions = {
approvedSetupContentHash?: string
}
function repoColor(name: string): string {
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
let hash = 0
for (let i = 0; i < name.length; i += 1) {
hash = (hash * 31 + name.charCodeAt(i)) | 0
}
return palette[Math.abs(hash) % palette.length]!
}
function repoBadgeColor(repo: Repo | null): string {
return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository')
}
@@ -121,6 +121,25 @@ describe('buildMobileAiVaultResumeCommand', () => {
})
describe('buildMobileAiVaultResumeLaunch', () => {
it('routes Kimi through the resumable-agent startup plan', () => {
// Why: kimi joining RESUMABLE_TUI_AGENTS moves it off the plain command fallback, so the
// cd prefix Kimi needs (sessions are work-dir-scoped) must survive the new branch.
const launch = buildMobileAiVaultResumeLaunch({
session: session({
agent: 'kimi',
sessionId: 'session_431324d7-2165-42f0-9ecd-9f93437b3201'
}),
hostPlatform: 'darwin'
})
expect(launch).toMatchObject({
command:
"cd '/Users/ada/repo' && kimi '--yolo' '--session' 'session_431324d7-2165-42f0-9ecd-9f93437b3201'",
launchConfig: { agentCommand: "kimi '--yolo'" },
launchAgent: 'kimi'
})
})
it('preserves an arbitrary OMP transcript locator for later cold resume', () => {
const launch = buildMobileAiVaultResumeLaunch({
session: session({
@@ -16,7 +16,7 @@ type BulkCloseSheetDeps = {
}
/**
* Builds the long-press bulk-close entries (Close Others / Left / Right) shared
* Builds the long-press bulk-close entries (Close Others / Left) shared
* by every session tab sheet. Lives outside the session route to keep the
* orchestration out of its max-lines budget; anchors are passed by tab id so
* sheets never need the full tab object.
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { selectBulkCloseTabs } from './mobile-tab-close-selection'
import { BULK_TAB_CLOSE_ACTIONS, selectBulkCloseTabs } from './mobile-tab-close-selection'
const tab = (id: string, isDirty?: boolean, isPinned?: boolean) => ({
id,
@@ -10,6 +10,13 @@ const tab = (id: string, isDirty?: boolean, isPinned?: boolean) => ({
describe('selectBulkCloseTabs', () => {
const tabs = [tab('a'), tab('b'), tab('c'), tab('d')]
it('offers only close-others and close-left actions', () => {
expect(BULK_TAB_CLOSE_ACTIONS).toEqual([
{ mode: 'others', label: 'Close Other Tabs' },
{ mode: 'left', label: 'Close Tabs to the Left' }
])
})
it('selects every tab except the anchor for mode "others"', () => {
expect(selectBulkCloseTabs(tabs, 'b', 'others').map((t) => t.id)).toEqual(['a', 'c', 'd'])
})
@@ -18,13 +25,8 @@ describe('selectBulkCloseTabs', () => {
expect(selectBulkCloseTabs(tabs, 'c', 'left').map((t) => t.id)).toEqual(['a', 'b'])
})
it('selects tabs after the anchor for mode "right"', () => {
expect(selectBulkCloseTabs(tabs, 'b', 'right').map((t) => t.id)).toEqual(['c', 'd'])
})
it('returns empty when the anchor is at the edge', () => {
it('returns empty when the anchor is at the left edge', () => {
expect(selectBulkCloseTabs(tabs, 'a', 'left')).toEqual([])
expect(selectBulkCloseTabs(tabs, 'd', 'right')).toEqual([])
})
it('returns empty when the anchor is not in the list', () => {
@@ -1,10 +1,9 @@
export type BulkTabCloseMode = 'others' | 'left' | 'right'
export type BulkTabCloseMode = 'others' | 'left'
/** Long-press sheet entries, in display order. */
export const BULK_TAB_CLOSE_ACTIONS: { mode: BulkTabCloseMode; label: string }[] = [
{ mode: 'others', label: 'Close Other Tabs' },
{ mode: 'left', label: 'Close Tabs to the Left' },
{ mode: 'right', label: 'Close Tabs to the Right' }
{ mode: 'left', label: 'Close Tabs to the Left' }
]
type BulkClosableTab = {
@@ -15,7 +14,7 @@ type BulkClosableTab = {
/**
* Pick the tabs a long-press bulk close ("Close Other Tabs" / "Close Tabs to
* the Left/Right") should target, in strip order relative to the pressed tab.
* the Left") should target, in strip order relative to the pressed tab.
* Dirty documents are skipped — mobile has no save prompt on close, so bulk
* closing must never silently discard unsaved edits.
*/
@@ -31,8 +30,6 @@ export function selectBulkCloseTabs<T extends BulkClosableTab>(
const candidates =
mode === 'others'
? tabs.filter((_, index) => index !== anchorIndex)
: mode === 'left'
? tabs.slice(0, anchorIndex)
: tabs.slice(anchorIndex + 1)
: tabs.slice(0, anchorIndex)
return candidates.filter((tab) => tab.isDirty !== true && tab.isPinned !== true)
}
+472 -4
View File
@@ -1,24 +1,87 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { createWorktreeWithNameRetry } from './worktree-create-retry'
import type { ConnectionState } from '../transport/types'
import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../../src/shared/new-workspace/worktree-create-retry-policy'
import {
createWorktreeWithNameRetry,
WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS,
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
} from './worktree-create-retry'
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
type Attempt = { method: string; params: Record<string, unknown> }
// Drives the transport state a replay has to wait on. Production couples the two:
// a socket close rejects the pending frame AND drops the client off 'connected'.
function connectionController(): {
getState: () => ConnectionState
onStateChange: (listener: (state: ConnectionState) => void) => () => void
set: (next: ConnectionState) => void
} {
let state: ConnectionState = 'connected'
const listeners = new Set<(next: ConnectionState) => void>()
return {
getState: () => state,
onStateChange: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
set: (next) => {
state = next
for (const listener of listeners) {
listener(next)
}
}
}
}
// Lets a parked replay reach its state wait before the test resumes the transport.
async function flush(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
// A client whose per-call outcome is scripted: return an id, a server error
// message, or throw (transport-level rejection, e.g. a connection-migration
// cutover). Records every call so tests can assert on the clientMutationId.
function scriptedClient(
outcomes: Array<{ id: string } | { errorMessage: string } | { throws: unknown }>,
attempts: Attempt[]
outcomes: Array<
| { id: string }
| { errorMessage: string }
// takesMs models how long the ambiguity took to SURFACE — a clean close is
// instant, a half-open socket waits out the liveness watchdog or the timeout.
// reconnectsAfterMs brings the transport back so the next replay can run.
| { throws: unknown; dropsConnection?: boolean; takesMs?: number; reconnectsAfterMs?: number }
>,
attempts: Attempt[],
connection?: ReturnType<typeof connectionController>,
// Wall-clock stamp of the last frame that really arrived. Absent = a transport that
// can't vouch for one, which must fall back to the send; a function models a live
// socket whose stamp keeps advancing.
lastInboundAt?: number | (() => number)
): RpcClient {
let call = 0
return {
getState: () => connection?.getState() ?? 'connected',
getLastInboundAt: () =>
(typeof lastInboundAt === 'function' ? lastInboundAt() : lastInboundAt) ?? null,
onStateChange: (listener: (state: ConnectionState) => void) =>
connection?.onStateChange(listener) ?? (() => {}),
sendRequest: async (method: string, params?: unknown) => {
attempts.push({ method, params: (params ?? {}) as Record<string, unknown> })
const outcome = outcomes[Math.min(call, outcomes.length - 1)]!
call += 1
if ('throws' in outcome) {
if (outcome.takesMs !== undefined) {
await new Promise((resolve) => setTimeout(resolve, outcome.takesMs))
}
if (outcome.dropsConnection) {
connection?.set('reconnecting')
if (outcome.reconnectsAfterMs !== undefined) {
setTimeout(() => connection?.set('connected'), outcome.reconnectsAfterMs)
}
}
throw outcome.throws
}
if ('errorMessage' in outcome) {
@@ -117,6 +180,8 @@ describe('createWorktreeWithNameRetry', () => {
expect(attempts).toHaveLength(6)
})
// Also the delivery-ambiguity guard: an unmarked error is a DEFINITE failure, so
// neither the cutover nor the replay path may re-issue it.
it('does not treat an ordinary transport error as a cutover', async () => {
const attempts: Attempt[] = []
const client = scriptedClient([{ throws: new Error('Request timed out') }], attempts)
@@ -188,4 +253,407 @@ describe('createWorktreeWithNameRetry', () => {
expect(attempts).toHaveLength(1)
expect(attempts[0]!.params.clientMutationId).toBeUndefined()
})
it('replays a delivery-ambiguous socket drop with the SAME key once the transport returns', async () => {
const attempts: Attempt[] = []
const connection = connectionController()
const client = scriptedClient(
[
{
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true
},
{ id: 'wt-drop' }
],
attempts,
connection
)
const pending = createWorktreeWithNameRetry({
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
mintMutationId: () => 'key-drop'
})
await flush()
// Parked on the reconnect, not resent onto a dead socket.
expect(attempts).toHaveLength(1)
connection.set('connected')
await expect(pending).resolves.toEqual({ worktreeId: 'wt-drop', name: 'urchin' })
expect(attempts).toHaveLength(2)
// Idempotency: the host dedupes the replay against the create it may already
// have finished, instead of building a second worktree.
expect(attempts[1]!.params.clientMutationId).toBe('key-drop')
expect(attempts[1]!.params.name).toBe('urchin')
})
it('does not replay a delivery-ambiguous drop when the host lacks idempotency support', async () => {
const attempts: Attempt[] = []
const client = scriptedClient(
[{ throws: markRpcDeliveryUnknown(new Error('Connection interrupted')) }],
attempts
)
await expect(
createWorktreeWithNameRetry({
client,
baseName: 'limpet',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: false,
mintMutationId: () => 'must-not-be-used'
})
).rejects.toThrow('Connection interrupted')
expect(attempts).toHaveLength(1)
expect(attempts[0]!.params.clientMutationId).toBeUndefined()
})
it('gives up after the delivery-ambiguity replay budget, still inside the dedupe TTL', async () => {
vi.useFakeTimers()
try {
const attempts: Attempt[] = []
const connection = connectionController()
const client = scriptedClient(
[
{
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true,
reconnectsAfterMs: 1_000
}
],
attempts,
connection
)
const startedAt = Date.now()
let settledAt = -1
const pending = createWorktreeWithNameRetry({
client,
baseName: 'barnacle',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
mintMutationId: () => 'key-budget'
}).catch((error: unknown) => {
settledAt = Date.now()
throw error
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_MS)
await settled
// Initial attempt + 2 replays.
expect(attempts).toHaveLength(3)
// The budget is a count, but what keeps a replay reconciling instead of building
// a second worktree is wall clock: every attempt has to land while the host still
// holds the record, with the watchdog's detection latency already deducted.
expect(settledAt - startedAt).toBeLessThanOrEqual(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
} finally {
vi.useRealTimers()
}
// Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks,
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
it('surfaces the original ambiguity when the transport never comes back', async () => {
vi.useFakeTimers()
try {
const attempts: Attempt[] = []
const connection = connectionController()
const client = scriptedClient(
[
{
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true
}
],
attempts,
connection
)
const pending = createWorktreeWithNameRetry({
client,
baseName: 'anemone',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
mintMutationId: () => 'key-stuck'
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
// A phone that never reconnects must not leave the Create spinner parked.
await vi.advanceTimersByTimeAsync(60_000)
await settled
expect(attempts).toHaveLength(1)
} finally {
vi.useRealTimers()
}
// Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks,
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
it('does not replay an ambiguity that surfaced while the transport stayed connected', async () => {
vi.useFakeTimers()
try {
const attempts: Attempt[] = []
const connection = connectionController()
const client = scriptedClient(
[
{
// A silently dropped response frame leaves the socket alive, so nothing
// rejects until the request timeout — by then the host resolved at an
// unknowable point and its dedupe record is very likely gone.
throws: markRpcDeliveryUnknown(new Error('Request timed out: worktree.create')),
takesMs: WORKTREE_CREATE_TIMEOUT_MS
},
{ id: 'wt-duplicate' }
],
attempts,
connection,
// Watchdog probes keep answering throughout, so the replay window looks wide
// open on arrival: only the still-connected guard can stop this replay.
() => Date.now()
)
const pending = createWorktreeWithNameRetry({
client,
baseName: 'cuttlefish',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
mintMutationId: () => 'key-expired'
})
const settled = expect(pending).rejects.toThrow('Request timed out')
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_TIMEOUT_MS)
await settled
// Never left 'connected', so no drop was ever detected and the staleness of the
// ambiguity is unbounded. Re-sending would miss the record and build a SECOND
// worktree — for a folder workspace, one with the very same name.
expect(connection.getState()).toBe('connected')
expect(attempts).toHaveLength(1)
} finally {
vi.useRealTimers()
}
// Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks,
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
it('clamps a later reconnect wait to what is left of the replay window', async () => {
vi.useFakeTimers()
try {
const attempts: Attempt[] = []
const connection = connectionController()
const client = scriptedClient(
[
{
// Most of the window is already gone by the time the drop is reported, so the
// remainder — not the full wait — is what the second wait gets.
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true,
takesMs: 30_000,
reconnectsAfterMs: 1_000
},
{
// Second drop, and this time the phone never comes back.
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true
}
],
attempts,
connection
)
const startedAt = Date.now()
let settledAt = -1
const pending = createWorktreeWithNameRetry({
client,
baseName: 'nautilus',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
mintMutationId: () => 'key-clamped'
}).catch((error: unknown) => {
settledAt = Date.now()
throw error
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
await settled
expect(attempts).toHaveLength(2)
// The window is anchored at the FIRST ambiguity, so the second wait gets only the
// remainder — it must not restart a full wait and carry the replay past the record.
expect(settledAt - startedAt).toBe(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
} finally {
vi.useRealTimers()
}
// Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks,
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
it('refuses a replay when the ambiguity surfaced long after the last inbound frame', async () => {
vi.useFakeTimers()
try {
const attempts: Attempt[] = []
const connection = connectionController()
const startedAt = Date.now()
const client = scriptedClient(
[
{
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true,
reconnectsAfterMs: 1_000,
// The phone was backgrounded: the OS suspended JS and killed the socket, and
// nothing observed the drop until the app came back ten minutes later.
takesMs: 600_000
}
],
attempts,
connection,
startedAt + 2_000
)
const pending = createWorktreeWithNameRetry({
client,
baseName: 'limpet',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
mintMutationId: () => 'key-stale'
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
await vi.advanceTimersByTimeAsync(700_000)
await settled
// The host forgot this create ~9 minutes ago. A replay would not reconcile — it
// would build a second worktree — so the create fails instead.
expect(attempts).toHaveLength(1)
} finally {
vi.useRealTimers()
}
// Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks,
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
it('still replays a long-running create whose socket stayed live until it dropped', async () => {
vi.useFakeTimers()
try {
const attempts: Attempt[] = []
const connection = connectionController()
const startedAt = Date.now()
const client = scriptedClient(
[
{
// Three minutes of clone/fetch with the socket answering probes throughout,
// then it drops and the watchdog reports it four seconds later.
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true,
reconnectsAfterMs: 1_000,
takesMs: 184_000
},
{ id: 'wt-long' }
],
attempts,
connection,
startedAt + 180_000
)
const pending = createWorktreeWithNameRetry({
client,
baseName: 'kelp',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
mintMutationId: () => 'key-long'
})
await vi.advanceTimersByTimeAsync(250_000)
// Anchoring at the send would refuse this — the create outran the TTL long before it
// went ambiguous — but the record only starts ticking when the host RESOLVES, and the
// live socket proves our knowledge was current.
expect(await pending).toEqual({ worktreeId: 'wt-long', name: 'kelp' })
expect(attempts).toHaveLength(2)
expect(attempts[0]!.params.clientMutationId).toBe('key-long')
expect(attempts[1]!.params.clientMutationId).toBe('key-long')
} finally {
vi.useRealTimers()
}
// Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks,
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
it('does not extend the replay window when the replacement session reports fresher inbound activity', async () => {
vi.useFakeTimers()
try {
const attempts: Attempt[] = []
const connection = connectionController()
const startedAt = Date.now()
// The replacement session starts answering probes, so its inbound stamp is far
// newer than anything that bears on when the ORIGINAL create resolved host-side.
let lastInboundAt = startedAt + 1_000
setTimeout(() => {
lastInboundAt = startedAt + 45_000
}, 45_000)
const client = scriptedClient(
[
{
throws: markRpcDeliveryUnknown(new Error('Connection lost')),
dropsConnection: true,
takesMs: 40_000,
reconnectsAfterMs: 1_000
},
{
throws: markRpcDeliveryUnknown(new Error('Connection lost')),
dropsConnection: true,
takesMs: 20_000,
reconnectsAfterMs: 1_000
},
{ id: 'wt-third' }
],
attempts,
connection,
() => lastInboundAt
)
const pending = createWorktreeWithNameRetry({
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
mintMutationId: () => 'key-anchored'
})
const settled = expect(pending).rejects.toThrow('Connection lost')
await vi.advanceTimersByTimeAsync(120_000)
await settled
// The deadline is fixed at the FIRST ambiguity. Re-reading it here would buy another
// 50s of window off a stamp that says nothing about the original request.
expect(attempts).toHaveLength(2)
} finally {
vi.useRealTimers()
}
// Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks,
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
it('does not replay a dropped transport error that was never marked delivery-unknown', async () => {
const attempts: Attempt[] = []
const connection = connectionController()
const client = scriptedClient(
// Unmarked: the frame is known NOT to have reached the wire, so the host never saw
// it and a replay would be a second create, not a reconciliation. The transport
// still leaves 'connected', so only the delivery-unknown check stops this.
[{ throws: new Error('Socket closed before send'), dropsConnection: true }],
attempts,
connection
)
await expect(
createWorktreeWithNameRetry({
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
mintMutationId: () => 'key-unmarked'
})
).rejects.toThrow('Socket closed before send')
expect(attempts).toHaveLength(1)
})
it('keeps the replay window strictly inside the host dedupe TTL', () => {
// The window is measured from a lower bound on when the host could have resolved, so
// it has to leave the record room for the replay to still be in flight. Widening it
// to the whole TTL would let the last replay land on a record that just expired.
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeGreaterThan(0)
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_MS)
// A single wait must not be able to consume the window on its own.
expect(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS).toBeLessThan(
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
)
})
})
+90 -9
View File
@@ -1,11 +1,14 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { waitForRpcClientReconnected } from '../transport/rpc-client-reconnect-wait'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import {
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
getClientWorktreeCreateCandidate,
getGeneratedWorktreeCreateRetryCandidate,
isRetryableWorktreeCreateConflict
isRetryableWorktreeCreateConflict,
WORKTREE_CREATE_DEDUPE_TTL_MS
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
@@ -24,6 +27,29 @@ export type WorktreeCreateResult = { worktreeId: string; name: string } | { erro
// worktree silently created.
const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5
// Why: a connection migration is not the only way a create goes delivery-ambiguous.
// A plain socket close (cellular flap, relay drop, the phone backgrounding and the
// supervisor suspending a billed relay splice) rejects the in-flight frame as
// delivery-unknown with no generation bump, and create holds the longest budget of
// any mobile RPC — 10 minutes of clone/fetch/setup — so it is the likeliest request
// to be caught by one. Surfacing that as a failure is wrong: the host may well have
// finished the worktree. Replay on the same clientMutationId instead.
const WORKTREE_CREATE_AMBIGUOUS_MAX_RETRIES = 2
// Why: the host keeps a settled create's dedupe record for WORKTREE_CREATE_DEDUPE_TTL_MS
// after it resolves; a replay that lands later misses it and the host's suffix loop
// builds a SECOND worktree — and for folder workspaces a second one with the SAME name
// and no collision check at all. So a replay is bounded by how stale our knowledge of the
// host is, and that has to be measured in WALL CLOCK: iOS/Android suspend JS timers while
// the app is backgrounded, so any ceiling derived from timer intervals — a watchdog probe
// budget, a request timeout — can be arbitrarily wrong across a background cycle, which is
// exactly when a create sits ambiguous for minutes.
const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
export const WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS =
WORKTREE_CREATE_DEDUPE_TTL_MS - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS
// Bounded so the Create spinner doesn't sit for the whole window; the deadline still caps it.
export const WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS = 20_000
export type CreateWorktreeWithNameRetryArgs = {
client: RpcClient
baseName: string
@@ -78,32 +104,87 @@ export async function createWorktreeWithNameRetry(
return { error: lastError ?? 'Failed to create workspace' }
}
// Sends worktree.create, re-issuing on a connection-migration cutover only. The
// shared clientMutationId in `params` keeps the retry idempotent host-side.
// Sends worktree.create, re-issuing whenever the request went delivery-ambiguous —
// the frame reached the wire but no response came back, so the host may already have
// built the worktree. The shared clientMutationId in `params` keeps the retry
// idempotent host-side. A definite failure (never sent, or a server error response)
// is returned to the caller untouched.
async function sendWorktreeCreateResilient(
client: RpcClient,
params: Record<string, unknown>,
supportsIdempotentCutoverRetry: boolean
): Promise<RpcResponse> {
for (let migrationRetry = 0; ; migrationRetry += 1) {
let migrationRetry = 0
let ambiguousRetry = 0
const firstSentAt = Date.now()
let replayDeadlineAt: number | null = null
for (;;) {
try {
return await client.sendRequest('worktree.create', params, {
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
})
} catch (error) {
if (!supportsIdempotentCutoverRetry) {
throw error
}
if (isLogicalClientCutoverError(error)) {
if (migrationRetry >= WORKTREE_CREATE_CUTOVER_MAX_RETRIES) {
throw error
}
migrationRetry += 1
// Why: LogicalClientCutoverError is raised only after migrateTo installs an
// authenticated replacement, so retry immediately instead of adding UI lag.
continue
}
if (!isRpcDeliveryUnknown(error) || ambiguousRetry >= WORKTREE_CREATE_AMBIGUOUS_MAX_RETRIES) {
throw error
}
// Why: every transport path that reports a *drop* leaves 'connected' before the
// rejection reaches us (rpc-client.ts:675/695/1213 set state first or reject via
// queueMicrotask; the relay's fail() publishes synchronously). So still being
// 'connected' here means the socket was healthy the whole time and only the
// response went missing — the request-timeout path, which surfaces after
// WORKTREE_CREATE_TIMEOUT_MS. That says nothing about when the host actually
// resolved, so the dedupe record may be long gone and a replay would build a
// second worktree instead of reconciling. Fail the create instead.
if (client.getState() === 'connected') {
throw error
}
// Computed once: a later ambiguity reads a fresher lastInboundAt from the
// replacement session, which would push the deadline past the record it respects.
replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt)
const remainingWindowMs = replayDeadlineAt - Date.now()
if (remainingWindowMs <= 0) {
throw error
}
ambiguousRetry += 1
// Why: unlike a cutover, no replacement session exists yet — resending now
// would just hit the dead one, so wait for the transport to come back and
// surface the original ambiguity if it does not. Clamped to the window so the
// wait itself cannot carry the replay past the host's record.
if (
!supportsIdempotentCutoverRetry ||
!isLogicalClientCutoverError(error) ||
migrationRetry >= WORKTREE_CREATE_CUTOVER_MAX_RETRIES
!(await waitForRpcClientReconnected(
client,
Math.min(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS, remainingWindowMs)
))
) {
throw error
}
// Why: LogicalClientCutoverError is raised only after migrateTo installs an
// authenticated replacement, so retry immediately instead of adding UI lag.
}
}
}
// Latest instant the host's dedupe record is still guaranteed to exist, from the earliest
// point the create could have resolved. lastInboundAt stamps a frame that really arrived,
// so it stays honest across a suspension in a way a timer budget cannot; the send is the
// fallback, and a sound floor either way, because the host cannot resolve a create it has
// not yet received.
function resolveReplayDeadline(client: RpcClient, firstSentAt: number): number {
const lastInboundAt = client.getLastInboundAt?.() ?? null
const anchor = lastInboundAt !== null && lastInboundAt > firstSentAt ? lastInboundAt : firstSentAt
return anchor + WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
}
function defaultWorktreeCreateMutationId(): string {
const randomPart = Math.random().toString(36).slice(2, 10)
return `worktree-create:${Date.now().toString(36)}:${randomPart}`
@@ -111,6 +111,7 @@ export function connectMobileRelayRpcSession(args: {
getState: () => state,
getReconnectAttempt: () => 0,
getLastConnectedAt: () => lastConnectedAt,
getLastInboundAt: () => livenessWatchdog.getLastInboundAt() || null,
onStateChange(listener) {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from './rpc-client'
import type { ConnectionState } from './types'
import { waitForRpcClientReconnected } from './rpc-client-reconnect-wait'
// notifyDuringSubscribe models a client that fires the listener synchronously from
// onStateChange — the branch that has to survive `unsubscribe` not being assigned yet.
function fakeClient(
initial: ConnectionState,
options: { notifyDuringSubscribe?: ConnectionState } = {}
): { client: RpcClient; set: (next: ConnectionState) => void; listenerCount: () => number } {
let state = initial
const listeners = new Set<(next: ConnectionState) => void>()
const client = {
getState: () => state,
onStateChange: (listener: (next: ConnectionState) => void) => {
listeners.add(listener)
if (options.notifyDuringSubscribe) {
state = options.notifyDuringSubscribe
listener(state)
}
return () => listeners.delete(listener)
}
} as unknown as RpcClient
return {
client,
set: (next) => {
state = next
// Safe to delete during iteration: finish() unsubscribes the current listener.
for (const listener of listeners) {
listener(next)
}
},
listenerCount: () => listeners.size
}
}
describe('waitForRpcClientReconnected', () => {
it('resolves immediately when the transport is already connected', async () => {
const { client, listenerCount } = fakeClient('connected')
await expect(waitForRpcClientReconnected(client, 10_000)).resolves.toBe(true)
// No subscription at all on the fast path, so nothing to leak.
expect(listenerCount()).toBe(0)
})
it('resolves once the transport comes back, and unsubscribes', async () => {
const { client, set, listenerCount } = fakeClient('reconnecting')
const pending = waitForRpcClientReconnected(client, 10_000)
expect(listenerCount()).toBe(1)
set('connected')
await expect(pending).resolves.toBe(true)
expect(listenerCount()).toBe(0)
})
it('gives up at the timeout and unsubscribes', async () => {
vi.useFakeTimers()
try {
const { client, listenerCount } = fakeClient('reconnecting')
const pending = waitForRpcClientReconnected(client, 10_000)
await vi.advanceTimersByTimeAsync(10_000)
await expect(pending).resolves.toBe(false)
expect(listenerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
// Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks,
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
it('does not wait out the timeout when the pairing is already revoked', async () => {
const { client, listenerCount } = fakeClient('auth-failed')
await expect(waitForRpcClientReconnected(client, 10_000)).resolves.toBe(false)
expect(listenerCount()).toBe(0)
})
it('does not wait out the timeout when the pairing is revoked mid-wait', async () => {
vi.useFakeTimers()
try {
const { client, set, listenerCount } = fakeClient('reconnecting')
const pending = waitForRpcClientReconnected(client, 10_000)
set('auth-failed')
await expect(pending).resolves.toBe(false)
expect(listenerCount()).toBe(0)
// Nothing left armed that could fire after the caller moved on.
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
// Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks,
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
it('tears down cleanly when the state change fires synchronously during subscribe', async () => {
vi.useFakeTimers()
try {
const { client, listenerCount } = fakeClient('reconnecting', {
notifyDuringSubscribe: 'connected'
})
await expect(waitForRpcClientReconnected(client, 10_000)).resolves.toBe(true)
expect(listenerCount()).toBe(0)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
// Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks,
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
})
@@ -0,0 +1,55 @@
import type { RpcClient } from './rpc-client'
// Why: after a socket drop the direct client parks a send in waitForConnected, but a
// relay session rejects it outright ('relay session not connected') until the supervisor
// migrates a replacement in. A replay that fires the instant the drop lands therefore
// burns its budget on the dead session, so callers that mean to re-issue an idempotent
// request wait for the transport to come back first.
//
// Unlike waitForAuthenticated, intermediate 'disconnected'/'reconnecting' states are the
// expected path here, not a failure. Only a revoked pairing and the timeout end the wait
// unsuccessfully.
export function waitForRpcClientReconnected(
client: RpcClient,
timeoutMs: number
): Promise<boolean> {
const state = client.getState()
if (state === 'connected') {
return Promise.resolve(true)
}
if (state === 'auth-failed') {
return Promise.resolve(false)
}
return new Promise((resolve) => {
let settled = false
let unsubscribe: (() => void) | null = null
// Armed before subscribing so a synchronous notification during registration
// still finds a timer to clear.
const timer = setTimeout(() => finish(false), timeoutMs)
unsubscribe = client.onStateChange((state) => {
if (state === 'connected') {
finish(true)
} else if (state === 'auth-failed') {
// Revoked pairing never reaches 'connected', so waiting out the timeout only
// delays the error the caller is going to surface anyway.
finish(false)
}
})
if (settled) {
// The notification fired inside onStateChange, before we held the handle.
unsubscribe()
unsubscribe = null
}
function finish(reconnected: boolean): void {
if (settled) {
return
}
settled = true
clearTimeout(timer)
unsubscribe?.()
unsubscribe = null
resolve(reconnected)
}
})
}
+8
View File
@@ -111,6 +111,10 @@ export type RpcClient = {
getReconnectAttempt: () => number
// Last 'connected' timestamp (ms epoch); null = never connected. Lets the UI tell "never reachable" from "transient blip".
getLastConnectedAt: () => number | null
// Wall-clock stamp of the last inbound frame on the current session, or null when the
// transport can't vouch for one. Optional so older/foreign RpcClient shapes stay valid;
// callers must treat absent/null as "unknown" and fall back to a safe bound.
getLastInboundAt?: () => number | null
onStateChange: (listener: (state: ConnectionState) => void) => () => void
// Why: app-resume hook — iOS/Android can kill the TCP path while backgrounded; call on AppState 'active' to recover.
// The reason routes relay handling (probe vs replace); the direct socket probes regardless.
@@ -1150,6 +1154,10 @@ export function connect(
return lastConnectedAt
},
getLastInboundAt(): number | null {
return livenessWatchdog.getLastInboundAt() || null
},
onStateChange(listener: (state: ConnectionState) => void): () => void {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
@@ -52,6 +52,13 @@ export class RpcSessionLivenessWatchdog {
this.armIdle(identity)
}
// Wall-clock stamp of the last frame that actually arrived; 0 before the first
// start(). Unlike a timer deadline this survives a JS suspension, so callers can
// tell how stale their knowledge of the peer really is.
getLastInboundAt(): number {
return this.lastInboundAt
}
noteAuthenticatedInbound(identity: RpcSessionIdentity): void {
if (this.identity !== identity) {
return
@@ -160,6 +160,7 @@ export function createStableLogicalRpcClient(
getState: () => state,
getReconnectAttempt: () => connectionPath.reconnectAttempt(activeSession.getReconnectAttempt()),
getLastConnectedAt: () => activeSession.getLastConnectedAt(),
getLastInboundAt: () => activeSession.getLastInboundAt?.() ?? null,
onStateChange(listener) {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { applyMobileWorkspaceLineage } from './mobile-workspace-lineage'
import {
applyMobileWorkspaceLineage,
getMobileWorkspaceLineageGroupKey
} from './mobile-workspace-lineage'
import type { Worktree } from './workspace-list-sections'
function worktree(overrides: Partial<Worktree> = {}): Worktree {
@@ -61,7 +64,10 @@ describe('applyMobileWorkspaceLineage', () => {
const parent = worktree({ worktreeId: 'parent' })
const child = worktree({ worktreeId: 'child', parentWorktreeId: 'parent' })
const rows = applyMobileWorkspaceLineage([child, parent], new Set(['workspace-lineage:parent']))
const rows = applyMobileWorkspaceLineage(
[child, parent],
new Set([getMobileWorkspaceLineageGroupKey(parent)])
)
expect(rows.map((row) => row.worktreeId)).toEqual(['parent'])
expect(rows[0]?.lineageChildCount).toBe(1)
@@ -101,4 +107,13 @@ describe('applyMobileWorkspaceLineage', () => {
expect(rows.map((row) => row.worktreeId).sort()).toEqual(['first', 'second'])
})
it('keeps same-id rows on different hosts visible', () => {
const local = worktree({ worktreeId: 'shared', hostId: 'local' })
const remote = worktree({ worktreeId: 'shared', hostId: 'ssh:builder' })
const rows = applyMobileWorkspaceLineage([local, remote])
expect(rows.map((row) => row.hostId)).toEqual(['local', 'ssh:builder'])
})
})
+31 -21
View File
@@ -1,7 +1,10 @@
import type { Worktree } from './workspace-list-types'
import { getWorktreeRowIdentity } from './worktree-host-row-identity'
export function getMobileWorkspaceLineageGroupKey(worktreeId: string): string {
return `workspace-lineage:${encodeURIComponent(worktreeId)}`
type WorktreeLineageIdentitySource = Pick<Worktree, 'worktreeId' | 'hostId'>
export function getMobileWorkspaceLineageGroupKey(worktree: WorktreeLineageIdentitySource): string {
return `workspace-lineage:${encodeURIComponent(getWorktreeRowIdentity(worktree))}`
}
function hasValidLineageParent(worktree: Worktree, parent: Worktree): boolean {
@@ -23,48 +26,55 @@ export function applyMobileWorkspaceLineage(
worktrees: readonly Worktree[],
collapsedGroups: ReadonlySet<string> = new Set()
): Worktree[] {
const visibleIds = new Set(worktrees.map((worktree) => worktree.worktreeId))
const worktreeById = new Map(worktrees.map((worktree) => [worktree.worktreeId, worktree]))
const visibleIds = new Set(worktrees.map((worktree) => getWorktreeRowIdentity(worktree)))
const worktreeById = new Map(
worktrees.map((worktree) => [getWorktreeRowIdentity(worktree), worktree])
)
const childrenByParentId = new Map<string, Worktree[]>()
const childIds = new Set<string>()
for (const worktree of worktrees) {
const worktreeId = getWorktreeRowIdentity(worktree)
const parentId = worktree.parentWorktreeId
const parent = parentId ? worktreeById.get(parentId) : undefined
const parentIdentity = parentId
? getWorktreeRowIdentity({ worktreeId: parentId, hostId: worktree.hostId })
: null
const parent = parentIdentity ? worktreeById.get(parentIdentity) : undefined
if (
!parentId ||
parentId === worktree.worktreeId ||
!visibleIds.has(parentId) ||
!parentIdentity ||
parentIdentity === worktreeId ||
!visibleIds.has(parentIdentity) ||
!parent ||
!hasValidLineageParent(worktree, parent)
) {
continue
}
childIds.add(worktree.worktreeId)
const children = childrenByParentId.get(parentId) ?? []
childIds.add(worktreeId)
const children = childrenByParentId.get(parentIdentity) ?? []
children.push(worktree)
childrenByParentId.set(parentId, children)
childrenByParentId.set(parentIdentity, children)
}
const result: Worktree[] = []
const emitted = new Set<string>()
const markDescendantsEmitted = (worktree: Worktree): void => {
for (const child of childrenByParentId.get(worktree.worktreeId) ?? []) {
if (!emitted.has(child.worktreeId)) {
emitted.add(child.worktreeId)
for (const child of childrenByParentId.get(getWorktreeRowIdentity(worktree)) ?? []) {
const childId = getWorktreeRowIdentity(child)
if (!emitted.has(childId)) {
emitted.add(childId)
markDescendantsEmitted(child)
}
}
}
const emit = (worktree: Worktree, depth: number, isLastChild: boolean): void => {
if (emitted.has(worktree.worktreeId)) {
const worktreeId = getWorktreeRowIdentity(worktree)
if (emitted.has(worktreeId)) {
return
}
const children = childrenByParentId.get(worktree.worktreeId) ?? []
const children = childrenByParentId.get(worktreeId) ?? []
const lineageCollapsed =
children.length > 0 &&
collapsedGroups.has(getMobileWorkspaceLineageGroupKey(worktree.worktreeId))
emitted.add(worktree.worktreeId)
children.length > 0 && collapsedGroups.has(getMobileWorkspaceLineageGroupKey(worktree))
emitted.add(worktreeId)
result.push({
...worktree,
lineageDepth: depth,
@@ -81,13 +91,13 @@ export function applyMobileWorkspaceLineage(
})
}
const roots = worktrees.filter((worktree) => !childIds.has(worktree.worktreeId))
const roots = worktrees.filter((worktree) => !childIds.has(getWorktreeRowIdentity(worktree)))
roots.forEach((worktree, index) => {
emit(worktree, 0, index === roots.length - 1)
})
for (const worktree of worktrees) {
if (!emitted.has(worktree.worktreeId)) {
if (!emitted.has(getWorktreeRowIdentity(worktree))) {
// Why: malformed cyclic lineage should not hide every participant.
emit(worktree, 0, true)
}
@@ -9,6 +9,7 @@ import {
sortWorktrees
} from './workspace-list-sections'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses'
import { getMobileWorkspaceLineageGroupKey } from './mobile-workspace-lineage'
function worktree(overrides: Partial<Worktree> = {}): Worktree {
const worktreePath = join('/tmp', 'orca', 'worktrees', 'feature')
@@ -240,6 +241,24 @@ describe('getWorktreeStatus', () => {
})
describe('buildSections', () => {
it('keys same-id rows by host inside a section', () => {
const worktreeId = 'repo-1::/work/orca'
const sections = buildSections(
[worktree({ worktreeId, hostId: 'local' }), worktree({ worktreeId, hostId: 'ssh:builder' })],
'name',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'none',
new Set()
)
const keys = sections[0]?.data.map((item) => item.sectionListKey) ?? []
expect(new Set(keys).size).toBe(2)
expect(keys).toEqual(
expect.arrayContaining([`all:local|${worktreeId}`, `all:ssh:builder|${worktreeId}`])
)
})
it('matches desktop Name sort by display name', () => {
const beta = worktree({ worktreeId: 'beta', displayName: 'Beta', repo: 'aaa' })
const alpha = worktree({ worktreeId: 'alpha', displayName: 'Alpha', repo: 'zzz' })
@@ -776,7 +795,7 @@ describe('buildSections', () => {
new Set(),
new Map(),
DEFAULT_MOBILE_WORKSPACE_STATUSES,
new Set(['workspace-lineage:parent'])
new Set([getMobileWorkspaceLineageGroupKey(parent)])
)
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['parent'])
@@ -10,6 +10,7 @@ import { getPRGroupKey, PR_GROUP_LABELS, PR_GROUP_ORDER } from './workspace-pr-s
import type { FilterState, Section, Worktree } from './workspace-list-types'
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
import { sortWorktrees } from './workspace-list-ordering'
import { getWorktreeRowIdentity } from './worktree-host-row-identity'
export type { FilterState, Section, Worktree } from './workspace-list-types'
export { CREATE_GRACE_MS, getWorktreeStatus, sortWorktrees } from './workspace-list-ordering'
@@ -28,7 +29,7 @@ function makeSection(
...(icon ? { icon } : {}),
data: rows.map((worktree) => ({
...worktree,
sectionListKey: `${key}:${worktree.worktreeId}`
sectionListKey: `${key}:${getWorktreeRowIdentity(worktree)}`
}))
}
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import {
applyWorktreeRowDisplayState,
clearConfirmedActiveWorktreeIdentity,
getWorktreeRowIdentity,
removeWorktreeRow,
retainLiveSleptWorktreeIdentities
} from './worktree-host-row-identity'
import type { Worktree } from './workspace-list-types'
function row(worktreeId: string, hostId: string, overrides: Partial<Worktree> = {}): Worktree {
return { worktreeId, hostId, displayName: worktreeId, ...overrides } as Worktree
}
describe('removeWorktreeRow', () => {
// Deleting on one host used to clear the other host's identically-named row from the list.
it('keeps a same-id workspace that lives on another host', () => {
const local = row('shared', 'host-a')
const remote = row('shared', 'host-b')
expect(removeWorktreeRow([local, remote], local)).toEqual([remote])
})
it('removes the matching row', () => {
const only = row('shared', 'host-a')
expect(removeWorktreeRow([only], only)).toEqual([])
})
})
describe('getWorktreeRowIdentity', () => {
it('uses the shared host-qualified identity format', () => {
expect(getWorktreeRowIdentity(row('shared', 'ssh:builder'))).toBe('ssh:builder|shared')
})
})
describe('host-qualified row display state', () => {
it('clears the optimistic active row only when the same host confirms it', () => {
const pending = getWorktreeRowIdentity(row('shared', 'host-a'))
expect(
clearConfirmedActiveWorktreeIdentity(pending, [row('shared', 'host-b', { isActive: true })])
).toBe(pending)
expect(
clearConfirmedActiveWorktreeIdentity(pending, [row('shared', 'host-a', { isActive: true })])
).toBeNull()
})
it('retains slept rows only while the same host still has live terminals', () => {
const local = row('shared', 'host-a')
const remote = row('shared', 'host-b')
const retained = retainLiveSleptWorktreeIdentities(
new Set([getWorktreeRowIdentity(local), getWorktreeRowIdentity(remote)]),
[
row('shared', 'host-a', { liveTerminalCount: 0 }),
row('shared', 'host-b', { liveTerminalCount: 1 })
]
)
expect([...retained]).toEqual([getWorktreeRowIdentity(remote)])
})
it('applies active and slept overrides to the matching host row only', () => {
const local = row('shared', 'host-a')
const remote = row('shared', 'host-b')
const rows = applyWorktreeRowDisplayState(
[local, remote],
new Set([getWorktreeRowIdentity(local)]),
getWorktreeRowIdentity(remote)
)
expect(rows).toMatchObject([
{ hostId: 'host-a', liveTerminalCount: 0, status: 'inactive', isActive: false },
{ hostId: 'host-b', isActive: true }
])
})
})
@@ -0,0 +1,69 @@
import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity'
import type { Worktree } from './workspace-list-types'
type WorktreeRowIdentitySource = Pick<Worktree, 'worktreeId' | 'hostId'>
export function getWorktreeRowIdentity(row: WorktreeRowIdentitySource): string {
return composeWorktreeHostIdentity(row.hostId, row.worktreeId)
}
export function isSameWorktreeRow(
a: WorktreeRowIdentitySource,
b: WorktreeRowIdentitySource
): boolean {
return getWorktreeRowIdentity(a) === getWorktreeRowIdentity(b)
}
/** Drops only the removed row, leaving a same-id workspace on another host visible. */
export function removeWorktreeRow(
list: readonly Worktree[],
removed: WorktreeRowIdentitySource
): Worktree[] {
return list.filter((entry) => !isSameWorktreeRow(entry, removed))
}
export function clearConfirmedActiveWorktreeIdentity(
pending: string | null,
confirmed: readonly Worktree[]
): string | null {
return pending && confirmed.some((w) => getWorktreeRowIdentity(w) === pending && w.isActive)
? null
: pending
}
export function retainLiveSleptWorktreeIdentities(
previous: Set<string>,
confirmed: readonly Worktree[]
): Set<string> {
if (previous.size === 0) {
return previous
}
const still = new Set<string>()
for (const id of previous) {
const wt = confirmed.find((w) => getWorktreeRowIdentity(w) === id)
if (wt && wt.liveTerminalCount > 0) {
still.add(id)
}
}
return still.size === previous.size ? previous : still
}
export function applyWorktreeRowDisplayState(
base: Worktree[],
sleptIds: ReadonlySet<string>,
optimisticActiveIdentity: string | null
): Worktree[] {
if (sleptIds.size === 0 && optimisticActiveIdentity === null) {
return base
}
return base.map((w) => {
const slept = sleptIds.has(getWorktreeRowIdentity(w))
? { liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const }
: null
const active =
optimisticActiveIdentity !== null
? { isActive: getWorktreeRowIdentity(w) === optimisticActiveIdentity }
: null
return slept || active ? { ...w, ...slept, ...active } : w
})
}
+12 -3
View File
@@ -11,7 +11,7 @@
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
@@ -29,6 +29,10 @@
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
"build:orcad": "node config/scripts/build-orcad.mjs",
"smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad",
"smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
"generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write",
"verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check",
@@ -54,7 +58,7 @@
"dev": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs",
"dev-stable-name": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name",
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
"build:relay": "node config/scripts/build-relay.mjs",
"build:relay": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/build-relay.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
"build:keyboard-layout-macos": "node config/scripts/build-keyboard-layout-macos.mjs",
"build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs",
@@ -96,6 +100,7 @@
"test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:agent-tui-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-agent-tui-launch.spec.ts tests/e2e/golden-shell-after-agent-exit.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:tab-bar-agent-launch-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-tab-bar-agent-launch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
"test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
"test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs",
@@ -110,6 +115,7 @@
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"win-update-e2e": "node tests/tools/win-update-e2e/run.mjs",
"win-crash-survival-e2e": "node tests/tools/win-crash-survival-e2e/run.mjs",
"smoke:windows-pty-native-capability": "node tests/tools/windows-pty-native-capability-smoke/run.mjs",
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
@@ -263,6 +269,7 @@
"zustand": "^5.0.14"
},
"optionalDependencies": {
"@vscode/windows-process-tree": "0.8.0",
"sherpa-onnx-darwin-arm64": "1.12.37",
"sherpa-onnx-darwin-x64": "1.12.37",
"sherpa-onnx-linux-arm64": "1.12.37",
@@ -313,7 +320,9 @@
"@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch",
"@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch",
"@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch",
"@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch"
"@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch",
"lint-staged@16.4.0": "config/patches/lint-staged@16.4.0.patch",
"@vscode/windows-process-tree@0.8.0": "config/patches/@vscode__windows-process-tree@0.8.0.patch"
}
},
"reactDoctor": {
+29 -5
View File
@@ -8,6 +8,9 @@ overrides:
monaco-editor>dompurify: 3.4.13
patchedDependencies:
'@vscode/windows-process-tree@0.8.0':
hash: 7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05
path: config/patches/@vscode__windows-process-tree@0.8.0.patch
'@xterm/addon-ligatures@0.11.0-beta.287':
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
@@ -20,8 +23,11 @@ patchedDependencies:
'@xterm/xterm@6.1.0-beta.287':
hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
lint-staged@16.4.0:
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
path: config/patches/lint-staged@16.4.0.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
hash: 710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -63,7 +69,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8)
version: 1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -274,7 +280,7 @@ importers:
version: 0.16.45
lint-staged:
specifier: ^16.4.0
version: 16.4.0
version: 16.4.0(patch_hash=7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673)
lowlight:
specifier: ^3.3.0
version: 3.3.0
@@ -399,6 +405,9 @@ importers:
specifier: ^5.0.14
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
optionalDependencies:
'@vscode/windows-process-tree':
specifier: 0.8.0
version: 0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05)
sherpa-onnx-darwin-arm64:
specifier: 1.12.37
version: 1.12.37
@@ -3375,6 +3384,9 @@ packages:
'@vitest/utils@4.1.5':
resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
'@vscode/windows-process-tree@0.8.0':
resolution: {integrity: sha512-TI+h2GRwX+igD/YYJMQQVAFcsCNSg7Te2yYxQpKMzwto5RsJ8d2KKgOeur/p/6sAOQwZvopiTDOClyTHEn9MhQ==}
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
@@ -5423,6 +5435,10 @@ packages:
node-addon-api@4.3.0:
resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==}
node-addon-api@7.1.0:
resolution: {integrity: sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==}
engines: {node: ^16 || ^18 || >= 20}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
@@ -9589,6 +9605,11 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@vscode/windows-process-tree@0.8.0(patch_hash=7c08bebce9b36829be6035218db2383f1a889c21ec907ea7e85c36fc54795a05)':
dependencies:
node-addon-api: 7.1.0
optional: true
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
@@ -11374,7 +11395,7 @@ snapshots:
linkifyjs@4.3.2: {}
lint-staged@16.4.0:
lint-staged@16.4.0(patch_hash=7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673):
dependencies:
commander: 14.0.3
listr2: 9.0.5
@@ -12001,6 +12022,9 @@ snapshots:
node-addon-api@4.3.0:
optional: true
node-addon-api@7.1.0:
optional: true
node-addon-api@7.1.1: {}
node-api-version@0.2.1:
@@ -12022,7 +12046,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8):
node-pty@1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5):
dependencies:
node-addon-api: 7.1.1

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