mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" \
|
||||
|
||||
@@ -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 != '' }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" />
|
||||
<img src="docs/assets/wechat-qr-group8.jpg" alt="WeChat group 8 QR code for the Orca community" width="160" />
|
||||
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+1221
-49
File diff suppressed because it is too large
Load Diff
@@ -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/).
|
||||
|
||||
@@ -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.`
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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.'])
|
||||
})
|
||||
})
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/**/*",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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" />
|
||||
<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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+29
-5
@@ -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
Reference in New Issue
Block a user