mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
Merge remote-tracking branch 'origin/main' into improve-vps-setup
# Conflicts: # src/renderer/src/i18n/locales/en.json
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
name: Daily macOS Dev Build
|
||||
|
||||
# Why: gives developers a signed macOS build of main once a day that the in-app
|
||||
# updater can install directly, without waiting for an RC cut — less noise than
|
||||
# hourly.
|
||||
#
|
||||
# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules).
|
||||
# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute
|
||||
# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour.
|
||||
#
|
||||
# Deliberately narrow scope:
|
||||
# - macOS only. Other platforms keep using RC/stable.
|
||||
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
|
||||
# and release-cut remain the gates that matter.
|
||||
# - Signed AND notarized, exactly like a release. The notary round trip is the
|
||||
# one slow step kept: macOS anchors a notarized app's TCC grants on identifier
|
||||
# + team rather than on its cdhash, so those grants survive an update. Without
|
||||
# a ticket every daily reads as a new client and silently loses file access
|
||||
# under Documents/Desktop/Downloads.
|
||||
#
|
||||
# Artifacts publish to stablyai/orca-daily, never to stablyai/orca: the main
|
||||
# repo's releases atom feed exposes only its 10 newest entries, so high-volume
|
||||
# dev tags would evict every stable/RC entry and break updates for real users.
|
||||
# Separate from orca-hourly so someone riding main's hourlies never sees the
|
||||
# sparse daily series mixed into that list.
|
||||
#
|
||||
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the
|
||||
# same GitHub App as hourly (installed on orca-daily with Contents: Read and
|
||||
# write). Provision the App secrets with
|
||||
# `bash config/scripts/setup-hourly-release-token.sh`, then grant the App access
|
||||
# to orca-daily with `bash config/scripts/setup-daily-release-repo.sh`:
|
||||
# HOURLY_RELEASE_APP_ID the App's numeric id (shared with hourly/adhoc)
|
||||
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
|
||||
#
|
||||
# Installation tokens live one hour, which is why this mints three times. Install
|
||||
# and build need no token at all, and notarization can hold the publish step for
|
||||
# tens of minutes; minting again once the build is done starts the clock at the
|
||||
# first call that actually uses it rather than burning a third of it on
|
||||
# `pnpm install`. The upload step's own retry budget (2x45m) can outlive that
|
||||
# second token, so a third is minted after it for verify/publish/prune/cleanup —
|
||||
# without it a stuck notary run would strand a draft that cleanup gets a 401 on.
|
||||
# The release stays an unpublished draft until the manifest check passes, so the
|
||||
# worst case is still an invisible draft — and the build-number query counts
|
||||
# drafts, so it holds its number and the next run does not reuse it.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Once a day, early morning Pacific. Single cron — no DST twin, no clock gate.
|
||||
- cron: '15 14 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: Build even if main has not moved since the last daily
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: daily-mac-build
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
DAILY_REPO: stablyai/orca-daily
|
||||
# Keep ~30 days so a regression can be bisected without holding a full year.
|
||||
DAILY_RETAIN_COUNT: 30
|
||||
|
||||
jobs:
|
||||
build-daily-mac:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
# Why 150: it must exceed the worst case the retry budgets below can produce
|
||||
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
|
||||
# the job is killed mid-retry and no cleanup step runs at all. A typical run
|
||||
# is far shorter — this is the notary queue's tail, not its median.
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
# Why: this job only reads stablyai/orca and never pushes; every write
|
||||
# goes to the daily repo through a minted App token passed by env.
|
||||
# Not persisting the checkout credential shrinks the blast radius if a
|
||||
# build step is compromised (zizmor: artipacked).
|
||||
persist-credentials: false
|
||||
|
||||
- name: Mint daily 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-daily
|
||||
|
||||
# Why: main is often idle. Rebuilding an unchanged commit burns a runner
|
||||
# hour and adds a redundant tag to the retention window.
|
||||
- name: Check whether main moved since the last daily
|
||||
id: freshness
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
head_sha="$(git rev-parse HEAD)"
|
||||
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
|
||||
if [[ "$FORCED" == "true" ]]; then
|
||||
echo "should_build=true" >>"$GITHUB_OUTPUT"
|
||||
echo "Forced dispatch; building $head_sha."
|
||||
exit 0
|
||||
fi
|
||||
# The previous daily records its source commit in the release body.
|
||||
# Drafts are excluded: an unpublished leftover never shipped, so treating
|
||||
# it as "the last build" would skip a build that never actually happened.
|
||||
last_body="$(gh release list --repo "$DAILY_REPO" --limit 20 --json tagName,isDraft \
|
||||
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
|
||||
if [[ -z "$last_body" ]]; then
|
||||
echo "should_build=true" >>"$GITHUB_OUTPUT"
|
||||
echo "No prior daily release found; building $head_sha."
|
||||
exit 0
|
||||
fi
|
||||
last_sha="$(gh release view "$last_body" --repo "$DAILY_REPO" --json body \
|
||||
--jq '.body | capture("commit `(?<sha>[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)"
|
||||
if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then
|
||||
echo "should_build=false" >>"$GITHUB_OUTPUT"
|
||||
echo "main is unchanged since $last_body ($last_sha); skipping."
|
||||
else
|
||||
echo "should_build=true" >>"$GITHUB_OUTPUT"
|
||||
echo "main moved to $head_sha (last daily built $last_sha); building."
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/electron
|
||||
~/Library/Caches/electron-builder
|
||||
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-mac-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: signing is what makes a daily installable over an existing Orca, so
|
||||
# a missing cert must fail here rather than after a 20-minute build.
|
||||
- name: Verify macOS signing environment
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
run: node config/scripts/verify-macos-release-env.mjs
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Compute daily version
|
||||
id: daily
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
MAIN_REPO_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Existing titles, which carry the build number this series continues
|
||||
# from. The script picks the number, because it restarts per base version
|
||||
# and only the script knows which base this build resolved to.
|
||||
#
|
||||
# Why drafts count here but not in the freshness check: that check asks
|
||||
# "did this commit ship", where a draft is a no. This one asks "is the
|
||||
# number free", where a stranded draft still holds one.
|
||||
names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \
|
||||
--jq '.[].name // empty')"
|
||||
# Why the main repo's tags decide the base version rather than
|
||||
# package.json: main's version only moves on `release:` commits, and
|
||||
# stable patches are cut from release branches that never merge back, so
|
||||
# package.json can sit several patches behind what users are running. A
|
||||
# separate token because GH_TOKEN above is the App's, scoped to the
|
||||
# daily repo. Empty on failure — the script then falls back to
|
||||
# package.json, which is stale but never wrong enough to fail a build.
|
||||
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
|
||||
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
|
||||
--json tagName --jq '.[].tagName' || true)"
|
||||
echo "Highest published tag seen: $(head -1 <<<"$published")"
|
||||
ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \
|
||||
node config/scripts/daily-build-version.mjs \
|
||||
>"$RUNNER_TEMP/daily-identity.txt"
|
||||
grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt"
|
||||
# Why check rather than trust: the checkout above pins `ref: main`, but a
|
||||
# workflow_dispatch runs this file from whatever branch was dispatched. A
|
||||
# branch that edits this step while main still has the old script yields
|
||||
# an empty name and an untitled release — silent, and only visible once
|
||||
# someone opens the releases page. Fail here instead.
|
||||
if ! grep -q '^name=' "$RUNNER_TEMP/daily-identity.txt"; then
|
||||
echo "::error::daily-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync."
|
||||
exit 1
|
||||
fi
|
||||
cat "$RUNNER_TEMP/daily-identity.txt" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build app
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
run: pnpm build:release
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# Why: daily builds are not an official channel — telemetry's transport
|
||||
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
|
||||
# silent, which is correct for unvetted dev artifacts.
|
||||
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
|
||||
|
||||
# Why a second mint: everything from here on writes to the daily repo, and
|
||||
# the notary round trip inside the publish step can be tens of minutes. The
|
||||
# token minted at the top has already spent install + build of its one hour
|
||||
# on steps that never touched it; restarting the clock here gives the slow
|
||||
# part the full budget.
|
||||
- name: Re-mint daily repo token for publish
|
||||
id: app_token_publish
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
|
||||
owner: stablyai
|
||||
repositories: orca-daily
|
||||
|
||||
- name: Create daily release
|
||||
id: release
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
|
||||
TAG: v${{ steps.daily.outputs.version }}
|
||||
NAME: ${{ steps.daily.outputs.name }}
|
||||
SHA: ${{ steps.freshness.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Kept at 12 even though the title shows 7: the freshness check above
|
||||
# parses this back out of the body to decide whether main has moved.
|
||||
short_sha="${SHA:0:12}"
|
||||
# Why a file rather than an inline string: the publish step re-asserts
|
||||
# this same body, and the freshness check only works if the two agree
|
||||
# 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\`.
|
||||
|
||||
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.
|
||||
EOF
|
||||
# Why create it up front: electron-builder then uploads into a known tag
|
||||
# rather than inferring one from package.json.
|
||||
#
|
||||
# Why --draft: everything between here and the manifest check is a window
|
||||
# where the release exists but has no installable assets. A draft is
|
||||
# absent from the releases list and from listReleaseBuilds, so a job that
|
||||
# dies in that window — including a hard kill by the job timeout, which
|
||||
# runs no cleanup step at all — leaves something invisible rather than a
|
||||
# tag the picker offers and the download 404s on. It is flipped live only
|
||||
# after the manifest is verified.
|
||||
gh release create "$TAG" \
|
||||
--repo "$DAILY_REPO" \
|
||||
--title "$NAME" \
|
||||
--draft \
|
||||
--notes-file "$notes_file"
|
||||
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
|
||||
with:
|
||||
# Why 45 like the release pipeline: an attempt is pack + notarize +
|
||||
# upload, and the notary queue is the unbounded part. Why 2 attempts and
|
||||
# not 3: a missed daily costs a day, and a third attempt buys less than
|
||||
# it costs in runner time once the notary is that stuck.
|
||||
timeout_minutes: 45
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 30
|
||||
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_DAILY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
|
||||
env:
|
||||
# Why: electron-builder's github publisher targets the repo named in the
|
||||
# config; the token must therefore carry write access to orca-daily.
|
||||
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
|
||||
ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }}
|
||||
ORCA_BUILD_COMMIT: ${{ steps.daily.outputs.commit }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
# Why all three: electron-builder's notarize step authenticates to the
|
||||
# Apple notary service with the app-specific password, not with the
|
||||
# signing cert. Omitting them fails the build rather than skipping it,
|
||||
# since `notarize` is now on for this path.
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Why a third mint: the upload step above allows two 45-minute attempts, so
|
||||
# it can outlive the one-hour token minted before it. Every remaining step
|
||||
# writes to the daily repo, including the failure path that discards the
|
||||
# draft — a 401 there is exactly the stranded draft nobody can clean up.
|
||||
#
|
||||
# Why always(): a failed or cancelled upload is the case that needs this
|
||||
# most, since the cleanup step below runs only on that path.
|
||||
- name: Re-mint daily repo token for verify and cleanup
|
||||
id: app_token_final
|
||||
if: always() && steps.freshness.outputs.should_build == 'true'
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
|
||||
owner: stablyai
|
||||
repositories: orca-daily
|
||||
|
||||
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
|
||||
# release missing that manifest is a tag the picker offers and the download
|
||||
# 404s on, so fail loudly instead of leaving a broken entry.
|
||||
- name: Verify update manifest published
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets="$(gh release view "$TAG" --repo "$DAILY_REPO" --json assets --jq '.assets[].name')"
|
||||
echo "Published assets:"
|
||||
echo "$assets"
|
||||
# Why exit 1 without deleting here: the release is still a draft, so it is
|
||||
# already invisible to users, and the failure handler below owns cleanup.
|
||||
# Deleting inline under `set -e` would also let the delete's exit code
|
||||
# preempt this explicit failure.
|
||||
for required in latest-mac.yml; do
|
||||
if ! grep -qx "$required" <<<"$assets"; then
|
||||
echo "::error::Daily draft $TAG is missing $required; the updater could not install it."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! grep -q '\.zip$' <<<"$assets"; then
|
||||
echo "::error::Daily draft $TAG has no ZIP artifact for the updater to download."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Why this is the last mutating step: publishing the draft is what makes the
|
||||
# build visible to listReleaseBuilds. Doing it only after the manifest check
|
||||
# means the picker can never offer a release whose assets are incomplete.
|
||||
- name: Publish the verified release
|
||||
id: publish_live
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
NAME: ${{ steps.daily.outputs.name }}
|
||||
NOTES_FILE: ${{ steps.release.outputs.notes_file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# --title again: electron-builder resolves this draft by tag and may
|
||||
# rewrite its title on upload. Re-asserting here means the name the
|
||||
# picker reads is the one composed above, whatever it did in between.
|
||||
# --notes-file for the same reason, and it matters more: the next run's
|
||||
# freshness check parses the source commit back out of this body, so a
|
||||
# body the publisher overwrote would rebuild an unchanged main daily.
|
||||
gh release edit "$TAG" --repo "$DAILY_REPO" --draft=false --prerelease \
|
||||
--title "$NAME" --notes-file "$NOTES_FILE"
|
||||
echo "Published $TAG as \"$NAME\""
|
||||
|
||||
# Why: a draft left behind by a failed publish is invisible to users but still
|
||||
# holds its tag name, so the next run for the same minute would collide.
|
||||
#
|
||||
# Why it is gated on publish_live not having succeeded: a later failure (the
|
||||
# prune step) must not delete a release that already went live and that users
|
||||
# may already be installing. A job killed by the outer timeout runs no steps
|
||||
# at all — which is exactly why the release stays a draft until verified.
|
||||
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
|
||||
# so without it a manual cancel mid-publish would strand the draft.
|
||||
- name: Discard the draft release on failure
|
||||
if: >-
|
||||
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
|
||||
steps.publish_live.outcome != 'success'
|
||||
shell: bash
|
||||
env:
|
||||
# Fall back to the publish token: if the re-mint itself is what failed,
|
||||
# the older token is the only one left and may still have time on it.
|
||||
GH_TOKEN: ${{ steps.app_token_final.outputs.token || steps.app_token_publish.outputs.token }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# No --cleanup-tag: an unpublished draft never created a git tag.
|
||||
echo "Run failed before publish; discarding draft $TAG"
|
||||
gh release delete "$TAG" --repo "$DAILY_REPO" --yes ||
|
||||
echo "::warning::Could not discard draft $TAG; remove it manually."
|
||||
|
||||
- name: Prune old daily releases
|
||||
# Only after a live publish: $TAG is then a non-draft we must not delete,
|
||||
# and failed runs should not reshuffle retention around a draft that the
|
||||
# failure path is about to discard.
|
||||
if: steps.publish_live.outcome == 'success'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
|
||||
# Protect the tag this run just shipped; at the retain cap, a bad sort
|
||||
# can otherwise mark the newest release as stale and delete it.
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
|
||||
# keep showing up in tag lists with no release or assets attached.
|
||||
# Drafts are excluded so retention counts shipped builds only; a stale
|
||||
# draft is handled by the failure path, not by the retention window.
|
||||
#
|
||||
# Sort by publishedAt (not createdAt). Many non-draft dailies can share
|
||||
# one createdAt (bulk import / re-create), so createdAt ranking is
|
||||
# unstable. publishedAt is real recency; tagName (...YYYYMMDDHHMM) is
|
||||
# the deterministic tie-break.
|
||||
#
|
||||
# Why force $TAG to the front before slicing: a hard retain-window seat
|
||||
# for this run's release. Dropping $TAG from the list *before* the slice
|
||||
# would permanently keep retain+1 releases; skipping it only in the
|
||||
# delete loop would under-prune when the sort is still wrong. Partition
|
||||
# keeps relative order of every other tag.
|
||||
jq_filter='map(select(.isDraft | not)) | sort_by(.publishedAt // "", .tagName) | reverse'
|
||||
if [[ -n "${TAG:-}" ]]; then
|
||||
jq_filter+=" | (map(select(.tagName == \"${TAG//\"/\\\"}\")) + map(select(.tagName != \"${TAG//\"/\\\"}\")))"
|
||||
fi
|
||||
jq_filter+=" | .[${DAILY_RETAIN_COUNT}:] | .[].tagName"
|
||||
stale="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName,publishedAt,isDraft \
|
||||
--jq "$jq_filter")"
|
||||
if [[ -z "$stale" ]]; then
|
||||
echo "Nothing to prune; at or under $DAILY_RETAIN_COUNT retained builds."
|
||||
exit 0
|
||||
fi
|
||||
while read -r tag; do
|
||||
[[ -n "$tag" ]] || continue
|
||||
# Belt-and-suspenders: partition above should already exclude $TAG.
|
||||
if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then
|
||||
echo "::warning::Prune list still included just-published $tag after protect; skipping delete."
|
||||
continue
|
||||
fi
|
||||
echo "Pruning $tag"
|
||||
gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \
|
||||
echo "::warning::Could not prune $tag"
|
||||
done <<<"$stale"
|
||||
@@ -20,21 +20,32 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.
|
||||
// Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to
|
||||
// swap them over an installed Orca and macOS treats each build as a new app.
|
||||
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
|
||||
const isMacDaily = process.env.ORCA_MAC_DAILY === '1'
|
||||
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc
|
||||
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
|
||||
? process.env.ORCA_HOURLY_BUILD_VERSION
|
||||
: isMacAdhoc
|
||||
? process.env.ORCA_ADHOC_BUILD_VERSION
|
||||
: undefined
|
||||
: isMacDaily
|
||||
? process.env.ORCA_DAILY_BUILD_VERSION
|
||||
: isMacAdhoc
|
||||
? process.env.ORCA_ADHOC_BUILD_VERSION
|
||||
: undefined
|
||||
// Why each dev channel gets its own repo rather than tagging into the main one:
|
||||
// the releases atom feed exposes only the 10 newest entries, so 24 hourly tags a
|
||||
// day would evict every stable/RC entry and strand users on a feed with nothing
|
||||
// to install. Keeping adhoc separate from hourly too means a branch build cannot
|
||||
// be picked up by someone who only meant to ride main.
|
||||
const devChannelRepo = isMacHourly ? 'orca-hourly' : isMacAdhoc ? 'orca-adhoc' : null
|
||||
// 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
|
||||
? 'orca-hourly'
|
||||
: isMacDaily
|
||||
? 'orca-daily'
|
||||
: isMacAdhoc
|
||||
? 'orca-adhoc'
|
||||
: null
|
||||
const appId = 'com.stablyai.orca'
|
||||
const featureWallResources = {
|
||||
from: 'resources/onboarding/feature-wall',
|
||||
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
|
||||
import {
|
||||
readPublishedVersionsFromEnv,
|
||||
resolveDevChannelBaseVersion
|
||||
} from './dev-channel-base-version.mjs'
|
||||
|
||||
/** `1.4.160-daily.202607281300` — UTC to the minute, so tags sort chronologically
|
||||
* by semver and every build is uniquely versioned. */
|
||||
export function createDailyBuildVersion(baseVersion, date) {
|
||||
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion)
|
||||
if (!match) {
|
||||
throw new Error(`Package version is not valid semver: ${baseVersion}`)
|
||||
}
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
throw new Error('Daily build timestamp is invalid.')
|
||||
}
|
||||
const pad = (value, width = 2) => String(value).padStart(width, '0')
|
||||
const stamp = [
|
||||
pad(date.getUTCFullYear(), 4),
|
||||
pad(date.getUTCMonth() + 1),
|
||||
pad(date.getUTCDate()),
|
||||
pad(date.getUTCHours()),
|
||||
pad(date.getUTCMinutes())
|
||||
].join('')
|
||||
// Why: drop any -rc.N tail. Keeping it makes every daily semver-NEWER than the
|
||||
// RC it was cut from (1.4.160-rc.3-daily.X > 1.4.160-rc.3), which would let an
|
||||
// ordinary RC-channel check offer untested daily builds to RC users. Stripping
|
||||
// to the base parks dailies below both rc.N and stable ('daily' < 'rc'
|
||||
// alphabetically), reachable only by an explicit pinned jump.
|
||||
return `${match[1]}-daily.${stamp}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The next build number for `baseVersion`, counting from the titles of existing
|
||||
* daily releases.
|
||||
*
|
||||
* Why the series restarts at 01 on every base version: the number answers "which
|
||||
* build of 1.4.163 is this", so a counter shared across versions makes it
|
||||
* meaningless — 1.4.164 would open at 38 for no reason a reader can see.
|
||||
*
|
||||
* Why the maximum rather than a count: the prune step trims to
|
||||
* DAILY_RETAIN_COUNT, so a count would roll backwards and reissue a number
|
||||
* already in use. Titles that predate this naming simply do not match, which is
|
||||
* how the first build of a version lands on 01.
|
||||
*/
|
||||
export function nextDailyBuildNumber(baseVersion, releaseNames = []) {
|
||||
const prefix = `${baseVersion} • `
|
||||
const highest = releaseNames.reduce((max, entry) => {
|
||||
const name = String(entry ?? '')
|
||||
if (!name.startsWith(prefix)) {
|
||||
return max
|
||||
}
|
||||
const match = /^(\d+) • /.exec(name.slice(prefix.length))
|
||||
return match ? Math.max(max, Number(match[1])) : max
|
||||
}, 0)
|
||||
return highest + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • 01 • Aug 9, 6:15AM • e698241` — the human-facing release title,
|
||||
* shown verbatim in both the GitHub releases list and the in-app build picker.
|
||||
*/
|
||||
export function formatDailyReleaseName(version, buildNumber, commit, date) {
|
||||
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
|
||||
throw new Error(`Daily build number must be a positive integer: ${buildNumber}`)
|
||||
}
|
||||
return [
|
||||
version.split('-')[0],
|
||||
String(buildNumber).padStart(2, '0'),
|
||||
formatReleaseTitleTimestamp(date),
|
||||
commit.slice(0, 7)
|
||||
].join(' • ')
|
||||
}
|
||||
|
||||
// Why the number is derived here rather than passed in: it counts builds of the
|
||||
// base version, and the base is only known once the published tags have been
|
||||
// resolved just above. Computing it outside meant numbering against whatever
|
||||
// version the caller guessed.
|
||||
export function getDailyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? [])
|
||||
const version = createDailyBuildVersion(base, now)
|
||||
const buildNumber = nextDailyBuildNumber(base, releaseNames ?? [])
|
||||
return {
|
||||
commit,
|
||||
version,
|
||||
buildNumber,
|
||||
name: formatDailyReleaseName(version, buildNumber, commit, now)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const identity = getDailyBuildIdentity(new Date(), {
|
||||
publishedVersions: readPublishedVersionsFromEnv(),
|
||||
// Titles are newline separated and contain spaces, so this cannot reuse the
|
||||
// whitespace split the version list gets.
|
||||
releaseNames: (process.env.ORCA_DAILY_RELEASE_NAMES ?? '').split('\n').filter(Boolean)
|
||||
})
|
||||
// Consumed by the workflow via $GITHUB_OUTPUT.
|
||||
process.stdout.write(
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createDailyBuildVersion,
|
||||
formatDailyReleaseName,
|
||||
nextDailyBuildNumber
|
||||
} from './daily-build-version.mjs'
|
||||
import { compareAppVersions } from '../../src/shared/app-version'
|
||||
|
||||
describe('createDailyBuildVersion', () => {
|
||||
it('stamps the version with a zero-padded UTC timestamp', () => {
|
||||
expect(createDailyBuildVersion('1.4.160', new Date('2026-07-28T13:00:00Z'))).toBe(
|
||||
'1.4.160-daily.202607281300'
|
||||
)
|
||||
})
|
||||
|
||||
// Why: main's package.json carries the in-flight RC tail. Keeping it would make
|
||||
// every daily semver-NEWER than the RC it was cut from (1.4.160-rc.3-daily.X >
|
||||
// 1.4.160-rc.3), so an ordinary RC-channel check would offer untested daily
|
||||
// builds to RC users. Dropping it parks dailies below both rc.N and stable,
|
||||
// reachable only by an explicit pinned jump.
|
||||
it('drops an in-flight rc tail so dailies never outrank the rc series', () => {
|
||||
const version = createDailyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T13:00:00Z'))
|
||||
expect(version).toBe('1.4.160-daily.202607281300')
|
||||
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
|
||||
expect(compareAppVersions('1.4.160-rc.3-daily.202607281300', '1.4.160-rc.3')).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// Why: 'daily' sorts before 'hourly' alphabetically, so a daily of the same
|
||||
// base never outranks an hourly — both stay below rc/stable and only the
|
||||
// channel picker offers them.
|
||||
it('sorts below the hourly build of the same base version', () => {
|
||||
expect(
|
||||
compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400')
|
||||
).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('rejects invalid input', () => {
|
||||
expect(() => createDailyBuildVersion('nope', new Date())).toThrow(/valid semver/)
|
||||
expect(() => createDailyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDailyReleaseName', () => {
|
||||
const name = (iso, buildNumber = 1, commit = 'e698241abcde') =>
|
||||
formatDailyReleaseName('1.4.163-daily.x', buildNumber, commit, new Date(iso))
|
||||
|
||||
it('renders version, number, Pacific timestamp, and short sha', () => {
|
||||
// 13:15 UTC is 6:15AM PDT in July (the daily cut time).
|
||||
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
|
||||
})
|
||||
|
||||
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
|
||||
// the offset between them is not a constant. A test pinned to one season would
|
||||
// pass all summer and start failing in November.
|
||||
it('follows the Pacific offset across DST', () => {
|
||||
expect(name('2026-01-15T14:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 6:15AM • e698241')
|
||||
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
|
||||
})
|
||||
|
||||
it('pads to two digits and grows past them', () => {
|
||||
expect(name('2026-07-28T13:15:00Z', 9)).toContain(' • 09 • ')
|
||||
expect(name('2026-07-28T13:15:00Z', 42)).toContain(' • 42 • ')
|
||||
})
|
||||
|
||||
it('rejects a build number that is not a positive integer', () => {
|
||||
expect(() => name('2026-07-28T13:15:00Z', 0)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-28T13:15:00Z', -1)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-28T13:15:00Z', 1.5)).toThrow(/positive integer/)
|
||||
})
|
||||
|
||||
it('rejects an invalid timestamp', () => {
|
||||
expect(() => formatDailyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow(
|
||||
/invalid/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextDailyBuildNumber', () => {
|
||||
const titles = [
|
||||
'1.4.163 • 01 • Jul 28, 6:00AM • e698241',
|
||||
'1.4.163 • 02 • Jul 29, 6:00AM • aaaaaaa',
|
||||
'1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'
|
||||
]
|
||||
|
||||
it('continues the series for the version being built', () => {
|
||||
expect(nextDailyBuildNumber('1.4.163', titles)).toBe(10)
|
||||
})
|
||||
|
||||
it('restarts at 1 when the base version moves', () => {
|
||||
expect(nextDailyBuildNumber('1.4.164', titles)).toBe(1)
|
||||
expect(
|
||||
nextDailyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 6:00AM • ccccccc'])
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
// Why max and not count: pruning trims to DAILY_RETAIN_COUNT, so counting
|
||||
// would roll backwards and reissue a number already used.
|
||||
it('takes the highest number, not the count', () => {
|
||||
expect(nextDailyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 6:00AM • e698241'])).toBe(10)
|
||||
})
|
||||
|
||||
it('starts at 1 with no history at all', () => {
|
||||
expect(nextDailyBuildNumber('1.4.163')).toBe(1)
|
||||
expect(nextDailyBuildNumber('1.4.163', [])).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores titles that are not this version', () => {
|
||||
expect(nextDailyBuildNumber('1.4.16', ['1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'])).toBe(1)
|
||||
expect(nextDailyBuildNumber('1.4.163', ['v1.4.163-daily.202607311300', null, ''])).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -19,42 +19,6 @@ const {
|
||||
verifyPackagedMainRuntimeDeps
|
||||
} = require('../packaged-runtime-node-modules.cjs')
|
||||
|
||||
const MUTABLE_BUILD_ENV = [
|
||||
'ORCA_MAC_HOURLY',
|
||||
'ORCA_MAC_ADHOC',
|
||||
'ORCA_MAC_RELEASE',
|
||||
'ORCA_HOURLY_BUILD_VERSION',
|
||||
'ORCA_ADHOC_BUILD_VERSION',
|
||||
'ORCA_LOCAL_BUILD_VERSION'
|
||||
]
|
||||
|
||||
/** Re-requires the config under a temporary env, then restores env and module cache. */
|
||||
function withEnv(env, assert) {
|
||||
const configPath = require.resolve('../electron-builder.config.cjs')
|
||||
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
|
||||
try {
|
||||
for (const key of MUTABLE_BUILD_ENV) {
|
||||
delete process.env[key]
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
delete require.cache[configPath]
|
||||
assert(require('../electron-builder.config.cjs'))
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(original)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
delete require.cache[configPath]
|
||||
require('../electron-builder.config.cjs')
|
||||
}
|
||||
}
|
||||
|
||||
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
|
||||
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
|
||||
|
||||
describe('electron-builder config', () => {
|
||||
it('keeps the packaged app identity aligned with local-build validation', () => {
|
||||
expect(electronBuilderConfig.appId).toBe(
|
||||
@@ -303,89 +267,6 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
|
||||
// same bundle id and a valid Developer ID signature. A hourly built on the local
|
||||
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
|
||||
// Orca — the whole point of the channel.
|
||||
it('builds hourly artifacts with the release signing identity', () => {
|
||||
withHourlyEnv((config) => {
|
||||
expect(config.mac.appId).toBeUndefined()
|
||||
expect(config.appId).toBe('com.stablyai.orca')
|
||||
expect(config.mac.hardenedRuntime).toBe(true)
|
||||
expect(config.forceCodeSigning).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Why hourly must notarize despite the round trip: TCC anchors a notarized
|
||||
// Developer ID app's grants on identifier + team, not on its cdhash, so they
|
||||
// survive an update. An unnotarized hourly reads as a new client every build
|
||||
// and loses file access under Documents/Desktop/Downloads with no re-prompt.
|
||||
it('notarizes hourly builds like releases, and neither locally', () => {
|
||||
withHourlyEnv((config) => {
|
||||
expect(config.mac.notarize).toBe(true)
|
||||
})
|
||||
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
|
||||
expect(config.mac.notarize).toBe(true)
|
||||
})
|
||||
expect(electronBuilderConfig.mac.notarize).toBe(false)
|
||||
})
|
||||
|
||||
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
|
||||
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
|
||||
// break update checks for every real user.
|
||||
it('publishes hourly builds to the separate hourly repo', () => {
|
||||
withHourlyEnv((config) => {
|
||||
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
|
||||
})
|
||||
expect(electronBuilderConfig.publish).toMatchObject({
|
||||
repo: 'orca',
|
||||
releaseType: 'release'
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps hourly packages with the hourly version', () => {
|
||||
withEnv(
|
||||
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
|
||||
(config) => {
|
||||
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Why adhoc carries the identical mac identity to hourly: it installs over a
|
||||
// real Orca through the same updater path, so the same signing and the same TCC
|
||||
// argument apply. Only the destination repo differs.
|
||||
it('builds adhoc artifacts with the release identity and its own repo', () => {
|
||||
withAdhocEnv((config) => {
|
||||
expect(config.appId).toBe('com.stablyai.orca')
|
||||
expect(config.mac.hardenedRuntime).toBe(true)
|
||||
expect(config.mac.notarize).toBe(true)
|
||||
expect(config.forceCodeSigning).toBe(true)
|
||||
expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' })
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps adhoc packages with the adhoc version', () => {
|
||||
withEnv(
|
||||
{ ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' },
|
||||
(config) => {
|
||||
expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the two dev channels share every packaging decision except where they
|
||||
// publish, so a future edit that collapses them must not also collapse the
|
||||
// repos — a branch build landing in orca-hourly would be offered to everyone
|
||||
// riding main.
|
||||
it('keeps the two dev channels on separate repos', () => {
|
||||
withHourlyEnv((hourly) => {
|
||||
withAdhocEnv((adhoc) => {
|
||||
expect(hourly.publish.repo).not.toBe(adhoc.publish.repo)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
|
||||
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
|
||||
expect(electronBuilderConfig.npmRebuild).toBe(true)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const electronBuilderConfig = require('../electron-builder.config.cjs')
|
||||
|
||||
const MUTABLE_BUILD_ENV = [
|
||||
'ORCA_MAC_HOURLY',
|
||||
'ORCA_MAC_DAILY',
|
||||
'ORCA_MAC_ADHOC',
|
||||
'ORCA_MAC_RELEASE',
|
||||
'ORCA_HOURLY_BUILD_VERSION',
|
||||
'ORCA_DAILY_BUILD_VERSION',
|
||||
'ORCA_ADHOC_BUILD_VERSION',
|
||||
'ORCA_LOCAL_BUILD_VERSION'
|
||||
]
|
||||
|
||||
/** Re-requires the config under a temporary env, then restores env and module cache. */
|
||||
function withEnv(env, assert) {
|
||||
const configPath = require.resolve('../electron-builder.config.cjs')
|
||||
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
|
||||
try {
|
||||
for (const key of MUTABLE_BUILD_ENV) {
|
||||
delete process.env[key]
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
delete require.cache[configPath]
|
||||
assert(require('../electron-builder.config.cjs'))
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(original)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
delete require.cache[configPath]
|
||||
require('../electron-builder.config.cjs')
|
||||
}
|
||||
}
|
||||
|
||||
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
|
||||
const withDailyEnv = (assert) => withEnv({ ORCA_MAC_DAILY: '1' }, assert)
|
||||
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
|
||||
|
||||
describe('electron-builder mac channel config', () => {
|
||||
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
|
||||
// same bundle id and a valid Developer ID signature. A hourly built on the local
|
||||
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
|
||||
// Orca — the whole point of the channel.
|
||||
it('builds hourly artifacts with the release signing identity', () => {
|
||||
withHourlyEnv((config) => {
|
||||
expect(config.mac.appId).toBeUndefined()
|
||||
expect(config.appId).toBe('com.stablyai.orca')
|
||||
expect(config.mac.hardenedRuntime).toBe(true)
|
||||
expect(config.forceCodeSigning).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Why hourly must notarize despite the round trip: TCC anchors a notarized
|
||||
// Developer ID app's grants on identifier + team, not on its cdhash, so they
|
||||
// survive an update. An unnotarized hourly reads as a new client every build
|
||||
// and loses file access under Documents/Desktop/Downloads with no re-prompt.
|
||||
it('notarizes hourly builds like releases, and neither locally', () => {
|
||||
withHourlyEnv((config) => {
|
||||
expect(config.mac.notarize).toBe(true)
|
||||
})
|
||||
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
|
||||
expect(config.mac.notarize).toBe(true)
|
||||
})
|
||||
expect(electronBuilderConfig.mac.notarize).toBe(false)
|
||||
})
|
||||
|
||||
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
|
||||
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
|
||||
// break update checks for every real user.
|
||||
it('publishes hourly builds to the separate hourly repo', () => {
|
||||
withHourlyEnv((config) => {
|
||||
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
|
||||
})
|
||||
expect(electronBuilderConfig.publish).toMatchObject({
|
||||
repo: 'orca',
|
||||
releaseType: 'release'
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps hourly packages with the hourly version', () => {
|
||||
withEnv(
|
||||
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
|
||||
(config) => {
|
||||
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Why adhoc carries the identical mac identity to hourly: it installs over a
|
||||
// real Orca through the same updater path, so the same signing and the same TCC
|
||||
// argument apply. Only the destination repo differs.
|
||||
it('builds adhoc artifacts with the release identity and its own repo', () => {
|
||||
withAdhocEnv((config) => {
|
||||
expect(config.appId).toBe('com.stablyai.orca')
|
||||
expect(config.mac.hardenedRuntime).toBe(true)
|
||||
expect(config.mac.notarize).toBe(true)
|
||||
expect(config.forceCodeSigning).toBe(true)
|
||||
expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' })
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps adhoc packages with the adhoc version', () => {
|
||||
withEnv(
|
||||
{ ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' },
|
||||
(config) => {
|
||||
expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('builds daily artifacts with the release identity and its own repo', () => {
|
||||
withDailyEnv((config) => {
|
||||
expect(config.appId).toBe('com.stablyai.orca')
|
||||
expect(config.mac.hardenedRuntime).toBe(true)
|
||||
expect(config.mac.notarize).toBe(true)
|
||||
expect(config.forceCodeSigning).toBe(true)
|
||||
expect(config.publish).toMatchObject({ repo: 'orca-daily', releaseType: 'prerelease' })
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps daily packages with the daily version', () => {
|
||||
withEnv(
|
||||
{ ORCA_MAC_DAILY: '1', ORCA_DAILY_BUILD_VERSION: '1.4.160-daily.202607281300' },
|
||||
(config) => {
|
||||
expect(config.extraMetadata).toEqual({ version: '1.4.160-daily.202607281300' })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the dev channels share every packaging decision except where they
|
||||
// publish, so a future edit that collapses them must not also collapse the
|
||||
// repos — a branch or daily build landing in orca-hourly would be offered to
|
||||
// everyone riding main's hourlies.
|
||||
it('keeps the dev channels on separate repos', () => {
|
||||
withHourlyEnv((hourly) => {
|
||||
withDailyEnv((daily) => {
|
||||
withAdhocEnv((adhoc) => {
|
||||
expect(new Set([hourly.publish.repo, daily.publish.repo, adhoc.publish.repo]).size).toBe(
|
||||
3
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmarks optioned localeCompare calls used by renderer list comparators.
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createJiti } from 'jiti'
|
||||
|
||||
const ROUND_COUNT = 5
|
||||
const MIN_ROUND_MS = 120
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
alias: { '@': fileURLToPath(new URL('../../src/renderer/src', import.meta.url)) }
|
||||
})
|
||||
const { compareBaseSensitivityLocaleText } = await jiti.import(
|
||||
'../../src/renderer/src/lib/locale-text-collators.ts'
|
||||
)
|
||||
const { sortJiraIssues } = await jiti.import(
|
||||
'../../src/renderer/src/components/jira-issue-sorter.ts'
|
||||
)
|
||||
|
||||
let randomState = 0x9e3779b9
|
||||
function random() {
|
||||
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
|
||||
return randomState / 0x100000000
|
||||
}
|
||||
|
||||
function shuffle(values) {
|
||||
for (let index = values.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(random() * (index + 1))
|
||||
;[values[index], values[swapIndex]] = [values[swapIndex], values[index]]
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
const labels = ['alpha', 'Álpha', 'beta', 'BÉTA', 'café', 'Cafe', 'zeta', 'Ångström']
|
||||
|
||||
function makeJiraIssues(count) {
|
||||
return shuffle(
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
key: `TASK-${(index * 37) % (count + 17)}`,
|
||||
title: labels[index % labels.length],
|
||||
updatedAt: '2026-01-01T00:00:00.000Z'
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
function makeBaseSensitivityValues(count) {
|
||||
return shuffle(
|
||||
Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
`${labels[(index * 5) % labels.length]}-${String((index * 41) % (count + 23)).padStart(4, '0')}`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function calibrate(run) {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
run()
|
||||
}
|
||||
let iterations = 1
|
||||
while (true) {
|
||||
const startedAt = performance.now()
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
run()
|
||||
}
|
||||
if (performance.now() - startedAt >= MIN_ROUND_MS) {
|
||||
break
|
||||
}
|
||||
iterations *= 2
|
||||
}
|
||||
return iterations
|
||||
}
|
||||
|
||||
function measureRound(run, iterations) {
|
||||
const startedAt = performance.now()
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
run()
|
||||
}
|
||||
return (performance.now() - startedAt) / iterations
|
||||
}
|
||||
|
||||
function measurePair(before, after) {
|
||||
const beforeIterations = calibrate(before)
|
||||
const afterIterations = calibrate(after)
|
||||
const beforeSamples = []
|
||||
const afterSamples = []
|
||||
for (let round = 0; round < ROUND_COUNT; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
beforeSamples.push(measureRound(before, beforeIterations))
|
||||
afterSamples.push(measureRound(after, afterIterations))
|
||||
} else {
|
||||
afterSamples.push(measureRound(after, afterIterations))
|
||||
beforeSamples.push(measureRound(before, beforeIterations))
|
||||
}
|
||||
}
|
||||
const middle = Math.floor(ROUND_COUNT / 2)
|
||||
return {
|
||||
beforeMs: beforeSamples.sort((a, b) => a - b)[middle],
|
||||
afterMs: afterSamples.sort((a, b) => a - b)[middle]
|
||||
}
|
||||
}
|
||||
|
||||
function assertSameOrder(before, after, label) {
|
||||
const expected = before()
|
||||
const actual = after()
|
||||
if (
|
||||
expected.length !== actual.length ||
|
||||
expected.some((value, index) => value !== actual[index])
|
||||
) {
|
||||
throw new Error(`${label} sort order changed`)
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Renderer locale sort, ms per sort (median of 5 rounds). Lower is better.')
|
||||
console.log(
|
||||
`${pad('mode', 9)} ${pad('items', 7)} ${pad('per-call', 11)} ${pad('reused', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
for (const count of [36, 50, 250]) {
|
||||
const issues = makeJiraIssues(count)
|
||||
const before = () =>
|
||||
[...issues]
|
||||
.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
|
||||
.map((issue) => issue.key)
|
||||
const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key)
|
||||
assertSameOrder(before, after, `numeric ${count}`)
|
||||
const { beforeMs, afterMs } = measurePair(before, after)
|
||||
console.log(
|
||||
`${pad('numeric', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
for (const count of [10, 50, 250]) {
|
||||
const values = makeBaseSensitivityValues(count)
|
||||
const before = () =>
|
||||
[...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||
const after = () => [...values].sort(compareBaseSensitivityLocaleText)
|
||||
assertSameOrder(before, after, `base ${count}`)
|
||||
const { beforeMs, afterMs } = measurePair(before, after)
|
||||
console.log(
|
||||
`${pad('base', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\n36 rows matches the Linear page size, 50 matches the picker/Jira scale, and\n250 is a stress case. Both arms assert identical output before timing.'
|
||||
)
|
||||
@@ -86,7 +86,7 @@ Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in
|
||||
1. Open: https://github.com/organizations/$ORG/settings/installations
|
||||
2. Configure -> $APP_SLUG
|
||||
3. Repository access -> Only select repositories -> add $ADHOC_REPO
|
||||
(keep orca-hourly selected; both dev channels use this one App)
|
||||
(keep orca-hourly/orca-daily selected; all dev channels use this one App)
|
||||
4. Save.
|
||||
EOF
|
||||
fi
|
||||
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Creates stablyai/orca-daily and grants the existing release App write access to
|
||||
# it, so daily-mac-build.yml can publish there.
|
||||
#
|
||||
# Why a separate repo rather than reusing orca-hourly: the daily channel is a
|
||||
# once-a-day cut that people ride deliberately. Sharing hourly's list would mix a
|
||||
# sparse daily series into the 72-entry hourly retention window and make both
|
||||
# pickers harder to read.
|
||||
#
|
||||
# Why no secrets are set here: the daily workflow reuses the same GitHub App as
|
||||
# hourly/adhoc — one App id, one private key, one thing to rotate. This script
|
||||
# only has to widen that App's installation to cover the new repo.
|
||||
#
|
||||
# Run once, after config/scripts/setup-hourly-release-token.sh:
|
||||
# bash config/scripts/setup-daily-release-repo.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ORG="stablyai"
|
||||
DAILY_REPO="$ORG/orca-daily"
|
||||
MAIN_REPO="$ORG/orca"
|
||||
APP_SLUG="orca-hourly-release"
|
||||
|
||||
fail() {
|
||||
echo "error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com"
|
||||
gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login"
|
||||
|
||||
if gh api "repos/$DAILY_REPO" --jq '.full_name' >/dev/null 2>&1; then
|
||||
echo "$DAILY_REPO already exists."
|
||||
else
|
||||
echo "Creating $DAILY_REPO..."
|
||||
# Why public: the in-app updater fetches release assets unauthenticated, exactly
|
||||
# as it does for orca-hourly. A private repo would 404 for every client.
|
||||
#
|
||||
# Why the features are off: this repo holds releases and nothing else. Leaving
|
||||
# issues open invites bug reports against an unvetted daily in a repo nobody
|
||||
# watches, where they are simply lost.
|
||||
#
|
||||
# Why --add-readme in a repo with no source: publishing a release creates a tag,
|
||||
# and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in.
|
||||
gh repo create "$DAILY_REPO" \
|
||||
--public \
|
||||
--description "Daily macOS dev builds of Orca, cut from main each morning. Not a source repo." \
|
||||
--add-readme \
|
||||
--disable-issues \
|
||||
--disable-wiki ||
|
||||
fail "Could not create $DAILY_REPO."
|
||||
fi
|
||||
|
||||
# Also checked outside the create branch: a repo made before --add-readme is here.
|
||||
if ! gh api "repos/$DAILY_REPO/commits" --jq 'length' >/dev/null 2>&1; then
|
||||
fail "$DAILY_REPO has no commits — releases cannot be tagged. Add any file to it first."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Granting $APP_SLUG access to $DAILY_REPO..."
|
||||
|
||||
# Why attempt the API before printing instructions: an org owner can do this in
|
||||
# one call. Everyone else gets a 403 and the manual path below — GitHub does not
|
||||
# let a mere admin widen an App's repository selection.
|
||||
INSTALL_ID="$(gh api "orgs/$ORG/installations" --paginate \
|
||||
--jq ".installations[] | select(.app_slug == \"$APP_SLUG\") | .id" 2>/dev/null || true)"
|
||||
REPO_ID="$(gh api "repos/$DAILY_REPO" --jq '.id' 2>/dev/null || true)"
|
||||
|
||||
GRANTED=false
|
||||
if [[ -n "$INSTALL_ID" && -n "$REPO_ID" ]]; then
|
||||
if gh api -X PUT "user/installations/$INSTALL_ID/repositories/$REPO_ID" >/dev/null 2>&1; then
|
||||
GRANTED=true
|
||||
echo "Done — $APP_SLUG can now write to $DAILY_REPO."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$GRANTED" != "true" ]]; then
|
||||
# Why no automated check afterwards: the endpoints that report an App's
|
||||
# repository access (repos/*/installation, user/installations/*/repositories)
|
||||
# both reject an ordinary `gh auth login` token, so any "verified" this script
|
||||
# printed would be guesswork. The smoke test below is the real check.
|
||||
cat <<EOF
|
||||
|
||||
Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in the browser:
|
||||
|
||||
1. Open: https://github.com/organizations/$ORG/settings/installations
|
||||
2. Configure -> $APP_SLUG
|
||||
3. Repository access -> Only select repositories -> add $DAILY_REPO
|
||||
(keep orca-hourly and orca-adhoc selected; all dev channels use this one App)
|
||||
4. Save.
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Smoke-test the pipeline (after this merges):"
|
||||
echo " gh workflow run daily-mac-build.yml --repo $MAIN_REPO -f force=true"
|
||||
echo " gh run watch --repo $MAIN_REPO"
|
||||
@@ -8,9 +8,10 @@
|
||||
# — no yearly rotation — and it belongs to the org rather than to the person who
|
||||
# created it, so it survives that person leaving.
|
||||
#
|
||||
# The same App also serves adhoc-mac-build.yml, which reads these same two
|
||||
# secrets: one credential, one rotation, both dev channels. Widening it to cover
|
||||
# stablyai/orca-adhoc is config/scripts/setup-adhoc-release-repo.sh's job.
|
||||
# The same App also serves adhoc-mac-build.yml and daily-mac-build.yml, which
|
||||
# read these same two secrets: one credential, one rotation, all dev channels.
|
||||
# Widening it to cover stablyai/orca-adhoc / orca-daily is
|
||||
# setup-adhoc-release-repo.sh / setup-daily-release-repo.sh's job.
|
||||
#
|
||||
# The key is read from a file and piped straight into `gh secret set`. It is never
|
||||
# echoed, never passed as a command-line argument (argv is world-readable via
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
// Run with: node config/scripts/worktree-base-pending-marker-benchmark.mjs
|
||||
import fs from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { syncBuiltinESMExports } from 'node:module'
|
||||
import os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { createJiti } from 'jiti'
|
||||
|
||||
const CANDIDATE_COUNT = Number(process.env.ORCA_PENDING_MARKER_BENCH_CANDIDATES ?? '64')
|
||||
const TOTAL_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_TICKS ?? '900')
|
||||
const STEADY_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_STEADY_TICKS ?? '300')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_PENDING_MARKER_BENCH_CANDIDATES', CANDIDATE_COUNT],
|
||||
['ORCA_PENDING_MARKER_BENCH_TICKS', TOTAL_TICKS],
|
||||
['ORCA_PENDING_MARKER_BENCH_STEADY_TICKS', STEADY_TICKS]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
if (STEADY_TICKS >= TOTAL_TICKS) {
|
||||
throw new Error('ORCA_PENDING_MARKER_BENCH_STEADY_TICKS must be smaller than total ticks')
|
||||
}
|
||||
|
||||
const root = await mkdtemp(join(os.tmpdir(), 'orca-pending-marker-bench-'))
|
||||
const markerPaths = new Set()
|
||||
for (let index = 0; index < CANDIDATE_COUNT; index += 1) {
|
||||
const candidate = join(root, `ordinary-folder-${index}`)
|
||||
await mkdir(candidate)
|
||||
markerPaths.add(join(candidate, '.git'))
|
||||
}
|
||||
|
||||
const originalStat = fs.promises.stat
|
||||
let visibleTick = 0
|
||||
const markerStatTicks = []
|
||||
fs.promises.stat = async (path, ...args) => {
|
||||
if (markerPaths.has(String(path))) {
|
||||
markerStatTicks.push(visibleTick)
|
||||
}
|
||||
return originalStat(path, ...args)
|
||||
}
|
||||
syncBuiltinESMExports()
|
||||
|
||||
let poller
|
||||
let timeout
|
||||
try {
|
||||
const jiti = createJiti(import.meta.url)
|
||||
const { startWorktreeBaseDirectoryPoller } = await jiti.import(
|
||||
'../../src/main/ipc/worktree-base-directory-poller.ts'
|
||||
)
|
||||
const repo = { repoId: 'repo-1', repoName: 'repo', nestWorkspaces: false }
|
||||
const target = {
|
||||
key: `base:local:${root}`,
|
||||
kind: 'base',
|
||||
path: root,
|
||||
repos: new Map([[repo.repoId, repo]])
|
||||
}
|
||||
let finish
|
||||
let fail
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
finish = resolve
|
||||
fail = reject
|
||||
})
|
||||
timeout = setTimeout(() => fail(new Error('poller benchmark timed out')), 30_000)
|
||||
const startedAt = performance.now()
|
||||
poller = await startWorktreeBaseDirectoryPoller(
|
||||
target,
|
||||
() => target.repos,
|
||||
() => {},
|
||||
{
|
||||
pollIntervalMs: 0,
|
||||
visibility: {
|
||||
isWindowVisible: () => {
|
||||
visibleTick += 1
|
||||
if (visibleTick > TOTAL_TICKS) {
|
||||
finish()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
onWindowBecameVisible: () => () => {}
|
||||
}
|
||||
}
|
||||
)
|
||||
await completed
|
||||
clearTimeout(timeout)
|
||||
await poller.unsubscribe()
|
||||
poller = undefined
|
||||
|
||||
const steadyStartTick = TOTAL_TICKS - STEADY_TICKS
|
||||
const steadyMarkerStats = markerStatTicks.filter((tick) => tick > steadyStartTick).length
|
||||
const totalMarkerStats = markerStatTicks.length
|
||||
const statsPerCandidateTick = steadyMarkerStats / CANDIDATE_COUNT / STEADY_TICKS
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
|
||||
console.log('Worktree-base marker cooldown benchmark. Lower is better.')
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
candidates: CANDIDATE_COUNT,
|
||||
totalTicks: TOTAL_TICKS,
|
||||
steadyTicks: STEADY_TICKS,
|
||||
totalMarkerStats,
|
||||
steadyMarkerStats,
|
||||
statsPerCandidateTick: Number(statsPerCandidateTick.toFixed(4)),
|
||||
elapsedMs: Number(elapsedMs.toFixed(1))
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
await poller?.unsubscribe()
|
||||
fs.promises.stat = originalStat
|
||||
syncBuiltinESMExports()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -106,7 +106,7 @@ import type {
|
||||
TerminalModes,
|
||||
TerminalWebViewHandle
|
||||
} from '../../../../src/terminal/terminal-webview-contract'
|
||||
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
|
||||
import { isTerminalOscLinkRanges } from '../../../../../src/shared/terminal-osc-link-ranges'
|
||||
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
|
||||
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
|
||||
import {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'
|
||||
import { Check } from 'lucide-react-native'
|
||||
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
|
||||
|
||||
type Props = {
|
||||
prompt: AskPrompt
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
@@ -2,25 +2,22 @@ import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { Image, Pressable, Text, View } from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native'
|
||||
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileMarkdown } from '../components/MobileMarkdown'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
isImageRefBlock,
|
||||
isTextBlock,
|
||||
pairToolBlocks,
|
||||
splitNativeChatBlocks,
|
||||
type ToolPair
|
||||
} from './mobile-native-chat-blocks'
|
||||
import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff'
|
||||
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
|
||||
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
|
||||
import { nativeChatMessageText } from './mobile-native-chat-message-text'
|
||||
import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff'
|
||||
import type { NativeChatDiffLine as DiffLine } from '../../../src/shared/native-chat-diff'
|
||||
import { pairToolBlocks, splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
|
||||
import type { NativeChatToolPair as ToolPair } from '../../../src/shared/native-chat-tool-fold'
|
||||
import {
|
||||
createToolInputDisplay,
|
||||
summarizeToolRun,
|
||||
truncateToolDetail
|
||||
} from './mobile-native-chat-tool-summary'
|
||||
} from '../../../src/shared/native-chat-tool-summary'
|
||||
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
|
||||
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileMarkdown } from '../components/MobileMarkdown'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
|
||||
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
|
||||
import { nativeChatMessageText } from './mobile-native-chat-message-text'
|
||||
|
||||
const MAX_VISIBLE_TOOL_PAIRS = 6
|
||||
const MAX_TOOL_RUN_DIFF_ROWS = 240
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native'
|
||||
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-native-chat-view-styles'
|
||||
@@ -26,7 +27,6 @@ import { MobileNativeChatComposer } from './MobileNativeChatComposer'
|
||||
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
|
||||
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
|
||||
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
|
||||
import type { MobileChatPermission } from './mobile-native-chat-permission'
|
||||
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { DiffComment, DiffReviewScope } from '../../../src/shared/types'
|
||||
import { formatDiffComment, formatDiffComments } from '../../../src/shared/diff-comments-format'
|
||||
|
||||
export { formatDiffComment, formatDiffComments }
|
||||
|
||||
export type CreateMobileDiffCommentInput = {
|
||||
worktreeId: string
|
||||
@@ -16,44 +19,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function isMarkdownComment(comment: Pick<DiffComment, 'source'>): boolean {
|
||||
return comment.source === 'markdown'
|
||||
}
|
||||
|
||||
function normalizeScope(value: unknown): DiffReviewScope | undefined {
|
||||
return value === 'unstaged' || value === 'staged' || value === 'branch' ? value : undefined
|
||||
}
|
||||
|
||||
// Why: mobile Vitest/Metro run from the mobile package and cannot transform
|
||||
// runtime imports from root src/shared. Keep this byte-for-byte compatible with
|
||||
// the desktop shared formatter contract.
|
||||
export function formatDiffComment(c: DiffComment): string {
|
||||
const escaped = c.body
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\n/g, '\\n')
|
||||
const locationLabel =
|
||||
c.lineNumber === 0
|
||||
? 'Scope: file'
|
||||
: c.startLine !== undefined && c.startLine !== c.lineNumber
|
||||
? `Lines: ${c.startLine}-${c.lineNumber}`
|
||||
: `Line: ${c.lineNumber}`
|
||||
if (!isMarkdownComment(c)) {
|
||||
return [`File: ${c.filePath}`, locationLabel, `User comment: "${escaped}"`].join('\n')
|
||||
}
|
||||
return [
|
||||
`File: ${c.filePath}`,
|
||||
'Source: markdown',
|
||||
locationLabel,
|
||||
`User comment: "${escaped}"`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function formatDiffComments(comments: readonly DiffComment[]): string {
|
||||
return comments.map(formatDiffComment).join('\n\n')
|
||||
}
|
||||
|
||||
export function formatMobileDiffReviewPrompt(comments: readonly DiffComment[]): string {
|
||||
return [
|
||||
'You are reviewing the current worktree. Address the following mobile review notes.',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
buildAskAnswerKeys,
|
||||
extractPendingAsk,
|
||||
formatAskAnswer,
|
||||
parseAskFromStatus
|
||||
} from './mobile-native-chat-ask'
|
||||
} from '../../../src/shared/native-chat-ask'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' }
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift.
|
||||
export * from '../../../src/shared/native-chat-ask'
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
foldToolMessages,
|
||||
pairToolBlocks,
|
||||
splitNativeChatBlocks
|
||||
} from './mobile-native-chat-blocks'
|
||||
} from '../../../src/shared/native-chat-tool-fold'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
function msg(
|
||||
role: NativeChatMessage['role'],
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export {
|
||||
isImageRefBlock,
|
||||
isTextBlock,
|
||||
NATIVE_CHAT_SOURCE_PRIORITY
|
||||
} from '../../../src/shared/native-chat-types'
|
||||
export {
|
||||
foldToolMessages,
|
||||
pairToolBlocks,
|
||||
splitNativeChatBlocks,
|
||||
type NativeChatToolPair as ToolPair
|
||||
} from '../../../src/shared/native-chat-tool-fold'
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
|
||||
import type {
|
||||
AskAnswerSelection,
|
||||
AskPrompt,
|
||||
parseAskFromStatus
|
||||
} from '../../../src/shared/native-chat-ask'
|
||||
import type { detectAgentPermission } from './mobile-native-chat-permission'
|
||||
import type { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
|
||||
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff'
|
||||
import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff'
|
||||
|
||||
describe('diffFromToolCall', () => {
|
||||
it('builds del/add lines from an Edit tool call', () => {
|
||||
@@ -36,8 +36,6 @@ describe('diffFromText', () => {
|
||||
expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull()
|
||||
})
|
||||
|
||||
// Parity guard: the mobile module re-exports the shared parser, so the -- / ++
|
||||
// content collision must stay fixed on both platforms.
|
||||
it('counts a removed -- comment line despite the --- prefix collision', () => {
|
||||
const lines = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---sql comment\n+new')
|
||||
expect(lines).toEqual([
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
diffFromText,
|
||||
diffFromToolCall,
|
||||
type NativeChatDiffLine as DiffLine,
|
||||
type NativeChatDiffLineKind as DiffLineKind
|
||||
} from '../../../src/shared/native-chat-diff'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mergeNativeChatMessages } from '../../../src/shared/native-chat-merge'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { mergeNativeChatMessages } from './mobile-native-chat-merge'
|
||||
|
||||
function msg(id: string, overrides: Partial<NativeChatMessage> = {}): NativeChatMessage {
|
||||
return {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// Keep the established mobile import path while executing the shared desktop/mobile merger.
|
||||
export {
|
||||
applyAppend,
|
||||
boundNativeChatWindow,
|
||||
createNativeChatMerger,
|
||||
mergeNativeChatMessages,
|
||||
replaceList,
|
||||
type NativeChatMerger
|
||||
} from '../../../src/shared/native-chat-merge'
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createNativeChatMerger,
|
||||
mergeNativeChatMessages,
|
||||
replaceList
|
||||
} from './mobile-native-chat-merge'
|
||||
} from '../../../src/shared/native-chat-merge'
|
||||
|
||||
function msg(id: string, overrides: Partial<NativeChatMessage> = {}): NativeChatMessage {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
|
||||
import { isTextBlock } from './mobile-native-chat-blocks'
|
||||
import { isTextBlock, type NativeChatBlock } from '../../../src/shared/native-chat-types'
|
||||
|
||||
/** Concatenate a message's text blocks into a single copyable string. Tool
|
||||
* calls/results and image refs are skipped — Copy is for the agent's prose. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise'
|
||||
|
||||
function msg(
|
||||
role: NativeChatMessage['role'],
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise'
|
||||
@@ -3,10 +3,10 @@ import {
|
||||
formatNativeChatEmptyStateCopy,
|
||||
type NativeChatEmptyStateCopy
|
||||
} from '../../../src/shared/native-chat-empty-state'
|
||||
import { stripNoiseMessages } from '../../../src/shared/native-chat-noise'
|
||||
import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold'
|
||||
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { foldToolMessages } from './mobile-native-chat-blocks'
|
||||
import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers'
|
||||
import { stripNoiseMessages } from './mobile-native-chat-noise'
|
||||
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||||
|
||||
/** The centered empty-state copy for a chat with no messages, mirroring the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge'
|
||||
import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame'
|
||||
|
||||
function message(id: string): NativeChatMessage {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { applyAppend, replaceList } from '../../../src/shared/native-chat-merge'
|
||||
import type { NativeChatMerger } from '../../../src/shared/native-chat-merge'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge'
|
||||
|
||||
export type MobileNativeChatStreamFrame = {
|
||||
type?: string
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
describeToolInput,
|
||||
summarizeToolInput,
|
||||
toolFilePath
|
||||
} from './mobile-native-chat-tool-summary'
|
||||
} from '../../../src/shared/native-chat-tool-summary'
|
||||
|
||||
describe('summarizeToolInput', () => {
|
||||
it('passes short strings through, collapsing whitespace', () => {
|
||||
@@ -59,7 +59,7 @@ describe('toolFilePath', () => {
|
||||
})
|
||||
|
||||
describe('describeToolInput', () => {
|
||||
it('is re-exported and labels rows with the path or primary argument', () => {
|
||||
it('labels rows with the path or primary argument', () => {
|
||||
expect(describeToolInput({ file_path: 'src/a.ts', offset: 3 })).toBe('src/a.ts')
|
||||
expect(describeToolInput('{"cmd":"git status"}')).toBe('git status')
|
||||
})
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export {
|
||||
briefToolArg,
|
||||
countToolCalls,
|
||||
createToolInputDisplay,
|
||||
describeToolInput,
|
||||
formatToolInput,
|
||||
isStructuredToolInput,
|
||||
MAX_TOOL_DETAIL_LENGTH,
|
||||
summarizeToolInput,
|
||||
summarizeToolRun,
|
||||
toolFilePath,
|
||||
truncateToolDetail
|
||||
} from '../../../src/shared/native-chat-tool-summary'
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AskPrompt } from '../../../src/shared/native-chat-ask'
|
||||
import type { AgentType } from '../../../src/shared/native-chat-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
|
||||
import type { AskPrompt } from './mobile-native-chat-ask'
|
||||
import {
|
||||
isMobileNativeChatInputStale,
|
||||
markMobileNativeChatInputStale,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
|
||||
import {
|
||||
buildAskAnswerKeys,
|
||||
buildCodexAskAnswerKeys,
|
||||
@@ -8,7 +6,9 @@ import {
|
||||
hasAskAnswer,
|
||||
type AskAnswerSelection,
|
||||
type AskPrompt
|
||||
} from './mobile-native-chat-ask'
|
||||
} from '../../../src/shared/native-chat-ask'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
|
||||
import {
|
||||
openMobileNativeChatSendBudget,
|
||||
sendMobileNativeChatMessageWithOutcome
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { AskPrompt } from './mobile-native-chat-ask'
|
||||
import type { AskPrompt } from '../../../src/shared/native-chat-ask'
|
||||
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
|
||||
|
||||
describe('useMobileNativeChatAskDismiss', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { nativeChatAskDismissKey, type AskPrompt } from './mobile-native-chat-ask'
|
||||
import { nativeChatAskDismissKey, type AskPrompt } from '../../../src/shared/native-chat-ask'
|
||||
|
||||
type AskDismissal = { sessionKey: string | null; askKey: string }
|
||||
type DetectedAsk = { sessionKey: string | null; askKey: string | null }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import { parseAskFromStatus, resolveNativeChatAsk } from '../../../src/shared/native-chat-ask'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask'
|
||||
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
|
||||
import { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ import {
|
||||
createNativeChatTranscriptRetention,
|
||||
encodeNativeChatTranscriptIdentity
|
||||
} from '../../../src/shared/native-chat-transcript-retention'
|
||||
import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge'
|
||||
import {
|
||||
applyMobileNativeChatStreamFrame,
|
||||
type MobileNativeChatStreamFrame
|
||||
|
||||
@@ -2,7 +2,6 @@ import { hostedReviewCopy } from './hosted-review-copy'
|
||||
import {
|
||||
buildMobileHostedReviewCreateParams,
|
||||
createMobileHostedReview,
|
||||
fetchMobileHostedReviewEligibility,
|
||||
mobileRepoSelectorFromWorktreeId,
|
||||
resolveMobileHostedReviewPrefill,
|
||||
shouldPushBeforeMobileHostedReviewCreate,
|
||||
@@ -20,7 +19,6 @@ export type MobilePrCreateOutcome = MobileHostedReviewCreateOutcome
|
||||
export {
|
||||
buildMobileHostedReviewCreateParams as buildMobilePrCreateParams,
|
||||
createMobileHostedReview as createMobilePr,
|
||||
fetchMobileHostedReviewEligibility as fetchMobilePrEligibility,
|
||||
mobileRepoSelectorFromWorktreeId,
|
||||
resolveMobileHostedReviewPrefill as resolveMobilePrPrefill,
|
||||
shouldPushBeforeMobileHostedReviewCreate as shouldPushBeforeMobilePrCreate
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
|
||||
import { Platform, View } from 'react-native'
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
|
||||
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
|
||||
import {
|
||||
TerminalWebViewEngineErrorOverlay,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
|
||||
|
||||
export type { TerminalOscLinkRange }
|
||||
|
||||
export function isTerminalOscLinkRanges(value: unknown): value is TerminalOscLinkRange[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(entry) =>
|
||||
entry != null &&
|
||||
typeof entry === 'object' &&
|
||||
Number.isInteger((entry as TerminalOscLinkRange).row) &&
|
||||
Number.isInteger((entry as TerminalOscLinkRange).startCol) &&
|
||||
Number.isInteger((entry as TerminalOscLinkRange).endCol) &&
|
||||
typeof (entry as TerminalOscLinkRange).uri === 'string'
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
|
||||
import type { StyleProp, ViewStyle } from 'react-native'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
|
||||
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
|
||||
|
||||
export type TerminalWebViewCommand =
|
||||
| { type: 'ping'; id?: number }
|
||||
|
||||
@@ -1044,11 +1044,7 @@ private func screenCaptureTrusted() -> Bool {
|
||||
}
|
||||
|
||||
private func screenCaptureTrustedSettled() -> Bool {
|
||||
PermissionTrustSettling.settleWithFallback(
|
||||
finalTimeoutMs: 500,
|
||||
probe: screenCaptureTrusted,
|
||||
fallbackProbe: screenCaptureTrustedByCaptureProbe
|
||||
)
|
||||
PermissionTrustSettling.settle(timeoutMs: 2_000, probe: screenCaptureTrusted).settled
|
||||
}
|
||||
|
||||
private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot {
|
||||
@@ -1058,40 +1054,6 @@ private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot {
|
||||
)
|
||||
}
|
||||
|
||||
private func screenCaptureTrustedByCaptureProbe() -> Bool {
|
||||
guard let infos = CGWindowListCopyWindowInfo(
|
||||
[.optionOnScreenOnly],
|
||||
kCGNullWindowID
|
||||
) as? [[String: Any]] else {
|
||||
return false
|
||||
}
|
||||
let windows = infos.compactMap { info -> ScreenCaptureProbeWindow? in
|
||||
guard let layer = info[kCGWindowLayer as String] as? Int,
|
||||
let ownerPid = info[kCGWindowOwnerPID as String] as? NSNumber,
|
||||
let number = info[kCGWindowNumber as String] as? NSNumber
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return ScreenCaptureProbeWindow(
|
||||
layer: layer,
|
||||
ownerPid: ownerPid.int32Value,
|
||||
windowId: number.uint32Value
|
||||
)
|
||||
}
|
||||
guard let windowId = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
|
||||
ownPid: ProcessInfo.processInfo.processIdentifier,
|
||||
windows: windows
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
return CGWindowListCreateImage(
|
||||
.null,
|
||||
[.optionIncludingWindow],
|
||||
CGWindowID(windowId),
|
||||
[.boundsIgnoreFraming]
|
||||
) != nil
|
||||
}
|
||||
|
||||
private func requestScreenCaptureAccess() -> Bool {
|
||||
CGRequestScreenCaptureAccess()
|
||||
}
|
||||
|
||||
-29
@@ -41,33 +41,4 @@ public enum PermissionTrustSettling {
|
||||
waitedMs += interval
|
||||
}
|
||||
}
|
||||
|
||||
public static func settleWithFallback(
|
||||
initialTimeoutMs: Int = defaultTimeoutMs,
|
||||
finalTimeoutMs: Int,
|
||||
intervalMs: Int = defaultIntervalMs,
|
||||
sleepMs: (Int) -> Void = { interval in
|
||||
Thread.sleep(forTimeInterval: TimeInterval(interval) / 1_000)
|
||||
},
|
||||
probe: () -> Bool,
|
||||
fallbackProbe: () -> Bool
|
||||
) -> Bool {
|
||||
if settle(
|
||||
timeoutMs: initialTimeoutMs,
|
||||
intervalMs: intervalMs,
|
||||
sleepMs: sleepMs,
|
||||
probe: probe
|
||||
).settled {
|
||||
return true
|
||||
}
|
||||
if fallbackProbe() {
|
||||
return true
|
||||
}
|
||||
return settle(
|
||||
timeoutMs: finalTimeoutMs,
|
||||
intervalMs: intervalMs,
|
||||
sleepMs: sleepMs,
|
||||
probe: probe
|
||||
).settled
|
||||
}
|
||||
}
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
public struct ScreenCaptureProbeWindow: Equatable {
|
||||
public let layer: Int
|
||||
public let ownerPid: Int32
|
||||
public let windowId: UInt32
|
||||
|
||||
public init(layer: Int, ownerPid: Int32, windowId: UInt32) {
|
||||
self.layer = layer
|
||||
self.ownerPid = ownerPid
|
||||
self.windowId = windowId
|
||||
}
|
||||
}
|
||||
|
||||
public enum ScreenCaptureProbeWindowSelection {
|
||||
public static func firstCrossProcessNormalWindow(
|
||||
ownPid: Int32,
|
||||
windows: [ScreenCaptureProbeWindow]
|
||||
) -> UInt32? {
|
||||
windows.first { window in
|
||||
window.layer == 0 && window.ownerPid != ownPid
|
||||
}?.windowId
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import XCTest
|
||||
|
||||
final class ScreenCapturePermissionPreflightSafetyTests: XCTestCase {
|
||||
func testPermissionPreflightCannotRequestOrCaptureTheScreen() throws {
|
||||
let source = try computerUseSource()
|
||||
let start = try XCTUnwrap(source.range(of: "private func screenCaptureTrusted()"))
|
||||
let end = try XCTUnwrap(
|
||||
source.range(of: "private func requestScreenCaptureAccess()", range: start.upperBound..<source.endIndex)
|
||||
)
|
||||
let preflightSource = source[start.lowerBound..<end.lowerBound]
|
||||
|
||||
XCTAssertTrue(preflightSource.contains("CGPreflightScreenCaptureAccess()"))
|
||||
XCTAssertTrue(preflightSource.contains("timeoutMs: 2_000"))
|
||||
XCTAssertFalse(preflightSource.contains("CGRequestScreenCaptureAccess()"))
|
||||
XCTAssertFalse(preflightSource.contains("CGWindowListCreateImage"))
|
||||
XCTAssertFalse(preflightSource.contains("SCScreenshotManager"))
|
||||
}
|
||||
|
||||
private func computerUseSource() throws -> String {
|
||||
let testFile = URL(fileURLWithPath: #filePath)
|
||||
let packageRoot = testFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let mainPath = packageRoot
|
||||
.appendingPathComponent("Sources")
|
||||
.appendingPathComponent("OrcaComputerUseMacOS")
|
||||
.appendingPathComponent("main.swift")
|
||||
return try String(contentsOf: mainPath, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
import Testing
|
||||
@testable import OrcaComputerUseMacOSCore
|
||||
|
||||
@Suite("Screen Capture permission settling")
|
||||
struct ScreenCapturePermissionSettlingTests {
|
||||
@Test("initial preflight success skips fallback")
|
||||
func initialSuccess() {
|
||||
var fallbackCalls = 0
|
||||
let trusted = PermissionTrustSettling.settleWithFallback(
|
||||
finalTimeoutMs: 500,
|
||||
sleepMs: { _ in },
|
||||
probe: { true },
|
||||
fallbackProbe: {
|
||||
fallbackCalls += 1
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
#expect(trusted)
|
||||
#expect(fallbackCalls == 0)
|
||||
}
|
||||
|
||||
@Test("capture fallback can establish trust")
|
||||
func fallbackSuccess() {
|
||||
var fallbackCalls = 0
|
||||
let trusted = PermissionTrustSettling.settleWithFallback(
|
||||
initialTimeoutMs: 200,
|
||||
finalTimeoutMs: 200,
|
||||
intervalMs: 100,
|
||||
sleepMs: { _ in },
|
||||
probe: { false },
|
||||
fallbackProbe: {
|
||||
fallbackCalls += 1
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
#expect(trusted)
|
||||
#expect(fallbackCalls == 1)
|
||||
}
|
||||
|
||||
@Test("preflight is retried after fallback failure")
|
||||
func finalPreflightSuccess() {
|
||||
var probeCalls = 0
|
||||
let trusted = PermissionTrustSettling.settleWithFallback(
|
||||
initialTimeoutMs: 100,
|
||||
finalTimeoutMs: 200,
|
||||
intervalMs: 100,
|
||||
sleepMs: { _ in },
|
||||
probe: {
|
||||
probeCalls += 1
|
||||
return probeCalls == 4
|
||||
},
|
||||
fallbackProbe: { false }
|
||||
)
|
||||
|
||||
#expect(trusted)
|
||||
#expect(probeCalls == 4)
|
||||
}
|
||||
|
||||
@Test("persistent denial remains denied")
|
||||
func persistentDenial() {
|
||||
var fallbackCalls = 0
|
||||
let trusted = PermissionTrustSettling.settleWithFallback(
|
||||
initialTimeoutMs: 100,
|
||||
finalTimeoutMs: 100,
|
||||
intervalMs: 100,
|
||||
sleepMs: { _ in },
|
||||
probe: { false },
|
||||
fallbackProbe: {
|
||||
fallbackCalls += 1
|
||||
return false
|
||||
}
|
||||
)
|
||||
|
||||
#expect(!trusted)
|
||||
#expect(fallbackCalls == 1)
|
||||
}
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import Testing
|
||||
@testable import OrcaComputerUseMacOSCore
|
||||
|
||||
@Suite("Screen capture probe window selection")
|
||||
struct ScreenCaptureProbeWindowSelectionTests {
|
||||
@Test("own process windows cannot prove cross-app capture permission")
|
||||
func excludesOwnWindows() {
|
||||
let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
|
||||
ownPid: 42,
|
||||
windows: [
|
||||
.init(layer: 0, ownerPid: 42, windowId: 1),
|
||||
.init(layer: 0, ownerPid: 84, windowId: 2)
|
||||
]
|
||||
)
|
||||
|
||||
#expect(selected == 2)
|
||||
}
|
||||
|
||||
@Test("non-normal windows are skipped")
|
||||
func excludesNonNormalWindows() {
|
||||
let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
|
||||
ownPid: 42,
|
||||
windows: [
|
||||
.init(layer: 1, ownerPid: 84, windowId: 1),
|
||||
.init(layer: 0, ownerPid: 84, windowId: 2)
|
||||
]
|
||||
)
|
||||
|
||||
#expect(selected == 2)
|
||||
}
|
||||
|
||||
@Test("no cross-process normal window returns nil")
|
||||
func noCandidate() {
|
||||
let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
|
||||
ownPid: 42,
|
||||
windows: [
|
||||
.init(layer: 0, ownerPid: 42, windowId: 1),
|
||||
.init(layer: 1, ownerPid: 84, windowId: 2)
|
||||
]
|
||||
)
|
||||
|
||||
#expect(selected == nil)
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,42 @@ describe('AgentAwakeService', () => {
|
||||
expect(linuxAssertion.start).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stays awake in On mode without a working agent', () => {
|
||||
const blocker = createBlocker()
|
||||
const service = createService(() => 1_000, blocker)
|
||||
|
||||
service.setMode('on')
|
||||
|
||||
expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep')
|
||||
expect(service.getStatus()).toEqual({ mode: 'on', active: true })
|
||||
})
|
||||
|
||||
it('publishes Auto activity changes to status subscribers', () => {
|
||||
const service = createService(() => 1_000)
|
||||
const listener = vi.fn()
|
||||
service.subscribe(listener)
|
||||
|
||||
service.setMode('auto')
|
||||
service.setStatuses([workingStatus()])
|
||||
service.setStatuses([workingStatus(), workingStatus()])
|
||||
service.setStatuses([workingStatus()])
|
||||
service.setStatuses([])
|
||||
|
||||
expect(listener).toHaveBeenNthCalledWith(1, {
|
||||
mode: 'auto',
|
||||
active: false
|
||||
})
|
||||
expect(listener).toHaveBeenNthCalledWith(2, {
|
||||
mode: 'auto',
|
||||
active: true
|
||||
})
|
||||
expect(listener).toHaveBeenNthCalledWith(3, {
|
||||
mode: 'auto',
|
||||
active: false
|
||||
})
|
||||
expect(listener).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('starts and stops from settings flips around an already-running status', () => {
|
||||
const blocker = createBlocker()
|
||||
const macosAssertion = createMacosAssertion()
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { powerMonitor, powerSaveBlocker } from 'electron'
|
||||
import type { AgentStatusState } from '../shared/agent-status-types'
|
||||
import {
|
||||
normalizeComputerAwakeMode,
|
||||
type ComputerAwakeMode,
|
||||
type ComputerAwakeStatus
|
||||
} from '../shared/computer-awake-mode'
|
||||
import { LinuxLidSleepAssertion } from './linux-lid-sleep-assertion'
|
||||
import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion'
|
||||
|
||||
@@ -40,10 +45,12 @@ type AgentAwakeServiceOptions = {
|
||||
}
|
||||
|
||||
export class AgentAwakeService {
|
||||
private enabled = false
|
||||
private mode: ComputerAwakeMode = 'off'
|
||||
private statuses: AgentAwakeStatus[] = []
|
||||
private blockerId: number | null = null
|
||||
private staleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private readonly statusListeners = new Set<(status: ComputerAwakeStatus) => void>()
|
||||
private lastPublishedStatus: ComputerAwakeStatus | null = null
|
||||
private readonly blocker: PowerSaveBlocker
|
||||
private readonly linuxAssertion: PlatformAwakeAssertion
|
||||
private readonly logger: Logger
|
||||
@@ -82,10 +89,15 @@ export class AgentAwakeService {
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
if (this.enabled === enabled) {
|
||||
this.setMode(enabled ? 'auto' : 'off')
|
||||
}
|
||||
|
||||
setMode(mode: ComputerAwakeMode): void {
|
||||
const normalized = normalizeComputerAwakeMode(mode)
|
||||
if (this.mode === normalized) {
|
||||
return
|
||||
}
|
||||
this.enabled = enabled
|
||||
this.mode = normalized
|
||||
this.refresh('settings-change')
|
||||
}
|
||||
|
||||
@@ -94,6 +106,19 @@ export class AgentAwakeService {
|
||||
this.refresh('status-change')
|
||||
}
|
||||
|
||||
getStatus(): ComputerAwakeStatus {
|
||||
const workingAgentCount = this.getEligibleRunningStatusCount()
|
||||
return {
|
||||
mode: this.mode,
|
||||
active: this.mode === 'on' || (this.mode === 'auto' && workingAgentCount > 0)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (status: ComputerAwakeStatus) => void): () => void {
|
||||
this.statusListeners.add(listener)
|
||||
return () => this.statusListeners.delete(listener)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clearStaleTimer()
|
||||
this.unsubscribeResume?.()
|
||||
@@ -105,7 +130,7 @@ export class AgentAwakeService {
|
||||
private refresh(reason: string): void {
|
||||
this.scheduleStaleTimer()
|
||||
const runningStatusCount = this.getEligibleRunningStatusCount()
|
||||
const shouldBlock = this.enabled && runningStatusCount > 0
|
||||
const shouldBlock = this.mode === 'on' || (this.mode === 'auto' && runningStatusCount > 0)
|
||||
if (shouldBlock) {
|
||||
this.startBlocker(reason, runningStatusCount)
|
||||
this.startMacosAssertion(reason)
|
||||
@@ -115,6 +140,21 @@ export class AgentAwakeService {
|
||||
this.stopMacosAssertion(reason)
|
||||
this.stopLinuxAssertion(reason)
|
||||
}
|
||||
this.publishStatus(shouldBlock)
|
||||
}
|
||||
|
||||
private publishStatus(active: boolean): void {
|
||||
const status = { mode: this.mode, active }
|
||||
if (
|
||||
this.lastPublishedStatus?.mode === status.mode &&
|
||||
this.lastPublishedStatus.active === status.active
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.lastPublishedStatus = status
|
||||
for (const listener of this.statusListeners) {
|
||||
listener(status)
|
||||
}
|
||||
}
|
||||
|
||||
private getEligibleRunningStatusCount(): number {
|
||||
@@ -182,7 +222,7 @@ export class AgentAwakeService {
|
||||
} catch (err) {
|
||||
this.logger.warn('[agent-awake] failed to start blocker', {
|
||||
reason,
|
||||
enabled: this.enabled,
|
||||
mode: this.mode,
|
||||
runningStatusCount,
|
||||
error: err
|
||||
})
|
||||
@@ -195,7 +235,7 @@ export class AgentAwakeService {
|
||||
} catch (err) {
|
||||
this.logger.warn('[agent-awake] failed to start macOS system sleep assertion', {
|
||||
reason,
|
||||
enabled: this.enabled,
|
||||
mode: this.mode,
|
||||
error: err
|
||||
})
|
||||
}
|
||||
@@ -207,7 +247,7 @@ export class AgentAwakeService {
|
||||
} catch (err) {
|
||||
this.logger.warn('[agent-awake] failed to start Linux lid sleep assertion', {
|
||||
reason,
|
||||
enabled: this.enabled,
|
||||
mode: this.mode,
|
||||
error: err
|
||||
})
|
||||
}
|
||||
@@ -219,7 +259,7 @@ export class AgentAwakeService {
|
||||
} catch (err) {
|
||||
this.logger.warn('[agent-awake] failed to stop macOS system sleep assertion', {
|
||||
reason,
|
||||
enabled: this.enabled,
|
||||
mode: this.mode,
|
||||
error: err
|
||||
})
|
||||
}
|
||||
@@ -231,7 +271,7 @@ export class AgentAwakeService {
|
||||
} catch (err) {
|
||||
this.logger.warn('[agent-awake] failed to stop Linux lid sleep assertion', {
|
||||
reason,
|
||||
enabled: this.enabled,
|
||||
mode: this.mode,
|
||||
error: err
|
||||
})
|
||||
}
|
||||
@@ -247,7 +287,7 @@ export class AgentAwakeService {
|
||||
} catch (err) {
|
||||
this.logger.warn('[agent-awake] failed to stop blocker', {
|
||||
reason,
|
||||
enabled: this.enabled,
|
||||
mode: this.mode,
|
||||
runningStatusCount,
|
||||
blockerId: id,
|
||||
error: err
|
||||
|
||||
@@ -233,6 +233,13 @@ describe('WslHookRelayManager', () => {
|
||||
return { manager: new WslHookRelayManager(deps), deps }
|
||||
}
|
||||
|
||||
it('exposes the stable guest endpoint instance key with a port fallback', () => {
|
||||
expect(createManager({}).manager.getInstanceKey()).toBe('testinstance')
|
||||
expect(createManager({ instanceKey: () => 'unsafe/key' }).manager.getInstanceKey()).toBe(
|
||||
'port43117'
|
||||
)
|
||||
})
|
||||
|
||||
it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => {
|
||||
const { manager, deps } = createManager({})
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
|
||||
@@ -109,6 +109,15 @@ export class WslHookRelayManager {
|
||||
return this.stateFor(distro)?.opencodeOverlayDir ?? null
|
||||
}
|
||||
|
||||
getInstanceKey(): string | null {
|
||||
const stableKey = sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined)
|
||||
if (stableKey) {
|
||||
return stableKey
|
||||
}
|
||||
const port = Number(this.deps.hookCoordsEnv().ORCA_AGENT_HOOK_PORT ?? '')
|
||||
return Number.isInteger(port) && port > 0 ? `port${port}` : null
|
||||
}
|
||||
|
||||
/** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the
|
||||
* manager reusable, so re-enabling hooks can start relays again without an app restart. */
|
||||
disposeAll({ permanent = true }: { permanent?: boolean } = {}): void {
|
||||
@@ -172,8 +181,7 @@ export class WslHookRelayManager {
|
||||
}
|
||||
// Why: restart-stable instance identity keeps the guest endpoint file at
|
||||
// ONE path across restarts so daemon-surviving agents re-coordinate.
|
||||
const instanceKey =
|
||||
sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) ?? `port${port}`
|
||||
const instanceKey = this.getInstanceKey() ?? `port${port}`
|
||||
if (existing) {
|
||||
this.recovery.clearTimers(existing)
|
||||
}
|
||||
|
||||
@@ -604,6 +604,8 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
|
||||
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
|
||||
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
|
||||
const primeWrapperLine =
|
||||
'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"'
|
||||
expect(zshrc).toContain(restoreLine)
|
||||
expect(zlogin).toContain(restoreLine)
|
||||
expect(bashRc).toContain(restoreLine)
|
||||
@@ -625,6 +627,9 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
expect(zshrc).toContain(ompWrapperLine)
|
||||
expect(zlogin).toContain(ompWrapperLine)
|
||||
expect(bashRc).toContain(ompWrapperLine)
|
||||
expect(zshrc).toContain(primeWrapperLine)
|
||||
expect(zlogin).toContain(primeWrapperLine)
|
||||
expect(bashRc).toContain(primeWrapperLine)
|
||||
})
|
||||
|
||||
// Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows.
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isPowerShellExecutableName
|
||||
} from '../powershell-osc133-bootstrap'
|
||||
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
|
||||
import { getPosixPrimeAgentShellWrapper } from '../pty/prime-agent-shell-wrapper'
|
||||
import {
|
||||
getFishShellReadyInitCommand,
|
||||
getZshEnvTemplate,
|
||||
@@ -122,6 +123,7 @@ __orca_restore_agent_teams_path
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
${getPosixPrimeAgentShellWrapper()}
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
# Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent
|
||||
@@ -243,6 +245,7 @@ if [[ ! -o login ]]; then
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
${getPosixPrimeAgentShellWrapper()}
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
fi
|
||||
__orca_osc133_precmd() {
|
||||
@@ -306,6 +309,7 @@ __orca_restore_agent_teams_path
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
${getPosixPrimeAgentShellWrapper()}
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)}
|
||||
${getZshFinalZdotdirRestoreBlock()}
|
||||
|
||||
@@ -225,10 +225,14 @@ describe('GitHub PR local runtime routing', () => {
|
||||
return { stdout: '[]' }
|
||||
}
|
||||
if (endpoint.endsWith('/pulls/7/comments/11/replies')) {
|
||||
return { stdout: JSON.stringify({ id: 12, user: null, body: 'Reply' }) }
|
||||
return {
|
||||
stdout: JSON.stringify({ id: 12, node_id: 'PRRC_reply_12', user: null, body: 'Reply' })
|
||||
}
|
||||
}
|
||||
if (endpoint.endsWith('/pulls/7/comments')) {
|
||||
return { stdout: JSON.stringify({ id: 13, user: null, body: 'Inline' }) }
|
||||
return {
|
||||
stdout: JSON.stringify({ id: 13, node_id: 'PRRC_inline_13', user: null, body: 'Inline' })
|
||||
}
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
@@ -250,7 +254,10 @@ describe('GitHub PR local runtime routing', () => {
|
||||
prRepo,
|
||||
localGitOptions
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
comment: { reactionSubjectId: 'PRRC_reply_12' }
|
||||
})
|
||||
await expect(
|
||||
addPRReviewComment({
|
||||
repoPath: '/repo-root',
|
||||
@@ -263,7 +270,10 @@ describe('GitHub PR local runtime routing', () => {
|
||||
path: 'src/app.ts',
|
||||
line: 10
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
comment: { reactionSubjectId: 'PRRC_inline_13' }
|
||||
})
|
||||
await expect(
|
||||
updatePRTitle('/repo-root', 7, 'New title', null, prRepo, localGitOptions)
|
||||
).resolves.toBe(true)
|
||||
|
||||
@@ -178,6 +178,7 @@ import {
|
||||
getPullRequestPushTarget,
|
||||
mergePR,
|
||||
resolveReviewThread,
|
||||
setPRCommentReaction,
|
||||
setPRAutoMerge,
|
||||
updatePRState,
|
||||
updatePRTitle,
|
||||
@@ -4043,6 +4044,60 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
expect(noteRateLimitSpendMock).not.toHaveBeenCalledWith('graphql')
|
||||
})
|
||||
|
||||
it('maps review summary reaction subjects from GraphQL', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
reviewThreads: { nodes: [] },
|
||||
comments: { nodes: [] },
|
||||
reviews: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'PRR_44',
|
||||
databaseId: 44,
|
||||
author: {
|
||||
__typename: 'Bot',
|
||||
login: 'coderabbitai',
|
||||
avatarUrl: 'https://avatar'
|
||||
},
|
||||
body: 'Automated review summary',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
url: 'https://github.com/acme/widgets/pull/7#pullrequestreview-44',
|
||||
reactionGroups: [
|
||||
{
|
||||
content: 'ROCKET',
|
||||
viewerHasReacted: true,
|
||||
reactors: { totalCount: 2 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await expect(getPRComments('/repo-root', 7)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 44,
|
||||
reactionSubjectId: 'PRR_44',
|
||||
isBot: true,
|
||||
reactions: [{ content: 'rocket', count: 2, viewerHasReacted: true }]
|
||||
})
|
||||
])
|
||||
expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('reviews(first: 100)')])
|
||||
)
|
||||
})
|
||||
|
||||
it('uses explicit PR repo for comments when a fork PR is discovered', async () => {
|
||||
rateLimitGuardMock.mockImplementation(((bucket: string) =>
|
||||
bucket === 'graphql'
|
||||
@@ -4082,6 +4137,113 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns GraphQL reaction subjects and viewer state for PR comments', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'IC_1',
|
||||
databaseId: 10,
|
||||
author: { login: 'octo', avatarUrl: '', __typename: 'User' },
|
||||
body: 'Issue comment',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
url: 'https://example.test/issue-comment',
|
||||
reactionGroups: [
|
||||
{
|
||||
content: 'THUMBS_UP',
|
||||
viewerHasReacted: true,
|
||||
reactors: { totalCount: 2 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
reviewThreads: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'PRRT_1',
|
||||
isResolved: false,
|
||||
line: 4,
|
||||
startLine: null,
|
||||
originalLine: 4,
|
||||
originalStartLine: null,
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'PRRC_1',
|
||||
databaseId: 11,
|
||||
author: { login: 'reviewer', avatarUrl: '', __typename: 'User' },
|
||||
body: 'Inline comment',
|
||||
createdAt: '2026-04-01T00:01:00Z',
|
||||
url: 'https://example.test/review-comment',
|
||||
path: 'src/app.ts',
|
||||
reactionGroups: [
|
||||
{
|
||||
content: 'EYES',
|
||||
viewerHasReacted: false,
|
||||
reactors: { totalCount: 1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
const comments = await getPRComments('/repo-root', 7, {
|
||||
prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' }
|
||||
})
|
||||
|
||||
expect(comments).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 10,
|
||||
reactionSubjectId: 'IC_1',
|
||||
reactions: [{ content: '+1', count: 2, viewerHasReacted: true }]
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 11,
|
||||
reactionSubjectId: 'PRRC_1',
|
||||
reactions: [{ content: 'eyes', count: 1, viewerHasReacted: false }]
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('adds and removes PR comment reactions through GraphQL', async () => {
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '{}', stderr: '' })
|
||||
|
||||
await expect(setPRCommentReaction('/repo-root', 'IC_1', '+1', true)).resolves.toBe(true)
|
||||
await expect(setPRCommentReaction('/repo-root', 'PRRC_1', 'eyes', false)).resolves.toBe(true)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.arrayContaining(['subjectId=IC_1', 'content=THUMBS_UP']),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[0]?.[0].join(' ')).toContain('addReaction')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.arrayContaining(['subjectId=PRRC_1', 'content=EYES']),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[1]?.[0].join(' ')).toContain('removeReaction')
|
||||
expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('uses explicit PR repo for merge and title mutations', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
+109
-2
@@ -12,6 +12,7 @@ import type {
|
||||
PRCheckDetail,
|
||||
PRCheckRunDetails,
|
||||
GitHubCommentResult,
|
||||
GitHubReactionContent,
|
||||
GitHubPRReviewCommentInput,
|
||||
PRComment,
|
||||
GitHubViewer,
|
||||
@@ -110,7 +111,11 @@ import {
|
||||
mapPRState,
|
||||
deriveCheckStatus
|
||||
} from './mappers'
|
||||
import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './comment-reactions'
|
||||
import {
|
||||
mapGraphQLReactionGroups,
|
||||
toGraphQLReactionContent,
|
||||
type GitHubGraphQLReactionGroup
|
||||
} from './comment-reactions'
|
||||
import {
|
||||
getRateLimit,
|
||||
noteRepositoryRateLimitSpend,
|
||||
@@ -4117,6 +4122,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
originalStartLine
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author { __typename login avatarUrl(size: 48) }
|
||||
body
|
||||
@@ -4125,6 +4131,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
path
|
||||
reactionGroups {
|
||||
content
|
||||
viewerHasReacted
|
||||
reactors {
|
||||
totalCount
|
||||
}
|
||||
@@ -4135,6 +4142,7 @@ query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
}
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author { __typename login avatarUrl(size: 48) }
|
||||
body
|
||||
@@ -4142,6 +4150,24 @@ query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
url
|
||||
reactionGroups {
|
||||
content
|
||||
viewerHasReacted
|
||||
reactors {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reviews(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author { __typename login avatarUrl(size: 48) }
|
||||
body
|
||||
createdAt
|
||||
url
|
||||
reactionGroups {
|
||||
content
|
||||
viewerHasReacted
|
||||
reactors {
|
||||
totalCount
|
||||
}
|
||||
@@ -4255,6 +4281,7 @@ export async function getPRComments(
|
||||
originalStartLine: number | null
|
||||
comments: {
|
||||
nodes: {
|
||||
id: string
|
||||
databaseId: number
|
||||
author: { __typename?: string; login: string; avatarUrl: string } | null
|
||||
body: string
|
||||
@@ -4266,6 +4293,7 @@ export async function getPRComments(
|
||||
}
|
||||
}
|
||||
type GQLIssueComment = {
|
||||
id: string
|
||||
databaseId: number
|
||||
author: { __typename?: string; login: string; avatarUrl: string } | null
|
||||
body: string
|
||||
@@ -4273,6 +4301,7 @@ export async function getPRComments(
|
||||
url: string
|
||||
reactionGroups?: GitHubGraphQLReactionGroup[] | null
|
||||
}
|
||||
let graphQLReviewSummaries: PRComment[] | undefined
|
||||
const reviewComments: PRComment[] = []
|
||||
if (threadsResult.status === 'fulfilled' && threadsResult.value) {
|
||||
const threadsData = JSON.parse(threadsResult.value.stdout) as {
|
||||
@@ -4281,6 +4310,7 @@ export async function getPRComments(
|
||||
pullRequest: {
|
||||
reviewThreads: { nodes: GQLThread[] }
|
||||
comments?: { nodes: GQLIssueComment[] }
|
||||
reviews?: { nodes: GQLIssueComment[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4295,12 +4325,28 @@ export async function getPRComments(
|
||||
createdAt: c.createdAt,
|
||||
url: c.url,
|
||||
isBot: c.author?.__typename === 'Bot',
|
||||
reactionSubjectId: c.id,
|
||||
reactions: mapGraphQLReactionGroups(c.reactionGroups)
|
||||
})
|
||||
)
|
||||
if (graphQLIssueComments.length > 0) {
|
||||
issueComments = graphQLIssueComments
|
||||
}
|
||||
graphQLReviewSummaries = (pullRequest.reviews?.nodes ?? [])
|
||||
.filter((review) => review.body?.trim())
|
||||
.map(
|
||||
(review): PRComment => ({
|
||||
id: review.databaseId,
|
||||
author: review.author?.login ?? 'ghost',
|
||||
authorAvatarUrl: review.author?.avatarUrl ?? '',
|
||||
body: review.body,
|
||||
createdAt: review.createdAt,
|
||||
url: review.url,
|
||||
isBot: review.author?.__typename === 'Bot',
|
||||
reactionSubjectId: review.id,
|
||||
reactions: mapGraphQLReactionGroups(review.reactionGroups)
|
||||
})
|
||||
)
|
||||
|
||||
const threads = pullRequest.reviewThreads.nodes
|
||||
for (const thread of threads) {
|
||||
@@ -4313,6 +4359,7 @@ export async function getPRComments(
|
||||
createdAt: c.createdAt,
|
||||
url: c.url,
|
||||
isBot: c.author?.__typename === 'Bot',
|
||||
reactionSubjectId: c.id,
|
||||
reactions: mapGraphQLReactionGroups(c.reactionGroups),
|
||||
path: c.path,
|
||||
threadId: thread.id,
|
||||
@@ -4340,7 +4387,9 @@ export async function getPRComments(
|
||||
html_url: string
|
||||
}
|
||||
let reviewSummaries: PRComment[] = []
|
||||
if (reviewsResult.status === 'fulfilled') {
|
||||
if (graphQLReviewSummaries) {
|
||||
reviewSummaries = graphQLReviewSummaries
|
||||
} else if (reviewsResult.status === 'fulfilled') {
|
||||
reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[])
|
||||
.filter((r) => r.body?.trim())
|
||||
.map(
|
||||
@@ -4393,6 +4442,62 @@ export async function getPRComments(
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPRCommentReaction(
|
||||
repoPath: string,
|
||||
reactionSubjectId: string,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean,
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<boolean> {
|
||||
const mutation = reacted ? 'addReaction' : 'removeReaction'
|
||||
const query = `mutation($subjectId: ID!, $content: ReactionContent!) {
|
||||
${mutation}(input: { subjectId: $subjectId, content: $content }) {
|
||||
subject { id }
|
||||
}
|
||||
}`
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return false
|
||||
}
|
||||
const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
|
||||
if (guard.blocked) {
|
||||
console.warn(
|
||||
`${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})`
|
||||
)
|
||||
return false
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`query=${query}`,
|
||||
'-f',
|
||||
`subjectId=${reactionSubjectId}`,
|
||||
'-f',
|
||||
`content=${toGraphQLReactionContent(content)}`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn(`${mutation} failed:`, err)
|
||||
return false
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark or unmark a PR file as viewed via GitHub's GraphQL API.
|
||||
*/
|
||||
@@ -4492,6 +4597,7 @@ export async function resolveReviewThread(
|
||||
function mapReviewCommentResponse(
|
||||
data: {
|
||||
id?: number
|
||||
node_id?: string | null
|
||||
user: { login: string; avatar_url: string; type?: string } | null
|
||||
body?: string
|
||||
created_at?: string
|
||||
@@ -4507,6 +4613,7 @@ function mapReviewCommentResponse(
|
||||
): PRComment {
|
||||
return {
|
||||
id: data.id ?? Date.now(),
|
||||
reactionSubjectId: data.node_id?.trim() || undefined,
|
||||
author: data.user?.login ?? 'You',
|
||||
authorAvatarUrl: data.user?.avatar_url ?? '',
|
||||
body: data.body ?? body,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mapGraphQLReactionGroups } from './comment-reactions'
|
||||
import { mapGraphQLReactionGroups, toGraphQLReactionContent } from './comment-reactions'
|
||||
|
||||
describe('mapGraphQLReactionGroups', () => {
|
||||
it('normalizes GraphQL reaction groups into GitHub comment reactions', () => {
|
||||
expect(
|
||||
mapGraphQLReactionGroups([
|
||||
{ content: 'EYES', reactors: { totalCount: 1 } },
|
||||
{ content: 'THUMBS_UP', reactors: { totalCount: 2 } },
|
||||
{ content: 'EYES', reactors: { totalCount: 1 }, viewerHasReacted: false },
|
||||
{ content: 'THUMBS_UP', reactors: { totalCount: 2 }, viewerHasReacted: true },
|
||||
{ content: 'HEART', reactors: { totalCount: 0 } },
|
||||
{ content: 'UNKNOWN', reactors: { totalCount: 9 } }
|
||||
])
|
||||
).toEqual([
|
||||
{ content: '+1', count: 2 },
|
||||
{ content: 'eyes', count: 1 }
|
||||
{ content: '+1', count: 2, viewerHasReacted: true },
|
||||
{ content: 'eyes', count: 1, viewerHasReacted: false }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -22,4 +22,9 @@ describe('mapGraphQLReactionGroups', () => {
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('maps REST reaction content to the GraphQL enum', () => {
|
||||
expect(toGraphQLReactionContent('+1')).toBe('THUMBS_UP')
|
||||
expect(toGraphQLReactionContent('hooray')).toBe('HOORAY')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ type GitHubGraphQLReactionContent =
|
||||
export type GitHubGraphQLReactionGroup = {
|
||||
content?: string | null
|
||||
reactors?: { totalCount?: number | null } | null
|
||||
viewerHasReacted?: boolean | null
|
||||
}
|
||||
|
||||
const GRAPHQL_REACTION_CONTENT: Record<GitHubGraphQLReactionContent, GitHubReactionContent> = {
|
||||
@@ -37,10 +38,27 @@ const REACTION_ORDER: GitHubReactionContent[] = [
|
||||
'eyes'
|
||||
]
|
||||
|
||||
const REACTION_CONTENT_TO_GRAPHQL: Record<GitHubReactionContent, GitHubGraphQLReactionContent> = {
|
||||
'+1': 'THUMBS_UP',
|
||||
'-1': 'THUMBS_DOWN',
|
||||
laugh: 'LAUGH',
|
||||
confused: 'CONFUSED',
|
||||
heart: 'HEART',
|
||||
hooray: 'HOORAY',
|
||||
rocket: 'ROCKET',
|
||||
eyes: 'EYES'
|
||||
}
|
||||
|
||||
export function toGraphQLReactionContent(
|
||||
content: GitHubReactionContent
|
||||
): GitHubGraphQLReactionContent {
|
||||
return REACTION_CONTENT_TO_GRAPHQL[content]
|
||||
}
|
||||
|
||||
export function mapGraphQLReactionGroups(
|
||||
groups?: GitHubGraphQLReactionGroup[] | null
|
||||
): GitHubReaction[] | undefined {
|
||||
const counts = new Map<GitHubReactionContent, number>()
|
||||
const reactionsByContent = new Map<GitHubReactionContent, GitHubReaction>()
|
||||
for (const group of groups ?? []) {
|
||||
const content =
|
||||
group.content && group.content in GRAPHQL_REACTION_CONTENT
|
||||
@@ -50,12 +68,20 @@ export function mapGraphQLReactionGroups(
|
||||
if (!content || count <= 0) {
|
||||
continue
|
||||
}
|
||||
counts.set(content, (counts.get(content) ?? 0) + count)
|
||||
const existing = reactionsByContent.get(content)
|
||||
const viewerHasReacted = Boolean(existing?.viewerHasReacted || group.viewerHasReacted)
|
||||
reactionsByContent.set(content, {
|
||||
content,
|
||||
count: (existing?.count ?? 0) + count,
|
||||
...(existing?.viewerHasReacted !== undefined || group.viewerHasReacted != null
|
||||
? { viewerHasReacted }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
const reactions = REACTION_ORDER.flatMap((content) => {
|
||||
const count = counts.get(content) ?? 0
|
||||
return count > 0 ? [{ content, count }] : []
|
||||
const reaction = reactionsByContent.get(content)
|
||||
return reaction ? [reaction] : []
|
||||
})
|
||||
return reactions.length > 0 ? reactions : undefined
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ describe('issue source operations', () => {
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
id: 9,
|
||||
node_id: 'IC_enterprise_9',
|
||||
user: { login: 'octo', avatar_url: '', type: 'User' },
|
||||
body: 'Enterprise comment',
|
||||
created_at: '2026-07-16T00:00:00.000Z',
|
||||
@@ -208,7 +209,10 @@ describe('issue source operations', () => {
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
comment: { reactionSubjectId: 'IC_enterprise_9' }
|
||||
})
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
|
||||
@@ -473,6 +473,7 @@ export async function addIssueComment(
|
||||
)
|
||||
const data = JSON.parse(stdout) as {
|
||||
id?: number
|
||||
node_id?: string | null
|
||||
user: { login: string; avatar_url: string; type?: string } | null
|
||||
body?: string
|
||||
created_at?: string
|
||||
@@ -483,6 +484,7 @@ export async function addIssueComment(
|
||||
}
|
||||
const comment: PRComment = {
|
||||
id: data.id,
|
||||
reactionSubjectId: data.node_id?.trim() || undefined,
|
||||
author: data.user?.login ?? 'You',
|
||||
authorAvatarUrl: data.user?.avatar_url ?? '',
|
||||
body: data.body ?? body,
|
||||
|
||||
+7
-1
@@ -267,6 +267,7 @@ import { AutomationService } from './automations/service'
|
||||
import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch'
|
||||
import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create'
|
||||
import { AgentAwakeService } from './agent-awake-service'
|
||||
import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode'
|
||||
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
|
||||
import { settleTeardownWithinDeadline } from './quit-teardown-deadline'
|
||||
import { quitTeardownStartGate } from './quit-teardown-start-gate'
|
||||
@@ -2216,7 +2217,12 @@ void app.whenReady().then(async () => {
|
||||
})
|
||||
unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast()
|
||||
agentAwakeService = new AgentAwakeService()
|
||||
agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)
|
||||
agentAwakeService.setMode(
|
||||
normalizeComputerAwakeMode(
|
||||
store.getSettings().computerAwakeMode,
|
||||
store.getSettings().keepComputerAwakeWhileAgentsRun
|
||||
)
|
||||
)
|
||||
// Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake.
|
||||
agentAwakeService.setStatuses([])
|
||||
const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator()
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
getPRChecksMock,
|
||||
getPRCheckDetailsMock,
|
||||
getPRCommentsMock,
|
||||
setPRCommentReactionMock,
|
||||
resolveReviewThreadMock,
|
||||
setPRFileViewedMock,
|
||||
addPRReviewCommentMock,
|
||||
@@ -64,6 +65,7 @@ const {
|
||||
getPRChecksMock: vi.fn(),
|
||||
getPRCheckDetailsMock: vi.fn(),
|
||||
getPRCommentsMock: vi.fn(),
|
||||
setPRCommentReactionMock: vi.fn(),
|
||||
resolveReviewThreadMock: vi.fn(),
|
||||
setPRFileViewedMock: vi.fn(),
|
||||
addPRReviewCommentMock: vi.fn(),
|
||||
@@ -123,6 +125,7 @@ vi.mock('../github/client', () => ({
|
||||
getPRChecks: getPRChecksMock,
|
||||
getPRCheckDetails: getPRCheckDetailsMock,
|
||||
getPRComments: getPRCommentsMock,
|
||||
setPRCommentReaction: setPRCommentReactionMock,
|
||||
resolveReviewThread: resolveReviewThreadMock,
|
||||
setPRFileViewed: setPRFileViewedMock,
|
||||
addPRReviewComment: addPRReviewCommentMock,
|
||||
@@ -212,6 +215,7 @@ describe('registerGitHubHandlers', () => {
|
||||
getPRChecksMock.mockReset()
|
||||
getPRCheckDetailsMock.mockReset()
|
||||
getPRCommentsMock.mockReset()
|
||||
setPRCommentReactionMock.mockReset()
|
||||
resolveReviewThreadMock.mockReset()
|
||||
setPRFileViewedMock.mockReset()
|
||||
addPRReviewCommentMock.mockReset()
|
||||
@@ -907,6 +911,7 @@ describe('registerGitHubHandlers', () => {
|
||||
getPRChecksMock.mockResolvedValue([])
|
||||
getPRCheckDetailsMock.mockResolvedValue(null)
|
||||
getPRCommentsMock.mockResolvedValue([])
|
||||
setPRCommentReactionMock.mockResolvedValue(true)
|
||||
resolveReviewThreadMock.mockResolvedValue(true)
|
||||
setPRFileViewedMock.mockResolvedValue(true)
|
||||
addPRReviewCommentReplyMock.mockResolvedValue({ ok: true })
|
||||
@@ -964,6 +969,13 @@ describe('registerGitHubHandlers', () => {
|
||||
prRepo,
|
||||
noCache: true
|
||||
})
|
||||
await handlers['gh:setPRCommentReaction'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
reactionSubjectId: ' IC_1 ',
|
||||
content: '+1',
|
||||
reacted: true,
|
||||
prRepo
|
||||
})
|
||||
await handlers['gh:resolveReviewThread'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
threadId: 'thread-1',
|
||||
@@ -1124,6 +1136,15 @@ describe('registerGitHubHandlers', () => {
|
||||
null,
|
||||
localGitOptions
|
||||
)
|
||||
expect(setPRCommentReactionMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'IC_1',
|
||||
'+1',
|
||||
true,
|
||||
null,
|
||||
prRepo,
|
||||
localGitOptions
|
||||
)
|
||||
expect(resolveReviewThreadMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'thread-1',
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
GitHubPRRefreshCandidate,
|
||||
GitHubPRRefreshEnqueueResult,
|
||||
GitHubPRRefreshReason,
|
||||
GitHubReactionContent,
|
||||
PRRefreshOutcome,
|
||||
GitHubPRFile
|
||||
} from '../../shared/types'
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
getPRChecks,
|
||||
getPRCheckDetails,
|
||||
getPRComments,
|
||||
setPRCommentReaction,
|
||||
resolveReviewThread,
|
||||
setPRFileViewed,
|
||||
addPRReviewComment,
|
||||
@@ -667,6 +669,36 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:setPRCommentReaction',
|
||||
(
|
||||
_event,
|
||||
args: {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
reactionSubjectId: string
|
||||
content: GitHubReactionContent
|
||||
reacted: boolean
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
if (!args.reactionSubjectId?.trim()) {
|
||||
return false
|
||||
}
|
||||
return setPRCommentReaction(
|
||||
repo.path,
|
||||
args.reactionSubjectId.trim(),
|
||||
args.content,
|
||||
args.reacted,
|
||||
repoConnectionId(repo),
|
||||
args.prRepo ?? null,
|
||||
...localGitOptionArgs(store, repo)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:resolveReviewThread',
|
||||
async (
|
||||
|
||||
+311
-1
@@ -16,6 +16,7 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-a
|
||||
import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority'
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
|
||||
import type * as Wsl from '../wsl'
|
||||
|
||||
const isWindowsHost = process.platform === 'win32'
|
||||
const posixOnlyIt = isWindowsHost ? it.skip : it
|
||||
@@ -55,8 +56,10 @@ const {
|
||||
clearAgentHookPaneStateMock,
|
||||
registerPaneKeyAliasMock,
|
||||
piBuildPtyEnvMock,
|
||||
piBuildStatusOnlyPtyEnvMock,
|
||||
piClearPtyMock,
|
||||
isPwshAvailableMock,
|
||||
wslUncDirectoryExistsAsyncMock,
|
||||
trackMock,
|
||||
classifyErrorMock,
|
||||
registerPtyMock,
|
||||
@@ -86,11 +89,13 @@ const {
|
||||
openCodeBuildPtyEnvMock: vi.fn(),
|
||||
mimoCodeBuildPtyEnvMock: vi.fn(),
|
||||
isPwshAvailableMock: vi.fn(),
|
||||
wslUncDirectoryExistsAsyncMock: vi.fn(),
|
||||
openCodeClearPtyMock: vi.fn(),
|
||||
buildAgentHookEnvMock: vi.fn(),
|
||||
clearAgentHookPaneStateMock: vi.fn(),
|
||||
registerPaneKeyAliasMock: vi.fn(),
|
||||
piBuildPtyEnvMock: vi.fn(),
|
||||
piBuildStatusOnlyPtyEnvMock: vi.fn(),
|
||||
piClearPtyMock: vi.fn(),
|
||||
trackMock: vi.fn(),
|
||||
classifyErrorMock: vi.fn(),
|
||||
@@ -175,6 +180,7 @@ vi.mock('../agent-hooks/server', () => ({
|
||||
vi.mock('../pi/titlebar-extension-service', () => ({
|
||||
piTitlebarExtensionService: {
|
||||
buildPtyEnv: piBuildPtyEnvMock,
|
||||
buildStatusOnlyPtyEnv: piBuildStatusOnlyPtyEnvMock,
|
||||
clearPty: piClearPtyMock
|
||||
}
|
||||
}))
|
||||
@@ -183,6 +189,11 @@ vi.mock('../pwsh', () => ({
|
||||
isPwshAvailableAsync: isPwshAvailableMock
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof Wsl>()),
|
||||
wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args)
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/client', () => ({
|
||||
track: trackMock
|
||||
}))
|
||||
@@ -333,6 +344,7 @@ describe('registerPtyHandlers', () => {
|
||||
const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION
|
||||
const savedPrimeAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR
|
||||
const savedOrcaPrimeAgentSourceDir = process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
|
||||
const savedOrcaPrimeAgentStatusExtension = process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION
|
||||
const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
|
||||
const savedProcessPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const savedDisableMacosLoginShell = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
@@ -360,6 +372,7 @@ describe('registerPtyHandlers', () => {
|
||||
delete process.env.ORCA_OMP_STATUS_EXTENSION
|
||||
delete process.env.PRIME_AGENT_CODING_AGENT_DIR
|
||||
delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
|
||||
delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
onMock.mockReset()
|
||||
@@ -382,8 +395,11 @@ describe('registerPtyHandlers', () => {
|
||||
clearAgentHookPaneStateMock.mockReset()
|
||||
registerPaneKeyAliasMock.mockReset()
|
||||
piBuildPtyEnvMock.mockReset()
|
||||
piBuildStatusOnlyPtyEnvMock.mockReset()
|
||||
piClearPtyMock.mockReset()
|
||||
isPwshAvailableMock.mockReset()
|
||||
wslUncDirectoryExistsAsyncMock.mockReset()
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
|
||||
trackMock.mockReset()
|
||||
classifyErrorMock.mockReset()
|
||||
registerPtyMock.mockReset()
|
||||
@@ -481,6 +497,10 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
piBuildStatusOnlyPtyEnvMock.mockReturnValue({
|
||||
ORCA_PRIME_AGENT_STATUS_EXTENSION:
|
||||
'C:\\Users\\test\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts'
|
||||
})
|
||||
isPwshAvailableMock.mockReturnValue(false)
|
||||
spawnMock.mockReturnValue({
|
||||
onData: vi.fn(() => makeDisposable()),
|
||||
@@ -586,6 +606,11 @@ describe('registerPtyHandlers', () => {
|
||||
} else {
|
||||
delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
|
||||
}
|
||||
if (savedOrcaPrimeAgentStatusExtension !== undefined) {
|
||||
process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION = savedOrcaPrimeAgentStatusExtension
|
||||
} else {
|
||||
delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION
|
||||
}
|
||||
if (savedOrcaClaudeAgentStatusSettings === undefined) {
|
||||
delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
|
||||
} else {
|
||||
@@ -4196,7 +4221,7 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not materialize Windows-host Prime extensions for a WSL launch', async () => {
|
||||
it('bridges a host-managed Prime status extension into WSL without redirecting Prime home', async () => {
|
||||
await withWin32Platform(async () => {
|
||||
const env = await daemonSpawnAndGetEnv(
|
||||
{ PRIME_AGENT_CODING_AGENT_DIR: 'C:\\Users\\test\\.prime\\agent' },
|
||||
@@ -4207,11 +4232,30 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
|
||||
expect(piBuildPtyEnvMock).not.toHaveBeenCalled()
|
||||
expect(piBuildStatusOnlyPtyEnvMock).toHaveBeenCalledWith('prime-agent')
|
||||
expect(env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR).toBeUndefined()
|
||||
expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toBe(
|
||||
'C:\\Users\\test\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts'
|
||||
)
|
||||
expect(env.ORCA_WSL_HOOK_INSTANCE).toBe('port5678')
|
||||
expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBe('C:\\Users\\test\\.prime\\agent')
|
||||
})
|
||||
})
|
||||
|
||||
it('prepares the Prime bridge for a typed launch in a bare WSL shell', async () => {
|
||||
await withWin32Platform(async () => {
|
||||
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, undefined, {
|
||||
shellOverride: 'wsl.exe'
|
||||
})
|
||||
|
||||
expect(piBuildStatusOnlyPtyEnvMock).toHaveBeenCalledWith('prime-agent')
|
||||
expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toContain(
|
||||
'prime-agent-managed-status-extension'
|
||||
)
|
||||
expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('points OPENCODE_CONFIG_DIR at the guest overlay when the WSL relay reports it', async () => {
|
||||
const guestDir = '/home/jin/.orca-relay/opencode-overlays/abc'
|
||||
const spy = vi.spyOn(wslHookRelayManager, 'getOpenCodeOverlayDir').mockReturnValue(guestDir)
|
||||
@@ -12125,6 +12169,272 @@ describe('registerPtyHandlers', () => {
|
||||
expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath })
|
||||
})
|
||||
|
||||
it('keeps an existing POSIX startup cwd for the selected WSL runtime', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-cwd' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(true)
|
||||
statSyncMock.mockImplementation((target: string) => {
|
||||
if (target === '/home/alice/repo') {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
return { isDirectory: () => true, mode: 0o755 }
|
||||
})
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/repo',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::C:\\Users\\alice\\repo',
|
||||
projectRuntime: {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'wsl',
|
||||
hostPlatform: 'wsl',
|
||||
projectId: 'repo-1',
|
||||
distro: 'Ubuntu-24.04',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'repo-1:wsl'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo')
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(
|
||||
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo'
|
||||
)
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: '/home/alice/repo', shellOverride: 'wsl.exe' })
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back when the selected WSL runtime reports a POSIX cwd missing', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-cwd' })
|
||||
const worktreePath = 'C:/Users/alice/repo'
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/deleted',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: `repo-1::${worktreePath}`,
|
||||
projectRuntime: {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'wsl',
|
||||
hostPlatform: 'wsl',
|
||||
projectId: 'repo-1',
|
||||
distro: 'Ubuntu-24.04',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'repo-1:wsl'
|
||||
}
|
||||
}
|
||||
})) as { startupCwdFallback?: { kind: string; cwd: string } }
|
||||
|
||||
expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: worktreePath }))
|
||||
expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a POSIX WSL cwd when the distro probe is inconclusive', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-inconclusive-cwd' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/repo',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::C:/Users/alice/repo',
|
||||
shellOverride: 'wsl.exe'
|
||||
})) as { startupCwdFallback?: unknown }
|
||||
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: '/home/alice/repo' })
|
||||
)
|
||||
expect(result.startupCwdFallback).toBeUndefined()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a POSIX cwd when WSL owns it but no distro can be resolved', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-no-distro-cwd' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
_setWslCachesForTests({ available: true, distros: [] })
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/repo',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::C:/Users/alice/repo',
|
||||
shellOverride: 'wsl.exe'
|
||||
})) as { startupCwdFallback?: unknown }
|
||||
|
||||
expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo')
|
||||
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: '/home/alice/repo' })
|
||||
)
|
||||
expect(result.startupCwdFallback).toBeUndefined()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ exists: true, expectedCwd: '/home/alice/repo', expectedFallback: undefined },
|
||||
{
|
||||
exists: false,
|
||||
expectedCwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice',
|
||||
expectedFallback: {
|
||||
kind: 'worktree',
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice'
|
||||
}
|
||||
}
|
||||
])('resolves POSIX cwd existence for a WSL UNC workspace ($exists)', async (testCase) => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-unc-cwd' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(testCase.exists)
|
||||
if (!testCase.exists) {
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(true)
|
||||
}
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/repo',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice'
|
||||
})) as { startupCwdFallback?: { kind: string; cwd: string } }
|
||||
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: testCase.expectedCwd })
|
||||
)
|
||||
expect(result.startupCwdFallback).toEqual(testCase.expectedFallback)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a missing POSIX cwd when its WSL workspace root is also missing', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-root' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/alice/deleted',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice'
|
||||
})) as { startupCwdFallback?: unknown }
|
||||
|
||||
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: '/home/alice/deleted' })
|
||||
)
|
||||
expect(result.startupCwdFallback).toBeUndefined()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('validates a /mnt drive cwd through its native Windows path', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-mnt-cwd' })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/mnt/c/Users/alice/repo',
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: 'repo-1::C:/Users/alice/repo',
|
||||
shellOverride: 'wsl.exe'
|
||||
})
|
||||
|
||||
expect(statSyncMock).toHaveBeenCalledWith('C:\\Users\\alice\\repo')
|
||||
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: '/mnt/c/Users/alice/repo' })
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('still falls back a missing Windows cwd when the selected runtime is WSL', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-windows-cwd' })
|
||||
const worktreePath = 'C:/Users/alice/repo'
|
||||
const missingCwd = `${worktreePath}/deleted-folder`
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
statSyncMock.mockImplementation((target: string) => {
|
||||
if (target === missingCwd) {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
return { isDirectory: () => true, mode: 0o755 }
|
||||
})
|
||||
|
||||
try {
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: missingCwd,
|
||||
cwdFallback: 'worktree',
|
||||
worktreeId: `repo-1::${worktreePath}`,
|
||||
shellOverride: 'wsl.exe'
|
||||
})) as { startupCwdFallback?: { kind: string; cwd: string } }
|
||||
|
||||
expect(providerSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: worktreePath, shellOverride: 'wsl.exe' })
|
||||
)
|
||||
expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a missing cwd unchanged without the fallback flag', async () => {
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder')
|
||||
|
||||
+85
-11
@@ -139,7 +139,7 @@ import {
|
||||
resolveTerminalStartupCwdForWorkspace,
|
||||
type TerminalStartupCwdMissingDirFallback
|
||||
} from '../../shared/terminal-startup-cwd'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
|
||||
import {
|
||||
@@ -150,7 +150,7 @@ import {
|
||||
clearMigrationUnsupportedPty,
|
||||
clearMigrationUnsupportedPtysForPaneKey
|
||||
} from '../agent-hooks/migration-unsupported-pty-state'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { parseWslPath, wslUncDirectoryExistsAsync } from '../wsl'
|
||||
import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path'
|
||||
import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env'
|
||||
import { PtyProducerFlowController } from './pty-producer-flow-control'
|
||||
@@ -1513,6 +1513,7 @@ function clearPiAgentShadowEnv(baseEnv: Record<string, string>, kind: PiAgentKin
|
||||
}
|
||||
if (kind === 'prime-agent') {
|
||||
delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
|
||||
delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION
|
||||
return
|
||||
}
|
||||
delete baseEnv.ORCA_PI_CODING_AGENT_DIR
|
||||
@@ -1544,6 +1545,11 @@ function exposePiManagedExtensionEnv(
|
||||
} else {
|
||||
delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
|
||||
}
|
||||
if (managedEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION) {
|
||||
baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION = managedEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION
|
||||
} else {
|
||||
delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION
|
||||
}
|
||||
return
|
||||
}
|
||||
delete baseEnv.ORCA_PI_CODING_AGENT_DIR
|
||||
@@ -1686,6 +1692,8 @@ export function buildPtyHostEnv(
|
||||
})
|
||||
|
||||
const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand
|
||||
const shouldPreparePrimeStatus =
|
||||
piAgentKind === 'prime-agent' || (opts.isWsl && !hasLaunchCommand)
|
||||
// Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing.
|
||||
const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi')
|
||||
const preexistingOmpAgentDir =
|
||||
@@ -1744,6 +1752,12 @@ export function buildPtyHostEnv(
|
||||
if (opts.isWsl === true) {
|
||||
// Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one.
|
||||
const distro = opts.wslDistro ?? null
|
||||
const hookInstance = wslHookRelayManager.getInstanceKey()
|
||||
if (hookInstance) {
|
||||
baseEnv.ORCA_WSL_HOOK_INSTANCE = hookInstance
|
||||
} else {
|
||||
delete baseEnv.ORCA_WSL_HOOK_INSTANCE
|
||||
}
|
||||
wslHookRelayManager.ensureForDistro(distro)
|
||||
const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro)
|
||||
if (guestEndpoint) {
|
||||
@@ -1790,13 +1804,14 @@ export function buildPtyHostEnv(
|
||||
exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv)
|
||||
}
|
||||
|
||||
if (piAgentKind === 'prime-agent' && !opts.isWsl) {
|
||||
const primeEnv = piTitlebarExtensionService.buildPtyEnv(
|
||||
id,
|
||||
preexistingPrimeAgentDir,
|
||||
'prime-agent',
|
||||
{ materializeDefaultHome: explicitPiAgentKind === 'prime-agent' }
|
||||
)
|
||||
if (shouldPreparePrimeStatus) {
|
||||
// Why: the Windows host cannot safely resolve or write Prime's guest
|
||||
// config root; WSL loads the host-managed status file explicitly.
|
||||
const primeEnv = opts.isWsl
|
||||
? piTitlebarExtensionService.buildStatusOnlyPtyEnv('prime-agent')
|
||||
: piTitlebarExtensionService.buildPtyEnv(id, preexistingPrimeAgentDir, 'prime-agent', {
|
||||
materializeDefaultHome: explicitPiAgentKind === 'prime-agent'
|
||||
})
|
||||
Object.assign(baseEnv, primeEnv)
|
||||
exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv)
|
||||
}
|
||||
@@ -1814,6 +1829,7 @@ export function buildPtyHostEnv(
|
||||
})
|
||||
delete baseEnv.ORCA_OMP_STATUS_EXTENSION
|
||||
delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR
|
||||
delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION
|
||||
}
|
||||
|
||||
// Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex.
|
||||
@@ -5775,15 +5791,73 @@ export function registerPtyHandlers(
|
||||
await startupPromise
|
||||
}
|
||||
// Why: honor the fallback only for fresh local spawns — reattach needs exact cwd and SSH can't probe the local filesystem.
|
||||
const allowMissingCwdFallback =
|
||||
const requestedMissingCwdFallback =
|
||||
!args.connectionId && !args.sessionId && args.cwdFallback === 'worktree'
|
||||
const isWslOwnedPosixCwd =
|
||||
args.cwd?.startsWith('/') === true && !/^\/[A-Za-z](?:\/|$)/.test(args.cwd)
|
||||
const startupWorkspaceCwd =
|
||||
requestedMissingCwdFallback && isWslOwnedPosixCwd
|
||||
? resolvePtySpawnStartupCwd(args.worktreeId, '.')
|
||||
: undefined
|
||||
const initiallyResolvedStartupCwd =
|
||||
requestedMissingCwdFallback && isWslOwnedPosixCwd
|
||||
? resolvePtySpawnStartupCwd(args.worktreeId, args.cwd)
|
||||
: undefined
|
||||
const startupTerminalRuntimeOptions =
|
||||
requestedMissingCwdFallback && process.platform === 'win32'
|
||||
? resolveLocalWindowsTerminalRuntimeOptions({
|
||||
requestedShellOverride: args.shellOverride,
|
||||
settings: getSettings?.(),
|
||||
projectRuntime: args.projectRuntime,
|
||||
fallbackHostShell: process.env.COMSPEC || 'powershell.exe'
|
||||
})
|
||||
: undefined
|
||||
const wslRuntimeOwnsStartupCwd =
|
||||
requestedMissingCwdFallback &&
|
||||
isWslOwnedPosixCwd &&
|
||||
(isWslShellName(startupTerminalRuntimeOptions?.shellOverride) ||
|
||||
isWslUncPath(startupWorkspaceCwd ?? ''))
|
||||
const startupWslContext = wslRuntimeOwnsStartupCwd
|
||||
? resolveWslSessionContext({
|
||||
cwd: startupWorkspaceCwd,
|
||||
shellOverride: startupTerminalRuntimeOptions?.shellOverride,
|
||||
terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro
|
||||
})
|
||||
: undefined
|
||||
let wslStartupCwdExists: boolean | null = null
|
||||
let wslWorkspaceCwdExists: boolean | null = null
|
||||
if (startupWslContext && initiallyResolvedStartupCwd) {
|
||||
const validationCwd = toWindowsWslPath(
|
||||
initiallyResolvedStartupCwd,
|
||||
startupWslContext.distro
|
||||
)
|
||||
wslStartupCwdExists = isWslUncPath(validationCwd)
|
||||
? await wslUncDirectoryExistsAsync(validationCwd)
|
||||
: localStartupCwdDirectoryExists(validationCwd)
|
||||
if (wslStartupCwdExists === false && startupWorkspaceCwd) {
|
||||
wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd)
|
||||
? await wslUncDirectoryExistsAsync(startupWorkspaceCwd)
|
||||
: localStartupCwdDirectoryExists(startupWorkspaceCwd)
|
||||
}
|
||||
}
|
||||
const allowMissingCwdFallback =
|
||||
requestedMissingCwdFallback &&
|
||||
(!wslRuntimeOwnsStartupCwd ||
|
||||
(wslStartupCwdExists === false && wslWorkspaceCwdExists === true))
|
||||
let didFallbackToWorkspaceRootCwd = false
|
||||
const cwd = resolvePtySpawnStartupCwd(
|
||||
args.worktreeId,
|
||||
args.cwd,
|
||||
allowMissingCwdFallback
|
||||
? {
|
||||
directoryExists: localStartupCwdDirectoryExists,
|
||||
directoryExists: (path) =>
|
||||
startupWslContext &&
|
||||
wslStartupCwdExists === false &&
|
||||
path === initiallyResolvedStartupCwd
|
||||
? false
|
||||
: startupWslContext && path === startupWorkspaceCwd
|
||||
? wslWorkspaceCwdExists === true
|
||||
: localStartupCwdDirectoryExists(path),
|
||||
onFallbackToWorkspaceRoot: () => {
|
||||
didFallbackToWorkspaceRootCwd = true
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ describe('registerSettingsHandlers', () => {
|
||||
})
|
||||
|
||||
it('updates the agent awake service when the keep-awake setting changes', () => {
|
||||
const agentAwakeService = { setEnabled: vi.fn() }
|
||||
const agentAwakeService = { setMode: vi.fn() }
|
||||
store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false })
|
||||
store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: true })
|
||||
registerSettingsHandlers(store as never, agentAwakeService as never)
|
||||
@@ -356,11 +356,18 @@ describe('registerSettingsHandlers', () => {
|
||||
|
||||
handler(settingsInvokeEvent, { keepComputerAwakeWhileAgentsRun: true })
|
||||
|
||||
expect(agentAwakeService.setEnabled).toHaveBeenCalledWith(true)
|
||||
expect(store.updateSettings).toHaveBeenCalledWith(
|
||||
{
|
||||
computerAwakeMode: 'auto',
|
||||
keepComputerAwakeWhileAgentsRun: true
|
||||
},
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(agentAwakeService.setMode).toHaveBeenCalledWith('auto')
|
||||
})
|
||||
|
||||
it('does not notify the agent awake service for unrelated setting changes', () => {
|
||||
const agentAwakeService = { setEnabled: vi.fn() }
|
||||
const agentAwakeService = { setMode: vi.fn() }
|
||||
store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false })
|
||||
store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false })
|
||||
registerSettingsHandlers(store as never, agentAwakeService as never)
|
||||
@@ -372,7 +379,7 @@ describe('registerSettingsHandlers', () => {
|
||||
|
||||
handler(settingsInvokeEvent, { defaultTuiAgent: 'codex' })
|
||||
|
||||
expect(agentAwakeService.setEnabled).not.toHaveBeenCalled()
|
||||
expect(agentAwakeService.setMode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prepares local worktree roots when workspace directory changes', async () => {
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
normalizeMobilePairingCustomAddress,
|
||||
normalizeMobilePairingCustomAddresses
|
||||
} from '../../shared/mobile-pairing-custom-address'
|
||||
import {
|
||||
computerAwakeSettingsForMode,
|
||||
normalizeComputerAwakeMode
|
||||
} from '../../shared/computer-awake-mode'
|
||||
|
||||
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
|
||||
// to a Set once at module load lets the IPC handler's per-key membership
|
||||
@@ -65,6 +69,18 @@ export function registerSettingsHandlers(
|
||||
store: Store,
|
||||
agentAwakeService?: AgentAwakeService
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
'agentAwake:getStatus',
|
||||
() => agentAwakeService?.getStatus() ?? { mode: 'off', active: false }
|
||||
)
|
||||
agentAwakeService?.subscribe?.((status) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send('agentAwake:changed', status)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
store.onSettingsChanged((updates, _settings, originWebContentsId) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
const isOrigin =
|
||||
@@ -109,6 +125,22 @@ export function registerSettingsHandlers(
|
||||
// Why: Floating Workspace grants are trusted only when written by the
|
||||
// main-process directory picker, never by renderer-provided settings IPC.
|
||||
delete sanitizedArgs.floatingTerminalTrustedCwds
|
||||
if ('computerAwakeMode' in sanitizedArgs) {
|
||||
Object.assign(
|
||||
sanitizedArgs,
|
||||
computerAwakeSettingsForMode(
|
||||
normalizeComputerAwakeMode(
|
||||
sanitizedArgs.computerAwakeMode,
|
||||
sanitizedArgs.keepComputerAwakeWhileAgentsRun
|
||||
)
|
||||
)
|
||||
)
|
||||
} else if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) {
|
||||
Object.assign(
|
||||
sanitizedArgs,
|
||||
computerAwakeSettingsForMode(sanitizedArgs.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off')
|
||||
)
|
||||
}
|
||||
if (typeof args.floatingTerminalCwd === 'string') {
|
||||
sanitizedArgs.floatingTerminalCwd = await sanitizeFloatingWorkspaceDirectorySetting(
|
||||
store,
|
||||
@@ -161,8 +193,13 @@ export function registerSettingsHandlers(
|
||||
notifyListeners: true,
|
||||
originWebContentsId: event.sender.id
|
||||
})
|
||||
if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) {
|
||||
agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun)
|
||||
if (
|
||||
'computerAwakeMode' in sanitizedArgs ||
|
||||
'keepComputerAwakeWhileAgentsRun' in sanitizedArgs
|
||||
) {
|
||||
agentAwakeService?.setMode(
|
||||
normalizeComputerAwakeMode(result.computerAwakeMode, result.keepComputerAwakeWhileAgentsRun)
|
||||
)
|
||||
}
|
||||
const hookSettingChanged =
|
||||
('agentStatusHooksEnabled' in sanitizedArgs &&
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
createWorktreePollerWindowVisibility,
|
||||
startWorktreeBaseDirectoryPoller,
|
||||
WORKTREE_BASE_BACKSTOP_TICKS,
|
||||
type WorktreeBasePollEvent,
|
||||
type WorktreePollerWindowVisibility
|
||||
} from './worktree-base-directory-poller'
|
||||
@@ -204,6 +206,65 @@ describe('worktree base directory poller', () => {
|
||||
expect(fullScans.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('retires stale marker probes while preserving backstop detection and path reuse', async () => {
|
||||
const root = await makeRoot()
|
||||
const ordinaryFolder = join(root, 'ordinary-folder')
|
||||
const markerPath = join(ordinaryFolder, '.git')
|
||||
await mkdir(ordinaryFolder)
|
||||
|
||||
const received: WorktreeBasePollEvent[][] = []
|
||||
const target = makeTarget('base', root)
|
||||
let fullScans = 0
|
||||
let pendingMarkerProbes = 0
|
||||
let writeMarkerOnNextProbe = false
|
||||
const pendingMarkerMaxTicks = WORKTREE_BASE_BACKSTOP_TICKS * 2
|
||||
const markerCreationTick = pendingMarkerMaxTicks * 2 + WORKTREE_BASE_BACKSTOP_TICKS * 4
|
||||
const poller = await startWorktreeBaseDirectoryPoller(
|
||||
target,
|
||||
() => target.repos,
|
||||
(events) => received.push(events),
|
||||
{
|
||||
pollIntervalMs: 0,
|
||||
pendingMarkerMaxTicks,
|
||||
onFullScan: () => {
|
||||
fullScans += 1
|
||||
if (fullScans * WORKTREE_BASE_BACKSTOP_TICKS === markerCreationTick) {
|
||||
writeFileSync(markerPath, 'gitdir: elsewhere')
|
||||
}
|
||||
},
|
||||
onPendingMarkerProbe: (path) => {
|
||||
pendingMarkerProbes += 1
|
||||
if (writeMarkerOnNextProbe && path === markerPath) {
|
||||
writeMarkerOnNextProbe = false
|
||||
writeFileSync(markerPath, 'gitdir: elsewhere')
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
cleanups.push(() => poller.unsubscribe())
|
||||
|
||||
await waitForEvents(received, (flat) =>
|
||||
flat.some((event) => event.type === 'create' && event.path === markerPath)
|
||||
)
|
||||
|
||||
await rm(ordinaryFolder, { recursive: true })
|
||||
await waitForEvents(received, (flat) =>
|
||||
flat.some((event) => event.type === 'delete' && event.path === ordinaryFolder)
|
||||
)
|
||||
writeMarkerOnNextProbe = true
|
||||
await mkdir(ordinaryFolder)
|
||||
const events = await waitForEvents(
|
||||
received,
|
||||
(flat) =>
|
||||
flat.filter((event) => event.type === 'create' && event.path === markerPath).length === 2
|
||||
)
|
||||
|
||||
expect(pendingMarkerProbes).toBeLessThanOrEqual(pendingMarkerMaxTicks)
|
||||
expect(
|
||||
events.filter((event) => event.type === 'create' && event.path === markerPath)
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('parks base scans while hidden and losslessly detects changes on resume', async () => {
|
||||
const root = await makeRoot()
|
||||
const visibility = createVisibilityHarness()
|
||||
|
||||
@@ -63,6 +63,10 @@ export type WorktreeBasePollerOptions = {
|
||||
onWatchError?: (error: Error) => void
|
||||
/** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */
|
||||
onFullScan?: () => void
|
||||
/** Test hook: called before a pending `.git` marker stat. */
|
||||
onPendingMarkerProbe?: (path: string) => void
|
||||
/** Test hook: overrides the fast-probe window. */
|
||||
pendingMarkerMaxTicks?: number
|
||||
}
|
||||
|
||||
// Why: these targets used to be recursive FSEvents subscriptions spanning the
|
||||
@@ -193,7 +197,7 @@ async function startBasePoller(
|
||||
onEvents: (events: WorktreeBasePollEvent[]) => void,
|
||||
pollIntervalMs: number,
|
||||
visibility: WorktreePollerWindowVisibility,
|
||||
onFullScan?: () => void
|
||||
options: WorktreeBasePollerOptions
|
||||
): Promise<WorktreeBaseSubscription> {
|
||||
let disposed = false
|
||||
let ticking = false
|
||||
@@ -202,16 +206,17 @@ async function startBasePoller(
|
||||
let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let parkedWhileHidden = false
|
||||
// dir → tick when first seen without a `.git` marker
|
||||
const pendingMarkers = new Map<string, number>()
|
||||
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
|
||||
// dir → first probe tick; null means backstop scans only
|
||||
const markerProbeStartedAt = new Map<string, number | null>()
|
||||
for (const [dir, marker] of snapshot.markers) {
|
||||
if (!marker) {
|
||||
pendingMarkers.set(dir, 0)
|
||||
markerProbeStartedAt.set(dir, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const fullScan = async (): Promise<void> => {
|
||||
onFullScan?.()
|
||||
options.onFullScan?.()
|
||||
const next = await snapshotBase(target.path, getRepos())
|
||||
const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature))
|
||||
if (disposed) {
|
||||
@@ -220,14 +225,14 @@ async function startBasePoller(
|
||||
const events = diffBase(snapshot, next)
|
||||
for (const [dir, marker] of next.markers) {
|
||||
if (marker) {
|
||||
pendingMarkers.delete(dir)
|
||||
} else if (!pendingMarkers.has(dir)) {
|
||||
pendingMarkers.set(dir, tickCount)
|
||||
markerProbeStartedAt.delete(dir)
|
||||
} else if (!markerProbeStartedAt.has(dir)) {
|
||||
markerProbeStartedAt.set(dir, tickCount)
|
||||
}
|
||||
}
|
||||
for (const [dir, firstSeenTick] of pendingMarkers) {
|
||||
if (!next.markers.has(dir) || tickCount - firstSeenTick > PENDING_MARKER_MAX_TICKS) {
|
||||
pendingMarkers.delete(dir)
|
||||
for (const dir of markerProbeStartedAt.keys()) {
|
||||
if (!next.markers.has(dir)) {
|
||||
markerProbeStartedAt.delete(dir)
|
||||
}
|
||||
}
|
||||
snapshot = next
|
||||
@@ -239,9 +244,17 @@ async function startBasePoller(
|
||||
|
||||
const checkPendingMarkers = async (): Promise<void> => {
|
||||
const events: WorktreeBasePollEvent[] = []
|
||||
for (const dir of pendingMarkers.keys()) {
|
||||
for (const [dir, firstSeenTick] of markerProbeStartedAt) {
|
||||
if (firstSeenTick === null) {
|
||||
continue
|
||||
}
|
||||
if (tickCount - firstSeenTick > pendingMarkerMaxTicks) {
|
||||
markerProbeStartedAt.set(dir, null)
|
||||
continue
|
||||
}
|
||||
options.onPendingMarkerProbe?.(join(dir, '.git'))
|
||||
if (await hasGitMarker(dir)) {
|
||||
pendingMarkers.delete(dir)
|
||||
markerProbeStartedAt.delete(dir)
|
||||
snapshot.markers.set(dir, true)
|
||||
events.push({ type: 'create', path: join(dir, '.git') })
|
||||
}
|
||||
@@ -267,7 +280,7 @@ async function startBasePoller(
|
||||
await fullScan()
|
||||
return
|
||||
}
|
||||
if (pendingMarkers.size > 0) {
|
||||
if (markerProbeStartedAt.size > 0) {
|
||||
await checkPendingMarkers()
|
||||
}
|
||||
}
|
||||
@@ -356,5 +369,5 @@ export async function startWorktreeBaseDirectoryPoller(
|
||||
options.onWatchError
|
||||
)
|
||||
}
|
||||
return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options.onFullScan)
|
||||
return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import { credentialDecryptionMessage } from '../../shared/integration-credential
|
||||
const getClients = vi.fn()
|
||||
const clearToken = vi.fn()
|
||||
const isAuthError = vi.fn()
|
||||
const acquire = vi.fn().mockResolvedValue(undefined)
|
||||
const release = vi.fn()
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
acquire: vi.fn().mockResolvedValue(undefined),
|
||||
release: vi.fn(),
|
||||
acquire,
|
||||
release,
|
||||
getClients: (...args: unknown[]) => getClients(...args),
|
||||
isAuthError: (...args: unknown[]) => isAuthError(...args),
|
||||
clearToken: (...args: unknown[]) => clearToken(...args)
|
||||
@@ -283,6 +285,30 @@ describe('Linear teams', () => {
|
||||
expect(members).toHaveBeenCalledWith({ first: 100 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['getTeamStates', 'getTeamStatesOrThrow'],
|
||||
['getTeamLabels', 'getTeamLabelsOrThrow'],
|
||||
['getTeamMembers', 'getTeamMembersOrThrow']
|
||||
] as const)('preserves the %s error policy', async (fallbackReader, throwingReader) => {
|
||||
const error = new Error('request failed')
|
||||
const entry = makeTeamLookupEntry('workspace-1', 'Workspace', null)
|
||||
vi.mocked(entry.client.team).mockRejectedValue(error)
|
||||
getClients.mockReturnValue([entry])
|
||||
const readers = await import('./teams')
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await expect(readers[fallbackReader]('team-1', 'workspace-1')).resolves.toEqual([])
|
||||
await expect(readers[throwingReader]('team-1', 'workspace-1')).rejects.toBe(error)
|
||||
expect(warn).toHaveBeenCalledWith(`[linear] ${fallbackReader} failed:`, error)
|
||||
expect(release).toHaveBeenCalledTimes(2)
|
||||
|
||||
isAuthError.mockReturnValue(true)
|
||||
await expect(readers[fallbackReader]('team-1', 'workspace-1')).rejects.toBe(error)
|
||||
expect(clearToken).toHaveBeenCalledWith('workspace-1')
|
||||
expect(release).toHaveBeenCalledTimes(3)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('surfaces Linear credential decrypt errors on active team reads', async () => {
|
||||
const error = new Error(credentialDecryptionMessage('Linear'))
|
||||
getClients.mockImplementation(() => {
|
||||
|
||||
+23
-95
@@ -1,3 +1,4 @@
|
||||
import type { Team } from '@linear/sdk'
|
||||
import type {
|
||||
LinearTeam,
|
||||
LinearWorkflowState,
|
||||
@@ -108,10 +109,12 @@ export async function listTeamsForAgent(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTeamStates(
|
||||
async function readTeamResource<T>(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearWorkflowState[]> {
|
||||
workspaceId: string | null | undefined,
|
||||
fetchAll: (team: Team) => Promise<T[]>,
|
||||
fallbackWarning?: string
|
||||
): Promise<T[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
@@ -119,137 +122,62 @@ export async function getTeamStates(
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamStates(team)
|
||||
return await fetchAll(await entry.client.team(teamId))
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
throw error
|
||||
}
|
||||
console.warn('[linear] getTeamStates failed:', error)
|
||||
if (!fallbackWarning) {
|
||||
throw error
|
||||
}
|
||||
console.warn(`[linear] ${fallbackWarning} failed:`, error)
|
||||
return []
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTeamStates(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearWorkflowState[]> {
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamStates, 'getTeamStates')
|
||||
}
|
||||
|
||||
export async function getTeamStatesOrThrow(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearWorkflowState[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamStates(team)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamStates)
|
||||
}
|
||||
|
||||
export async function getTeamLabels(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearLabel[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamLabels(team)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
throw error
|
||||
}
|
||||
console.warn('[linear] getTeamLabels failed:', error)
|
||||
return []
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamLabels, 'getTeamLabels')
|
||||
}
|
||||
|
||||
export async function getTeamLabelsOrThrow(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearLabel[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamLabels(team)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamLabels)
|
||||
}
|
||||
|
||||
export async function getTeamMembers(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearMember[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamMembers(team)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
throw error
|
||||
}
|
||||
console.warn('[linear] getTeamMembers failed:', error)
|
||||
return []
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamMembers, 'getTeamMembers')
|
||||
}
|
||||
|
||||
export async function getTeamMembersOrThrow(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearMember[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
return await fetchAllTeamMembers(team)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamMembers)
|
||||
}
|
||||
|
||||
export async function getViewerForWorkspaceOrThrow(
|
||||
|
||||
@@ -12142,21 +12142,177 @@ describe('Store host-partitioned workspace sessions', () => {
|
||||
expect(session.terminalTopologyRevisionByRepoId?.['repo-gone']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a corrupt host partition to defaults without failing the others', async () => {
|
||||
it('resets only the corrupt required field of a host partition, not the partition', async () => {
|
||||
const worktreeId = 'repo-1::/worktree'
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
workspaceSessionsByHostId: {
|
||||
'runtime:good': makeHostSession('good-repo'),
|
||||
// activeRepoId must be string|null; a number fails the zod parse.
|
||||
'runtime:bad': { ...makeHostSession('x'), activeRepoId: 123 }
|
||||
'runtime:bad': {
|
||||
...makeHostSession('x'),
|
||||
activeRepoId: 123,
|
||||
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'bad-host-tab', worktreeId })] }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getWorkspaceSession('runtime:good').activeRepoId).toBe('good-repo')
|
||||
// Bad partition collapses to defaults rather than poisoning the map.
|
||||
// The unsalvageable field falls back to its default; the partition's tabs survive.
|
||||
expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull()
|
||||
expect(
|
||||
store.getWorkspaceSession('runtime:bad').tabsByWorktree[worktreeId]?.map((tab) => tab.id)
|
||||
).toEqual(['bad-host-tab'])
|
||||
})
|
||||
|
||||
it('keeps every other worktree when the local session has a corrupt required field', async () => {
|
||||
const worktreeId = 'repo-1::/worktree'
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
workspaceSession: {
|
||||
...makeHostSession('local-repo'),
|
||||
// A projected/truncated write can leave a top-level field the wrong type;
|
||||
// that must not cost every worktree's tabs the way a full reset did.
|
||||
activeTabId: 42,
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [makeTerminalTab({ id: 'local-keep', worktreeId })]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
const session = store.getWorkspaceSession('local')
|
||||
expect(session.activeTabId).toBeNull()
|
||||
expect(session.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['local-keep'])
|
||||
})
|
||||
|
||||
type PersistedSessionsFile = {
|
||||
workspaceSession?: {
|
||||
tabsByWorktree?: Record<string, { id: string }[]>
|
||||
sleepingAgentSessionsByPaneKey?: Record<string, unknown>
|
||||
}
|
||||
workspaceSessionsByHostId?: Record<
|
||||
string,
|
||||
{ tabsByWorktree?: Record<string, { id: string }[]> }
|
||||
>
|
||||
}
|
||||
|
||||
// Why: flush() writes whatever the state hash says is dirty, so it passes even
|
||||
// when nothing scheduled a save — it cannot see the repair write at all. Loading
|
||||
// once first canonicalizes the profile (a second load of a canonical file
|
||||
// schedules nothing), so a later rewrite proves the salvage scheduled it.
|
||||
async function loadAndAwaitScheduledSave(): Promise<void> {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const store = await createStore()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
await store.waitForPendingWrite()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
|
||||
async function canonicalize(fixture: Record<string, unknown>): Promise<PersistedSessionsFile> {
|
||||
writeDataFile(fixture)
|
||||
await loadAndAwaitScheduledSave()
|
||||
const canonical = readFileSync(dataFile(), 'utf-8')
|
||||
// Why: the save assertions below are only meaningful if a clean load schedules
|
||||
// nothing. Prove that here rather than assume it — a future migration that
|
||||
// dirtied every load would otherwise leave those tests silently vacuous.
|
||||
await loadAndAwaitScheduledSave()
|
||||
expect(readFileSync(dataFile(), 'utf-8')).toBe(canonical)
|
||||
return JSON.parse(canonical) as PersistedSessionsFile
|
||||
}
|
||||
|
||||
it('schedules a save for a salvaged local session instead of re-salvaging every launch', async () => {
|
||||
const worktreeId = 'repo-1::/worktree'
|
||||
const profile = await canonicalize({
|
||||
schemaVersion: 1,
|
||||
workspaceSession: {
|
||||
...makeHostSession('local-repo'),
|
||||
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] }
|
||||
}
|
||||
})
|
||||
const tabs = profile.workspaceSession?.tabsByWorktree?.[worktreeId]
|
||||
expect(tabs).toBeDefined()
|
||||
tabs!.push({ id: 'tab-corrupt' })
|
||||
writeDataFile(profile)
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
await loadAndAwaitScheduledSave()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[persistence] Salvaged workspace session; dropped corrupt entries:',
|
||||
{ count: 1, fields: ['tabsByWorktree'], detailsTruncated: false }
|
||||
)
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain(worktreeId)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
|
||||
const persisted = readDataFile() as PersistedSessionsFile
|
||||
expect(persisted.workspaceSession?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual([
|
||||
'tab-keep'
|
||||
])
|
||||
})
|
||||
|
||||
it('schedules a save for salvaged host partitions', async () => {
|
||||
const worktreeId = 'repo-1::/worktree'
|
||||
const profile = await canonicalize({
|
||||
schemaVersion: 1,
|
||||
workspaceSessionsByHostId: {
|
||||
'runtime:env-a': {
|
||||
...makeHostSession('runtime-repo'),
|
||||
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'runtime-keep', worktreeId })] }
|
||||
},
|
||||
'ssh:target-b': {
|
||||
...makeHostSession('ssh-repo'),
|
||||
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'ssh-keep', worktreeId })] }
|
||||
}
|
||||
}
|
||||
})
|
||||
const partitions = profile.workspaceSessionsByHostId
|
||||
const runtimeTabs = partitions?.['runtime:env-a']?.tabsByWorktree?.[worktreeId]
|
||||
const sshTabs = partitions?.['ssh:target-b']?.tabsByWorktree?.[worktreeId]
|
||||
expect(runtimeTabs).toBeDefined()
|
||||
expect(sshTabs).toBeDefined()
|
||||
runtimeTabs!.push({ id: 'runtime-corrupt' })
|
||||
sshTabs!.push({ id: 'ssh-corrupt' })
|
||||
const mutablePartitions = partitions as Record<string, unknown>
|
||||
mutablePartitions['runtime:broken'] = 'not a session'
|
||||
writeDataFile(profile)
|
||||
await loadAndAwaitScheduledSave()
|
||||
|
||||
const persisted = (readDataFile() as PersistedSessionsFile).workspaceSessionsByHostId
|
||||
expect(
|
||||
persisted?.['runtime:env-a']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)
|
||||
).toEqual(['runtime-keep'])
|
||||
expect(persisted?.['ssh:target-b']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual(
|
||||
['ssh-keep']
|
||||
)
|
||||
expect(persisted).not.toHaveProperty('runtime:broken')
|
||||
})
|
||||
|
||||
it('writes back sleeping-agent records dropped during salvage', async () => {
|
||||
const profile = await canonicalize({
|
||||
schemaVersion: 1,
|
||||
workspaceSession: {
|
||||
...makeHostSession('local-repo'),
|
||||
sleepingAgentSessionsByPaneKey: {}
|
||||
}
|
||||
})
|
||||
profile.workspaceSession!.sleepingAgentSessionsByPaneKey = {
|
||||
'tab-bad:leaf': { paneKey: 'different:leaf' }
|
||||
}
|
||||
writeDataFile(profile)
|
||||
|
||||
await loadAndAwaitScheduledSave()
|
||||
|
||||
const persisted = readDataFile() as PersistedSessionsFile
|
||||
expect(persisted.workspaceSession?.sleepingAgentSessionsByPaneKey).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+45
-10
@@ -134,7 +134,7 @@ import {
|
||||
ONBOARDING_FLOW_VERSION,
|
||||
ONBOARDING_FINAL_STEP
|
||||
} from '../shared/constants'
|
||||
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
|
||||
import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage'
|
||||
import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display'
|
||||
import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode'
|
||||
import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice'
|
||||
@@ -555,15 +555,27 @@ function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatc
|
||||
)
|
||||
}
|
||||
|
||||
function workspaceSessionSalvageLogDetails(result: {
|
||||
droppedCount: number
|
||||
droppedPaths: string[]
|
||||
}): { count: number; fields: string[]; detailsTruncated: boolean } {
|
||||
return {
|
||||
count: result.droppedCount,
|
||||
fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))],
|
||||
detailsTruncated: result.droppedCount > result.droppedPaths.length
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge.
|
||||
* Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */
|
||||
function parseWorkspaceSessionsByHostId(
|
||||
raw: unknown,
|
||||
defaults: WorkspaceSessionState
|
||||
): Partial<Record<ExecutionHostId, WorkspaceSessionState>> {
|
||||
): { partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>>; repaired: boolean } {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return {}
|
||||
return { partitions: {}, repaired: raw !== undefined }
|
||||
}
|
||||
let repaired = false
|
||||
const partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>> = {}
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const hostId = normalizeExecutionHostId(key)
|
||||
@@ -571,17 +583,25 @@ function parseWorkspaceSessionsByHostId(
|
||||
if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) {
|
||||
continue
|
||||
}
|
||||
const result = parseWorkspaceSession(value)
|
||||
const result = parseWorkspaceSessionSalvaging(value)
|
||||
if (!result.ok) {
|
||||
repaired = true
|
||||
console.error(
|
||||
`[persistence] Corrupt workspace session for host ${hostId}, using defaults:`,
|
||||
result.error
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (result.droppedCount > 0) {
|
||||
console.warn(
|
||||
`[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`,
|
||||
workspaceSessionSalvageLogDetails(result)
|
||||
)
|
||||
repaired = true
|
||||
}
|
||||
partitions[hostId] = { ...defaults, ...result.value }
|
||||
}
|
||||
return partitions
|
||||
return { partitions, repaired }
|
||||
}
|
||||
|
||||
function backupPath(dataFile: string, index: number): string {
|
||||
@@ -3651,7 +3671,7 @@ export class Store {
|
||||
if (parsed.workspaceSession === undefined) {
|
||||
return defaults.workspaceSession
|
||||
}
|
||||
const result = parseWorkspaceSession(parsed.workspaceSession)
|
||||
const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession)
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
'[persistence] Corrupt workspace session, using defaults:',
|
||||
@@ -3659,13 +3679,28 @@ export class Store {
|
||||
)
|
||||
return defaults.workspaceSession
|
||||
}
|
||||
if (result.droppedCount > 0) {
|
||||
console.warn(
|
||||
'[persistence] Salvaged workspace session; dropped corrupt entries:',
|
||||
workspaceSessionSalvageLogDetails(result)
|
||||
)
|
||||
// Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch.
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
return { ...defaults.workspaceSession, ...result.value }
|
||||
})(),
|
||||
// Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat.
|
||||
workspaceSessionsByHostId: parseWorkspaceSessionsByHostId(
|
||||
parsed.workspaceSessionsByHostId,
|
||||
defaults.workspaceSession
|
||||
),
|
||||
workspaceSessionsByHostId: (() => {
|
||||
const { partitions, repaired } = parseWorkspaceSessionsByHostId(
|
||||
parsed.workspaceSessionsByHostId,
|
||||
defaults.workspaceSession
|
||||
)
|
||||
if (repaired) {
|
||||
// Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch.
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
return partitions
|
||||
})(),
|
||||
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
|
||||
deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases)
|
||||
? parsed.deletedSshConfigAliases.filter(
|
||||
|
||||
@@ -30,6 +30,7 @@ type Harness = {
|
||||
fsMock: {
|
||||
existsSync: ReturnType<typeof vi.fn>
|
||||
readFileSync: ReturnType<typeof vi.fn>
|
||||
statSync: ReturnType<typeof vi.fn>
|
||||
}
|
||||
handlers: Record<string, HookHandler>
|
||||
processEnv: Record<string, string | undefined>
|
||||
@@ -62,6 +63,7 @@ function createHarness(args: {
|
||||
argv?: string[]
|
||||
existsSync?: (path: string) => boolean
|
||||
readFileSync?: (path: string, encoding: string) => string
|
||||
statSync?: (path: string) => { mtimeMs: number; size: number; ino: number }
|
||||
fetchImpl?: (...params: Parameters<typeof fetch>) => Promise<unknown>
|
||||
}): Harness {
|
||||
const fetchMock = vi.fn(
|
||||
@@ -86,6 +88,12 @@ function createHarness(args: {
|
||||
|
||||
const fsMock = {
|
||||
existsSync: vi.fn(args.existsSync ?? (() => false)),
|
||||
statSync: vi.fn(
|
||||
args.statSync ??
|
||||
((path: string) => {
|
||||
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
|
||||
})
|
||||
),
|
||||
readFileSync: vi.fn(
|
||||
args.readFileSync ??
|
||||
((path: string) => {
|
||||
@@ -562,6 +570,31 @@ describe('getPiAgentStatusExtensionSource', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses current Windows coordinates when a same-token guest endpoint is stale', async () => {
|
||||
const endpointPath = '/home/u/.orca-wsl/agent-hooks/instance-test/endpoint.env'
|
||||
const harness = createHarness({
|
||||
kind: 'prime-agent',
|
||||
env: { WSL_DISTRO_NAME: 'Ubuntu', ORCA_AGENT_HOOK_ENDPOINT: endpointPath },
|
||||
existsSync: (path) => path === '/mnt/c/Windows/System32/curl.exe',
|
||||
statSync: () => ({ mtimeMs: 1, size: 80, ino: 1 }),
|
||||
readFileSync: (path) => {
|
||||
if (path === endpointPath) {
|
||||
return 'ORCA_AGENT_HOOK_PORT=9999\nORCA_AGENT_HOOK_TOKEN=token-1\n'
|
||||
}
|
||||
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
|
||||
},
|
||||
fetchImpl: vi.fn(async () => {
|
||||
throw new Error('stale guest relay')
|
||||
})
|
||||
})
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
|
||||
expect(harness.fetchMock.mock.calls[0]?.[0]).toBe('http://127.0.0.1:9999/hook/prime-agent')
|
||||
await vi.waitFor(() => expect(harness.spawnMock).toHaveBeenCalledTimes(1))
|
||||
expect(harness.spawnMock.mock.calls[0]?.[1]).toContain('http://127.0.0.1:4321/hook/prime-agent')
|
||||
})
|
||||
|
||||
it('probes WSL evidence and the curl path once per process', async () => {
|
||||
const harness = createHarness({
|
||||
kind: 'omp',
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import type { PiAgentKind } from '../../shared/pi-agent-kind'
|
||||
import { getPiAgentStatusHandlerSourceLines } from './agent-status-handler-source'
|
||||
import { getPiAgentStatusRuntimeDetectionSourceLines } from './agent-status-runtime-detection-source'
|
||||
import { getPiAgentStatusWslCurlSourceLines } from './agent-status-wsl-curl-source'
|
||||
|
||||
export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts'
|
||||
|
||||
@@ -228,92 +229,13 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin
|
||||
' // Why: status reporting must never fail the pi run just because Orca',
|
||||
' // is unavailable or the loopback request failed (e.g. Orca restart).',
|
||||
' if (!isWslRuntime()) return',
|
||||
' postViaWindowsCurl(url, coords, body)',
|
||||
' postViaWindowsCurl(body, ompRuntime)',
|
||||
' } finally {',
|
||||
' if (timeout) clearTimeout(timeout)',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'// Why: WSL-ness and curl.exe presence cannot change within a process',
|
||||
'// lifetime; re-probing /proc and /mnt/c on every failed event would add',
|
||||
'// filesystem work to the per-event path.',
|
||||
'let cachedIsWslRuntime: boolean | null = null',
|
||||
'let cachedWindowsCurlPath: string | null | undefined',
|
||||
'',
|
||||
'function isWslRuntime(): boolean {',
|
||||
' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime',
|
||||
' cachedIsWslRuntime = detectWslRuntime()',
|
||||
' return cachedIsWslRuntime',
|
||||
'}',
|
||||
'',
|
||||
'function detectWslRuntime(): boolean {',
|
||||
' if (process.env.WSL_DISTRO_NAME) return true',
|
||||
' try {',
|
||||
" const fs = require('fs')",
|
||||
" for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {",
|
||||
' try {',
|
||||
" const contents = String(fs.readFileSync(path, 'utf8'))",
|
||||
' if (/microsoft|wsl/i.test(contents)) return true',
|
||||
' } catch {',
|
||||
' // Why: probe the next runtime hint when a proc file is absent or unreadable.',
|
||||
' }',
|
||||
' }',
|
||||
' } catch {',
|
||||
' return false',
|
||||
' }',
|
||||
' return false',
|
||||
'}',
|
||||
'',
|
||||
'function resolveWindowsCurlPath(): string | null {',
|
||||
' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath',
|
||||
' try {',
|
||||
" const fs = require('fs')",
|
||||
" const curlPath = '/mnt/c/Windows/System32/curl.exe'",
|
||||
' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null',
|
||||
' } catch {',
|
||||
' cachedWindowsCurlPath = null',
|
||||
' }',
|
||||
' return cachedWindowsCurlPath',
|
||||
'}',
|
||||
'',
|
||||
'// Why: WSL loopback is not the Windows loopback, so a WSL-side POST cannot',
|
||||
'// reach Orca. curl.exe runs on the Windows side, where 127.0.0.1 IS the',
|
||||
'// listener Orca binds. Fire-and-forget: blocking on the spawn would stall',
|
||||
'// the pi event loop (and the TUI) on every hook event.',
|
||||
'function postViaWindowsCurl(url: string, coords: { token: string }, body: string): void {',
|
||||
' const curlPath = resolveWindowsCurlPath()',
|
||||
' if (!curlPath) return',
|
||||
' try {',
|
||||
" const { spawn } = require('child_process')",
|
||||
' const child = spawn(',
|
||||
' curlPath,',
|
||||
' [',
|
||||
" '-sS',",
|
||||
' // Why: the spawn is detached from the event loop, so these bounds',
|
||||
' // size a background process, not TUI latency. WSL->Win32 interop',
|
||||
' // connects can exceed 0.5s on loaded machines (observed 3/3 drops',
|
||||
' // to a healthy listener); size for delivery, not snappiness.',
|
||||
" '--connect-timeout', '3',",
|
||||
" '--max-time', '10',",
|
||||
" '--noproxy', '127.0.0.1',",
|
||||
" '-o', 'NUL',",
|
||||
" '-X', 'POST',",
|
||||
" '-H', 'Content-Type: application/json',",
|
||||
" '-H', `X-Orca-Agent-Hook-Token: ${coords.token}`,",
|
||||
" '--data-binary', '@-',",
|
||||
' url',
|
||||
' ],',
|
||||
" { stdio: ['pipe', 'ignore', 'ignore'] }",
|
||||
' )',
|
||||
" child.on('error', () => {})",
|
||||
" child.stdin.on('error', () => {})",
|
||||
' child.stdin.end(body)',
|
||||
' } catch {',
|
||||
' // Why: the bridge is best-effort; a failed spawn must not surface',
|
||||
' // inside the pi TUI.',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
...getPiAgentStatusWslCurlSourceLines(),
|
||||
...getPiAgentStatusHandlerSourceLines(kind)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export function getPiAgentStatusWslCurlSourceLines(): string[] {
|
||||
return [
|
||||
'// Why: WSL-ness and curl.exe presence cannot change within a process',
|
||||
'// lifetime; re-probing /proc and /mnt/c on every failed event would add',
|
||||
'// filesystem work to the per-event path.',
|
||||
'let cachedIsWslRuntime: boolean | null = null',
|
||||
'let cachedWindowsCurlPath: string | null | undefined',
|
||||
'',
|
||||
'function isWslRuntime(): boolean {',
|
||||
' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime',
|
||||
' cachedIsWslRuntime = detectWslRuntime()',
|
||||
' return cachedIsWslRuntime',
|
||||
'}',
|
||||
'',
|
||||
'function detectWslRuntime(): boolean {',
|
||||
' if (process.env.WSL_DISTRO_NAME) return true',
|
||||
' try {',
|
||||
" const fs = require('fs')",
|
||||
" for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {",
|
||||
' try {',
|
||||
" const contents = String(fs.readFileSync(path, 'utf8'))",
|
||||
' if (/microsoft|wsl/i.test(contents)) return true',
|
||||
' } catch {',
|
||||
' // Why: probe the next runtime hint when a proc file is absent or unreadable.',
|
||||
' }',
|
||||
' }',
|
||||
' } catch {',
|
||||
' return false',
|
||||
' }',
|
||||
' return false',
|
||||
'}',
|
||||
'',
|
||||
'function resolveWindowsCurlPath(): string | null {',
|
||||
' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath',
|
||||
' try {',
|
||||
" const fs = require('fs')",
|
||||
" const curlPath = '/mnt/c/Windows/System32/curl.exe'",
|
||||
' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null',
|
||||
' } catch {',
|
||||
' cachedWindowsCurlPath = null',
|
||||
' }',
|
||||
' return cachedWindowsCurlPath',
|
||||
'}',
|
||||
'',
|
||||
'// Why: WSL loopback is not the Windows loopback, so use curl.exe on the host.',
|
||||
'function postViaWindowsCurl(body: string, ompRuntime: boolean): void {',
|
||||
' const curlPath = resolveWindowsCurlPath()',
|
||||
' const windowsPort = process.env.ORCA_AGENT_HOOK_PORT',
|
||||
' const windowsToken = process.env.ORCA_AGENT_HOOK_TOKEN',
|
||||
' if (!curlPath || !windowsPort || !windowsToken) return',
|
||||
' // Why: a stale guest endpoint must fall back to current host coordinates.',
|
||||
' const windowsUrl = `http://127.0.0.1:${windowsPort}${resolveHookPath(ompRuntime)}`',
|
||||
' try {',
|
||||
" const { spawn } = require('child_process')",
|
||||
' const child = spawn(',
|
||||
' curlPath,',
|
||||
' [',
|
||||
" '-sS',",
|
||||
' // Why: detached delivery may take seconds under loaded WSL interop.',
|
||||
" '--connect-timeout', '3',",
|
||||
" '--max-time', '10',",
|
||||
" '--noproxy', '127.0.0.1',",
|
||||
" '-o', 'NUL',",
|
||||
" '-X', 'POST',",
|
||||
" '-H', 'Content-Type: application/json',",
|
||||
" '-H', `X-Orca-Agent-Hook-Token: ${windowsToken}`,",
|
||||
" '--data-binary', '@-',",
|
||||
' windowsUrl',
|
||||
' ],',
|
||||
" { stdio: ['pipe', 'ignore', 'ignore'] }",
|
||||
' )',
|
||||
" child.on('error', () => {})",
|
||||
" child.stdin.on('error', () => {})",
|
||||
' child.stdin.end(body)',
|
||||
' } catch {',
|
||||
' // Why: status delivery must not surface inside the agent TUI.',
|
||||
' }',
|
||||
'}',
|
||||
''
|
||||
]
|
||||
}
|
||||
@@ -94,6 +94,10 @@ describe('PiTitlebarExtensionService', () => {
|
||||
rmSync(join(userDataDir, 'pi-agent-overlays'), { recursive: true, force: true })
|
||||
rmSync(join(userDataDir, 'omp-agent-overlays'), { recursive: true, force: true })
|
||||
rmSync(join(userDataDir, 'omp-managed-status-extension'), { recursive: true, force: true })
|
||||
rmSync(join(userDataDir, 'prime-agent-managed-status-extension'), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
|
||||
function expectPiHomeIntact(): void {
|
||||
@@ -181,6 +185,21 @@ describe('PiTitlebarExtensionService', () => {
|
||||
expectPiHomeIntact()
|
||||
})
|
||||
|
||||
it('writes a Prime status-only extension outside the guest-owned config dir', () => {
|
||||
const svc = new PiTitlebarExtensionService()
|
||||
const env = svc.buildStatusOnlyPtyEnv('prime-agent')
|
||||
const statusPath = join(
|
||||
userDataDir,
|
||||
'prime-agent-managed-status-extension',
|
||||
'orca-agent-status.ts'
|
||||
)
|
||||
|
||||
expect(env).toEqual({ ORCA_PRIME_AGENT_STATUS_EXTENSION: statusPath })
|
||||
expect(readFileSync(statusPath, 'utf8')).toContain('/hook/prime-agent')
|
||||
expect(readFileSync(statusPath, 'utf8')).not.toContain("return '/hook/omp'")
|
||||
expectPiHomeIntact()
|
||||
})
|
||||
|
||||
it('uses the same source dir for multiple PTYs with the same Pi dir', () => {
|
||||
const svc = new PiTitlebarExtensionService()
|
||||
const firstEnv = svc.buildPtyEnv('pty-shared-1', piHome, 'pi')
|
||||
|
||||
@@ -27,7 +27,10 @@ export const isSafeDescendCandidate = sharedIsSafeDescendCandidate
|
||||
|
||||
const PI_AGENT_SUBDIR = 'agent'
|
||||
const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension'
|
||||
const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension'
|
||||
const MANAGED_STATUS_EXTENSION_DIR: Record<'omp' | 'prime-agent', string> = {
|
||||
omp: 'omp-managed-status-extension',
|
||||
'prime-agent': 'prime-agent-managed-status-extension'
|
||||
}
|
||||
|
||||
type ManagedExtensionWriteResult = 'written' | 'skipped-user-owned' | 'failed'
|
||||
|
||||
@@ -121,8 +124,11 @@ export class PiTitlebarExtensionService {
|
||||
}
|
||||
}
|
||||
|
||||
private writeOmpFallbackStatusExtension(source: string): string | undefined {
|
||||
const fallbackDir = join(app.getPath('userData'), OMP_MANAGED_STATUS_EXTENSION_DIR)
|
||||
private writeFallbackStatusExtension(
|
||||
source: string,
|
||||
kind: keyof typeof MANAGED_STATUS_EXTENSION_DIR
|
||||
): string | undefined {
|
||||
const fallbackDir = join(app.getPath('userData'), MANAGED_STATUS_EXTENSION_DIR[kind])
|
||||
try {
|
||||
mkdirSync(fallbackDir, { recursive: true })
|
||||
} catch {
|
||||
@@ -165,7 +171,7 @@ export class PiTitlebarExtensionService {
|
||||
statusResult === 'written'
|
||||
? statusExtensionPath
|
||||
: kind === 'omp'
|
||||
? this.writeOmpFallbackStatusExtension(statusSource)
|
||||
? this.writeFallbackStatusExtension(statusSource, 'omp')
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -195,7 +201,7 @@ export class PiTitlebarExtensionService {
|
||||
if (!existsSync(sourceAgentDir) && !materializeDefaultHome) {
|
||||
if (kind === 'omp') {
|
||||
const statusSource = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind))
|
||||
const statusExtensionPath = this.writeOmpFallbackStatusExtension(statusSource)
|
||||
const statusExtensionPath = this.writeFallbackStatusExtension(statusSource, 'omp')
|
||||
return statusExtensionPath ? { ORCA_OMP_STATUS_EXTENSION: statusExtensionPath } : {}
|
||||
}
|
||||
return {}
|
||||
@@ -220,6 +226,12 @@ export class PiTitlebarExtensionService {
|
||||
return env
|
||||
}
|
||||
|
||||
buildStatusOnlyPtyEnv(kind: 'prime-agent'): Record<string, string> {
|
||||
const source = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind))
|
||||
const statusExtensionPath = this.writeFallbackStatusExtension(source, kind)
|
||||
return statusExtensionPath ? { ORCA_PRIME_AGENT_STATUS_EXTENSION: statusExtensionPath } : {}
|
||||
}
|
||||
|
||||
clearPty(ptyId: string): void {
|
||||
// Why: PTY teardown doesn't know which kind was launched (the daemon
|
||||
// exit path discards the launch command). Sweep both old PTY-scoped
|
||||
|
||||
@@ -978,6 +978,31 @@ describe('LocalPtyProvider', () => {
|
||||
expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian')
|
||||
})
|
||||
|
||||
it('translates a POSIX cwd through the preferred WSL distro', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
|
||||
cwd: '/home/jin/repo',
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[1]).toEqual([
|
||||
'-d',
|
||||
'Debian',
|
||||
'--',
|
||||
'sh',
|
||||
'-c',
|
||||
expect.stringContaining("cd '/home/jin/repo'")
|
||||
])
|
||||
expect(spawnCall[2].cwd).not.toBe('/home/jin/repo')
|
||||
})
|
||||
|
||||
it('resolves and persists the default distro for Windows cwd WSL terminals', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const buildSpawnEnv = vi.fn(
|
||||
|
||||
@@ -239,9 +239,9 @@ function getWslContextFromWorktreeId(
|
||||
*/
|
||||
function getWslContextFromPreferredDistro(
|
||||
distro: string | null | undefined
|
||||
): { distro: string } | undefined {
|
||||
): { distro: string; treatPosixCwdAsWsl: true } | undefined {
|
||||
const trimmed = distro?.trim()
|
||||
return trimmed ? { distro: trimmed } : undefined
|
||||
return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -526,6 +526,8 @@ describePosix('local PTY shell-ready launch config', () => {
|
||||
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
|
||||
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
|
||||
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
|
||||
const primeWrapperLine =
|
||||
'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"'
|
||||
expect(zshrc).toContain(restoreLine)
|
||||
expect(zlogin).toContain(restoreLine)
|
||||
expect(bashRc).toContain(restoreLine)
|
||||
@@ -547,6 +549,9 @@ describePosix('local PTY shell-ready launch config', () => {
|
||||
expect(zshrc).toContain(ompWrapperLine)
|
||||
expect(zlogin).toContain(ompWrapperLine)
|
||||
expect(bashRc).toContain(ompWrapperLine)
|
||||
expect(zshrc).toContain(primeWrapperLine)
|
||||
expect(zlogin).toContain(primeWrapperLine)
|
||||
expect(bashRc).toContain(primeWrapperLine)
|
||||
})
|
||||
|
||||
// Why: issue #2422 — without OSC 133 C/D markers, bash sessions kept the worktree spinner "working" ~30min after the agent exited.
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
isPowerShellExecutableName
|
||||
} from '../powershell-osc133-bootstrap'
|
||||
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
|
||||
import { getPosixPrimeAgentShellWrapper } from '../pty/prime-agent-shell-wrapper'
|
||||
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
|
||||
import {
|
||||
getFishShellReadyInitCommand,
|
||||
@@ -129,6 +130,7 @@ __orca_restore_agent_teams_path
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
${getPosixPrimeAgentShellWrapper()}
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
# Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent
|
||||
@@ -245,6 +247,7 @@ if [[ ! -o login ]]; then
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
${getPosixPrimeAgentShellWrapper()}
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
fi
|
||||
@@ -305,6 +308,7 @@ __orca_restore_agent_teams_path
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
${getPosixPrimeAgentShellWrapper()}
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)}
|
||||
${getZshFinalZdotdirRestoreBlock()}
|
||||
|
||||
@@ -280,13 +280,16 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true)
|
||||
expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true)
|
||||
|
||||
// Why: the point of materializing wrappers for WSL is that a typed `omp`
|
||||
// picks up Orca's status extension; pin that shim end to end.
|
||||
// Why: typed agents need the host-managed status extension in WSL.
|
||||
const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
|
||||
const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
|
||||
for (const wrapperFile of [bashRcfile, zshLogin]) {
|
||||
expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"')
|
||||
expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }')
|
||||
expect(wrapperFile).toContain(
|
||||
'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"'
|
||||
)
|
||||
expect(wrapperFile).toContain('prime-agent() { __orca_prime_agent "$@"; }')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getPosixPrimeAgentShellWrapper } from './prime-agent-shell-wrapper'
|
||||
|
||||
const describePosix = process.platform === 'win32' ? describe.skip : describe
|
||||
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
|
||||
const itWithBash = hasBash ? it : it.skip
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function makeFixture(): {
|
||||
root: string
|
||||
extensionPath: string
|
||||
capturePath: string
|
||||
endpointPath: string
|
||||
primeHome: string
|
||||
rcfile: string
|
||||
} {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-prime-wrapper-'))
|
||||
tempDirs.push(root)
|
||||
const binDir = join(root, 'bin')
|
||||
const primeHome = join(root, 'guest-prime-home')
|
||||
const extensionPath = join(root, 'orca-agent-status.ts')
|
||||
const capturePath = join(root, 'capture')
|
||||
const endpointPath = join(
|
||||
root,
|
||||
'.orca-wsl',
|
||||
'agent-hooks',
|
||||
'instance-testinstance',
|
||||
'endpoint.env'
|
||||
)
|
||||
const rcfile = join(root, 'rcfile')
|
||||
mkdirSync(binDir)
|
||||
mkdirSync(primeHome)
|
||||
mkdirSync(dirname(endpointPath), { recursive: true })
|
||||
writeFileSync(extensionPath, 'export default {}')
|
||||
writeFileSync(endpointPath, 'ORCA_AGENT_HOOK_PORT=4567\nORCA_AGENT_HOOK_TOKEN=current-token\n')
|
||||
writeFileSync(rcfile, getPosixPrimeAgentShellWrapper())
|
||||
writeFileSync(
|
||||
join(binDir, 'prime-agent'),
|
||||
`#!/bin/sh
|
||||
{
|
||||
printf 'HOME=%s\n' "$PRIME_AGENT_CODING_AGENT_DIR"
|
||||
printf 'ENDPOINT=%s\n' "$ORCA_AGENT_HOOK_ENDPOINT"
|
||||
i=0
|
||||
for arg in "$@"; do
|
||||
i=$((i + 1))
|
||||
printf 'ARG%s=%s\n' "$i" "$arg"
|
||||
done
|
||||
} > "$ORCA_CAPTURE_FILE"
|
||||
if [ "\${1:-}" = "config" ]; then
|
||||
printf 'preserved\n' > "$PRIME_AGENT_CODING_AGENT_DIR/config.yml"
|
||||
fi
|
||||
`,
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
chmodSync(join(binDir, 'prime-agent'), 0o755)
|
||||
return { root, extensionPath, capturePath, endpointPath, primeHome, rcfile }
|
||||
}
|
||||
|
||||
function runPrime(
|
||||
fixture: ReturnType<typeof makeFixture>,
|
||||
command: string,
|
||||
extensionPath = fixture.extensionPath
|
||||
): string {
|
||||
const result = spawnSync('bash', ['--noprofile', '--rcfile', fixture.rcfile, '-ic', command], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: fixture.root,
|
||||
PATH: `${join(fixture.root, 'bin')}:${process.env.PATH ?? ''}`,
|
||||
PRIME_AGENT_CODING_AGENT_DIR: fixture.primeHome,
|
||||
ORCA_PRIME_AGENT_STATUS_EXTENSION: extensionPath,
|
||||
ORCA_WSL_HOOK_INSTANCE: 'testinstance',
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '/mnt/c/current/endpoint.cmd',
|
||||
ORCA_AGENT_HOOK_PORT: '4567',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'current-token',
|
||||
ORCA_CAPTURE_FILE: fixture.capturePath
|
||||
},
|
||||
encoding: 'utf8'
|
||||
})
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
return readFileSync(fixture.capturePath, 'utf8')
|
||||
}
|
||||
|
||||
describePosix('Prime Agent shell wrapper', () => {
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
itWithBash('injects the managed extension into an interactive launch', () => {
|
||||
const fixture = makeFixture()
|
||||
const capture = runPrime(fixture, 'prime-agent ask')
|
||||
|
||||
expect(capture).toContain(`HOME=${fixture.primeHome}`)
|
||||
expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`)
|
||||
expect(capture).toContain('ARG1=--extension')
|
||||
expect(capture).toContain(`ARG2=${fixture.extensionPath}`)
|
||||
expect(capture).toContain('ARG3=ask')
|
||||
})
|
||||
|
||||
itWithBash('preserves config subcommands and the guest Prime home', () => {
|
||||
const fixture = makeFixture()
|
||||
const capture = runPrime(fixture, 'prime-agent config')
|
||||
|
||||
expect(capture).toContain(`HOME=${fixture.primeHome}`)
|
||||
expect(capture).toContain('ARG1=config')
|
||||
expect(capture).not.toContain('ARG1=--extension')
|
||||
expect(readFileSync(join(fixture.primeHome, 'config.yml'), 'utf8')).toBe('preserved\n')
|
||||
})
|
||||
|
||||
itWithBash.each([
|
||||
'agents',
|
||||
'doctor',
|
||||
'help',
|
||||
'list',
|
||||
'model',
|
||||
'package',
|
||||
'rename',
|
||||
'schedule',
|
||||
'send',
|
||||
'session',
|
||||
'shutdown',
|
||||
'status',
|
||||
'stop',
|
||||
'update'
|
||||
])('preserves the %s subcommand argv', (subcommand) => {
|
||||
const fixture = makeFixture()
|
||||
const capture = runPrime(fixture, `prime-agent ${subcommand}`)
|
||||
|
||||
expect(capture).toContain(`ARG1=${subcommand}`)
|
||||
expect(capture).not.toContain('ARG1=--extension')
|
||||
})
|
||||
|
||||
itWithBash.each(['stop', 'rename'])(
|
||||
'preserves --daemon-socket before the %s management command',
|
||||
(subcommand) => {
|
||||
const fixture = makeFixture()
|
||||
const capture = runPrime(
|
||||
fixture,
|
||||
`prime-agent --daemon-socket /tmp/prime.sock ${subcommand} agent-123`
|
||||
)
|
||||
|
||||
expect(capture).toContain('ARG1=--daemon-socket')
|
||||
expect(capture).toContain('ARG2=/tmp/prime.sock')
|
||||
expect(capture).toContain(`ARG3=${subcommand}`)
|
||||
expect(capture).not.toContain(fixture.extensionPath)
|
||||
}
|
||||
)
|
||||
|
||||
itWithBash('injects the extension after the attach agent operand', () => {
|
||||
const fixture = makeFixture()
|
||||
const capture = runPrime(fixture, 'prime-agent attach agent-123 --follow')
|
||||
|
||||
expect(capture).toContain('ARG1=attach')
|
||||
expect(capture).toContain('ARG2=agent-123')
|
||||
expect(capture).toContain('ARG3=--extension')
|
||||
expect(capture).toContain(`ARG4=${fixture.extensionPath}`)
|
||||
expect(capture).toContain('ARG5=--follow')
|
||||
})
|
||||
|
||||
itWithBash('preserves attach help without inventing an agent operand', () => {
|
||||
const fixture = makeFixture()
|
||||
const capture = runPrime(fixture, 'prime-agent attach --help')
|
||||
|
||||
expect(capture).toContain('ARG1=attach')
|
||||
expect(capture).toContain('ARG2=--help')
|
||||
expect(capture).not.toContain(fixture.extensionPath)
|
||||
})
|
||||
|
||||
itWithBash('does not override an explicit user extension', () => {
|
||||
const fixture = makeFixture()
|
||||
const capture = runPrime(fixture, 'prime-agent --extension /tmp/user-extension.ts ask')
|
||||
|
||||
expect(capture).toContain('ARG1=--extension')
|
||||
expect(capture).toContain('ARG2=/tmp/user-extension.ts')
|
||||
expect(capture).not.toContain(fixture.extensionPath)
|
||||
})
|
||||
|
||||
itWithBash('does not add a second extension to attach', () => {
|
||||
const fixture = makeFixture()
|
||||
const capture = runPrime(
|
||||
fixture,
|
||||
'prime-agent attach agent-123 --extension /tmp/user-extension.ts'
|
||||
)
|
||||
|
||||
expect(capture).toContain('ARG1=attach')
|
||||
expect(capture).toContain('ARG2=agent-123')
|
||||
expect(capture).toContain('ARG3=--extension')
|
||||
expect(capture).toContain('ARG4=/tmp/user-extension.ts')
|
||||
expect(capture).not.toContain(fixture.extensionPath)
|
||||
})
|
||||
|
||||
itWithBash('degrades to the original argv when the managed extension is unavailable', () => {
|
||||
const fixture = makeFixture()
|
||||
const capture = runPrime(fixture, 'prime-agent ask', join(fixture.root, 'missing.ts'))
|
||||
|
||||
expect(capture).toContain('ARG1=ask')
|
||||
expect(capture).not.toContain('ARG1=--extension')
|
||||
})
|
||||
|
||||
itWithBash('keeps the stable guest endpoint path while its relay refreshes stale data', () => {
|
||||
const fixture = makeFixture()
|
||||
writeFileSync(fixture.endpointPath, 'ORCA_AGENT_HOOK_PORT=4567\nORCA_AGENT_HOOK_TOKEN=stale\n')
|
||||
|
||||
const capture = runPrime(fixture, 'prime-agent ask')
|
||||
|
||||
expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`)
|
||||
expect(capture).toContain('ARG1=--extension')
|
||||
})
|
||||
|
||||
itWithBash('uses the guest endpoint path when the relay binds a fallback port', () => {
|
||||
const fixture = makeFixture()
|
||||
writeFileSync(
|
||||
fixture.endpointPath,
|
||||
'ORCA_AGENT_HOOK_PORT=9876\nORCA_AGENT_HOOK_TOKEN=current-token\n'
|
||||
)
|
||||
|
||||
const capture = runPrime(fixture, 'prime-agent ask')
|
||||
|
||||
expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`)
|
||||
expect(capture).toContain('ARG1=--extension')
|
||||
})
|
||||
|
||||
itWithBash('does not define wrapper helpers when the bridge env is absent', () => {
|
||||
const fixture = makeFixture()
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
['--noprofile', '--rcfile', fixture.rcfile, '-ic', 'type -t __orca_prime_agent'],
|
||||
{
|
||||
cwd: fixture.root,
|
||||
env: { ...process.env, ORCA_PRIME_AGENT_STATUS_EXTENSION: '' },
|
||||
encoding: 'utf8'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stdout.trim()).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { wslHookRelayEndpointFilePath } from '../../shared/wsl-hook-relay-contract'
|
||||
|
||||
const PRIME_AGENT_SUBCOMMANDS = [
|
||||
'agents',
|
||||
'config',
|
||||
'doctor',
|
||||
'help',
|
||||
'list',
|
||||
'model',
|
||||
'package',
|
||||
'rename',
|
||||
'schedule',
|
||||
'send',
|
||||
'session',
|
||||
'shutdown',
|
||||
'status',
|
||||
'stop',
|
||||
'update'
|
||||
] as const
|
||||
|
||||
export function getPosixPrimeAgentShellWrapper(): string {
|
||||
const subcommands = PRIME_AGENT_SUBCOMMANDS.join('|')
|
||||
const guestEndpointPath = wslHookRelayEndpointFilePath('${HOME%/}', '${ORCA_WSL_HOOK_INSTANCE}')
|
||||
return `# Why: WSL cannot install into Prime's guest-owned config root from the
|
||||
# Windows host, so pass Orca's status extension only to interactive launches.
|
||||
if [[ -n "\${ORCA_PRIME_AGENT_STATUS_EXTENSION:-}" ]]; then
|
||||
__orca_prime_agent_should_skip_extension() {
|
||||
if [[ "\${1:-}" == "--daemon-socket" && ( "\${3:-}" == "stop" || "\${3:-}" == "rename" ) ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "\${1:-}" == --daemon-socket=* && ( "\${2:-}" == "stop" || "\${2:-}" == "rename" ) ]]; then
|
||||
return 0
|
||||
fi
|
||||
case "\${1:-}" in
|
||||
help|--help|-h|--version|-v|--extension|--extension=*) return 0 ;;
|
||||
${subcommands}) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_prime_agent_has_explicit_extension() {
|
||||
local __orca_arg
|
||||
for __orca_arg in "$@"; do
|
||||
case "$__orca_arg" in
|
||||
--extension|--extension=*) return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
__orca_prime_agent() {
|
||||
if [[ "\${1:-}" == "attach" && ( -z "\${2:-}" || "\${2:-}" == -* ) ]]; then
|
||||
command prime-agent "$@"
|
||||
return
|
||||
fi
|
||||
if ! __orca_prime_agent_should_skip_extension "$@" && ! __orca_prime_agent_has_explicit_extension "$@" && [[ -f "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" ]]; then
|
||||
local __orca_guest_endpoint="${guestEndpointPath}"
|
||||
if [[ -n "\${HOME:-}" && -n "\${ORCA_WSL_HOOK_INSTANCE:-}" ]]; then
|
||||
local ORCA_AGENT_HOOK_ENDPOINT="$__orca_guest_endpoint"
|
||||
export ORCA_AGENT_HOOK_ENDPOINT
|
||||
fi
|
||||
if [[ "\${1:-}" == "attach" && -n "\${2:-}" && "\${2:-}" != -* ]]; then
|
||||
local __orca_attach_agent="$2"
|
||||
shift 2
|
||||
command prime-agent attach "$__orca_attach_agent" --extension "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"
|
||||
else
|
||||
command prime-agent --extension "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"
|
||||
fi
|
||||
else
|
||||
command prime-agent "$@"
|
||||
fi
|
||||
}
|
||||
prime-agent() { __orca_prime_agent "$@"; }
|
||||
fi
|
||||
`
|
||||
}
|
||||
@@ -48,6 +48,8 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca',
|
||||
ORCA_CLI_COMMAND: 'orca-ide',
|
||||
ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts',
|
||||
ORCA_PRIME_AGENT_STATUS_EXTENSION:
|
||||
'C:\\Users\\jin\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts',
|
||||
ORCA_PANE_KEY: 'tab-1:leaf-1',
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
|
||||
@@ -56,6 +58,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_AGENT_HOOK_ENV: 'dev',
|
||||
ORCA_AGENT_HOOK_VERSION: '1',
|
||||
ORCA_WSL_HOOK_INSTANCE: 'testinstance',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu'
|
||||
@@ -67,6 +70,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p')
|
||||
expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u')
|
||||
expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p')
|
||||
expect(env.WSLENV).toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION/p')
|
||||
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
|
||||
expect(env.WSLENV).toContain('ORCA_TAB_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u')
|
||||
@@ -75,6 +79,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u')
|
||||
expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u')
|
||||
|
||||
@@ -96,6 +96,7 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
'ORCA_WSL_HOOK_INSTANCE/u',
|
||||
'ORCA_OMP_SOURCE_AGENT_DIR/p',
|
||||
'ORCA_OMP_STATUS_EXTENSION/p',
|
||||
'ORCA_PRIME_AGENT_STATUS_EXTENSION/p',
|
||||
...worktreeSetupWslenvEntries(env)
|
||||
]
|
||||
applyWslenvPassthrough(env, passthroughEntries)
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target'
|
||||
|
||||
function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings {
|
||||
const next = { ...settings } as Partial<GlobalSettings>
|
||||
delete next.localAccountRuntime
|
||||
return next as GlobalSettings
|
||||
}
|
||||
|
||||
describe('getInitialClaudeRateLimitTarget', () => {
|
||||
it('uses the configured WSL account runtime before agent detection runtime', () => {
|
||||
expect(
|
||||
getInitialClaudeRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'wsl',
|
||||
localAccountWslDistro: 'Fedora',
|
||||
localAgentRuntime: 'host',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' })
|
||||
})
|
||||
|
||||
it('uses the single selected WSL account distro when account runtime is WSL default', () => {
|
||||
expect(
|
||||
getInitialClaudeRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'wsl',
|
||||
activeClaudeManagedAccountIdsByRuntime: {
|
||||
host: 'host-account-1',
|
||||
wsl: { Ubuntu: 'wsl-account-1' }
|
||||
}
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('ignores stale terminal WSL distro when account runtime is WSL default', () => {
|
||||
expect(
|
||||
getInitialClaudeRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'wsl',
|
||||
localAccountWslDistro: null,
|
||||
terminalWindowsWslDistro: 'Debian',
|
||||
activeClaudeManagedAccountIdsByRuntime: {
|
||||
host: 'host-account-1',
|
||||
wsl: {}
|
||||
}
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: null })
|
||||
})
|
||||
|
||||
it('uses the global WSL project runtime default when account runtime is unset', () => {
|
||||
expect(
|
||||
getInitialClaudeRateLimitTarget(
|
||||
legacySettingsWithoutAccountRuntime({
|
||||
...getDefaultSettings('/tmp'),
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' },
|
||||
localAgentRuntime: 'host',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
}),
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => {
|
||||
expect(
|
||||
getInitialClaudeRateLimitTarget(
|
||||
legacySettingsWithoutAccountRuntime({
|
||||
...getDefaultSettings('/tmp'),
|
||||
localWindowsRuntimeDefault: { kind: 'windows-host' },
|
||||
localAgentRuntime: 'wsl',
|
||||
localAgentWslDistro: 'Ubuntu',
|
||||
terminalWindowsShell: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
}),
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'host' })
|
||||
})
|
||||
|
||||
it('uses a single WSL-only active account after restart', () => {
|
||||
expect(
|
||||
getInitialClaudeRateLimitTarget(
|
||||
legacySettingsWithoutAccountRuntime({
|
||||
...getDefaultSettings('/tmp'),
|
||||
activeClaudeManagedAccountIdsByRuntime: {
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'wsl-account-1' }
|
||||
}
|
||||
})
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('auto follows the global WSL project runtime default', () => {
|
||||
expect(
|
||||
getInitialClaudeRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'auto',
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('auto resolves to host when the global project runtime is windows-host', () => {
|
||||
expect(
|
||||
getInitialClaudeRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'auto',
|
||||
localWindowsRuntimeDefault: { kind: 'windows-host' }
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'host' })
|
||||
})
|
||||
|
||||
it('does not let a stale WSL-only selection override an auto host target', () => {
|
||||
expect(
|
||||
getInitialClaudeRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'auto',
|
||||
localWindowsRuntimeDefault: { kind: 'windows-host' },
|
||||
activeClaudeManagedAccountIdsByRuntime: {
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'wsl-account-1' }
|
||||
}
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'host' })
|
||||
})
|
||||
|
||||
it('keeps explicit host runtime on host', () => {
|
||||
expect(
|
||||
getInitialClaudeRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'host',
|
||||
localAgentRuntime: 'host',
|
||||
terminalWindowsShell: 'wsl.exe',
|
||||
activeClaudeManagedAccountIdsByRuntime: {
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'wsl-account-1' }
|
||||
}
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'host' })
|
||||
})
|
||||
|
||||
it('ignores a stale explicit WSL runtime on non-Windows hosts', () => {
|
||||
expect(
|
||||
getInitialClaudeRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'wsl',
|
||||
localAccountWslDistro: 'Ubuntu'
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
).toEqual({ runtime: 'host' })
|
||||
})
|
||||
})
|
||||
@@ -1,71 +1,21 @@
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime'
|
||||
import {
|
||||
getClaudeWslSelectionKey,
|
||||
normalizeClaudeRuntimeSelection,
|
||||
type ClaudeAccountSelectionTarget
|
||||
} from '../claude-accounts/runtime-selection'
|
||||
import {
|
||||
getProjectRuntimeRateLimitTarget,
|
||||
normalizeOptionalDistro
|
||||
} from './project-runtime-rate-limit-target'
|
||||
|
||||
function getSingleSelectedWslDistro(settings: GlobalSettings): string | null {
|
||||
const selection = normalizeClaudeRuntimeSelection(settings)
|
||||
const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) =>
|
||||
Boolean(accountId)
|
||||
)
|
||||
if (selectedWslEntries.length !== 1) {
|
||||
return null
|
||||
}
|
||||
const [distroKey] = selectedWslEntries[0]
|
||||
return distroKey === getClaudeWslSelectionKey(null) ? null : distroKey
|
||||
}
|
||||
import { getInitialAccountRateLimitTarget } from './initial-account-rate-limit-target'
|
||||
|
||||
export function getInitialClaudeRateLimitTarget(
|
||||
settings: GlobalSettings,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): ClaudeAccountSelectionTarget {
|
||||
if (settings.localAccountRuntime === 'host') {
|
||||
return { runtime: 'host' }
|
||||
}
|
||||
if (settings.localAccountRuntime === 'wsl') {
|
||||
if (platform !== 'win32') {
|
||||
return { runtime: 'host' }
|
||||
}
|
||||
return {
|
||||
runtime: 'wsl',
|
||||
wslDistro:
|
||||
normalizeOptionalDistro(settings.localAccountWslDistro) ??
|
||||
getSingleSelectedWslDistro(settings)
|
||||
}
|
||||
}
|
||||
if (settings.localAccountRuntime === 'auto') {
|
||||
const target = resolveLocalAccountRuntimeTarget(settings, platform)
|
||||
return target.runtime === 'wsl'
|
||||
? { runtime: 'wsl', wslDistro: target.wslDistro }
|
||||
: { runtime: 'host' }
|
||||
}
|
||||
|
||||
// Why: pre-setting profiles used account selection as their startup fallback.
|
||||
const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform)
|
||||
if (projectRuntimeTarget) {
|
||||
return projectRuntimeTarget
|
||||
}
|
||||
|
||||
const selection = normalizeClaudeRuntimeSelection(settings)
|
||||
if (!selection.host) {
|
||||
const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) =>
|
||||
Boolean(accountId)
|
||||
)
|
||||
if (selectedWslEntries.length === 1) {
|
||||
const [distroKey] = selectedWslEntries[0]
|
||||
return {
|
||||
runtime: 'wsl',
|
||||
wslDistro: distroKey === getClaudeWslSelectionKey(null) ? null : distroKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { runtime: 'host' }
|
||||
return getInitialAccountRateLimitTarget(
|
||||
settings,
|
||||
{
|
||||
getWslSelectionKey: getClaudeWslSelectionKey,
|
||||
normalizeRuntimeSelection: normalizeClaudeRuntimeSelection
|
||||
},
|
||||
platform
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target'
|
||||
|
||||
function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings {
|
||||
const next = { ...settings } as Partial<GlobalSettings>
|
||||
delete next.localAccountRuntime
|
||||
return next as GlobalSettings
|
||||
}
|
||||
|
||||
describe('getInitialCodexRateLimitTarget', () => {
|
||||
it('uses the configured WSL account runtime before agent detection runtime', () => {
|
||||
expect(
|
||||
getInitialCodexRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'wsl',
|
||||
localAccountWslDistro: 'Fedora',
|
||||
localAgentRuntime: 'host',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' })
|
||||
})
|
||||
|
||||
it('uses the single selected WSL account distro when account runtime is WSL default', () => {
|
||||
expect(
|
||||
getInitialCodexRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'wsl',
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: 'host-account-1',
|
||||
wsl: { Ubuntu: 'wsl-account-1' }
|
||||
}
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('ignores stale terminal WSL distro when account runtime is WSL default', () => {
|
||||
expect(
|
||||
getInitialCodexRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'wsl',
|
||||
localAccountWslDistro: null,
|
||||
terminalWindowsWslDistro: 'Debian',
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: 'host-account-1',
|
||||
wsl: {}
|
||||
}
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: null })
|
||||
})
|
||||
|
||||
it('uses the global WSL project runtime default when account runtime is unset', () => {
|
||||
expect(
|
||||
getInitialCodexRateLimitTarget(
|
||||
legacySettingsWithoutAccountRuntime({
|
||||
...getDefaultSettings('/tmp'),
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' },
|
||||
localAgentRuntime: 'host',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
}),
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => {
|
||||
expect(
|
||||
getInitialCodexRateLimitTarget(
|
||||
legacySettingsWithoutAccountRuntime({
|
||||
...getDefaultSettings('/tmp'),
|
||||
localWindowsRuntimeDefault: { kind: 'windows-host' },
|
||||
localAgentRuntime: 'wsl',
|
||||
localAgentWslDistro: 'Ubuntu',
|
||||
terminalWindowsShell: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
}),
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'host' })
|
||||
})
|
||||
|
||||
it('uses a single WSL-only active account after restart', () => {
|
||||
expect(
|
||||
getInitialCodexRateLimitTarget(
|
||||
legacySettingsWithoutAccountRuntime({
|
||||
...getDefaultSettings('/tmp'),
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'wsl-account-1' }
|
||||
}
|
||||
})
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('auto follows the global WSL project runtime default', () => {
|
||||
expect(
|
||||
getInitialCodexRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'auto',
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('auto resolves to host when the global project runtime is windows-host', () => {
|
||||
expect(
|
||||
getInitialCodexRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'auto',
|
||||
localWindowsRuntimeDefault: { kind: 'windows-host' }
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'host' })
|
||||
})
|
||||
|
||||
it('does not let a stale WSL-only selection override an auto host target', () => {
|
||||
expect(
|
||||
getInitialCodexRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'auto',
|
||||
localWindowsRuntimeDefault: { kind: 'windows-host' },
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'wsl-account-1' }
|
||||
}
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'host' })
|
||||
})
|
||||
|
||||
it('keeps explicit host runtime on host', () => {
|
||||
expect(
|
||||
getInitialCodexRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'host',
|
||||
localAgentRuntime: 'host',
|
||||
terminalWindowsShell: 'wsl.exe',
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
host: null,
|
||||
wsl: { Ubuntu: 'wsl-account-1' }
|
||||
}
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toEqual({ runtime: 'host' })
|
||||
})
|
||||
|
||||
it('ignores a stale explicit WSL runtime on non-Windows hosts', () => {
|
||||
expect(
|
||||
getInitialCodexRateLimitTarget(
|
||||
{
|
||||
...getDefaultSettings('/tmp'),
|
||||
localAccountRuntime: 'wsl',
|
||||
localAccountWslDistro: 'Ubuntu'
|
||||
},
|
||||
'darwin'
|
||||
)
|
||||
).toEqual({ runtime: 'host' })
|
||||
})
|
||||
})
|
||||
@@ -1,73 +1,18 @@
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime'
|
||||
import {
|
||||
getWslSelectionKey,
|
||||
normalizeCodexRuntimeSelection,
|
||||
type CodexAccountSelectionTarget
|
||||
} from '../codex-accounts/runtime-selection'
|
||||
import {
|
||||
getProjectRuntimeRateLimitTarget,
|
||||
normalizeOptionalDistro
|
||||
} from './project-runtime-rate-limit-target'
|
||||
|
||||
function getSingleSelectedWslDistro(settings: GlobalSettings): string | null {
|
||||
const selection = normalizeCodexRuntimeSelection(settings)
|
||||
const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) =>
|
||||
Boolean(accountId)
|
||||
)
|
||||
if (selectedWslEntries.length !== 1) {
|
||||
return null
|
||||
}
|
||||
const [distroKey] = selectedWslEntries[0]
|
||||
return distroKey === getWslSelectionKey(null) ? null : distroKey
|
||||
}
|
||||
import { getInitialAccountRateLimitTarget } from './initial-account-rate-limit-target'
|
||||
|
||||
export function getInitialCodexRateLimitTarget(
|
||||
settings: GlobalSettings,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): CodexAccountSelectionTarget {
|
||||
if (settings.localAccountRuntime === 'host') {
|
||||
return { runtime: 'host' }
|
||||
}
|
||||
if (settings.localAccountRuntime === 'wsl') {
|
||||
if (platform !== 'win32') {
|
||||
return { runtime: 'host' }
|
||||
}
|
||||
return {
|
||||
runtime: 'wsl',
|
||||
wslDistro:
|
||||
normalizeOptionalDistro(settings.localAccountWslDistro) ??
|
||||
getSingleSelectedWslDistro(settings)
|
||||
}
|
||||
}
|
||||
if (settings.localAccountRuntime === 'auto') {
|
||||
const target = resolveLocalAccountRuntimeTarget(settings, platform)
|
||||
return target.runtime === 'wsl'
|
||||
? { runtime: 'wsl', wslDistro: target.wslDistro }
|
||||
: { runtime: 'host' }
|
||||
}
|
||||
|
||||
// Why: pre-setting profiles used account selection as their startup fallback.
|
||||
const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform)
|
||||
if (projectRuntimeTarget) {
|
||||
return projectRuntimeTarget
|
||||
}
|
||||
|
||||
const selection = normalizeCodexRuntimeSelection(settings)
|
||||
if (!selection.host) {
|
||||
const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) =>
|
||||
Boolean(accountId)
|
||||
)
|
||||
if (selectedWslEntries.length === 1) {
|
||||
const [distroKey] = selectedWslEntries[0]
|
||||
// Why: after restart there is no last-clicked switcher target, but a
|
||||
// single WSL-only active account is the least surprising quota context.
|
||||
return {
|
||||
runtime: 'wsl',
|
||||
wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { runtime: 'host' }
|
||||
return getInitialAccountRateLimitTarget(
|
||||
settings,
|
||||
{ getWslSelectionKey, normalizeRuntimeSelection: normalizeCodexRuntimeSelection },
|
||||
platform
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target'
|
||||
import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target'
|
||||
|
||||
type RuntimeSelection = {
|
||||
host: string | null
|
||||
wsl: Record<string, string | null>
|
||||
}
|
||||
|
||||
type Scenario = {
|
||||
name: string
|
||||
settings: Partial<GlobalSettings>
|
||||
expected: { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string | null }
|
||||
legacy?: boolean
|
||||
platform?: NodeJS.Platform
|
||||
selection?: RuntimeSelection
|
||||
}
|
||||
|
||||
const providers = [
|
||||
{
|
||||
name: 'Codex',
|
||||
getTarget: getInitialCodexRateLimitTarget,
|
||||
selectionSettings: (selection: RuntimeSelection) => ({
|
||||
activeCodexManagedAccountIdsByRuntime: selection
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'Claude',
|
||||
getTarget: getInitialClaudeRateLimitTarget,
|
||||
selectionSettings: (selection: RuntimeSelection) => ({
|
||||
activeClaudeManagedAccountIdsByRuntime: selection
|
||||
})
|
||||
}
|
||||
] as const
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
name: 'uses the trimmed configured WSL distro before the selected account',
|
||||
settings: {
|
||||
localAccountRuntime: 'wsl',
|
||||
localAccountWslDistro: ' Fedora ',
|
||||
localAgentRuntime: 'host',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
},
|
||||
selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } },
|
||||
expected: { runtime: 'wsl', wslDistro: 'Fedora' }
|
||||
},
|
||||
{
|
||||
name: 'uses the single selected distro for an unpinned WSL runtime',
|
||||
settings: { localAccountRuntime: 'wsl' },
|
||||
selection: { host: 'host-account-1', wsl: { Ubuntu: 'wsl-account-1' } },
|
||||
expected: { runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
},
|
||||
{
|
||||
name: 'ignores a stale terminal distro for an unpinned WSL runtime',
|
||||
settings: {
|
||||
localAccountRuntime: 'wsl',
|
||||
localAccountWslDistro: null,
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
},
|
||||
selection: { host: 'host-account-1', wsl: {} },
|
||||
expected: { runtime: 'wsl', wslDistro: null }
|
||||
},
|
||||
{
|
||||
name: 'keeps an explicit host runtime on host',
|
||||
settings: { localAccountRuntime: 'host', terminalWindowsShell: 'wsl.exe' },
|
||||
selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } },
|
||||
expected: { runtime: 'host' }
|
||||
},
|
||||
{
|
||||
name: 'ignores an explicit WSL runtime on non-Windows hosts',
|
||||
settings: { localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' },
|
||||
platform: 'linux',
|
||||
expected: { runtime: 'host' }
|
||||
},
|
||||
{
|
||||
name: 'auto follows the global WSL runtime before stale selections',
|
||||
settings: {
|
||||
localAccountRuntime: 'auto',
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
},
|
||||
selection: { host: null, wsl: { Debian: 'wsl-account-1' } },
|
||||
expected: { runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
},
|
||||
{
|
||||
name: 'auto keeps the global host runtime before stale selections',
|
||||
settings: {
|
||||
localAccountRuntime: 'auto',
|
||||
localWindowsRuntimeDefault: { kind: 'windows-host' }
|
||||
},
|
||||
selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } },
|
||||
expected: { runtime: 'host' }
|
||||
},
|
||||
{
|
||||
name: 'legacy settings use the global WSL runtime before selections',
|
||||
settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } },
|
||||
selection: { host: null, wsl: { Debian: 'wsl-account-1' } },
|
||||
legacy: true,
|
||||
expected: { runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
},
|
||||
{
|
||||
name: 'legacy settings ignore stale agent and terminal WSL values',
|
||||
settings: {
|
||||
localWindowsRuntimeDefault: { kind: 'windows-host' },
|
||||
localAgentRuntime: 'wsl',
|
||||
localAgentWslDistro: 'Ubuntu',
|
||||
terminalWindowsShell: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
},
|
||||
legacy: true,
|
||||
expected: { runtime: 'host' }
|
||||
},
|
||||
{
|
||||
name: 'legacy settings preserve a single WSL selection on macOS',
|
||||
settings: {},
|
||||
selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } },
|
||||
legacy: true,
|
||||
platform: 'darwin',
|
||||
expected: { runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
},
|
||||
{
|
||||
name: 'legacy settings map the provider default WSL key to no distro',
|
||||
settings: {},
|
||||
selection: { host: null, wsl: { __default__: 'wsl-account-1' } },
|
||||
legacy: true,
|
||||
platform: 'linux',
|
||||
expected: { runtime: 'wsl', wslDistro: null }
|
||||
},
|
||||
{
|
||||
name: 'legacy selection fallback requires no selected host account',
|
||||
settings: {},
|
||||
selection: { host: 'host-account-1', wsl: { Ubuntu: 'wsl-account-1' } },
|
||||
legacy: true,
|
||||
expected: { runtime: 'host' }
|
||||
},
|
||||
{
|
||||
name: 'legacy selection fallback requires exactly one selected WSL account',
|
||||
settings: {},
|
||||
selection: {
|
||||
host: null,
|
||||
wsl: { Debian: 'wsl-account-1', Fedora: null, Ubuntu: 'wsl-account-2' }
|
||||
},
|
||||
legacy: true,
|
||||
expected: { runtime: 'host' }
|
||||
}
|
||||
]
|
||||
|
||||
describe.each(providers)('$name initial rate-limit target', ({ getTarget, selectionSettings }) => {
|
||||
it.each(scenarios)('$name', (scenario) => {
|
||||
const settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
...scenario.settings,
|
||||
...(scenario.selection ? selectionSettings(scenario.selection) : {})
|
||||
}
|
||||
if (scenario.legacy) {
|
||||
delete (settings as Partial<GlobalSettings>).localAccountRuntime
|
||||
}
|
||||
|
||||
expect(getTarget(settings, scenario.platform ?? 'win32')).toEqual(scenario.expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
|
||||
type AccountRuntimeSelection = {
|
||||
host: string | null
|
||||
wsl: Record<string, string | null>
|
||||
}
|
||||
|
||||
type AccountRuntimeSelectionProvider = {
|
||||
getWslSelectionKey: (wslDistro: string | null | undefined) => string
|
||||
normalizeRuntimeSelection: (settings: GlobalSettings) => AccountRuntimeSelection
|
||||
}
|
||||
|
||||
type WslRateLimitRuntimeTarget = { runtime: 'wsl'; wslDistro: string | null }
|
||||
|
||||
export type AccountRateLimitRuntimeTarget = { runtime: 'host' } | WslRateLimitRuntimeTarget
|
||||
|
||||
export function getInitialAccountRateLimitTarget(
|
||||
settings: GlobalSettings,
|
||||
provider: AccountRuntimeSelectionProvider,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): AccountRateLimitRuntimeTarget {
|
||||
if (settings.localAccountRuntime === 'host') {
|
||||
return { runtime: 'host' }
|
||||
}
|
||||
|
||||
if (settings.localAccountRuntime === 'wsl') {
|
||||
if (platform !== 'win32') {
|
||||
return { runtime: 'host' }
|
||||
}
|
||||
const configuredTarget = resolveLocalAccountRuntimeTarget(settings, platform)
|
||||
return {
|
||||
runtime: 'wsl',
|
||||
wslDistro:
|
||||
configuredTarget.wslDistro ??
|
||||
getSingleSelectedWslTarget(
|
||||
provider.normalizeRuntimeSelection(settings),
|
||||
provider.getWslSelectionKey
|
||||
)?.wslDistro ??
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-setting profiles omit the policy and fall back to project/account selection.
|
||||
const resolvedTarget = toRateLimitTarget(resolveLocalAccountRuntimeTarget(settings, platform))
|
||||
if (settings.localAccountRuntime === 'auto' || resolvedTarget.runtime === 'wsl') {
|
||||
return resolvedTarget
|
||||
}
|
||||
|
||||
const selection = provider.normalizeRuntimeSelection(settings)
|
||||
return selection.host
|
||||
? { runtime: 'host' }
|
||||
: (getSingleSelectedWslTarget(selection, provider.getWslSelectionKey) ?? { runtime: 'host' })
|
||||
}
|
||||
|
||||
function getSingleSelectedWslTarget(
|
||||
selection: AccountRuntimeSelection,
|
||||
getWslSelectionKey: AccountRuntimeSelectionProvider['getWslSelectionKey']
|
||||
): WslRateLimitRuntimeTarget | null {
|
||||
const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) =>
|
||||
Boolean(accountId)
|
||||
)
|
||||
if (selectedWslEntries.length !== 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [distroKey] = selectedWslEntries[0]
|
||||
return {
|
||||
runtime: 'wsl',
|
||||
wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey
|
||||
}
|
||||
}
|
||||
|
||||
function toRateLimitTarget(
|
||||
target: ReturnType<typeof resolveLocalAccountRuntimeTarget>
|
||||
): AccountRateLimitRuntimeTarget {
|
||||
return target.runtime === 'wsl'
|
||||
? { runtime: 'wsl', wslDistro: target.wslDistro }
|
||||
: { runtime: 'host' }
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { normalizeGlobalWindowsRuntimeDefault } from '../../shared/project-execution-runtime'
|
||||
|
||||
export type AccountRateLimitRuntimeTarget =
|
||||
| { runtime: 'host' }
|
||||
| { runtime: 'wsl'; wslDistro: string | null }
|
||||
|
||||
export function getProjectRuntimeRateLimitTarget(
|
||||
settings: GlobalSettings,
|
||||
platform: NodeJS.Platform
|
||||
): AccountRateLimitRuntimeTarget | null {
|
||||
if (platform !== 'win32') {
|
||||
return null
|
||||
}
|
||||
|
||||
const runtimeDefault = normalizeGlobalWindowsRuntimeDefault(settings.localWindowsRuntimeDefault)
|
||||
if (runtimeDefault.kind !== 'wsl') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: account quota polling has no project id, so its best default is the
|
||||
// global project runtime instead of stale terminal/agent WSL settings.
|
||||
return { runtime: 'wsl', wslDistro: runtimeDefault.distro }
|
||||
}
|
||||
|
||||
export function normalizeOptionalDistro(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
@@ -228,6 +228,7 @@ import type {
|
||||
GitHubPullRequestStateUpdate,
|
||||
GitHubPRFile,
|
||||
GitHubPRReviewCommentInput,
|
||||
GitHubReactionContent,
|
||||
GitLabIssueUpdate,
|
||||
GitLabMRInlineCommentInput,
|
||||
GitLabProjectRef,
|
||||
@@ -591,6 +592,7 @@ import {
|
||||
getPRCheckDetails,
|
||||
rerunPRChecks,
|
||||
getPRComments,
|
||||
setPRCommentReaction,
|
||||
getIssue,
|
||||
resolveReviewThread,
|
||||
setPRFileViewed,
|
||||
@@ -20147,6 +20149,25 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
}
|
||||
|
||||
async setRepoPRCommentReaction(
|
||||
repoSelector: string,
|
||||
reactionSubjectId: string,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean,
|
||||
prRepo?: GitHubOwnerRepo | null
|
||||
): Promise<Awaited<ReturnType<typeof setPRCommentReaction>>> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
return setPRCommentReaction(
|
||||
repo.path,
|
||||
reactionSubjectId,
|
||||
content,
|
||||
reacted,
|
||||
repo.connectionId ?? null,
|
||||
prRepo ?? null,
|
||||
...this.getLocalGitExecutionOptionArgs(repo)
|
||||
)
|
||||
}
|
||||
|
||||
async getRepoPRFileContents(
|
||||
repoSelector: string,
|
||||
args: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user