mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Merge branch 'brennanb2025/structured-remove-chat-button' into brennanb2025/structured-tui-adoption
This commit is contained in:
@@ -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."
|
||||
@@ -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 }}
|
||||
|
||||
+121
-7
@@ -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,6 +312,8 @@ 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
|
||||
@@ -359,21 +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
|
||||
@@ -403,16 +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/** \
|
||||
@@ -420,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:
|
||||
@@ -447,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:
|
||||
@@ -470,6 +531,8 @@ jobs:
|
||||
|
||||
package:
|
||||
name: package
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -531,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
|
||||
|
||||
@@ -564,11 +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
|
||||
@@ -596,8 +680,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
|
||||
@@ -677,6 +762,7 @@ jobs:
|
||||
verify:
|
||||
if: always()
|
||||
needs:
|
||||
- code_paths
|
||||
- static_analysis
|
||||
- root_directory_guard
|
||||
- typecheck
|
||||
@@ -700,6 +786,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 }}
|
||||
@@ -711,7 +799,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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -304,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'),
|
||||
|
||||
@@ -32,7 +32,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;
|
||||
};
|
||||
|
||||
|
||||
+517
-19
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-18",
|
||||
"updatedAt": "2026-08-20",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -10,6 +10,306 @@
|
||||
}
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "cmd-j-tabs.host-qualified-candidate-ownership",
|
||||
"title": "Cmd-J tab candidates retain execution-host ownership",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "renderer-workspace-tabs",
|
||||
"layer": "renderer-state-and-navigation",
|
||||
"surfaces": [
|
||||
"Cmd-J browser tabs",
|
||||
"Cmd-J simulator tabs",
|
||||
"Cmd-J workspace tabs",
|
||||
"paired-runtime tab mirror"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh", "remote-runtime"],
|
||||
"coverageNotes": "Deterministic candidate-builder contracts inject local and paired-runtime worktrees with the same bare id into the real aggregated tab buckets, including folder workspaces and a paired runtime transporting an SSH-owned worktree. A mirror contract preserves distinct same-id runtime tabs. Manual local-emulator contracts retain local ownership and dedupe explicitly owned tabs before worktree hydration. A paired Electron journey verifies both visible browser rows and both visible simulator rows, then requires each selection to render its owning active tab and update backing host-qualified state.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4965",
|
||||
"https://github.com/stablyai/orca/pull/15371"
|
||||
],
|
||||
"invariant": "Candidate identity is the tuple of execution-host identity and worktree identity: same-id sibling hosts retain separate correct browser, simulator, and workspace tabs, each tab appears exactly once, and selection activates its owning host.",
|
||||
"oracle": "Aggregate two hosts' tabs under one intentionally colliding worktree id, stamp each unified tab with its execution host, and require exactly one candidate per owner. Repeat with an SSH physical owner transported by a paired-runtime alias. Independently require each browser and simulator Cmd-J selection to update activeWorkspaceExecutionHostId plus its owning workspace/group and render the selected tab active. Ambiguous legacy tabs without ownership fail closed.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/WorktreeJumpPalette.test.tsx",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/lib/open-mobile-emulator-tab.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts",
|
||||
"src/renderer/src/components/WorktreeJumpPalette.test.tsx",
|
||||
"src/renderer/src/components/tab-bar/use-open-tab-search.test.ts",
|
||||
"src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts",
|
||||
"src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"src/renderer/src/lib/browser-palette-page-entries.test.ts",
|
||||
"src/renderer/src/lib/browser-palette-search.test.ts",
|
||||
"src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"src/renderer/src/lib/workspace-tab-palette-search.test.ts",
|
||||
"src/renderer/src/lib/browser-page-palette-activation.test.ts",
|
||||
"src/renderer/src/lib/simulator-tab-palette-activation.test.ts",
|
||||
"src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts",
|
||||
"src/renderer/src/lib/open-mobile-emulator-tab.test.ts",
|
||||
"src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts",
|
||||
"src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts",
|
||||
"src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts",
|
||||
"src/shared/workspace-session-schema.test.ts",
|
||||
"tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts",
|
||||
"assertions": [
|
||||
"a runtime-owned SSH worktree never exposes review metadata cached for its physical host"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/WorktreeJumpPalette.test.tsx",
|
||||
"assertions": [
|
||||
"a runtime-owned worktree row never renders repository metadata borrowed from its physical SSH host"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts",
|
||||
"assertions": [
|
||||
"browser, simulator, and workspace candidates recognize a paired-runtime alias for an SSH-owned worktree",
|
||||
"paired-runtime aliases retain current-row state and unverifiable explicit legacy owners fail closed",
|
||||
"host filtering and scoped open-tab search cannot hide a same-id sibling from the legacy ownership ambiguity set"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts",
|
||||
"assertions": [
|
||||
"active local tab creation stamps local ownership instead of leaving a same-id row ambiguous"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"assertions": [
|
||||
"paired SSH rows resolve display metadata through their runtime owner alias",
|
||||
"paired SSH worktrees resolve their runtime-owned repository metadata",
|
||||
"an unavailable explicit runtime owner never borrows physical-host or bare repository metadata",
|
||||
"an explicit unknown owner never borrows another host's metadata"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts",
|
||||
"assertions": [
|
||||
"same-id folder simulator reuse selects the active owning host",
|
||||
"inactive local simulator creation remains host-stamped amid a collision",
|
||||
"an explicitly owned simulator is reused before its matching worktree row hydrates"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/open-mobile-emulator-tab.test.ts",
|
||||
"assertions": ["manual emulator launch lookup and creation stay on the local provider"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/browser-palette-page-entries.test.ts",
|
||||
"assertions": [
|
||||
"same-id local and remote browser tabs appear once under their owning hosts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"assertions": [
|
||||
"same-id local and remote simulator tabs appear once under their owning hosts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts",
|
||||
"assertions": [
|
||||
"both browser selections activate and render their owning host and workspace",
|
||||
"both simulator selections activate and render their owning host and group"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 1.8,
|
||||
"summary": "The first review-round oracle failed 4/40: browser and simulator dropped the SSH worktree's paired-runtime-owned tab, generic workspace rows attributed the remote tab to local, and active local tab creation left ownership unstamped."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.5,
|
||||
"summary": "The byte-identical oracle on affected baseline 012e9f410c failed 4 tests and passed 24: each same-id browser and simulator tab was routed through both hosts, and ambiguous unstamped rows were duplicated instead of failing closed."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.3,
|
||||
"summary": "6 test files passed, 104 tests passed on the STA-4965 candidate."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 12.7,
|
||||
"summary": "The paired Electron journey passed real host browser publication, same-id collision injection, separate browser/simulator Cmd-J rows, and selected-host backing-state assertions."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 24.1,
|
||||
"summary": "A fresh electron-vite --mode e2e build passed the paired journey: the visible Cmd-J list retained one owning browser/simulator row and each selection changed the execution host plus its owning workspace or group."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.5,
|
||||
"summary": "The same oracle on fe45b73c20 with only browser-palette-page-entries.ts and simulator-palette-search.ts restored from origin/main failed the same 4 tests and passed 24; restored candidate files passed all 28."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 10.5,
|
||||
"summary": "The review-hardened candidate passed 189/189 focused contracts, including direct browser-result host fallback, SSH plus paired-runtime current-row aliases, explicit-owner fail-closed behavior, generic workspace rows, simulator reuse, local creation stamping, activation, and same-id distinct-runtime mirror preservation."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.8,
|
||||
"summary": "The final-review oracle failed 4/16 before the follow-up: same-id folder simulator reuse selected no owning tab, inactive local simulator creation was unstamped, the paired SSH/runtime alias resolved local metadata, and an explicit unknown owner fell back locally."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 24.3,
|
||||
"summary": "The OpenCode-review-hardened candidate passed 225/225 focused contracts, including unfiltered ownership ambiguity under host filters and scoped open-tab search, review-cache ownership, rendered palette repo metadata, folder workspaces, inactive local simulator creation, paired SSH/runtime display metadata, and explicit-owner fail-closed resolution."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.1,
|
||||
"summary": "The final reviewer repo-label oracle failed 1/6 before the follow-up: a paired SSH worktree resolved the local bare-id repository instead of its runtime-owned repository."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/palette-repo-resolution.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.1,
|
||||
"summary": "The final clean-round fail-closed oracle failed 1/7 before the follow-up: an unavailable explicit runtime owner borrowed physical SSH repository metadata."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/WorktreeJumpPalette.test.tsx",
|
||||
"result": "failed",
|
||||
"durationSeconds": 5.1,
|
||||
"summary": "With the component's physical-host prelookup restored, the rendered palette oracle failed 1/12: the runtime-owned row displayed the physical SSH repository badge."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.2,
|
||||
"summary": "The final review-cache oracle failed 1/6: a runtime-owned SSH worktree exposed the physical host's cached pull request."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 0.4,
|
||||
"summary": "The exact-head clean-round oracle failed 1/6: after the host-qualified index rejected a physical SSH review, the palette document still surfaced its unqualified legacy cache entry for the runtime-owned worktree."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 36.7,
|
||||
"summary": "A fresh e2e-mode Electron build passed the paired journey: each remote/local browser and simulator candidate appeared exactly once, selected the owning host plus workspace or group tab, and rendered that tab active. The group active-tab oracle remains stable while asynchronous paired session mirroring updates the legacy bare-worktree activeTabType field."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 20.6,
|
||||
"summary": "After a fresh build exposed an unrelated mirrored terminal entering the broad backing-state diagnostic, the exact built candidate passed with that oracle scoped to the four seeded browser/simulator tabs; rendered active-tab and backing execution-host assertions remained green."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 180,
|
||||
"scope": "focused deterministic contracts plus one paired Electron journey"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The candidate contract has no timers. Initial Electron attempts exposed seeded-worktree discovery and programmatic keyboard-selection harness races before the ownership assertions; soak history is not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Byte-identical browser and simulator candidate tests failed 4/28 on affected baseline 012e9f410c, passed 28/28 on candidate fe45b73c20, failed the same 4/28 when only the two candidate filters were disabled, and returned to 28/28 after restoration. Review-round topology contracts failed 4/40 before the alias/generic/local-stamping follow-up; final-review contracts then failed 4/16 before folder/background/worktree-metadata hardening, 1/6 before paired repository-metadata hardening, 1/7 before unavailable-runtime fail-closed hardening, 1/12 with the component's physical-host bypass restored, 1/6 before review-cache ownership used the same resolver, 1/6 before the downstream document rejected a remote owner's unqualified legacy cache entry, and 1/6 when a host filter or scoped open-tab search hid the same-id sibling from legacy ambiguity detection. Manual local-provider ownership failed 1/12 before explicit host routing, and pre-hydration dedupe failed 1/13 before exact stamped-owner reuse; the combined contracts then passed 25/25 and the six-file review suite passed 73/73. The prior completed focused suite passed 225/225."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Candidate construction adds bounded in-memory ownership filtering over the already-read worktree tab bucket and introduces no polling or network work."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect paired Electron CI soak history without unexplained flakes.",
|
||||
"Add a live paired-runtime-over-SSH journey when the E2E harness can provision that combined topology."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Live Linux, Windows, and paired-runtime-over-SSH journeys are not collected; the SSH/runtime dual-owner topology is deterministic renderer coverage.",
|
||||
"The paired Electron simulator path seeds renderer state because remote web clients cannot launch a native emulator.",
|
||||
"The paired Electron journey proves a real host mirror stamp before injecting the same-id sibling state; it does not create the collision through the wire.",
|
||||
"Mixed-version behavior is covered by optional-field parsing and fail-closed legacy ambiguity, not a live old-client/new-host matrix."
|
||||
],
|
||||
"demotionRule": "Cannot promote if a same-id sibling tab duplicates, disappears, activates another host, or if ambiguous unstamped ownership is guessed."
|
||||
},
|
||||
{
|
||||
"id": "ephemeral-vm-runtime.rollback-readable-sidecar",
|
||||
"title": "VM lifecycle stays rollback-readable and cleanup-retryable",
|
||||
@@ -3380,7 +3680,7 @@
|
||||
"providers": ["local", "daemon", "ssh", "runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "ssh", "runtime"],
|
||||
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.",
|
||||
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. That claim now covers BOTH shapes, including the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId - each asserted by its own test. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/8628",
|
||||
"https://github.com/stablyai/orca/pull/8706",
|
||||
@@ -3395,7 +3695,9 @@
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/runtime/worktree-teardown.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/terminal-tab-close-restart-persistence.spec.ts --workers=1",
|
||||
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1",
|
||||
"pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1"
|
||||
"pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1",
|
||||
"pnpm run test:e2e -- tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts --workers=1",
|
||||
"pnpm run test:e2e -- tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
|
||||
@@ -3422,7 +3724,9 @@
|
||||
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
|
||||
"tests/e2e/terminal-tab-close-restart-persistence.spec.ts",
|
||||
"tests/e2e/terminal-parked-close-retirement.spec.ts",
|
||||
"tests/e2e/agent-descendant-process-kill.spec.ts"
|
||||
"tests/e2e/agent-descendant-process-kill.spec.ts",
|
||||
"tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts",
|
||||
"tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -3468,6 +3772,25 @@
|
||||
"closing the parked tab removes the exact PTY from the provider inventory and the visible tab model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts",
|
||||
"assertions": [
|
||||
"an attached-window desktop host publishes the CLI-created terminal under the renderer publication epoch it inherits, proving the vulnerable classification path was taken rather than a headless or serve/SSH-shaped one",
|
||||
"with an earlier host-created terminal already present, a renderer graph sync leaves the `terminal.create` tab in the host session inventory still naming its original daemon PTY",
|
||||
"the original fixture process answers a write through that handle with its own pid and a single READY line, so a respawned replacement cannot pass as a survivor",
|
||||
"the paired client applied that same sync and kept the tab with no replacement or resume tab appended",
|
||||
"an unrelated workspace keeps its own CLI terminal and PTY, and the client raises no direct local connection attempt or uncaught renderer error"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts",
|
||||
"assertions": [
|
||||
"a windowless serve host publishes host-created terminals under a headless epoch, never a renderer one, which is why it cannot reach the window-conditioned prune the desktop arm reproduces",
|
||||
"a later CLI dispatch leaves the earlier host-created terminal in the serve-host inventory still bound to its original PTY",
|
||||
"both original fixture processes answer writes through their own handles after the later dispatch",
|
||||
"the paired client mirrors exactly those two tabs with no replacement or resume tab appended, no direct local connection attempt, and no uncaught renderer error"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/agent-pane-authority-ownership.test.ts",
|
||||
"assertions": [
|
||||
@@ -3607,9 +3930,12 @@
|
||||
"Daemon owner leases and durable retry inventory remain follow-up hardening.",
|
||||
"Windows ConPTY, SSH-hosted PTYs, app-quit killAll, and daemon dispose retain foreground-tree-only teardown.",
|
||||
"A process born in the capture second is SIGTERMed but not SIGKILLed because ps cannot prove its recycled-PID identity.",
|
||||
"A descendant orphaned before or during root ownership loss requires the separate crash-orphan sweep and is not recovered from a stale kill-time snapshot."
|
||||
"A descendant orphaned before or during root ownership loss requires the separate crash-orphan sweep and is not recovered from a stale kill-time snapshot.",
|
||||
"recordTerminalSurfaceRetirement advances the INCOMING session's terminal topology revision, so a tombstoned close whose write carries a revision at or below the store's own is rebased away instead of persisting. Pre-existing for every repo already past revision 0 and unchanged by the host-admitted membership fix, which only makes more repos reach that state sooner; the authoritative close path (persistTerminalSurfaceRetirements) computes from the store's session and is unaffected. Known, unaddressed, and deliberately out of scope here: the safe correction is per-tab rather than per-worktree rebase arbitration, because raising the incoming revision wholesale would re-open the host-tab loss whenever a close coincides with a host create.",
|
||||
"The SSH relay reattach binding (src/main/ssh/ssh-relay-session.ts persistPtyBinding) is NOT flagged host-admitted, so a lease rebind that has to mint a missing tab raises no fence. It rebinds an existing lease rather than admitting a new surface, so it is believed not to need one, but that was not established either way. Known, unaddressed, and deliberately out of scope here.",
|
||||
"The host-created retention journey is macOS-local; the reported incident was on a physical Windows host, which we did not run against."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, or late hooks can recreate closed authority."
|
||||
"demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, late hooks can recreate closed authority, or a renderer graph sync retires a host-created tab that no explicit close retired."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.daemon-generation-reconnect-safety",
|
||||
@@ -11057,7 +11383,7 @@
|
||||
},
|
||||
{
|
||||
"id": "terminal-input.cooked-reply-queue",
|
||||
"title": "Cooked-echo-safe terminal reply backlog stays bounded without evicting ordinary input",
|
||||
"title": "Terminal query replies stay atomic and bounded without evicting ordinary input",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
@@ -11066,6 +11392,7 @@
|
||||
"desktop terminal input",
|
||||
"OSC 10/11 replies",
|
||||
"mode-2031 replies",
|
||||
"DA1 and CPR replies",
|
||||
"local PTY",
|
||||
"daemon and SSH PTYs"
|
||||
],
|
||||
@@ -11073,15 +11400,17 @@
|
||||
"providers": ["local", "daemon", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh"],
|
||||
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, one-reply-per-write delivery, real xterm OSC reply generation, drain-failure containment, and clear/reuse generation fencing. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves a real Linux SSH process receives xterm's OSC reply through the same queue. No live daemon, WSL, physical Linux/Windows client, or mixed-version run is registered.",
|
||||
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, one-reply-per-write delivery for OSC, DA1, and CPR replies, real xterm OSC reply generation, drain-failure containment, and clear/reuse generation fencing. Remote-runtime tests preserve separate query-reply writes across pending input, async validation, and viewport-claim buffering. Host-contract tests prove a later DA1/CPR reply cannot overtake a deferred OSC reply, including a coalesced legacy-client payload. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves an upstream-node-pty Linux relay keeps OSC/DA1 replies out of the next fish child's stdin. No live daemon, paired-runtime, WSL, physical Linux/Windows client, or binary mixed-version run is registered.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/13137",
|
||||
"https://github.com/stablyai/orca/issues/7329"
|
||||
"https://github.com/stablyai/orca/issues/7329",
|
||||
"https://github.com/stablyai/orca/issues/13892"
|
||||
],
|
||||
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending cooked-echo-safe replies and 4096 UTF-16 code units. Overflow removes the oldest pending query-reply entries, never ordinary input or reply-shaped bytes from the ordinary path, so later user input has a finite reply backlog; every retained reply reaches the established provider path as one atomic write. Drain failures settle without an unhandled rejection and cannot clear a newer queue generation.",
|
||||
"oracle": "Synchronously enqueue 10,000 cooked-echo-safe query replies before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. Repeat behind 10,000 ordinary inputs and through the real IPC transport, requiring ordinary and reply-shaped ordinary-path bytes to survive while only the newest 64 explicit replies remain. Exercise the text ceiling, real xterm OSC 10/11 generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
|
||||
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, and the host writes each reply the moment it accepts it, so replies reach the PTY in the order they were produced with no queue that could reorder them. A reply's own echo is contained on the output side by projecting its known echo shapes; the ESC-initial verbatim shape is matched only when complete, never held as a partial, so a query torn at its own ESC is still answered. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and drain failures cannot clear a newer queue generation.",
|
||||
"oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
@@ -11089,6 +11418,9 @@
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts",
|
||||
"src/shared/terminal-query-reply.test.ts",
|
||||
"src/shared/pty-startup-ingress-live-query-reply.test.ts",
|
||||
"tests/e2e/terminal-osc-color-queries.spec.ts",
|
||||
@@ -11104,7 +11436,7 @@
|
||||
"reply-shaped bytes from the ordinary input path never enter the shed-able reply budget",
|
||||
"reply count and text overflow remove only the oldest pending replies",
|
||||
"real xterm OSC 10/11 query handlers remain subject to the same retention ceiling",
|
||||
"retained cooked-echo-safe replies stay one provider write each",
|
||||
"retained OSC, DA1, and CPR replies stay one provider write each and both OSC and DA1 floods remain bounded",
|
||||
"provider-write and yield failures settle without unhandled rejection, repeated same-generation admission, or stale-generation clearing",
|
||||
"clear releases saturated reply accounting and fences in-flight validation before later input"
|
||||
]
|
||||
@@ -11116,10 +11448,29 @@
|
||||
"a thrown renderer write triggers one owning-transport recovery callback and rejects later input in that queue generation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts",
|
||||
"assertions": [
|
||||
"a validation barrier runs after earlier deferred input and before later input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts",
|
||||
"assertions": [
|
||||
"pending ordinary input and asynchronously validated paste stay ahead of but separate from an immediate query reply"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts",
|
||||
"assertions": [
|
||||
"viewport-claim buffering replays ordinary input, a query reply, and later ordinary input as three ordered stream frames",
|
||||
"a 10,000-reply viewport-claim flood retains and replays only the newest 64 query replies"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/terminal-query-reply.test.ts",
|
||||
"assertions": [
|
||||
"only complete cooked-echo-risk replies are classified and consecutive replies extract losslessly"
|
||||
"only complete query replies are classified and consecutive mixed reply kinds extract losslessly"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -11143,7 +11494,8 @@
|
||||
{
|
||||
"file": "tests/e2e/pty-input-write-queue-ssh.spec.ts",
|
||||
"assertions": [
|
||||
"a real Linux SSH process emits an OSC query and receives xterm's synthetic reply through the live PTY queue"
|
||||
"a real Linux SSH process emits an OSC query and receives xterm's synthetic reply through the live PTY queue",
|
||||
"an upstream-node-pty Linux relay has no echoState export and keeps OSC/DA1 reply bytes out of the next fish child stdin"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -11183,6 +11535,15 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 36.8,
|
||||
"summary": "A real Linux process behind Docker OpenSSH emitted an OSC 10 query and observed xterm's synthetic RGB reply after it traversed the live renderer queue, Electron IPC, SSH provider, and relay PTY."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 36.9,
|
||||
"summary": "Run with SKIP_BUILD=1 against the existing e2e bundle. Both Docker OpenSSH Electron tests passed: the live OSC query returned its RGB reply, and an upstream-node-pty Linux ARM64 relay without echoState kept OSC/DA1 bytes out of the next fish 4.8.1 child's stdin."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -11191,7 +11552,7 @@
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Ten consecutive local unit-gate runs passed 1,380 tests in 29.57 seconds total with no fixed timing sleeps. The exact Docker SSH OSC oracle passed three consecutive runs before extraction into its dedicated spec and then passed the registered dedicated command once. CI and the normal soak window are not yet available."
|
||||
"evidence": "Ten consecutive local unit-gate runs passed 1,380 tests in 29.57 seconds total with no fixed timing sleeps. The exact Docker SSH OSC oracle passed three consecutive runs before extraction and then passed both registered full-spec runs. The upstream-node-pty fish handoff oracle passed three local runs, including the final post-rebase run. CI and the normal soak window are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
@@ -11199,7 +11560,7 @@
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Pending cooked replies are capped at 64 and 4096 UTF-16 code units. Explicit source routing and separate sequence-merged FIFOs keep ordinary input out of the fixed-size reply store; oldest-reply eviction uses an amortized O(1) head advance without scanning ordinary input, consumed prefixes compact at a fixed threshold, and drain coalescing reads the stored classification without repeated reply-regex scans. A synchronous flood forwards only the immediate first and newest 64 replies before the trailing key. Ten complete gate runs, including two 10,000-entry flood shapes each, finished in 29.57 seconds with zero failures. The change adds no polling, timers, listeners, subprocesses, or network retries."
|
||||
"evidence": "Pending query replies are capped at 64 and 4096 UTF-16 code units. Explicit source routing and separate sequence-merged FIFOs keep ordinary input out of the fixed-size reply store; oldest-reply eviction uses an amortized O(1) head advance without scanning ordinary input, consumed prefixes compact at a fixed threshold, and drain coalescing reads the stored classification without repeated reply-regex scans. Separate synchronous OSC and DA1 floods forward only the immediate first and newest 64 replies before the trailing key, and the remote-runtime viewport-claim buffer likewise retains only the newest 64 query segments while coalescing adjacent ordinary input. The sync ECHO probe removes an `stty` subprocess from the common quiet-query path and the fallback adds no polling beyond the existing bounded ECHO probe."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the gate on Windows and Linux CI.",
|
||||
@@ -11208,10 +11569,9 @@
|
||||
"Accumulate the normal soak window with zero unexplained flakes."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live daemon, WSL, physical Linux/Windows client, mixed-version, or network-fault run is registered.",
|
||||
"The remote-runtime transport has a separate immediate-input queue and is unaffected by this gate.",
|
||||
"No live daemon, paired-runtime, WSL, physical Linux/Windows client, mixed-version, or network-fault run is registered.",
|
||||
"Ordinary-input queue growth predates this change and is outside this reply-only gate.",
|
||||
"Reply overload intentionally sheds the oldest synthetic replies; live application behavior under a pathological query flood is not measured."
|
||||
"Reply overload intentionally sheds the oldest synthetic replies; the modified-F3/CPR byte collision shares that budget, and live application behavior under a pathological query flood is not measured."
|
||||
],
|
||||
"demotionRule": "Keep experimental or revert if ordinary input is evicted, pending replies exceed either ceiling, retained replies are coalesced, a trailing key sits behind more than the bounded reply window, or the gate develops unexplained flakes."
|
||||
},
|
||||
@@ -13591,6 +13951,144 @@
|
||||
"The reverted-source failure is established from the old process-wide call path but was not executed."
|
||||
],
|
||||
"demotionRule": "Demote if unrelated reattach joins a stalled session tail, overlay or periodic work exceeds its admission bound, a deadline cancels durable work, final checkpoints defer, or failures and deadline fallbacks lose their diagnostic breadcrumbs."
|
||||
},
|
||||
{
|
||||
"id": "skill-upload.cross-process-staging-ownership",
|
||||
"title": "Concurrent runtime and relay uploads retain exact staging ownership",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "skill-upload-session-service",
|
||||
"layer": "host-filesystem-lifecycle",
|
||||
"surfaces": [
|
||||
"paired-runtime skill upload",
|
||||
"SSH relay skill upload",
|
||||
"upload cancellation",
|
||||
"process restart and disposal"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["orca-server", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["ssh"],
|
||||
"coverageNotes": "Two independent services share one parent under controlled ordering, four real crash boundaries run in child processes, and two bundled relays share one isolated home through distinct socket and hook endpoints. A current relay also shares that home safely with the originating relay in both launch orders. Graceful relay and desktop-runtime disposal join service cleanup. Linux, Windows, and paired headed/headless runtime process evidence remain uncollected; the change is host-local and changes no wire payload.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4967",
|
||||
"https://github.com/stablyai/orca/pull/14934"
|
||||
],
|
||||
"invariant": "Initialization, cleanup, or disposal by one service process can never remove bytes owned by a still-live upload in another process. Each service owns one atomic PID-and-UUID namespace, serializes begin admission at four active sessions, and retains the package-size limit. Abandoned owner cleanup inspects at most 64 entries per attempt, and success, failure, cancellation, restart, and disposal release only their exact owned paths.",
|
||||
"oracle": "Begin and partially append upload A, then begin and append upload B from an independent service or relay sharing the same parent. Require two exact PID-and-UUID owner directories, two upload-ID archives, and byte-for-byte survival of both. Cancel B then A and require archive counts 1 then 0. Stage two more live uploads, terminate relay A, and require relay B's exact owner, archive, and bytes to survive before B terminates and the root becomes empty. Kill real child owners at begun, partial, uploaded, and committed boundaries and require a fresh owner to remove only exited-process staging and complete its own transfer. Seed 65 exited owners, require one attempt to remove exactly 64 and reject before adding bytes, then require retry to drain the final stale owner and begin normally.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts --reporter=verbose",
|
||||
"ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
|
||||
"ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
|
||||
"ORCA_SKILL_UPLOAD_LEGACY_RELAY_ENTRY=<fa9b20cb4163-relay.js> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
|
||||
"ORCA_SKILL_UPLOAD_RELAY_ENTRY=<fa9b20cb4163-relay.js;sha256=4db8609c4e1772f1efa9b0c0cf6affa8f64ff9ebc8e2b6f9e71678992ccdcf1a> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
|
||||
"ORCA_SKILL_UPLOAD_RELAY_ENTRY=<012e9f410c-fix-reverted-relay.js;sha256=ca03bf47d93828ec30473391b542d945df887b7881cce3df17e95d90226e34fb> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/skills/skill-upload-session-service.test.ts",
|
||||
"src/main/skills/skill-upload-process-restart.integration.test.ts",
|
||||
"src/main/skills/skill-upload-process-restart-child.test.ts",
|
||||
"src/relay/skill-upload-multi-relay.integration.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/skills/skill-upload-session-service.test.ts",
|
||||
"assertions": [
|
||||
"a second service preserves the first service's exact file and bytes",
|
||||
"five concurrent begins admit exactly four sessions and disposal removes them",
|
||||
"disposal waits for an initializing begin and leaves no owner",
|
||||
"concurrent disposal callers join the same exact cleanup promise",
|
||||
"archive hashing failure releases the session and rejects later appends",
|
||||
"symlink or junction staging roots are rejected before cleanup or admission",
|
||||
"failed archive removal remains owned and counts toward the four-path bound",
|
||||
"cancellation and disposal reduce exact archive and owner counts",
|
||||
"one abandoned-owner sweep removes at most 64 entries and retry completes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/skills/skill-upload-process-restart.integration.test.ts",
|
||||
"assertions": [
|
||||
"begun, partial, uploaded, and committed crash residue is reclaimed",
|
||||
"the replacement transfer completes and leaves no owner directory"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/relay/skill-upload-multi-relay.integration.test.ts",
|
||||
"assertions": [
|
||||
"two bundled relay PIDs retain separate archives under one account home",
|
||||
"current and originating relay bundles retain separate archives in both launch orders",
|
||||
"relay cancellation and live-upload graceful shutdown clean only the exact owner"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "ORCA_REAL_PROCESS_SKILL_TEST=1 pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-upload-session-service.test.ts src/main/skills/skill-upload-process-restart.integration.test.ts src/main/skills/skill-upload-process-restart-child.test.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
|
||||
"durationSeconds": 5.95,
|
||||
"summary": "Fifteen service tests, four crash-boundary cases, and one two-current-relay topology passed; the child fixture and two explicitly separate mixed-version cases skipped."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "ORCA_SKILL_UPLOAD_LEGACY_RELAY_ENTRY=<fa9b20cb4163-relay.js> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
|
||||
"durationSeconds": 0.9,
|
||||
"summary": "The current/current case and both originating/current mixed-version launch orders passed with exact per-root archive bytes."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "failed",
|
||||
"command": "ORCA_SKILL_UPLOAD_RELAY_ENTRY=<fa9b20cb4163-relay.js;sha256=4db8609c4e1772f1efa9b0c0cf6affa8f64ff9ebc8e2b6f9e71678992ccdcf1a> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
|
||||
"durationSeconds": 0.4,
|
||||
"summary": "Expected red: zero PID/UUID owner directories and one archive were observed where two each were required; relay A's exact first five bytes were missing."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "failed",
|
||||
"command": "ORCA_SKILL_UPLOAD_RELAY_ENTRY=<012e9f410c-fix-reverted-relay.js;sha256=ca03bf47d93828ec30473391b542d945df887b7881cce3df17e95d90226e34fb> pnpm exec vitest run --config config/vitest.config.ts src/relay/skill-upload-multi-relay.integration.test.ts --pool=forks --maxWorkers=1 --no-file-parallelism --reporter=verbose",
|
||||
"durationSeconds": 0.4,
|
||||
"summary": "Expected red: zero PID/UUID owner directories and one archive were observed where two each were required; relay A's exact first five bytes were missing."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 15,
|
||||
"scope": "focused service, four child-process restarts, and two bundled relays"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The barriers are request acknowledgements, filesystem state, and process exit; CI and soak history have not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The byte-identical two-relay oracle failed at the exact two-owner count on originating merge fa9b20cb4163 and latest-main/reverted source 012e9f410c76, each observing one shared-root entry after relay B began. The candidate passed with two owner directories, two archives, A-byte survival, 1-to-0 cancellation cleanup, and 1-to-0 owner disposal cleanup."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Initialization scans at most 64 direct parent entries, creates one owner directory per live service, retains the existing four active sessions and package-size caps, and adds no polling, timer, subprocess, wire field, renderer work, or provider fanout. An over-budget parent rejects before creating new staged bytes."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive focused CI passes or 14 days without an unexplained flake.",
|
||||
"Run the same two-owner relay topology on Linux and Windows.",
|
||||
"Run paired headed and headless runtime uploads under one OS account.",
|
||||
"Keep exact owner/file counts, byte survival, bounded sweep, and all four crash boundaries green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The live two-relay and crash evidence was collected on macOS; Linux and Windows process/filesystem behavior remains CI coverage.",
|
||||
"The service is shared by paired runtimes, but separate headed and headless paired-runtime process journeys were not run.",
|
||||
"PID reuse is handled conservatively: an abandoned namespace whose PID is currently occupied is preserved until a later process generation rather than risking deletion of live bytes.",
|
||||
"The legacy remote-uploads root is intentionally not reclaimed while a mixed-version relay may still own bytes there; eventual legacy-root retirement requires a separate compatibility decision."
|
||||
],
|
||||
"demotionRule": "Demote if one live owner can remove another owner's bytes, cleanup exceeds 64 inspected entries per attempt, a lifecycle path retains its exact owned archive after settlement, or the process oracle flakes without an identified harness or product defect."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,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"')
|
||||
})
|
||||
})
|
||||
@@ -53,11 +53,17 @@ 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}"`)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -11,18 +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 = [
|
||||
@@ -31,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', () => {
|
||||
@@ -154,6 +157,9 @@ describe('PR workflow parallelism', () => {
|
||||
|
||||
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()
|
||||
|
||||
@@ -329,6 +335,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' })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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: 23m">
|
||||
<title>downloads: 23m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 26m">
|
||||
<title>downloads: 26m</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">23m</text>
|
||||
<text x="90" y="14">23m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">26m</text>
|
||||
<text x="90" y="14">26m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -150,6 +150,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 { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import { createWorktreeWithNameRetry } from './worktree-create-retry'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
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 { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
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}`
|
||||
|
||||
@@ -114,6 +114,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1122,6 +1122,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
|
||||
|
||||
@@ -147,6 +147,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)
|
||||
|
||||
+10
-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",
|
||||
@@ -265,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",
|
||||
@@ -315,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
|
||||
@@ -280,7 +286,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
|
||||
@@ -405,6 +411,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
|
||||
@@ -3384,6 +3393,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'}
|
||||
@@ -5432,6 +5444,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==}
|
||||
|
||||
@@ -9602,6 +9618,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))':
|
||||
@@ -11387,7 +11408,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
|
||||
@@ -12014,6 +12035,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:
|
||||
@@ -12035,7 +12059,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
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ orca linear list --filter assigned --limit 10 --workspace all --json
|
||||
orca linear list --filter open --team <key-or-id> --workspace <workspaceId> --json
|
||||
```
|
||||
|
||||
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. A cursor is workspace-specific, so combine `--cursor` with a concrete `--workspace` rather than `all`.
|
||||
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. Omitting `--limit` returns every match (`result.meta.limit` is `null`), so filter before listing a large workspace; `--limit <n>` caps the read. `--json` sets `result.truncated` (and `result.meta.hasMore`) when a cap held results back; human output prints `truncated: showing N`. Check `truncated` before reporting a count, then page with `--cursor` until `truncated` is false. Issued `--cursor` values bind the workspace; `--workspace all` cannot page; a raw Linear cursor still needs a concrete `--workspace`. Replay `--cursor` against the same Orca runtime that issued it. `--priority` is `0=none`, `1=urgent`, `2=high`, `3=medium`, `4=low`; JSON includes `priorityLabel` on each issue (CLI setter vocabulary). `orca linear search`, `orca linear list`, and `orca linear project list` still cap at their own `--limit` and set `result.truncated` when the cap is hit. Project JSON `priorityLabel` stays Linear's title-case provider string.
|
||||
|
||||
Prefer `label add` and `label remove` for incremental edits. `label set` replaces the full label set and should be used only when deliberate cleanup is intended.
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ orca linear list --filter assigned --limit 10 --workspace all --json
|
||||
orca linear list --filter open --team <key-or-id> --workspace <workspaceId> --json
|
||||
```
|
||||
|
||||
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. A cursor is workspace-specific, so combine `--cursor` with a concrete `--workspace` rather than `all`.
|
||||
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. Omitting `--limit` returns every match (`result.meta.limit` is `null`), so filter before listing a large workspace; `--limit <n>` caps the read. `--json` sets `result.truncated` (and `result.meta.hasMore`) when a cap held results back; human output prints `truncated: showing N`. Check `truncated` before reporting a count, then page with `--cursor` until `truncated` is false. Issued `--cursor` values bind the workspace; `--workspace all` cannot page; a raw Linear cursor still needs a concrete `--workspace`. Replay `--cursor` against the same Orca runtime that issued it. `--priority` is `0=none`, `1=urgent`, `2=high`, `3=medium`, `4=low`; JSON includes `priorityLabel` on each issue (CLI setter vocabulary). `orca linear search`, `orca linear list`, and `orca linear project list` still cap at their own `--limit` and set `result.truncated` when the cap is hit. Project JSON `priorityLabel` stays Linear's title-case provider string.
|
||||
|
||||
Prefer `label add` and `label remove` for incremental edits. `label set` replaces the full label set and should be used only when deliberate cleanup is intended.
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
|
||||
'artifacts',
|
||||
'automations',
|
||||
'project',
|
||||
'host',
|
||||
'repo',
|
||||
'worktree',
|
||||
'terminal',
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
vi.mock('./runtime-client', () => {
|
||||
class RuntimeClient {
|
||||
call = callMock
|
||||
getCliStatus = vi.fn()
|
||||
openOrca = vi.fn()
|
||||
}
|
||||
|
||||
class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
readonly response: unknown
|
||||
|
||||
constructor(response: unknown) {
|
||||
super('runtime_error', 'runtime_error')
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError
|
||||
}
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
import { okFixture, queueFixtures } from './test-fixtures'
|
||||
|
||||
// Why: StorageKeyValue requires a non-empty key but accepts any string value, empty included.
|
||||
describe('orca cli storage set preserves an empty value', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('passes an empty --value through to browser.storage.local.set', async () => {
|
||||
queueFixtures(callMock, okFixture('req_storage_local_set', { success: true }))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['storage', 'local', 'set', '--key', 'flag', '--value', '', '--worktree', 'all', '--json'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('browser.storage.local.set', {
|
||||
key: 'flag',
|
||||
value: '',
|
||||
worktree: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('passes an empty --value through to browser.storage.session.set', async () => {
|
||||
queueFixtures(callMock, okFixture('req_storage_session_set', { success: true }))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['storage', 'session', 'set', '--key', 'flag', '--value', '', '--worktree', 'all', '--json'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('browser.storage.session.set', {
|
||||
key: 'flag',
|
||||
value: '',
|
||||
worktree: undefined
|
||||
})
|
||||
})
|
||||
|
||||
// Why: PowerShell 5.1 drops empty native-exe args, so `--value=` is the Windows escape hatch.
|
||||
it('passes an empty --value= (equals form) through to browser.storage.local.set', async () => {
|
||||
queueFixtures(callMock, okFixture('req_storage_local_set', { success: true }))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['storage', 'local', 'set', '--key=flag', '--value=', '--worktree', 'all', '--json'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('browser.storage.local.set', {
|
||||
key: 'flag',
|
||||
value: '',
|
||||
worktree: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('still rejects an empty --key before RPC dispatch', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
['storage', 'local', 'set', '--key', '', '--value', 'x', '--worktree', 'all'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(errorSpy.mock.calls.flat().join('\n')).toContain('Missing required --key')
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('still rejects a missing --value before RPC dispatch', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
['storage', 'local', 'set', '--key', 'flag', '--worktree', 'all'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(errorSpy.mock.calls.flat().join('\n')).toContain('Missing required --value')
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
@@ -20,13 +20,19 @@ vi.mock('./runtime-client', () => ({
|
||||
}))
|
||||
|
||||
import {
|
||||
assertEnvironmentSelectorResolvable,
|
||||
hostFilterMatchesHostId,
|
||||
parseHostFlag,
|
||||
resolveHostFlagEnvironmentId
|
||||
} from './execution-host-flag'
|
||||
import { parseExecutionHostId } from '../shared/execution-host'
|
||||
|
||||
const NO_SELECTION = { pairingCode: null, environmentSelector: null }
|
||||
const listSshTargetsMock = vi.fn(async () => [] as { id: string; label: string }[])
|
||||
const NO_SELECTION = {
|
||||
listSshTargets: listSshTargetsMock,
|
||||
pairingCode: null,
|
||||
environmentSelector: null
|
||||
}
|
||||
|
||||
function flags(entries: Record<string, string | boolean>): Map<string, string | boolean> {
|
||||
return new Map(Object.entries(entries))
|
||||
@@ -84,15 +90,17 @@ describe('resolveHostFlagEnvironmentId', () => {
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-missing' }), NO_SELECTION)
|
||||
).rejects.toThrow('no paired environment has id env-missing')
|
||||
).rejects.toThrow('no paired Orca server is named or has id env-missing')
|
||||
})
|
||||
|
||||
it('matches by environment id only, never by environment name', async () => {
|
||||
// Why: the name is what a person or agent actually knows; requiring the raw uuid made the
|
||||
// obvious spelling fail against a server that is sitting right there.
|
||||
it('accepts the environment name as well as its id', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:gpu' }), NO_SELECTION)
|
||||
).rejects.toThrow('no paired environment has id gpu')
|
||||
).resolves.toBe('env-1')
|
||||
})
|
||||
|
||||
it('decodes percent-encoded environment ids', async () => {
|
||||
@@ -108,6 +116,7 @@ describe('resolveHostFlagEnvironmentId', () => {
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
||||
listSshTargets: listSshTargetsMock,
|
||||
pairingCode: 'orca://pair?x',
|
||||
environmentSelector: null
|
||||
})
|
||||
@@ -120,6 +129,7 @@ describe('resolveHostFlagEnvironmentId', () => {
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
||||
listSshTargets: listSshTargetsMock,
|
||||
pairingCode: null,
|
||||
environmentSelector: { value: 'other', label: '--environment' }
|
||||
})
|
||||
@@ -132,6 +142,7 @@ describe('resolveHostFlagEnvironmentId', () => {
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
||||
listSshTargets: listSshTargetsMock,
|
||||
pairingCode: null,
|
||||
environmentSelector: { value: 'staging', label: 'ORCA_ENVIRONMENT' }
|
||||
})
|
||||
@@ -142,7 +153,7 @@ describe('resolveHostFlagEnvironmentId', () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:gpu' }), NO_SELECTION)
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:missing' }), NO_SELECTION)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
data: { knownEnvironments: [{ id: 'env-1', name: 'gpu' }] }
|
||||
@@ -155,6 +166,7 @@ describe('resolveHostFlagEnvironmentId', () => {
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
||||
listSshTargets: listSshTargetsMock,
|
||||
pairingCode: null,
|
||||
environmentSelector: { value: 'gpu', label: '--environment' }
|
||||
})
|
||||
@@ -187,3 +199,78 @@ describe('hostFilterMatchesHostId', () => {
|
||||
expect(hostFilterMatchesHostId(runtimeHost, null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ambiguous environment names', () => {
|
||||
it('refuses --host runtime:<name> when two servers share that name', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([
|
||||
environment('env-1', 'awin'),
|
||||
environment('env-2', 'awin')
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:awin' }), NO_SELECTION)
|
||||
).rejects.toThrow('2 paired servers are named awin')
|
||||
})
|
||||
|
||||
it('refuses --environment <name> when two servers share that name', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([
|
||||
environment('env-1', 'awin'),
|
||||
environment('env-2', 'awin')
|
||||
])
|
||||
|
||||
await expect(assertEnvironmentSelectorResolvable('awin', listSshTargetsMock)).rejects.toThrow(
|
||||
'2 paired servers are named awin'
|
||||
)
|
||||
})
|
||||
|
||||
it('still routes an exact id past a colliding name', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([
|
||||
environment('env-1', 'awin'),
|
||||
environment('env-2', 'awin')
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-2' }), NO_SELECTION)
|
||||
).resolves.toBe('env-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEnvironmentSelectorResolvable', () => {
|
||||
it('accepts a paired server by name or id', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
|
||||
listSshTargetsMock.mockClear()
|
||||
|
||||
await expect(
|
||||
assertEnvironmentSelectorResolvable('awin', listSshTargetsMock)
|
||||
).resolves.toBeUndefined()
|
||||
await expect(
|
||||
assertEnvironmentSelectorResolvable('env-1', listSshTargetsMock)
|
||||
).resolves.toBeUndefined()
|
||||
expect(listSshTargetsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: the inverse of the --host case, and the one the report actually hit — a name that is
|
||||
// an SSH target dead-ended with a bare "unknown environment".
|
||||
it('names the SSH target when the selector is one', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')])
|
||||
listSshTargetsMock.mockResolvedValue([{ id: 'ssh-1-a', label: 'openclaw' }])
|
||||
|
||||
await expect(
|
||||
assertEnvironmentSelectorResolvable('openclaw', listSshTargetsMock)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
data: {
|
||||
nextSteps: expect.arrayContaining([expect.stringContaining('--host ssh:ssh-1-a')])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('still points at host list when the name is on neither axis', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([])
|
||||
listSshTargetsMock.mockResolvedValue([])
|
||||
|
||||
await expect(
|
||||
assertEnvironmentSelectorResolvable('nowhere', listSshTargetsMock)
|
||||
).rejects.toThrow('no paired Orca server is named or has id nowhere')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,11 +2,23 @@ import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ParsedExecutionHost
|
||||
} from '../shared/execution-host'
|
||||
import {
|
||||
ambiguousEnvironments,
|
||||
crossKindNextSteps,
|
||||
findEnvironmentByName,
|
||||
resolveSshHostTargetId,
|
||||
type SshTargetSummary
|
||||
} from './host-selector-alternatives'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime/types'
|
||||
|
||||
export type HostFlagRoutingSelection = {
|
||||
// Why: SSH targets live in the running Orca host, not on disk, so enumerating them needs a
|
||||
// client. Injected as a thunk so the lookup only happens on the error path we are explaining.
|
||||
listSshTargets: () => Promise<SshTargetSummary[]>
|
||||
pairingCode: string | null
|
||||
// Why: an ambient ORCA_ENVIRONMENT counts as a selection too, so carry the label to
|
||||
// name the real source in the conflict message.
|
||||
@@ -51,19 +63,29 @@ export async function resolveHostFlagEnvironmentId(
|
||||
])
|
||||
const userDataPath = getDefaultUserDataPath()
|
||||
const known = listEnvironments(userDataPath)
|
||||
const environment = known.find((candidate) => candidate.id === host.environmentId)
|
||||
// Why: --environment has always taken a name or an id, and the name is what people and agents
|
||||
// actually know. Requiring the raw uuid here made the obvious spelling fail; accept either and
|
||||
// canonicalize to the id so stored host ids still compare correctly downstream.
|
||||
const environment = findEnvironmentByName(
|
||||
known.map((candidate) => ({ id: candidate.id, name: candidate.name })),
|
||||
host.environmentId
|
||||
)
|
||||
if (!environment) {
|
||||
// Why: `runtime:<id>` is a host id that also appears in stored rows, so it resolves by id
|
||||
// only — unlike --environment, which also accepts a name. Say so, and hand back the ids an
|
||||
// agent can retry with instead of making it scrape the sentence.
|
||||
const environments = known.map((candidate) => ({ id: candidate.id, name: candidate.name }))
|
||||
assertEnvironmentNameUnambiguous(environments, host.environmentId, `--host ${host.id}`)
|
||||
const sshTargets = await selection.listSshTargets()
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown Orca server in --host ${host.id}: no paired environment has id ${host.environmentId}. --host runtime:<id> matches environment ids only, never names. Run \`orca environment list\` to see paired servers, or pass --host local.`,
|
||||
`Unknown Orca server in --host ${host.id}: no paired Orca server is named or has id ${host.environmentId}.`,
|
||||
{
|
||||
knownEnvironments: known.map((candidate) => ({ id: candidate.id, name: candidate.name })),
|
||||
knownEnvironments: environments,
|
||||
knownSshTargets: sshTargets,
|
||||
nextSteps: [
|
||||
...crossKindNextSteps(host.environmentId, { environments, sshTargets }, 'environment'),
|
||||
'Run `orca environment list` to see paired Orca servers.',
|
||||
'Use the environment id, not its name: --host runtime:<environment-id>.',
|
||||
'Use --host local to target this machine.'
|
||||
]
|
||||
}
|
||||
@@ -100,3 +122,86 @@ export function hostFilterMatchesHostId(
|
||||
}
|
||||
return filter.kind === 'runtime' && candidate === LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
// Why: `ssh:` reaches a machine the connected runtime owns, so it can only be checked against
|
||||
// that runtime — unlike `runtime:`, which is resolved from this machine's pairing store before a
|
||||
// client exists. Callers that act on a --host value run this so an unknown target fails loudly
|
||||
// instead of quietly filtering to nothing.
|
||||
export async function resolveHostFlagTarget(
|
||||
flags: Map<string, string | boolean>,
|
||||
client: RuntimeClient
|
||||
): Promise<ParsedExecutionHost | undefined> {
|
||||
const host = parseHostFlag(flags)
|
||||
if (host?.kind !== 'ssh') {
|
||||
return host
|
||||
}
|
||||
const [{ listEnvironments }, { getDefaultUserDataPath }] = await Promise.all([
|
||||
import('./runtime/environments.js'),
|
||||
import('./runtime-client.js')
|
||||
])
|
||||
const environments = listEnvironments(getDefaultUserDataPath()).map((candidate) => ({
|
||||
id: candidate.id,
|
||||
name: candidate.name
|
||||
}))
|
||||
const targetId = await resolveSshHostTargetId(client, host.targetId, environments)
|
||||
return parseExecutionHostId(toSshExecutionHostId(targetId)) ?? host
|
||||
}
|
||||
|
||||
// Why: the store refuses an ambiguous environment name rather than guessing which server was
|
||||
// meant. Resolving one here would put the guess back, in the flag whose whole purpose is to stop
|
||||
// a command reaching a machine the caller did not choose.
|
||||
function assertEnvironmentNameUnambiguous(
|
||||
environments: readonly { id: string; name: string }[],
|
||||
name: string,
|
||||
flag: string
|
||||
): void {
|
||||
const ambiguous = ambiguousEnvironments(environments, name)
|
||||
if (ambiguous.length === 0) {
|
||||
return
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Ambiguous Orca server in ${flag}: ${ambiguous.length} paired servers are named ${name}. Use the environment id.`,
|
||||
{
|
||||
knownEnvironments: ambiguous,
|
||||
nextSteps: ambiguous.map(
|
||||
(candidate) => `Use --host runtime:${candidate.id} for the server named ${candidate.name}.`
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the inverse of the --host case. `--environment openclaw` failed with a bare "Unknown
|
||||
// environment", when openclaw is very often an SSH target — a different axis, not a typo. The
|
||||
// store's own error cannot carry the hint (translateStoreError forwards code and message only,
|
||||
// dropping data), so resolve the selector here where the payload survives.
|
||||
export async function assertEnvironmentSelectorResolvable(
|
||||
selector: string,
|
||||
listSshTargetsForSuggestion: () => Promise<SshTargetSummary[]>
|
||||
): Promise<void> {
|
||||
const [{ listEnvironments }, { getDefaultUserDataPath }] = await Promise.all([
|
||||
import('./runtime/environments.js'),
|
||||
import('./runtime-client.js')
|
||||
])
|
||||
const environments = listEnvironments(getDefaultUserDataPath()).map((candidate) => ({
|
||||
id: candidate.id,
|
||||
name: candidate.name
|
||||
}))
|
||||
if (findEnvironmentByName(environments, selector)) {
|
||||
return
|
||||
}
|
||||
assertEnvironmentNameUnambiguous(environments, selector, `--environment ${selector}`)
|
||||
const sshTargets = await listSshTargetsForSuggestion()
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown Orca server in --environment ${selector}: no paired Orca server is named or has id ${selector}.`,
|
||||
{
|
||||
knownEnvironments: environments,
|
||||
knownSshTargets: sshTargets,
|
||||
nextSteps: [
|
||||
...crossKindNextSteps(selector, { environments, sshTargets }, 'environment'),
|
||||
'Run `orca host list` to see every machine you can target and the flag for each.'
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -175,6 +175,26 @@ function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type HostListEntry = {
|
||||
kind: 'local' | 'ssh' | 'environment'
|
||||
name: string
|
||||
id: string
|
||||
selector: string
|
||||
}
|
||||
|
||||
// Why: the selector column is the point of this command — the name alone is what callers already
|
||||
// had, and passing it on the wrong axis is the mistake this output exists to prevent.
|
||||
export function formatHostList(result: { hosts: HostListEntry[] }): string {
|
||||
const kindLabel: Record<HostListEntry['kind'], string> = {
|
||||
local: 'local',
|
||||
ssh: 'ssh target',
|
||||
environment: 'orca server'
|
||||
}
|
||||
return result.hosts
|
||||
.map((host) => `${kindLabel[host.kind].padEnd(11)} ${host.name} -> ${host.selector}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatCliStatus(status: CliStatusResult): string {
|
||||
return [
|
||||
...(status.target && status.target.kind === 'environment'
|
||||
|
||||
@@ -194,7 +194,13 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
},
|
||||
{
|
||||
name: 'environment',
|
||||
keys: ['environment add', 'environment list', 'environment show', 'environment rm'],
|
||||
keys: [
|
||||
'host list',
|
||||
'environment add',
|
||||
'environment list',
|
||||
'environment show',
|
||||
'environment rm'
|
||||
],
|
||||
load: async () => (await import('./handlers/environment.js')).ENVIRONMENT_HANDLERS
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { printResult } from '../format'
|
||||
import { getRequiredStringFlag } from '../flags'
|
||||
import { getRequiredStringFlag, getRequiredStringFlagAllowingEmpty } from '../flags'
|
||||
import { getBrowserCommandTarget } from '../selectors'
|
||||
|
||||
export const BROWSER_STORAGE_HANDLERS: Record<string, CommandHandler> = {
|
||||
@@ -12,7 +12,8 @@ export const BROWSER_STORAGE_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
'storage local set': async ({ flags, client, cwd, json }) => {
|
||||
const key = getRequiredStringFlag(flags, 'key')
|
||||
const value = getRequiredStringFlag(flags, 'value')
|
||||
// Why: the server accepts any string value; setItem(key, '') differs from an unset key.
|
||||
const value = getRequiredStringFlagAllowingEmpty(flags, 'value')
|
||||
const target = await getBrowserCommandTarget(flags, cwd, client)
|
||||
const result = await client.call<unknown>('browser.storage.local.set', {
|
||||
key,
|
||||
@@ -34,7 +35,7 @@ export const BROWSER_STORAGE_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
'storage session set': async ({ flags, client, cwd, json }) => {
|
||||
const key = getRequiredStringFlag(flags, 'key')
|
||||
const value = getRequiredStringFlag(flags, 'value')
|
||||
const value = getRequiredStringFlagAllowingEmpty(flags, 'value')
|
||||
const target = await getBrowserCommandTarget(flags, cwd, client)
|
||||
const result = await client.call<unknown>('browser.storage.session.set', {
|
||||
key,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatEnvironment, formatEnvironmentList, printResult } from '../format'
|
||||
import { formatEnvironment, formatEnvironmentList, formatHostList, printResult } from '../format'
|
||||
import { listSshTargets } from '../host-selector-alternatives'
|
||||
import { getDefaultUserDataPath, RuntimeClientError } from '../runtime-client'
|
||||
import type { RuntimeRpcSuccess } from '../runtime-client'
|
||||
import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
|
||||
@@ -29,6 +30,29 @@ export const ENVIRONMENT_HANDLERS: Record<string, CommandHandler> = {
|
||||
`Saved environment ${result.environment.name} (${result.environment.id}).`
|
||||
)
|
||||
},
|
||||
// Why: an agent told "run it on <name>" had nowhere to look. `orca environment list` showed
|
||||
// paired servers only, and nothing in the CLI listed SSH targets at all, so the wrong-axis
|
||||
// guess was the only move available. This is the one place that answers both.
|
||||
'host list': async ({ client, json }) => {
|
||||
const environments = listEnvironments(getDefaultUserDataPath()).map((environment) => ({
|
||||
kind: 'environment' as const,
|
||||
name: environment.name,
|
||||
id: environment.id,
|
||||
selector: `--environment ${environment.name}`
|
||||
}))
|
||||
const sshTargets = (await listSshTargets(client)).map((target) => ({
|
||||
kind: 'ssh' as const,
|
||||
name: target.label,
|
||||
id: target.id,
|
||||
selector: `--host ssh:${target.id}`
|
||||
}))
|
||||
const hosts = [
|
||||
{ kind: 'local' as const, name: 'this machine', id: 'local', selector: '--host local' },
|
||||
...sshTargets,
|
||||
...environments
|
||||
]
|
||||
printResult(localSuccess({ hosts }), json, formatHostList)
|
||||
},
|
||||
'environment list': async ({ json }) => {
|
||||
const environments = listEnvironments(getDefaultUserDataPath()).map(redactRuntimeEnvironment)
|
||||
printResult(localSuccess({ environments }), json, formatEnvironmentList)
|
||||
|
||||
@@ -117,6 +117,46 @@ describe('orca linear CLI handlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('prints truncation on human stdout and in JSON without using stderr for JSON', async () => {
|
||||
const listResult = {
|
||||
issues: [
|
||||
{
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-1',
|
||||
title: 'Fix auth',
|
||||
url: 'https://linear.app/acme/issue/ENG-1',
|
||||
labels: [],
|
||||
workspace: { id: 'workspace-1', name: 'Acme' }
|
||||
}
|
||||
],
|
||||
truncated: true,
|
||||
meta: {
|
||||
limit: 1,
|
||||
returned: 1,
|
||||
hasMore: true,
|
||||
nextCursor: 'next-page',
|
||||
orderBy: 'updatedAt',
|
||||
workspaceId: 'workspace-1',
|
||||
partial: false,
|
||||
workspaceErrors: []
|
||||
}
|
||||
}
|
||||
queueFixtures(callMock, okFixture('req_list', listResult))
|
||||
await main(['linear', 'list-issues', '--limit', '1'], '/tmp/repo')
|
||||
expect(vi.mocked(console.log).mock.calls[0][0]).toContain('truncated: showing 1')
|
||||
expect(
|
||||
vi.mocked(console.error).mock.calls.some((call) => String(call[0]).includes('more results'))
|
||||
).toBe(true)
|
||||
|
||||
vi.mocked(console.log).mockClear()
|
||||
vi.mocked(console.error).mockClear()
|
||||
queueFixtures(callMock, okFixture('req_list_json', listResult))
|
||||
await main(['linear', 'list-issues', '--limit', '1', '--json'], '/tmp/repo')
|
||||
const jsonOut = String(vi.mocked(console.log).mock.calls[0][0])
|
||||
expect(jsonOut).toContain('"truncated": true')
|
||||
expect(vi.mocked(console.error)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps global boolean flags before Linear commands from consuming command tokens', async () => {
|
||||
queueFixtures(callMock, okFixture('req_linear', issueResult()))
|
||||
|
||||
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
formatProjectList,
|
||||
printResult
|
||||
} from '../format'
|
||||
import { hostFilterMatchesHostId, parseHostFlag } from '../execution-host-flag'
|
||||
import {
|
||||
hostFilterMatchesHostId,
|
||||
parseHostFlag,
|
||||
resolveHostFlagTarget
|
||||
} from '../execution-host-flag'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
|
||||
import { resolveRepoPathArgument } from '../repo-path-arguments'
|
||||
import { RuntimeClientError, type RuntimeRpcSuccess } from '../runtime-client'
|
||||
@@ -53,6 +57,20 @@ async function callProjectHostSetup<TResult>(
|
||||
}
|
||||
}
|
||||
|
||||
async function getResolvedHostId(
|
||||
flags: Map<string, string | boolean>,
|
||||
client: HandlerContext['client']
|
||||
): Promise<ExecutionHostId> {
|
||||
const host = await resolveHostFlagTarget(flags, client)
|
||||
if (!host) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --host')
|
||||
}
|
||||
return host.id
|
||||
}
|
||||
|
||||
// Why: the setup paths keep the unresolved id on purpose. The runtime rejects every `ssh:` host
|
||||
// for those operations regardless of whether it exists, so resolving first would answer "no such
|
||||
// target" and imply the command would have worked with the right id.
|
||||
function getRequiredHostId(flags: Map<string, string | boolean>): ExecutionHostId {
|
||||
const host = parseHostFlag(flags)
|
||||
if (!host) {
|
||||
@@ -79,7 +97,7 @@ export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
'project setups': async ({ flags, client, json }) => {
|
||||
const projectFilter = getOptionalStringFlag(flags, 'project')
|
||||
const hostFilter = parseHostFlag(flags)
|
||||
const hostFilter = await resolveHostFlagTarget(flags, client)
|
||||
const result = await callProjectHostSetup<{ setups: ProjectHostSetup[] }>(
|
||||
client,
|
||||
'projectHostSetup.list'
|
||||
@@ -132,7 +150,12 @@ export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
|
||||
const path = getOptionalStringFlag(flags, 'path')
|
||||
const args: ProjectHostSetupCreateArgs = {
|
||||
projectId: getRequiredStringFlag(flags, 'project'),
|
||||
hostId: getRequiredHostId(flags),
|
||||
// Why: unlike the setup paths below, the runtime does not reject `ssh:` here — this records
|
||||
// independent metadata — so an unknown target would persist a row pointing at a machine
|
||||
// that does not exist. Resolving catches that. `local` and `runtime:` pass through
|
||||
// untouched, because this is also the provisioning path and a runtime host legitimately
|
||||
// may not exist yet when its metadata is written.
|
||||
hostId: await getResolvedHostId(flags, client),
|
||||
setupId: getOptionalStringFlag(flags, 'setup-id'),
|
||||
path:
|
||||
path === undefined
|
||||
|
||||
+11
-1
@@ -30,6 +30,9 @@ Skills:
|
||||
skills install Install bundled Orca skills globally via the community skills CLI
|
||||
skills update Update already-installed Orca skills via the community skills CLI
|
||||
|
||||
Hosts:
|
||||
host list List targetable machines and how to name each one
|
||||
|
||||
Environments:
|
||||
environment add Save a remote Orca runtime from a pairing code
|
||||
environment list List saved remote Orca runtimes
|
||||
@@ -225,6 +228,7 @@ Common Commands:
|
||||
orca agent-context [--json]
|
||||
orca account add [--agent claude|codex] [--json]
|
||||
orca account list [--json]
|
||||
orca host list [--json]
|
||||
orca environment add --name <name> --pairing-code <code> [--json]
|
||||
orca environment list [--json]
|
||||
orca environment show --environment <selector> [--json]
|
||||
@@ -448,7 +452,13 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
return '--workspace <id|all> Connected Linear workspace id, or all'
|
||||
}
|
||||
if (command === 'linear list-issues' && flag === 'cursor') {
|
||||
return '--cursor <cursor> Opaque cursor returned by a previous list-issues page'
|
||||
return '--cursor <cursor> Opaque cursor from a previous list-issues page; issued cursors bind the workspace, raw Linear cursors need --workspace'
|
||||
}
|
||||
if (command === 'linear list-issues' && flag === 'priority') {
|
||||
return '--priority <0-4> 0=none, 1=urgent, 2=high, 3=medium, 4=low'
|
||||
}
|
||||
if (command === 'linear list-issues' && flag === 'limit') {
|
||||
return '--limit <n> Max issues to return; omit to return every match'
|
||||
}
|
||||
if (command === 'artifacts list' && flag === 'cursor') {
|
||||
return '--cursor <cursor> Opaque cursor returned by a previous artifacts page'
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ambiguousEnvironments,
|
||||
ambiguousSshTargets,
|
||||
crossKindNextSteps,
|
||||
findEnvironmentByName,
|
||||
findSshTargetByName,
|
||||
listSshTargets,
|
||||
resolveSshHostTargetId
|
||||
} from './host-selector-alternatives'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
|
||||
const SSH_TARGETS = [{ id: 'ssh-1755000000000-a1b2c3', label: 'openclaw' }]
|
||||
const ENVIRONMENTS = [{ id: '03ef704c-b180-4b10-998d-e28fbd5de9a3', name: 'awin' }]
|
||||
|
||||
function clientReturning(targets: { id: string; label: string }[]): RuntimeClient {
|
||||
return {
|
||||
call: vi.fn(async () => ({ result: { targets } }))
|
||||
} as unknown as RuntimeClient
|
||||
}
|
||||
|
||||
describe('findSshTargetByName', () => {
|
||||
// Why: ids are machine-generated `ssh-<timestamp>-<random>`, so the label is the only name a
|
||||
// person or agent ever has. Matching ids alone reports "not found" for a target that exists.
|
||||
it('matches the human label as well as the generated id', () => {
|
||||
expect(findSshTargetByName(SSH_TARGETS, 'openclaw')?.id).toBe('ssh-1755000000000-a1b2c3')
|
||||
expect(findSshTargetByName(SSH_TARGETS, 'ssh-1755000000000-a1b2c3')?.label).toBe('openclaw')
|
||||
expect(findSshTargetByName(SSH_TARGETS, 'OpenClaw')?.label).toBe('openclaw')
|
||||
})
|
||||
|
||||
it('does not invent a match', () => {
|
||||
expect(findSshTargetByName(SSH_TARGETS, 'awin')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findEnvironmentByName', () => {
|
||||
it('matches a paired server by name or id', () => {
|
||||
expect(findEnvironmentByName(ENVIRONMENTS, 'awin')?.id).toBe(ENVIRONMENTS[0]!.id)
|
||||
expect(findEnvironmentByName(ENVIRONMENTS, ENVIRONMENTS[0]!.id)?.name).toBe('awin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('crossKindNextSteps', () => {
|
||||
const alternatives = { sshTargets: SSH_TARGETS, environments: ENVIRONMENTS }
|
||||
|
||||
it('points an SSH name at the SSH flag when a server was asked for', () => {
|
||||
const steps = crossKindNextSteps('openclaw', alternatives, 'environment')
|
||||
|
||||
expect(steps.join('\n')).toContain('--host ssh:ssh-1755000000000-a1b2c3')
|
||||
expect(steps.join('\n')).toContain('is an SSH target')
|
||||
})
|
||||
|
||||
it('points a server name at --environment when an SSH target was asked for', () => {
|
||||
const steps = crossKindNextSteps('awin', alternatives, 'ssh')
|
||||
|
||||
expect(steps.join('\n')).toContain('--environment awin')
|
||||
expect(steps.join('\n')).toContain('is a paired Orca server')
|
||||
})
|
||||
|
||||
it('says nothing when the name exists on neither axis', () => {
|
||||
expect(crossKindNextSteps('nowhere', alternatives, 'ssh')).toEqual([])
|
||||
})
|
||||
|
||||
// Why: suggesting the axis the caller already used would be noise, not help.
|
||||
it('does not suggest the axis that was already requested', () => {
|
||||
expect(crossKindNextSteps('openclaw', alternatives, 'ssh')).toEqual([])
|
||||
expect(crossKindNextSteps('awin', alternatives, 'environment')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSshHostTargetId', () => {
|
||||
it('resolves a label to the target id', async () => {
|
||||
await expect(
|
||||
resolveSshHostTargetId(clientReturning(SSH_TARGETS), 'openclaw', ENVIRONMENTS)
|
||||
).resolves.toBe('ssh-1755000000000-a1b2c3')
|
||||
})
|
||||
|
||||
// Why: this used to answer ok:true with an empty list — the same silent wrong-machine result
|
||||
// that unknown runtime ids gave before they were rejected.
|
||||
it('rejects an unknown target instead of letting it filter to nothing', async () => {
|
||||
await expect(
|
||||
resolveSshHostTargetId(clientReturning(SSH_TARGETS), 'nowhere', ENVIRONMENTS)
|
||||
).rejects.toThrow('no SSH target named or with id nowhere')
|
||||
})
|
||||
|
||||
it('names the paired server when an SSH target was asked for by a server name', async () => {
|
||||
await expect(
|
||||
resolveSshHostTargetId(clientReturning(SSH_TARGETS), 'awin', ENVIRONMENTS)
|
||||
).rejects.toMatchObject({
|
||||
data: {
|
||||
nextSteps: expect.arrayContaining([expect.stringContaining('--environment awin')])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('says so plainly when the host has no SSH targets at all', async () => {
|
||||
await expect(
|
||||
resolveSshHostTargetId(clientReturning([]), 'openclaw', ENVIRONMENTS)
|
||||
).rejects.toMatchObject({
|
||||
data: { nextSteps: expect.arrayContaining(['This Orca host has no SSH targets registered.']) }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('listSshTargets', () => {
|
||||
// Why: an older host answers listTargets but not listTargetSummaries. Treating that as "no
|
||||
// targets" would reject an ssh id that is valid on that host.
|
||||
it('falls back to the older listing when the newer method is absent', async () => {
|
||||
const { RuntimeClientError } = await import('./runtime/types.js')
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === 'ssh.listTargetSummaries') {
|
||||
throw new RuntimeClientError('method_not_found', 'Unknown method')
|
||||
}
|
||||
return { result: { targets: SSH_TARGETS } }
|
||||
})
|
||||
|
||||
await expect(listSshTargets({ call } as unknown as RuntimeClient)).resolves.toEqual(SSH_TARGETS)
|
||||
expect(call).toHaveBeenCalledWith('ssh.listTargets')
|
||||
})
|
||||
|
||||
// Why: this only ever runs to enrich an error we are already reporting; a failure here must
|
||||
// not replace that error with a confusing one about SSH enumeration.
|
||||
it('returns nothing rather than masking the error it was enriching', async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
}
|
||||
|
||||
await expect(listSshTargets(client as unknown as RuntimeClient)).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ambiguous names never resolve silently', () => {
|
||||
const twoOpenclaw = [
|
||||
{ id: 'ssh-1-a', label: 'openclaw' },
|
||||
{ id: 'ssh-2-b', label: 'openclaw' }
|
||||
]
|
||||
const twoAwin = [
|
||||
{ id: 'env-1', name: 'awin' },
|
||||
{ id: 'env-2', name: 'awin' }
|
||||
]
|
||||
|
||||
// Why: picking the first would choose a machine on the caller's behalf — the exact failure the
|
||||
// whole selector path exists to prevent.
|
||||
it('refuses to guess between two ssh targets sharing a label', () => {
|
||||
expect(findSshTargetByName(twoOpenclaw, 'openclaw')).toBeUndefined()
|
||||
expect(ambiguousSshTargets(twoOpenclaw, 'openclaw')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('refuses to guess between two servers sharing a name', () => {
|
||||
expect(findEnvironmentByName(twoAwin, 'awin')).toBeUndefined()
|
||||
expect(ambiguousEnvironments(twoAwin, 'awin')).toHaveLength(2)
|
||||
})
|
||||
|
||||
// An exact id is never ambiguous, even when labels collide.
|
||||
it('still resolves an exact id past a colliding label', () => {
|
||||
expect(findSshTargetByName(twoOpenclaw, 'ssh-2-b')?.id).toBe('ssh-2-b')
|
||||
expect(findEnvironmentByName(twoAwin, 'env-2')?.id).toBe('env-2')
|
||||
})
|
||||
|
||||
it('reports no ambiguity for a unique name', () => {
|
||||
expect(ambiguousSshTargets(SSH_TARGETS, 'openclaw')).toEqual([])
|
||||
expect(ambiguousEnvironments(ENVIRONMENTS, 'awin')).toEqual([])
|
||||
})
|
||||
|
||||
it('names both candidates when an ssh label is ambiguous', async () => {
|
||||
await expect(
|
||||
resolveSshHostTargetId(clientReturning(twoOpenclaw), 'openclaw', ENVIRONMENTS)
|
||||
).rejects.toMatchObject({
|
||||
data: {
|
||||
nextSteps: expect.arrayContaining([
|
||||
expect.stringContaining('ssh-1-a'),
|
||||
expect.stringContaining('ssh-2-b')
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
|
||||
export type SshTargetSummary = { id: string; label: string }
|
||||
export type EnvironmentSummary = { id: string; name: string }
|
||||
|
||||
export type HostAlternatives = {
|
||||
sshTargets: readonly SshTargetSummary[]
|
||||
environments: readonly EnvironmentSummary[]
|
||||
}
|
||||
|
||||
// Why: SSH target ids are machine-generated (`ssh-<timestamp>-<random>`), so the name a person
|
||||
// or an agent actually knows is the label. Looking a name up as an id alone reports "not found"
|
||||
// for a target that is sitting right there.
|
||||
export function findSshTargetByName(
|
||||
targets: readonly SshTargetSummary[],
|
||||
name: string
|
||||
): SshTargetSummary | undefined {
|
||||
const byId = targets.find((target) => target.id === name)
|
||||
if (byId) {
|
||||
return byId
|
||||
}
|
||||
const byLabel = matchesByLabel(targets, name)
|
||||
// Why: two targets can share a label. Picking the first would silently choose a machine for
|
||||
// the caller — the failure this whole selector path exists to prevent — so an ambiguous name
|
||||
// resolves to nothing and the caller is told to use an id.
|
||||
return byLabel.length === 1 ? byLabel[0] : undefined
|
||||
}
|
||||
|
||||
function matchesByLabel(targets: readonly SshTargetSummary[], name: string): SshTargetSummary[] {
|
||||
const wanted = name.trim().toLowerCase()
|
||||
return targets.filter((target) => target.label.trim().toLowerCase() === wanted)
|
||||
}
|
||||
|
||||
export function ambiguousSshTargets(
|
||||
targets: readonly SshTargetSummary[],
|
||||
name: string
|
||||
): SshTargetSummary[] {
|
||||
const byLabel = matchesByLabel(targets, name)
|
||||
return byLabel.length > 1 ? byLabel : []
|
||||
}
|
||||
|
||||
export function findEnvironmentByName(
|
||||
environments: readonly EnvironmentSummary[],
|
||||
name: string
|
||||
): EnvironmentSummary | undefined {
|
||||
const byId = environments.find((environment) => environment.id === name)
|
||||
if (byId) {
|
||||
return byId
|
||||
}
|
||||
const byName = matchesByEnvironmentName(environments, name)
|
||||
// Why: the environment store itself refuses an ambiguous name rather than guessing; resolving
|
||||
// one here would quietly reintroduce the guess it exists to prevent.
|
||||
return byName.length === 1 ? byName[0] : undefined
|
||||
}
|
||||
|
||||
function matchesByEnvironmentName(
|
||||
environments: readonly EnvironmentSummary[],
|
||||
name: string
|
||||
): EnvironmentSummary[] {
|
||||
const wanted = name.trim().toLowerCase()
|
||||
return environments.filter((environment) => environment.name.trim().toLowerCase() === wanted)
|
||||
}
|
||||
|
||||
export function ambiguousEnvironments(
|
||||
environments: readonly EnvironmentSummary[],
|
||||
name: string
|
||||
): EnvironmentSummary[] {
|
||||
const byName = matchesByEnvironmentName(environments, name)
|
||||
return byName.length > 1 ? byName : []
|
||||
}
|
||||
|
||||
// Why: a paired Orca server and an SSH target are different machines reached different ways, but
|
||||
// a caller only knows "the machine called X". When X misses on one axis, the useful answer is
|
||||
// almost always that it exists on the other — so say which, and give the exact flag.
|
||||
export function crossKindNextSteps(
|
||||
name: string,
|
||||
alternatives: HostAlternatives,
|
||||
requested: 'environment' | 'ssh'
|
||||
): string[] {
|
||||
const steps: string[] = []
|
||||
const ssh = findSshTargetByName(alternatives.sshTargets, name)
|
||||
const environment = findEnvironmentByName(alternatives.environments, name)
|
||||
if (requested !== 'ssh' && ssh) {
|
||||
steps.push(
|
||||
`"${name}" is an SSH target on this Orca host, not a paired server. Use --host ssh:${ssh.id}.`
|
||||
)
|
||||
}
|
||||
if (requested !== 'environment' && environment) {
|
||||
steps.push(
|
||||
`"${name}" is a paired Orca server, not an SSH target. Use --environment ${environment.name}.`
|
||||
)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
// Why: only display identity crosses this boundary — the RPC deliberately withholds addresses
|
||||
// and credentials — and an enumeration failure must never mask the error we are explaining.
|
||||
export async function listSshTargets(client: RuntimeClient): Promise<SshTargetSummary[]> {
|
||||
try {
|
||||
const result = await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries')
|
||||
return result.result.targets
|
||||
} catch (error) {
|
||||
// Why: hosts predating listTargetSummaries still answer listTargets, and both are served by
|
||||
// the same summariser. Without this an old host looks like one with no SSH targets at all,
|
||||
// which would reject a target id that is actually valid there.
|
||||
if (error instanceof Error && 'code' in error && error.code === 'method_not_found') {
|
||||
try {
|
||||
const legacy = await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargets')
|
||||
return legacy.result.targets
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Why: `--host ssh:<id>` was never validated, so an unknown target answered ok:true with an
|
||||
// empty list — the same silent wrong-machine answer that `runtime:` ids used to give. And since
|
||||
// target ids are machine-generated (`ssh-<timestamp>-<random>`), the label a caller actually
|
||||
// knows never matches one, so this fires on the common spelling rather than a rare typo.
|
||||
// Resolving the label and naming the alternative is what makes the failure recoverable.
|
||||
export async function resolveSshHostTargetId(
|
||||
client: RuntimeClient,
|
||||
targetId: string,
|
||||
environments: readonly EnvironmentSummary[]
|
||||
): Promise<string> {
|
||||
const targets = await listSshTargets(client)
|
||||
const matched = findSshTargetByName(targets, targetId)
|
||||
if (matched) {
|
||||
return matched.id
|
||||
}
|
||||
const { RuntimeClientError } = await import('./runtime/types.js')
|
||||
const ambiguous = ambiguousSshTargets(targets, targetId)
|
||||
if (ambiguous.length > 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Ambiguous SSH target in --host ssh:${targetId}: ${ambiguous.length} targets share that label. Use the target id.`,
|
||||
{
|
||||
knownSshTargets: ambiguous,
|
||||
nextSteps: ambiguous.map((target) => `Use --host ssh:${target.id} for ${target.label}.`)
|
||||
}
|
||||
)
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown SSH target in --host ssh:${targetId}: this Orca host has no SSH target named or with id ${targetId}.`,
|
||||
{
|
||||
knownSshTargets: targets,
|
||||
knownEnvironments: environments,
|
||||
nextSteps: [
|
||||
...crossKindNextSteps(targetId, { sshTargets: targets, environments }, 'ssh'),
|
||||
...(targets.length > 0
|
||||
? [`Known SSH targets: ${targets.map((target) => target.label).join(', ')}.`]
|
||||
: ['This Orca host has no SSH targets registered.'])
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -220,9 +220,10 @@ describe('orca cli worktree awareness', () => {
|
||||
|
||||
await main(['project', 'setups', '--host', 'runtime:not-a-real-env', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
// The command itself never reached a runtime; only the suggestion lookup did.
|
||||
expect(callMock).not.toHaveBeenCalledWith('projectHostSetup.list')
|
||||
const printed = [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')
|
||||
expect(printed).toContain('no paired environment has id not-a-real-env')
|
||||
expect(printed).toContain('no paired Orca server is named or has id not-a-real-env')
|
||||
// An agent reads the code and the retry candidates, not the prose.
|
||||
expect(JSON.parse(printed).error.code).toBe('invalid_argument')
|
||||
expect(JSON.parse(printed).error.data.knownEnvironments).toEqual([])
|
||||
@@ -231,6 +232,124 @@ describe('orca cli worktree awareness', () => {
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
// Why: ssh: was never validated, so an unknown target answered ok:true with an empty list —
|
||||
// the same silent wrong-machine answer unknown runtime ids used to give.
|
||||
it('rejects an unknown ssh host instead of answering empty', async () => {
|
||||
queueFixtures(callMock, okFixture('req_ssh_targets', { targets: [] }))
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['project', 'setups', '--host', 'ssh:openclaw', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalledWith('projectHostSetup.list')
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'no SSH target named or with id openclaw'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('tells a caller reaching for a paired server by ssh that it is an environment', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'awin')
|
||||
queueFixtures(callMock, okFixture('req_ssh_targets', { targets: [] }))
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['project', 'setups', '--host', 'ssh:awin', '--json'], '/tmp/repo')
|
||||
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'--environment awin'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('resolves an ssh label to its target id before filtering', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_ssh_targets', { targets: [{ id: 'ssh-123-abc', label: 'openclaw' }] }),
|
||||
okFixture('req_project_setups', {
|
||||
setups: [
|
||||
{
|
||||
id: 'setup-openclaw',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:ssh-123-abc',
|
||||
repoId: 'repo-openclaw',
|
||||
path: '/home/me/orca',
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['project', 'setups', '--host', 'ssh:openclaw'], '/tmp/repo')
|
||||
|
||||
expect(logSpy.mock.calls[0]?.[0]).toContain('setup-openclaw')
|
||||
})
|
||||
|
||||
// Why: `runtime:<id>` is a persisted token — it lands in ProjectHostSetup.hostId and is
|
||||
// embedded in generated setup ids. Accepting a name is only safe because it is canonicalized
|
||||
// to the id before anything downstream sees it; this pins that.
|
||||
it('never lets an environment name reach a persisted host id', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'env-uuid-1', 'awin')
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setup_create', {
|
||||
result: {
|
||||
project: {
|
||||
id: 'github:stablyai/orca',
|
||||
displayName: 'Orca',
|
||||
badgeColor: '#7c3aed',
|
||||
sourceRepoIds: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
setup: {
|
||||
id: 'setup-awin',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
repoId: '',
|
||||
path: '',
|
||||
displayName: 'awin',
|
||||
setupState: 'setting-up',
|
||||
setupMethod: 'provisioned',
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'project',
|
||||
'setup-create',
|
||||
'--project',
|
||||
'github:stablyai/orca',
|
||||
'--host',
|
||||
'runtime:awin',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'env-uuid-1')
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'projectHostSetup.create',
|
||||
expect.objectContaining({ hostId: 'runtime:env-uuid-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a malformed --host value before contacting any runtime', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
@@ -403,9 +403,13 @@ describe('orca root help', () => {
|
||||
|
||||
const listIssuesHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(listIssuesHelp).toContain(
|
||||
'--cursor <cursor> Opaque cursor returned by a previous list-issues page'
|
||||
'--cursor <cursor> Opaque cursor from a previous list-issues page; issued cursors bind the workspace, raw Linear cursors need --workspace'
|
||||
)
|
||||
expect(listIssuesHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
|
||||
expect(listIssuesHelp).toContain('0=none, 1=urgent, 2=high, 3=medium, 4=low')
|
||||
expect(listIssuesHelp).toContain(
|
||||
'--limit <n> Max issues to return; omit to return every match'
|
||||
)
|
||||
expect(listIssuesHelp).not.toContain('Line cursor from a previous read')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
+23
-1
@@ -9,7 +9,11 @@ import {
|
||||
validateCommandAndFlags
|
||||
} from './args'
|
||||
import { dispatch } from './dispatch'
|
||||
import { resolveHostFlagEnvironmentId } from './execution-host-flag'
|
||||
import {
|
||||
assertEnvironmentSelectorResolvable,
|
||||
resolveHostFlagEnvironmentId
|
||||
} from './execution-host-flag'
|
||||
import { listSshTargets } from './host-selector-alternatives'
|
||||
import { reportCliError } from './format'
|
||||
import { printHelp } from './help'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
@@ -90,6 +94,15 @@ export async function main(
|
||||
const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
|
||||
const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
|
||||
const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
|
||||
// Why: only the explicit flag is asserted eagerly. An ambient ORCA_ENVIRONMENT is background
|
||||
// config, and failing local-only commands because of a stale one would be a regression; the
|
||||
// explicit flag means the caller named that machine, so a bad name should fail immediately
|
||||
// with the cross-kind hint rather than a bare store error at first use.
|
||||
const listSshTargetsForSuggestion = async (): Promise<{ id: string; label: string }[]> =>
|
||||
listSshTargets(new RuntimeClientClass(undefined, undefined, null, null))
|
||||
if (typeof environmentSelector === 'string') {
|
||||
await assertEnvironmentSelectorResolvable(environmentSelector, listSshTargetsForSuggestion)
|
||||
}
|
||||
// Why: --host runtime:<id> names a paired server, not a filter over this
|
||||
// runtime's rows, so it has to pick the connection before the client exists.
|
||||
// An ambient ORCA_ENVIRONMENT is checked for disagreement too — silently
|
||||
@@ -99,6 +112,9 @@ export async function main(
|
||||
const hostEnvironmentId = ignoreRemoteSelection
|
||||
? null
|
||||
: await resolveHostFlagEnvironmentId(parsed.flags, {
|
||||
// Why: only consulted when the name missed, and against this machine's own runtime —
|
||||
// SSH targets are registered there, not in the paired server we failed to find.
|
||||
listSshTargets: listSshTargetsForSuggestion,
|
||||
pairingCode: typeof pairingCode === 'string' ? pairingCode : null,
|
||||
environmentSelector:
|
||||
typeof environmentSelector === 'string'
|
||||
@@ -107,6 +123,12 @@ export async function main(
|
||||
? { value: process.env.ORCA_ENVIRONMENT, label: 'ORCA_ENVIRONMENT' }
|
||||
: null
|
||||
})
|
||||
// Why: --host runtime:<name> is canonicalized to the environment's id so downstream host-id
|
||||
// comparisons against stored rows still match; rewrite the flag once, here, rather than
|
||||
// resolving the name again at every consumer.
|
||||
if (hostEnvironmentId !== null) {
|
||||
parsed.flags.set('host', `runtime:${hostEnvironmentId}`)
|
||||
}
|
||||
// Why: pass `null` (not `undefined`) when remote selection is suppressed
|
||||
// so the RuntimeClient default parameter does not re-activate the
|
||||
// ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import {
|
||||
formatLinearCreate,
|
||||
formatLinearIssue,
|
||||
formatLinearMcpIssueList,
|
||||
formatLinearProjectList,
|
||||
printLinearMcpIssueListWarnings,
|
||||
printLinearSearchWarnings
|
||||
@@ -41,6 +42,7 @@ describe('linear-format', () => {
|
||||
it('binds a list continuation hint to its Linear workspace', () => {
|
||||
printLinearMcpIssueListWarnings({
|
||||
issues: [],
|
||||
truncated: true,
|
||||
meta: {
|
||||
limit: 20,
|
||||
returned: 20,
|
||||
@@ -58,6 +60,66 @@ describe('linear-format', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('prints a stdout truncation marker when a list-issues page is partial', () => {
|
||||
const output = formatLinearMcpIssueList({
|
||||
issues: [
|
||||
{
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-1',
|
||||
title: 'Fix auth',
|
||||
url: 'https://linear.app/acme/issue/ENG-1',
|
||||
labels: [],
|
||||
state: { name: 'In Progress' },
|
||||
assignee: { displayName: 'Ada' },
|
||||
workspace: { id: 'workspace-1', name: 'Acme' }
|
||||
}
|
||||
],
|
||||
truncated: true,
|
||||
meta: {
|
||||
limit: 1,
|
||||
returned: 1,
|
||||
hasMore: true,
|
||||
nextCursor: 'next-page',
|
||||
orderBy: 'updatedAt',
|
||||
workspaceId: 'workspace-1',
|
||||
partial: false,
|
||||
workspaceErrors: []
|
||||
}
|
||||
})
|
||||
|
||||
expect(output).toContain('ENG-1')
|
||||
expect(output).toContain('truncated: showing 1')
|
||||
expect(output).not.toContain(' of ')
|
||||
})
|
||||
|
||||
it('omits the stdout truncation marker when the page is complete', () => {
|
||||
const output = formatLinearMcpIssueList({
|
||||
issues: [
|
||||
{
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-1',
|
||||
title: 'Fix auth',
|
||||
url: 'https://linear.app/acme/issue/ENG-1',
|
||||
labels: [],
|
||||
workspace: { id: 'workspace-1', name: 'Acme' }
|
||||
}
|
||||
],
|
||||
truncated: false,
|
||||
meta: {
|
||||
limit: 50,
|
||||
returned: 1,
|
||||
hasMore: false,
|
||||
orderBy: 'updatedAt',
|
||||
workspaceId: 'workspace-1',
|
||||
partial: false,
|
||||
workspaceErrors: []
|
||||
}
|
||||
})
|
||||
|
||||
expect(output).toContain('ENG-1')
|
||||
expect(output).not.toContain('truncated:')
|
||||
})
|
||||
|
||||
it('includes task fields in issue readback text', () => {
|
||||
const result = {
|
||||
issue: {
|
||||
|
||||
+18
-18
@@ -17,6 +17,8 @@ import type {
|
||||
LinearTeamStatesResult,
|
||||
LinearStatusSetResult
|
||||
} from '../shared/linear/agent-access'
|
||||
import { appendLinearListTruncation } from '../shared/linear/list-truncation-format'
|
||||
import { linearPriorityLabel } from '../shared/linear/priority-label'
|
||||
import {
|
||||
formatLinearProjectListRows,
|
||||
linearProjectListWarningLines
|
||||
@@ -70,7 +72,11 @@ export function formatLinearSearch(result: LinearSearchResult): string {
|
||||
if (result.issues.length === 0) {
|
||||
return 'No Linear issues found.'
|
||||
}
|
||||
return result.issues.map(formatSearchRow).join('\n')
|
||||
return appendLinearListTruncation(
|
||||
result.issues.map(formatSearchRow).join('\n'),
|
||||
result.issues.length,
|
||||
result.truncated ?? result.meta.limitReached
|
||||
)
|
||||
}
|
||||
|
||||
export function formatLinearTeamList(result: LinearTeamListResult): string {
|
||||
@@ -114,14 +120,22 @@ export function formatLinearIssueList(result: LinearIssueListResult): string {
|
||||
if (result.issues.length === 0) {
|
||||
return 'No Linear issues found.'
|
||||
}
|
||||
return result.issues.map(formatSearchRow).join('\n')
|
||||
return appendLinearListTruncation(
|
||||
result.issues.map(formatSearchRow).join('\n'),
|
||||
result.issues.length,
|
||||
result.truncated ?? result.meta.hasMore
|
||||
)
|
||||
}
|
||||
|
||||
export function formatLinearMcpIssueList(result: LinearMcpIssueListResult): string {
|
||||
if (result.issues.length === 0) {
|
||||
return 'No Linear issues found.'
|
||||
}
|
||||
return result.issues.map(formatSearchRow).join('\n')
|
||||
return appendLinearListTruncation(
|
||||
result.issues.map(formatSearchRow).join('\n'),
|
||||
result.issues.length,
|
||||
result.truncated ?? result.meta.hasMore
|
||||
)
|
||||
}
|
||||
|
||||
export function printLinearMcpIssueListWarnings(result: LinearMcpIssueListResult): void {
|
||||
@@ -237,21 +251,7 @@ function formatSearchRow(issue: LinearSearchIssueSummary): string {
|
||||
}
|
||||
|
||||
function formatPriority(priority: number | null | undefined): string {
|
||||
if (priority == null || priority === 0) {
|
||||
return 'none'
|
||||
}
|
||||
switch (priority) {
|
||||
case 1:
|
||||
return 'urgent'
|
||||
case 2:
|
||||
return 'high'
|
||||
case 3:
|
||||
return 'medium'
|
||||
case 4:
|
||||
return 'low'
|
||||
default:
|
||||
return 'none'
|
||||
}
|
||||
return linearPriorityLabel(priority)
|
||||
}
|
||||
|
||||
function taskOperationLabel(operation: LinearIssueTaskUpdateResult['operation']): string {
|
||||
|
||||
@@ -11,6 +11,16 @@ const { constructorArgsMock, callMock, getCliStatusMock } = vi.hoisted(() => ({
|
||||
// Why: `main` reaches RuntimeClient through `await import('./runtime-client.js')`
|
||||
// now. Mocking the same specifier the eager import used proves the dynamic
|
||||
// import still resolves to the module the 10 existing vi.mock suites target.
|
||||
// Why: --environment is now resolved against the paired-environment store before the client is
|
||||
// constructed, so a forwarding assertion needs an environment that actually resolves.
|
||||
vi.mock('./runtime/environments', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
listEnvironments: () => [{ id: 'env-1', name: 'env-1' }]
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./runtime-client', () => {
|
||||
class RuntimeClient {
|
||||
call = callMock
|
||||
@@ -21,7 +31,7 @@ vi.mock('./runtime-client', () => {
|
||||
constructorArgsMock(...args)
|
||||
}
|
||||
}
|
||||
return { RuntimeClient }
|
||||
return { RuntimeClient, getDefaultUserDataPath: () => '/tmp/orca-user-data' }
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
|
||||
@@ -204,7 +204,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'Omit --terminal to target the active terminal in the current worktree.',
|
||||
'By default this returns accumulated terminal output with escape sequences stripped, not the rendered screen. Any program that repaints a line — shells, progress bars, TUIs — comes back as stacked fragments, so one `clear` keystroke by keystroke reads as `cclclecleaclear`, and spaces a prompt draws by moving the cursor are absent.',
|
||||
'Use --screen to read what the terminal actually renders. Prefer it whenever the answer depends on how output looks rather than what was emitted over time; the default is unsuitable for verifying rendered output.',
|
||||
'The result reports source: stream or screen, so a caller can tell which question was answered. A --screen read falls back to source: stream when no rendered state exists rather than passing the stream off as a screen.',
|
||||
'The result reports source: stream when it is accumulated output, screen when it is the rendered screen, and screen-unavailable when a screen was asked for but none could be rendered and the accumulated output is being returned instead. An absent source means the host predates the field.',
|
||||
'--screen and --cursor are mutually exclusive: a screen read is the current frame and has no history to page.',
|
||||
'Use --cursor with the nextCursor value from a previous read to get only new output since that read.',
|
||||
'Use --limit to request more retained lines for long agent responses; output reports oldestCursor when older lines were dropped.',
|
||||
|
||||
@@ -2,6 +2,18 @@ import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['host', 'list'],
|
||||
summary: 'List every machine this Orca host can target, and how to name each one',
|
||||
usage: 'orca host list [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
notes: [
|
||||
'Answers "what can I target and what do I pass" in one place: this machine, the SSH targets registered on it, and the Orca servers paired with it.',
|
||||
'The three kinds are reached differently. A paired Orca server is a connection, selected with --environment <name>. An SSH target is a machine the connected Orca host reaches, selected with --host ssh:<id>. Passing one where the other belongs is the most common way to get an empty or missing-host answer.',
|
||||
"SSH targets are read from the Orca host you are currently connected to, so this lists that host's targets and not another server's."
|
||||
],
|
||||
examples: ['orca host list', 'orca host list --json']
|
||||
},
|
||||
{
|
||||
path: ['environment', 'add'],
|
||||
summary: 'Save a remote Orca runtime environment from a pairing code',
|
||||
|
||||
@@ -67,8 +67,15 @@ export const LINEAR_MCP_COMMAND_SPECS: CommandSpec[] = [
|
||||
],
|
||||
examples: [
|
||||
'orca linear list-issues --team ENG --state started --assignee me --json',
|
||||
'orca linear list-issues --query auth --updated-at -P7D --limit 100 --json',
|
||||
'orca linear list-issues --cursor <cursor> --workspace <id> --json'
|
||||
'orca linear list-issues --query auth --updated-at -P7D --json',
|
||||
'orca linear list-issues --assignee me --priority 1 --limit 100 --json',
|
||||
'orca linear list-issues --cursor <cursor> --json'
|
||||
],
|
||||
notes: [
|
||||
'Omitting --limit returns every match (result.meta.limit is null); --limit <n> caps the read.',
|
||||
'JSON sets result.truncated when a cap held results back; text prints truncated: showing N.',
|
||||
'Reuse --cursor from the previous page. Issued cursors bind the workspace; raw Linear cursors still need --workspace.',
|
||||
'--priority is 0=none, 1=urgent, 2=high, 3=medium, 4=low. JSON includes priorityLabel on each issue.'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { normalizeExecutionHostId, type ParsedExecutionHost } from '../shared/execution-host'
|
||||
import type { ProjectHostSetup } from '../shared/project-types'
|
||||
import { hostFilterMatchesHostId, parseHostFlag } from './execution-host-flag'
|
||||
import { hostFilterMatchesHostId, resolveHostFlagTarget } from './execution-host-flag'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
@@ -73,7 +73,7 @@ export async function resolveProjectCreateTarget(
|
||||
): Promise<ProjectCreateTarget | undefined> {
|
||||
const projectHostSetupId = getPresentStringFlag(flags, 'project-host-setup')
|
||||
const projectId = getPresentStringFlag(flags, 'project')
|
||||
const host = parseHostFlag(flags)
|
||||
const host = await resolveHostFlagTarget(flags, client)
|
||||
if (!projectHostSetupId && !projectId && !host) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -59,15 +59,23 @@ __orca_omp() {
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
function omp { __orca_omp "$@"; }
|
||||
fi
|
||||
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
|
||||
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
|
||||
# Why: a typed alias expands inside the shell, after pane launch prep.
|
||||
__orca_codex_binary="$(command -v codex 2>/dev/null || :)"
|
||||
# Why unalias inside the substitution: an alias named codex makes command -v
|
||||
# report the alias text, and the subshell leaves the user's own alias intact.
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
codex() {
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || :
|
||||
command codex "$@"
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Orca daemon zsh shell-ready wrapper
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
_orca_home="$_orca_resolved_config_dir"
|
||||
if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then
|
||||
_orca_wrapper_zdotdir="$ZDOTDIR"
|
||||
# Why: user startup files resolve plugin/config paths from their own ZDOTDIR;
|
||||
# Orca restores its wrapper dir afterward so zsh still loads wrapper files.
|
||||
export ZDOTDIR="$_orca_home"
|
||||
source "$_orca_home/.zlogin"
|
||||
export ZDOTDIR="$_orca_wrapper_zdotdir"
|
||||
unset _orca_wrapper_zdotdir
|
||||
fi
|
||||
|
||||
(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user