mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/review-pr-13413-dialog-ui
# Conflicts: # src/renderer/src/i18n/locales/ja.json # src/renderer/src/store/slices/diffComments.test.ts
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
function readRootEntries(sha) {
|
||||
// Why: a git pathname is arbitrary bytes, and 'utf8' folds every invalid
|
||||
// sequence to U+FFFD — that mangles the reported name and makes two different
|
||||
// entries compare equal, so a genuinely new one can slip past the Set below.
|
||||
// latin1 maps each byte to one code unit, so the bytes survive the round trip.
|
||||
const stdout = execFileSync('git', ['ls-tree', '-z', '--name-only', sha], {
|
||||
encoding: 'latin1',
|
||||
stdio: ['ignore', 'pipe', 'inherit']
|
||||
})
|
||||
return stdout.split('\0').filter(Boolean)
|
||||
}
|
||||
|
||||
function checkRootDirectoryEntries(argv) {
|
||||
if (argv.length !== 2) {
|
||||
console.error(`Usage: ${process.argv[1]} <base-sha> <head-sha>`)
|
||||
return 2
|
||||
}
|
||||
|
||||
const [baseSha, headSha] = argv
|
||||
const baseEntries = new Set(readRootEntries(baseSha))
|
||||
const blockedEntries = readRootEntries(headSha).filter((entry) => !baseEntries.has(entry))
|
||||
|
||||
if (blockedEntries.length === 0) {
|
||||
console.log('Root directory guard passed: no new root-level files or folders.')
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(
|
||||
'::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page.'
|
||||
)
|
||||
console.log('Root directory guard failed.')
|
||||
console.log(
|
||||
'New root-level files or folders are not allowed because they bloat the GitHub landing page.'
|
||||
)
|
||||
console.log('Move each new entry under an existing top-level directory.')
|
||||
console.log('Blocked entries:')
|
||||
// Why: an entry name is attacker-controlled and may start with '::' (the runner
|
||||
// trims leading spaces before matching) or embed a newline, so printing it bare
|
||||
// lets a PR forge annotations. Fence the untrusted list with an unguessable
|
||||
// stop-commands token, and write the raw bytes rather than a re-encoded string.
|
||||
const resumeToken = randomUUID()
|
||||
console.log(`::stop-commands::${resumeToken}`)
|
||||
for (const entry of blockedEntries) {
|
||||
process.stdout.write(Buffer.from(` ${entry}\n`, 'latin1'))
|
||||
}
|
||||
console.log(`::${resumeToken}::`)
|
||||
return 1
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: process.exit truncates a piped write part-way through on macOS, so set
|
||||
// exitCode and let node flush the blocked-entry list before it exits.
|
||||
process.exitCode = checkRootDirectoryEntries(process.argv.slice(2))
|
||||
} catch (error) {
|
||||
// Why: git already reported the failure on the inherited stderr, so surface its
|
||||
// status rather than a node stack trace. Anything else is a real bug — rethrow.
|
||||
if (typeof error.status !== 'number') {
|
||||
throw error
|
||||
}
|
||||
process.exitCode = error.status
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "Usage: $0 <base-sha> <head-sha>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
base_sha=$1
|
||||
head_sha=$2
|
||||
git rev-parse --verify "${base_sha}^{tree}" >/dev/null
|
||||
git rev-parse --verify "${head_sha}^{tree}" >/dev/null
|
||||
|
||||
declare -A base_entries=()
|
||||
while IFS= read -r -d '' entry; do
|
||||
base_entries["$entry"]=1
|
||||
done < <(git ls-tree -z --name-only "$base_sha")
|
||||
|
||||
blocked_entries=()
|
||||
while IFS= read -r -d '' entry; do
|
||||
if [[ -z "${base_entries[$entry]+present}" ]]; then
|
||||
blocked_entries+=("$entry")
|
||||
fi
|
||||
done < <(git ls-tree -z --name-only "$head_sha")
|
||||
|
||||
if (( ${#blocked_entries[@]} == 0 )); then
|
||||
echo "Root directory guard passed: no new root-level files or folders."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page."
|
||||
echo "Root directory guard failed."
|
||||
echo "New root-level files or folders are not allowed because they bloat the GitHub landing page."
|
||||
echo "Move each new entry under an existing top-level directory."
|
||||
printf 'Blocked entries:\n'
|
||||
printf ' %s\n' "${blocked_entries[@]}"
|
||||
exit 1
|
||||
@@ -0,0 +1,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"
|
||||
@@ -380,24 +380,52 @@ jobs:
|
||||
echo "::warning::Could not discard draft $TAG; remove it manually."
|
||||
|
||||
- name: Prune old hourly releases
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
# 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_publish.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.
|
||||
stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,createdAt,isDraft \
|
||||
--jq "map(select(.isDraft | not)) | sort_by(.createdAt) | reverse | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName")"
|
||||
#
|
||||
# Sort by publishedAt (not createdAt). Many non-draft hourlies share one
|
||||
# createdAt (bulk import / re-create), so createdAt ranking is unstable:
|
||||
# reverse of a stable sort then drops the just-published tag past the
|
||||
# retain window. 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+=" | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName"
|
||||
stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,publishedAt,isDraft \
|
||||
--jq "$jq_filter")"
|
||||
if [[ -z "$stale" ]]; then
|
||||
echo "Nothing to prune; at or under $HOURLY_RETAIN_COUNT retained builds."
|
||||
exit 0
|
||||
fi
|
||||
while read -r tag; do
|
||||
[[ -n "$tag" ]] || continue
|
||||
# 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 "$HOURLY_REPO" --yes --cleanup-tag || \
|
||||
echo "::warning::Could not prune $tag"
|
||||
|
||||
@@ -30,6 +30,11 @@ jobs:
|
||||
# (@MainActor isolation). Xcode 16.x (macos-15) can't even parse it
|
||||
# ("unknown attribute 'MainActor'"), so we need Xcode 26.x → macos-26.
|
||||
runs-on: macos-26
|
||||
# Archive + upload is ~30–40m when healthy; 90m leaves margin for setup.
|
||||
timeout-minutes: 90
|
||||
outputs:
|
||||
release_version: ${{ steps.release_metadata.outputs.version }}
|
||||
build_number: ${{ steps.release_metadata.outputs.build_number }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -76,6 +81,10 @@ jobs:
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
run: bundle exec fastlane ios prepare_release_version version:"$MOBILE_IOS_RELEASE_VERSION" bump_patch:"$MOBILE_IOS_BUMP_PATCH_VERSION"
|
||||
|
||||
- name: Capture release metadata
|
||||
id: release_metadata
|
||||
run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)'
|
||||
|
||||
- name: Expo prebuild
|
||||
run: npx expo prebuild --platform ios --no-install
|
||||
|
||||
@@ -119,11 +128,10 @@ jobs:
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TESTFLIGHT_CHANGELOG: ${{ github.event.inputs.testflight_changelog }}
|
||||
# Keep fastlane non-interactive and quiet about analytics in CI.
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
run: bundle exec fastlane ios release
|
||||
run: bundle exec fastlane ios build_and_upload
|
||||
|
||||
- name: Upload .ipa artifact
|
||||
if: always()
|
||||
@@ -132,3 +140,44 @@ jobs:
|
||||
name: orca-mobile-ipa
|
||||
path: mobile/build/*.ipa
|
||||
if-no-files-found: ignore
|
||||
|
||||
ios-distribute:
|
||||
name: Distribute to external TestFlight testers
|
||||
needs: ios-build
|
||||
runs-on: ubuntu-latest
|
||||
# Fastlane's processing wait fails after 20m; this outer bound leaves setup
|
||||
# margin without allowing App Store Connect polling to hang for hours.
|
||||
timeout-minutes: 30
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Ruby and fastlane
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
working-directory: mobile
|
||||
|
||||
- name: Wait for processing and distribute to peeps
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
|
||||
TESTFLIGHT_CHANGELOG: ${{ github.event.inputs.testflight_changelog }}
|
||||
MOBILE_IOS_RELEASE_VERSION: ${{ needs.ios-build.outputs.release_version }}
|
||||
MOBILE_IOS_BUILD_NUMBER: ${{ needs.ios-build.outputs.build_number }}
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
run: |
|
||||
if ! bundle exec fastlane ios distribute_testflight \
|
||||
version:"$MOBILE_IOS_RELEASE_VERSION" \
|
||||
build_number:"$MOBILE_IOS_BUILD_NUMBER"; then
|
||||
echo "::error::TestFlight $MOBILE_IOS_RELEASE_VERSION ($MOBILE_IOS_BUILD_NUMBER) was uploaded but not distributed to peeps. If App Store Connect is still processing it, rerun only this failed job after the build becomes Ready; otherwise inspect the Fastlane error above."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: bash .github/scripts/check-root-directory-entries.sh "$BASE_SHA" "$HEAD_SHA"
|
||||
run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA"
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -183,8 +183,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update && sudo apt-get install -y zsh
|
||||
# Why fish: shell-ready.test.ts gates its live fish test on the binary being
|
||||
# present, so without this the fish barrier is only covered by config-shape
|
||||
# assertions and never actually exercised.
|
||||
- name: Install zsh and fish
|
||||
run: sudo apt-get update && sudo apt-get install -y zsh fish
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -259,6 +262,29 @@ jobs:
|
||||
- name: Old/new client and server terminal journey
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Build relay companions
|
||||
run: pnpm run build:relay
|
||||
|
||||
- name: Setup Node 18 runtime
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Smoke managed-hook companions
|
||||
run: node config/scripts/smoke-managed-hook-runtime-node18.mjs
|
||||
|
||||
package:
|
||||
name: package
|
||||
runs-on: ubuntu-latest
|
||||
@@ -423,6 +449,7 @@ jobs:
|
||||
- git_compatibility
|
||||
- shell_contracts
|
||||
- test
|
||||
- managed_hook_node18
|
||||
- package
|
||||
- package_windows
|
||||
runs-on: ubuntu-latest
|
||||
@@ -444,6 +471,7 @@ jobs:
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
|
||||
run: |
|
||||
@@ -454,6 +482,7 @@ jobs:
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Release Policy
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-policy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
enforce:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Enforce release policy
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const release = context.payload.release;
|
||||
const tag = release.tag_name;
|
||||
const author = release.author?.login;
|
||||
const number = "(?:0|[1-9][0-9]*)";
|
||||
const version = `${number}\\.${number}\\.${number}`;
|
||||
const stableTag = new RegExp(`^v${version}$`);
|
||||
const prereleaseTag = new RegExp(
|
||||
`^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`,
|
||||
);
|
||||
const expectedPrerelease = prereleaseTag.test(tag);
|
||||
const allowed = author === "github-actions[bot]" &&
|
||||
(stableTag.test(tag) || expectedPrerelease);
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
async function restoreLatestStable() {
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const stable = releases
|
||||
.filter((candidate) =>
|
||||
!candidate.draft &&
|
||||
!candidate.prerelease &&
|
||||
candidate.author?.login === "github-actions[bot]" &&
|
||||
stableTag.test(candidate.tag_name),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const a = left.tag_name.slice(1).split(".").map(Number);
|
||||
const b = right.tag_name.slice(1).split(".").map(Number);
|
||||
return a.reduce((result, part, index) => result || part - b[index], 0);
|
||||
})
|
||||
.at(-1);
|
||||
if (stable) {
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: stable.id,
|
||||
make_latest: "true",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
if (release.prerelease !== expectedPrerelease) {
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
prerelease: expectedPrerelease,
|
||||
make_latest: expectedPrerelease ? "false" : "legacy",
|
||||
});
|
||||
}
|
||||
await restoreLatestStable();
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
draft: true,
|
||||
prerelease: true,
|
||||
make_latest: "false",
|
||||
});
|
||||
await restoreLatestStable();
|
||||
await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id });
|
||||
try {
|
||||
await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` });
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`);
|
||||
@@ -94,6 +94,7 @@ docs/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/ai-vault-process-isolation-plan.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
!docs/reference/git-compatibility.md
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'stt-worker',
|
||||
'warp-theme-parser-worker',
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'session-scanner-worker-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'port-scan-command-worker-entry'
|
||||
] as const
|
||||
|
||||
@@ -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',
|
||||
@@ -171,6 +182,7 @@ module.exports = {
|
||||
'out/main/grok/**',
|
||||
'out/main/hermes/**',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/session-scanner-service-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"src/main/speech/stt-worker.ts",
|
||||
"src/main/warp-themes/warp-theme-parser-worker.ts",
|
||||
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
|
||||
"src/main/ai-vault/session-scanner-worker-entry.ts",
|
||||
"src/main/ports/port-scan-command-worker-entry.ts",
|
||||
"src/main/ipc/parcel-watcher-process-entry.ts",
|
||||
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/sidebar/WorktreeCard.tsx",
|
||||
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
|
||||
"kind": "jsx-attribute:label",
|
||||
"text": "sidebar",
|
||||
"dynamic": false,
|
||||
|
||||
@@ -235,7 +235,6 @@ inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/settings/SettingsFormControls.tsx
|
||||
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeCard.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.tsx
|
||||
@@ -341,7 +340,6 @@ mobile-config app/h/*/source-control/*.tsx
|
||||
mobile-config app/h/*/tasks.tsx
|
||||
mobile-config app/index.tsx
|
||||
mobile-config app/pair-scan.tsx
|
||||
mobile-config app/terminal-settings.tsx
|
||||
mobile-config app/troubleshoot.tsx
|
||||
mobile-config scripts/mock-server.ts
|
||||
mobile-config scripts/repro-terminal-colors.ts
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+777
-57
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env node
|
||||
// Measures the no-URL PTY path that every terminal chunk enters.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import nodeModule from 'node:module'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
if (!process.execArgv.includes('--experimental-transform-types')) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--experimental-transform-types', '--no-warnings', import.meta.filename],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
nodeModule.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
|
||||
const candidate = new URL(`${specifier}.ts`, context.parentURL)
|
||||
if (existsSync(fileURLToPath(candidate))) {
|
||||
return { url: candidate.href, shortCircuit: true }
|
||||
}
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
}
|
||||
})
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('../../src/main/ports/advertised-url-watcher.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
for (const marker of [
|
||||
"return mayContainHttpUrl(finalized) ? stripTerminalControls(finalized) : ''",
|
||||
'if (chunk.length >= PER_PTY_BUFFER_LIMIT)',
|
||||
'this.raw.length + chunk.length > PER_PTY_BUFFER_LIMIT'
|
||||
]) {
|
||||
if (!source.includes(marker)) {
|
||||
throw new Error(`advertised-url-watcher.ts no longer contains \`${marker}\``)
|
||||
}
|
||||
}
|
||||
|
||||
const { AdvertisedUrlWatcher, extractUrlCandidates, stripTerminalControls } = await import(
|
||||
new URL('../../src/main/ports/advertised-url-watcher.ts', import.meta.url).href
|
||||
)
|
||||
|
||||
const BUFFER_LIMIT = 4096
|
||||
const ITERATIONS = Number(process.env.ORCA_ADVERTISED_URL_BENCH_ITERATIONS ?? '10000')
|
||||
const ROUNDS = Number(process.env.ORCA_ADVERTISED_URL_BENCH_ROUNDS ?? '12')
|
||||
const WARMUP = Number(process.env.ORCA_ADVERTISED_URL_BENCH_WARMUP ?? '1000')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_ADVERTISED_URL_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_ADVERTISED_URL_BENCH_ROUNDS', ROUNDS],
|
||||
['ORCA_ADVERTISED_URL_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
if (ROUNDS % 2 !== 0) {
|
||||
throw new Error('ORCA_ADVERTISED_URL_BENCH_ROUNDS must be even')
|
||||
}
|
||||
|
||||
class BeforePtyBuffer {
|
||||
raw = ''
|
||||
|
||||
ingest(chunk) {
|
||||
const chunkHasLineBreak = chunk.includes('\n') || chunk.includes('\r')
|
||||
this.raw += chunk
|
||||
if (this.raw.length > BUFFER_LIMIT) {
|
||||
this.raw = this.raw.slice(-BUFFER_LIMIT)
|
||||
}
|
||||
if (!chunkHasLineBreak) {
|
||||
return ''
|
||||
}
|
||||
const lastNewline = lastLineBreak(this.raw)
|
||||
if (lastNewline === -1) {
|
||||
return ''
|
||||
}
|
||||
const finalized = this.raw.slice(0, lastNewline + 1)
|
||||
this.raw = this.raw.slice(lastNewline + 1)
|
||||
return stripTerminalControls(finalized)
|
||||
}
|
||||
}
|
||||
|
||||
class BeforeWatcher {
|
||||
buffers = new Map()
|
||||
ptyToWorktree = new Map()
|
||||
|
||||
bindPty(ptyId, worktreeId) {
|
||||
this.ptyToWorktree.set(ptyId, worktreeId)
|
||||
}
|
||||
|
||||
ingest(ptyId, chunk) {
|
||||
if (!this.ptyToWorktree.has(ptyId)) {
|
||||
return 0
|
||||
}
|
||||
let buffer = this.buffers.get(ptyId)
|
||||
if (!buffer) {
|
||||
buffer = new BeforePtyBuffer()
|
||||
this.buffers.set(ptyId, buffer)
|
||||
}
|
||||
const finalized = buffer.ingest(chunk)
|
||||
return finalized ? extractUrlCandidates(finalized).length : 0
|
||||
}
|
||||
}
|
||||
|
||||
function lastLineBreak(text) {
|
||||
for (let index = text.length - 1; index >= 0; index -= 1) {
|
||||
const code = text.charCodeAt(index)
|
||||
if (code === 0x0a || code === 0x0d) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function repeatToLine(text, length, sample) {
|
||||
const unit = `${text}${sample}\n`
|
||||
return `${unit.repeat(Math.ceil(length / unit.length)).slice(0, length - 1)}\n`
|
||||
}
|
||||
|
||||
function repeatWithoutLineBreak(text, length, sample) {
|
||||
const unit = `${text}${sample}`
|
||||
return unit.repeat(Math.ceil(length / unit.length)).slice(0, length)
|
||||
}
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'plain 32B line',
|
||||
chunks: Array.from({ length: 32 }, (_, index) => `compiled module ${index} in 183ms\n`)
|
||||
},
|
||||
{
|
||||
label: 'ANSI 32B line',
|
||||
chunks: Array.from({ length: 32 }, (_, index) => `\x1b[2K\x1b[1GThinking ${index}...\r\n`)
|
||||
},
|
||||
{
|
||||
label: 'guard fallthrough line',
|
||||
chunks: Array.from(
|
||||
{ length: 32 },
|
||||
(_, index) => `path/to/http_server: compiled module ${index}\n`
|
||||
)
|
||||
},
|
||||
{
|
||||
label: 'plain 4KiB line',
|
||||
chunks: Array.from({ length: 32 }, (_, index) =>
|
||||
repeatToLine('INFO build completed module=renderer duration=12ms ', BUFFER_LIMIT, index)
|
||||
)
|
||||
},
|
||||
{
|
||||
label: 'ANSI 4KiB line',
|
||||
chunks: Array.from({ length: 32 }, (_, index) =>
|
||||
repeatToLine(
|
||||
'\x1b[2K\x1b[1Gthinking about compiler output and writing patches ',
|
||||
BUFFER_LIMIT,
|
||||
index
|
||||
)
|
||||
)
|
||||
},
|
||||
{
|
||||
label: 'guard fallthrough 4KiB',
|
||||
chunks: Array.from({ length: 32 }, (_, index) =>
|
||||
repeatToLine(
|
||||
'path/to/http_server: compiled without an advertised endpoint ',
|
||||
BUFFER_LIMIT,
|
||||
index
|
||||
)
|
||||
)
|
||||
},
|
||||
{
|
||||
label: '64KiB partial chunk',
|
||||
chunks: Array.from({ length: 32 }, (_, index) =>
|
||||
repeatWithoutLineBreak('compiler output without a line break ', 64 * 1024, index)
|
||||
)
|
||||
},
|
||||
{
|
||||
label: '256KiB partial chunk',
|
||||
chunks: Array.from({ length: 32 }, (_, index) =>
|
||||
repeatWithoutLineBreak('compiler output without a line break ', 256 * 1024, index)
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
let checksum = 0
|
||||
|
||||
function runBefore(chunks, iterations) {
|
||||
const watcher = new BeforeWatcher()
|
||||
watcher.bindPty('pty', 'repo::/bench')
|
||||
let found = 0
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
found += watcher.ingest('pty', chunks[index % chunks.length])
|
||||
}
|
||||
checksum = Math.imul(checksum ^ found ^ watcher.buffers.get('pty').raw.length, 16777619) >>> 0
|
||||
}
|
||||
|
||||
function runAfter(chunks, iterations) {
|
||||
const watcher = new AdvertisedUrlWatcher()
|
||||
watcher.bindPty('pty', 'repo::/bench')
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
watcher.ingest('pty', chunks[index % chunks.length], index)
|
||||
}
|
||||
const buffer = watcher.buffers.get('pty')
|
||||
checksum = Math.imul(checksum ^ (buffer?.raw.length ?? 0), 16777619) >>> 0
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
|
||||
function measure(fixture) {
|
||||
runBefore(fixture.chunks, WARMUP)
|
||||
runAfter(fixture.chunks, WARMUP)
|
||||
const before = []
|
||||
const after = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
const measureBefore = () => {
|
||||
const startedAt = performance.now()
|
||||
runBefore(fixture.chunks, ITERATIONS)
|
||||
before.push(((performance.now() - startedAt) * 1000) / ITERATIONS)
|
||||
}
|
||||
const measureAfter = () => {
|
||||
const startedAt = performance.now()
|
||||
runAfter(fixture.chunks, ITERATIONS)
|
||||
after.push(((performance.now() - startedAt) * 1000) / ITERATIONS)
|
||||
}
|
||||
if (round % 2 === 0) {
|
||||
measureBefore()
|
||||
measureAfter()
|
||||
} else {
|
||||
measureAfter()
|
||||
measureBefore()
|
||||
}
|
||||
}
|
||||
return { before: median(before), after: median(after) }
|
||||
}
|
||||
|
||||
console.log('Advertised URL watcher no-match cost in microseconds/chunk. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS}, rounds=${ROUNDS}, warmup=${WARMUP}`)
|
||||
console.log('fixture before after speedup')
|
||||
for (const fixture of fixtures) {
|
||||
const result = measure(fixture)
|
||||
console.log(
|
||||
`${fixture.label.padEnd(26)} ${result.before.toFixed(3).padStart(8)} ${result.after
|
||||
.toFixed(3)
|
||||
.padStart(10)} ${(result.before / result.after).toFixed(2).padStart(9)}x`
|
||||
)
|
||||
}
|
||||
console.log(`checksum=${checksum}`)
|
||||
console.log(
|
||||
'Limitations: synthetic in-process scan; explicit timestamps omit the production clock read and surrounding PTY dispatch.'
|
||||
)
|
||||
@@ -18,6 +18,7 @@ const __dirname = import.meta.dirname
|
||||
const ROOT = join(__dirname, '..', '..')
|
||||
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
|
||||
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
|
||||
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
|
||||
const MANAGED_HOOK_RUNTIME_ENTRY = join(
|
||||
ROOT,
|
||||
'src',
|
||||
@@ -88,6 +89,21 @@ for (const platform of PLATFORMS) {
|
||||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [AI_VAULT_SERVICE_ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'relay-ai-vault-service.js'),
|
||||
external: ['electron'],
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
|
||||
bundle: true,
|
||||
@@ -110,10 +126,12 @@ for (const platform of PLATFORMS) {
|
||||
// so a companion-only change always deploys beside the matching relay host.
|
||||
const relayContent = readFileSync(join(outDir, 'relay.js'))
|
||||
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
|
||||
const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js'))
|
||||
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
|
||||
const hash = createHash('sha256')
|
||||
.update(relayContent)
|
||||
.update(watcherContent)
|
||||
.update(aiVaultServiceContent)
|
||||
.update(managedHookRuntimeContent)
|
||||
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
|
||||
if (platform.startsWith('win32-')) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const guardScript = join(projectDir, '.github/scripts/check-root-directory-entries.sh')
|
||||
const guardScript = join(projectDir, '.github/scripts/check-root-directory-entries.mjs')
|
||||
const tempDirs = []
|
||||
|
||||
function git(cwd, args) {
|
||||
@@ -37,13 +37,45 @@ function commitFiles(root, files) {
|
||||
return git(root, ['rev-parse', 'HEAD'])
|
||||
}
|
||||
|
||||
// Why: a root entry name can be bytes no filesystem here accepts (APFS rejects
|
||||
// invalid UTF-8), so build the tree in the object database instead of on disk.
|
||||
// git ls-tree -z emits exactly the record format git mktree -z reads back.
|
||||
function commitRawEntries(root, parent, entries) {
|
||||
const parentTree = execFileSync('git', ['ls-tree', '-z', parent], { cwd: root })
|
||||
const records = entries.map((name) => {
|
||||
const blob = execFileSync('git', ['hash-object', '-w', '--stdin'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
input: 'too prominent\n'
|
||||
}).trim()
|
||||
return Buffer.concat([Buffer.from(`100644 blob ${blob}\t`), name, Buffer.from([0])])
|
||||
})
|
||||
const tree = execFileSync('git', ['mktree', '-z'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
input: Buffer.concat([parentTree, ...records])
|
||||
}).trim()
|
||||
return execFileSync('git', ['commit-tree', tree, '-p', parent, '-m', 'head'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function runGuard({ root, base, head }) {
|
||||
return spawnSync('bash', [guardScript, base, head], {
|
||||
return runGuardArgs(root, [base, head])
|
||||
}
|
||||
|
||||
function runGuardArgs(root, args) {
|
||||
return spawnSync(process.execPath, [guardScript, ...args], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
}
|
||||
|
||||
function runGuardBytes({ root, base, head }) {
|
||||
return spawnSync(process.execPath, [guardScript, base, head], { cwd: root })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
@@ -84,6 +116,100 @@ describe('root directory guard', () => {
|
||||
expect(output).toContain('new-folder')
|
||||
})
|
||||
|
||||
// Why: git escapes odd paths unless it is read NUL-delimited, so dropping -z
|
||||
// (or decoding the bytes wrong) reports a mangled name nobody can act on.
|
||||
it.skipIf(process.platform === 'win32')('reports a blocked entry byte-for-byte', () => {
|
||||
const awkwardName = '日本 root file\nwith newline.txt'
|
||||
const fixture = makeFixture()
|
||||
const head = commitFiles(fixture.root, [[awkwardName, 'too prominent\n']])
|
||||
|
||||
const result = runGuard({ ...fixture, head })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout).toContain(awkwardName)
|
||||
})
|
||||
|
||||
// Why: decoding git's output as UTF-8 rewrites every invalid byte to U+FFFD, so
|
||||
// the name the guard prints is not the name anyone has to rename.
|
||||
it('reports an entry whose name is not valid UTF-8 byte-for-byte', () => {
|
||||
const rawName = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('-raw.txt')])
|
||||
const fixture = makeFixture()
|
||||
const head = commitRawEntries(fixture.root, fixture.base, [rawName])
|
||||
|
||||
const result = runGuardBytes({ ...fixture, head })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout.includes(rawName)).toBe(true)
|
||||
})
|
||||
|
||||
// Why: U+FFFD is not injective, so two different invalid names decode to the
|
||||
// same string and a new root entry gets waved through as pre-existing.
|
||||
it('does not confuse two different invalid UTF-8 names for the same entry', () => {
|
||||
const fixture = makeFixture()
|
||||
const base = commitRawEntries(fixture.root, fixture.base, [
|
||||
Buffer.concat([Buffer.from([0xc0, 0x80]), Buffer.from('.txt')])
|
||||
])
|
||||
const head = commitRawEntries(fixture.root, fixture.base, [
|
||||
Buffer.concat([Buffer.from([0xc0, 0x81]), Buffer.from('.txt')])
|
||||
])
|
||||
|
||||
const result = runGuardBytes({ root: fixture.root, base, head })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout.toString('latin1')).not.toContain('guard passed')
|
||||
})
|
||||
|
||||
// Why: the runner trims leading spaces before matching '::', so an indented
|
||||
// entry name still reaches the workflow-command parser and can forge output.
|
||||
it('prints blocked entries with workflow-command parsing disabled', () => {
|
||||
const fixture = makeFixture()
|
||||
const forgedName = '::error title=forged::injected\n::warning::second line.txt'
|
||||
const head = commitRawEntries(fixture.root, fixture.base, [Buffer.from(forgedName)])
|
||||
|
||||
const result = runGuard({ ...fixture, head })
|
||||
const lines = result.stdout.split('\n')
|
||||
const stopIndex = lines.findIndex((line) => line.startsWith('::stop-commands::'))
|
||||
const resumeToken = lines[stopIndex]?.slice('::stop-commands::'.length)
|
||||
const resumeIndex = lines.indexOf(`::${resumeToken}::`)
|
||||
const escaped = lines.filter(
|
||||
(line, index) =>
|
||||
(index < stopIndex || index > resumeIndex) && line.trimStart().startsWith('::')
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(resumeToken).toMatch(/^[\da-f-]{36}$/)
|
||||
expect(stopIndex).toBeLessThan(resumeIndex)
|
||||
// Why: the guard's own annotation is the only line the runner may act on.
|
||||
expect(escaped).toHaveLength(1)
|
||||
expect(escaped[0]).toContain('Root-level additions blocked')
|
||||
expect(lines.slice(stopIndex, resumeIndex).join('\n')).toContain(forgedName)
|
||||
})
|
||||
|
||||
it('exits 2 with usage when the two shas are not both supplied', () => {
|
||||
const fixture = makeFixture()
|
||||
|
||||
const result = runGuardArgs(fixture.root, [fixture.base])
|
||||
|
||||
expect(result.status).toBe(2)
|
||||
expect(result.stderr).toContain('<base-sha> <head-sha>')
|
||||
})
|
||||
|
||||
it('fails loudly instead of passing when a sha does not resolve', () => {
|
||||
const fixture = makeFixture()
|
||||
|
||||
const result = runGuardArgs(fixture.root, [
|
||||
fixture.base,
|
||||
'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
|
||||
])
|
||||
|
||||
// Why: git's own exit status, not node's. An unhandled throw is also non-zero,
|
||||
// so assert the status and the absent stack trace or the guard's error
|
||||
// handling can be deleted without a test noticing.
|
||||
expect(result.status).toBe(128)
|
||||
expect(result.stderr).not.toContain('node:internal')
|
||||
expect(result.stdout).not.toContain('guard passed')
|
||||
})
|
||||
|
||||
it('is wired into the PR verify gate', () => {
|
||||
const workflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const guardJob = workflow.jobs.root_directory_guard
|
||||
@@ -93,7 +219,7 @@ describe('root directory guard', () => {
|
||||
|
||||
expect(guardJob.name).toBe('root directory guard')
|
||||
expect(guardJob.steps[0].with['fetch-depth']).toBe(0)
|
||||
expect(guardStep.run).toContain('.github/scripts/check-root-directory-entries.sh')
|
||||
expect(guardStep.run).toContain('node .github/scripts/check-root-directory-entries.mjs')
|
||||
expect(workflow.jobs.verify.needs).toContain('root_directory_guard')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,6 +54,19 @@ function makeSocketPath(userDataDir) {
|
||||
return join(userDataDir, 'daemon.sock')
|
||||
}
|
||||
|
||||
// 'connected' only when something actually answers; a dead entry left on the name is not it.
|
||||
function probeEndpoint(socketPath) {
|
||||
return new Promise((resolveProbe) => {
|
||||
const socket = connect(socketPath)
|
||||
const settle = (result) => {
|
||||
socket.destroy()
|
||||
resolveProbe(result)
|
||||
}
|
||||
socket.on('connect', () => settle('connected'))
|
||||
socket.on('error', () => settle('unreachable'))
|
||||
})
|
||||
}
|
||||
|
||||
function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) {
|
||||
return new Promise((resolveRpc, rejectRpc) => {
|
||||
let settled = false
|
||||
@@ -267,11 +280,16 @@ async function main() {
|
||||
if (existsSync(pidPath)) {
|
||||
throw new Error('daemon left its PID ownership record behind after shutdown')
|
||||
}
|
||||
if (process.platform !== 'win32' && existsSync(socketPath)) {
|
||||
throw new Error('daemon left its endpoint socket behind after shutdown')
|
||||
// Why not "the entry is gone": a departing daemon deliberately leaves its endpoint behind
|
||||
// for the next publisher to replace in one rename. What must be true is that nothing
|
||||
// answers there any more.
|
||||
if (process.platform !== 'win32' && (await probeEndpoint(socketPath)) === 'connected') {
|
||||
throw new Error('daemon still answers its endpoint after shutdown')
|
||||
}
|
||||
// The private bind name is consumed by the publish; nothing may linger in the runtime dir.
|
||||
const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b'))
|
||||
// The private bind name is consumed by the publish, and nothing sweeps the runtime dir any
|
||||
// more, so a leak here is permanent. Match what the code actually generates rather than a
|
||||
// literal prefix: this check silently matched nothing after the namespace moved from .b.
|
||||
const leaked = readdirSync(userDataDir).filter((entry) => /^\.[a-z][0-9a-f]{10}$/.test(entry))
|
||||
if (leaked.length > 0) {
|
||||
throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* Endpoint handover smoke — guards the split-brain failure with real daemon processes.
|
||||
*
|
||||
* The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still
|
||||
* alive used to delete the *replacement's* socket when it finally exited, because libuv
|
||||
* unlinks the pathname a server bound to with no ownership check. The replacement stayed
|
||||
* alive hosting PTYs that nothing could reach — terminals that acknowledge input and never
|
||||
* run it, and that a user cannot fix by restarting the app.
|
||||
* Two starting daemons race to replace one dead endpoint entry. The loser may exit after the
|
||||
* winner has published, but its close must not remove the winner's canonical socket. The survivor
|
||||
* must remain reachable through that path with its own token.
|
||||
*
|
||||
* Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur.
|
||||
*
|
||||
@@ -14,17 +12,36 @@
|
||||
import { fork } from 'node:child_process'
|
||||
import { connect } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..', '..')
|
||||
const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js')
|
||||
|
||||
// Why read it from source: the launcher keys adoption of a live incumbent on this exact code,
|
||||
// and hardcoding it here would let the two drift silently — which is the failure the assertion
|
||||
// below exists to catch.
|
||||
const DAEMON_EXIT_ENDPOINT_OCCUPIED = Number(
|
||||
readFileSync(join(repoRoot, 'src/main/daemon/daemon-endpoint-ownership.ts'), 'utf8').match(
|
||||
/DAEMON_EXIT_ENDPOINT_OCCUPIED = (\d+)/
|
||||
)?.[1]
|
||||
)
|
||||
const READY_TIMEOUT_MS = 20_000
|
||||
const EXIT_TIMEOUT_MS = 15_000
|
||||
const REACHABILITY_TIMEOUT_MS = 2_000
|
||||
|
||||
const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`)
|
||||
|
||||
function readProtocolVersion() {
|
||||
const source = readFileSync(join(repoRoot, 'src/main/daemon/daemon-protocol-version.ts'), 'utf8')
|
||||
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
|
||||
if (!match) {
|
||||
throw new Error('could not read daemon protocol version')
|
||||
}
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
function bootDaemon(tag, dir, socketPath) {
|
||||
const tokenPath = join(dir, `${tag}.token`)
|
||||
const pidPath = join(dir, `${tag}.pid`)
|
||||
@@ -53,7 +70,7 @@ function bootDaemon(tag, dir, socketPath) {
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
return new Promise((resolveReady, rejectReady) => {
|
||||
const ready = new Promise((resolveReady, rejectReady) => {
|
||||
const timer = setTimeout(
|
||||
() => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)),
|
||||
READY_TIMEOUT_MS
|
||||
@@ -61,7 +78,7 @@ function bootDaemon(tag, dir, socketPath) {
|
||||
child.on('message', (msg) => {
|
||||
if (msg && typeof msg === 'object' && msg.type === 'ready') {
|
||||
clearTimeout(timer)
|
||||
resolveReady({ child, tokenPath, pidPath })
|
||||
resolveReady()
|
||||
}
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
@@ -69,28 +86,80 @@ function bootDaemon(tag, dir, socketPath) {
|
||||
rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`))
|
||||
})
|
||||
})
|
||||
return { child, tokenPath, pidPath, ready }
|
||||
}
|
||||
|
||||
function isReachable(socketPath) {
|
||||
return new Promise((resolveReachable) => {
|
||||
const socket = connect({ path: socketPath })
|
||||
socket.on('connect', () => {
|
||||
socket.once('connect', () => {
|
||||
socket.destroy()
|
||||
resolveReachable(true)
|
||||
})
|
||||
socket.on('error', () => resolveReachable(false))
|
||||
socket.once('error', () => {
|
||||
socket.destroy()
|
||||
resolveReachable(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isDaemonReachable(socketPath, tokenPath, protocolVersion) {
|
||||
if (!existsSync(tokenPath)) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
const token = readFileSync(tokenPath, 'utf8').trim()
|
||||
return new Promise((resolveReachable) => {
|
||||
let buffer = ''
|
||||
let settled = false
|
||||
const socket = connect({ path: socketPath })
|
||||
const finish = (reachable) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
resolveReachable(reachable)
|
||||
}
|
||||
const timer = setTimeout(() => finish(false), REACHABILITY_TIMEOUT_MS)
|
||||
socket.once('error', () => finish(false))
|
||||
socket.once('connect', () => {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
type: 'hello',
|
||||
version: protocolVersion,
|
||||
token,
|
||||
clientId: randomUUID(),
|
||||
role: 'control'
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8')
|
||||
const newlineIndex = buffer.indexOf('\n')
|
||||
if (newlineIndex === -1) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const message = JSON.parse(buffer.slice(0, newlineIndex))
|
||||
finish(message.type === 'hello' && message.ok === true)
|
||||
} catch {
|
||||
finish(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolves with the child's exit code, so callers can assert on it. */
|
||||
function killAndWait(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return Promise.resolve()
|
||||
return Promise.resolve(child.exitCode)
|
||||
}
|
||||
return new Promise((resolveExit, rejectExit) => {
|
||||
const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS)
|
||||
child.on('exit', () => {
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
resolveExit()
|
||||
resolveExit(code)
|
||||
})
|
||||
child.kill('SIGTERM')
|
||||
})
|
||||
@@ -107,46 +176,103 @@ async function main() {
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-'))
|
||||
const socketPath = join(dir, 'daemon.sock')
|
||||
let replaced
|
||||
let replacement
|
||||
const protocolVersion = readProtocolVersion()
|
||||
const daemons = []
|
||||
try {
|
||||
replaced = await bootDaemon('replaced', dir, socketPath)
|
||||
const replacedInode = statSync(socketPath).ino
|
||||
log('daemon A published the endpoint')
|
||||
const departed = bootDaemon('departed', dir, socketPath)
|
||||
daemons.push(departed)
|
||||
await departed.ready
|
||||
const deadInode = statSync(socketPath).ino
|
||||
await killAndWait(departed.child)
|
||||
if (!existsSync(socketPath) || statSync(socketPath).ino !== deadInode) {
|
||||
throw new Error('departing daemon did not leave its dead endpoint entry in place')
|
||||
}
|
||||
if (await isReachable(socketPath)) {
|
||||
throw new Error('departed daemon remains reachable')
|
||||
}
|
||||
log('daemon A departed and left a dead endpoint entry')
|
||||
|
||||
// Reclaim the endpoint name the way daemon replacement does, while A is still alive.
|
||||
unlinkSync(socketPath)
|
||||
replacement = await bootDaemon('replacement', dir, socketPath)
|
||||
const replacementInode = statSync(socketPath).ino
|
||||
if (replacedInode === replacementInode) {
|
||||
throw new Error('daemon B did not publish a distinct endpoint')
|
||||
}
|
||||
if (!(await isReachable(socketPath))) {
|
||||
throw new Error('daemon B is not reachable through the canonical endpoint')
|
||||
}
|
||||
log('daemon B took over the endpoint and is reachable')
|
||||
|
||||
// A exits long after losing the endpoint. This is the step that used to break B.
|
||||
await killAndWait(replaced.child)
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
|
||||
if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) {
|
||||
throw new Error("daemon A's late exit deleted daemon B's endpoint")
|
||||
}
|
||||
if (!(await isReachable(socketPath))) {
|
||||
throw new Error('daemon B became unreachable after daemon A exited')
|
||||
}
|
||||
if (!existsSync(replacement.pidPath)) {
|
||||
throw new Error("daemon A's late exit removed daemon B's ownership record")
|
||||
}
|
||||
if (existsSync(replaced.pidPath)) {
|
||||
throw new Error('daemon A left its own ownership record behind')
|
||||
const racers = [bootDaemon('racer-b', dir, socketPath), bootDaemon('racer-c', dir, socketPath)]
|
||||
daemons.push(...racers)
|
||||
const readiness = await Promise.allSettled(racers.map((daemon) => daemon.ready))
|
||||
if (readiness.every((result) => result.status === 'rejected')) {
|
||||
throw new Error(
|
||||
`neither racing daemon published the endpoint:\n${readiness
|
||||
.map((result) => (result.status === 'rejected' ? result.reason.message : ''))
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
|
||||
log('PASS: the endpoint owner and the session host stayed the same daemon')
|
||||
const owners = []
|
||||
for (const daemon of racers) {
|
||||
if (await isDaemonReachable(socketPath, daemon.tokenPath, protocolVersion)) {
|
||||
owners.push(daemon)
|
||||
}
|
||||
}
|
||||
if (owners.length !== 1) {
|
||||
throw new Error(`expected one reachable racing daemon, found ${owners.length}`)
|
||||
}
|
||||
const survivor = owners[0]
|
||||
const loser = racers.find((daemon) => daemon !== survivor)
|
||||
const survivorInode = statSync(socketPath).ino
|
||||
if (survivorInode === deadInode) {
|
||||
throw new Error('survivor did not replace the dead endpoint entry')
|
||||
}
|
||||
log('racing daemon published over the dead entry and is reachable')
|
||||
|
||||
await killAndWait(loser.child)
|
||||
if (!existsSync(socketPath) || statSync(socketPath).ino !== survivorInode) {
|
||||
throw new Error("losing racer's exit removed the survivor's endpoint")
|
||||
}
|
||||
if (!(await isDaemonReachable(socketPath, survivor.tokenPath, protocolVersion))) {
|
||||
throw new Error("survivor became unreachable after the losing racer's exit")
|
||||
}
|
||||
if (!existsSync(survivor.pidPath)) {
|
||||
throw new Error('survivor lost its ownership record')
|
||||
}
|
||||
if (existsSync(loser.pidPath)) {
|
||||
throw new Error('losing racer left its ownership record behind')
|
||||
}
|
||||
|
||||
// Why a second, non-racing phase: above, both racers are awaited to ready-or-exit before a
|
||||
// winner is identified, so the loser has usually already gone and killing it proves little.
|
||||
// With a known-live incumbent the interleaving is forced rather than hoped for: the newcomer
|
||||
// must find the endpoint occupied, refuse to take it, and damage nothing on its way out.
|
||||
const survivorInodeBeforeBlocked = statSync(socketPath).ino
|
||||
const blocked = bootDaemon('blocked', dir, socketPath)
|
||||
daemons.push(blocked)
|
||||
await blocked.ready.then(
|
||||
() => {
|
||||
throw new Error('a daemon published onto an endpoint a live daemon already owned')
|
||||
},
|
||||
() => {
|
||||
// Expected: it cannot publish onto a live owner's name, so it exits instead.
|
||||
}
|
||||
)
|
||||
// Why pin the code: the launcher keys adoption of a live incumbent on exactly this exit
|
||||
// code, so a silent change to it would strand a concurrently starting app on local
|
||||
// non-persistent terminals with nothing failing.
|
||||
const blockedExit = await killAndWait(blocked.child)
|
||||
if (blockedExit !== DAEMON_EXIT_ENDPOINT_OCCUPIED) {
|
||||
throw new Error(
|
||||
`a daemon that lost the endpoint exited ${blockedExit}, not ${DAEMON_EXIT_ENDPOINT_OCCUPIED}`
|
||||
)
|
||||
}
|
||||
if (!existsSync(socketPath) || statSync(socketPath).ino !== survivorInodeBeforeBlocked) {
|
||||
throw new Error("a daemon that could not publish removed the live owner's endpoint")
|
||||
}
|
||||
if (!(await isDaemonReachable(socketPath, survivor.tokenPath, protocolVersion))) {
|
||||
throw new Error('live owner became unreachable after a newcomer failed to publish')
|
||||
}
|
||||
if (existsSync(blocked.pidPath)) {
|
||||
throw new Error('a daemon that could not publish left an ownership record behind')
|
||||
}
|
||||
log('a newcomer refused the live owner’s endpoint and left it intact')
|
||||
|
||||
log('PASS: the racing survivor remains reachable through the canonical endpoint')
|
||||
} finally {
|
||||
for (const daemon of [replaced, replacement]) {
|
||||
if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) {
|
||||
for (const daemon of daemons) {
|
||||
if (daemon.child.exitCode === null && daemon.child.signalCode === null) {
|
||||
try {
|
||||
daemon.child.kill('SIGKILL')
|
||||
} catch {
|
||||
|
||||
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,141 @@
|
||||
#!/usr/bin/env node
|
||||
// Run: node config/scripts/legacy-worker-recovery-persistence-benchmark.mjs
|
||||
import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, open, readFile, rename } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const runtimePath = join(repoRoot, 'src/main/runtime/orca-runtime.ts')
|
||||
const args = new Map(
|
||||
process.argv.slice(2).map((value, index, values) => [value, values[index + 1]])
|
||||
)
|
||||
const fixtureMiB = Number(args.get('--fixture-mib') ?? 24)
|
||||
const trials = Number(args.get('--trials') ?? 3)
|
||||
const jsonOutput = process.argv.includes('--json')
|
||||
|
||||
if (!Number.isInteger(fixtureMiB) || fixtureMiB < 1 || !Number.isInteger(trials) || trials < 1) {
|
||||
throw new Error('fixture-mib and trials must be positive integers')
|
||||
}
|
||||
|
||||
const runtimeSource = await readFile(runtimePath, 'utf8')
|
||||
const recoveryStart = runtimeSource.indexOf(
|
||||
'private async persistLegacyWorkerTerminalRecoveryBatch'
|
||||
)
|
||||
const recoveryEnd = runtimeSource.indexOf(
|
||||
'private reconcileMissingLegacyWorkerTerminal',
|
||||
recoveryStart
|
||||
)
|
||||
const recoverySource = runtimeSource.slice(recoveryStart, recoveryEnd)
|
||||
if (
|
||||
recoveryStart < 0 ||
|
||||
recoveryEnd < 0 ||
|
||||
!recoverySource.includes('await this.flushWorkspaceSessionOrThrowAsync()') ||
|
||||
recoverySource.includes('flushOrThrow()')
|
||||
) {
|
||||
throw new Error('recovery persistence implementation changed; update this benchmark')
|
||||
}
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-legacy-recovery-benchmark-'))
|
||||
const filler = 'x'.repeat(fixtureMiB * 1024 * 1024)
|
||||
|
||||
function payload(state) {
|
||||
return JSON.stringify({ state, filler })
|
||||
}
|
||||
|
||||
function writeDurableSync(path, body) {
|
||||
const tempPath = `${path}.sync.tmp`
|
||||
writeFileSync(tempPath, body)
|
||||
const fd = openSync(tempPath, 'r')
|
||||
try {
|
||||
fsyncSync(fd)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
renameSync(tempPath, path)
|
||||
}
|
||||
|
||||
async function writeDurableAsync(path, body) {
|
||||
const tempPath = `${path}.async.tmp`
|
||||
const handle = await open(tempPath, 'w')
|
||||
try {
|
||||
await handle.writeFile(body)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await rename(tempPath, path)
|
||||
}
|
||||
|
||||
async function measure(run) {
|
||||
let maxEventLoopDelayMs = 0
|
||||
let expected = performance.now() + 1
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
maxEventLoopDelayMs = Math.max(maxEventLoopDelayMs, now - expected)
|
||||
expected = now + 1
|
||||
}, 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const startedAt = performance.now()
|
||||
await run()
|
||||
const durationMs = performance.now() - startedAt
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
clearInterval(timer)
|
||||
return { durationMs, maxEventLoopDelayMs }
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
async function runLegacy(path) {
|
||||
const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true }
|
||||
state.fenced = true
|
||||
writeDurableSync(path, payload(state))
|
||||
state.surfacePresent = false
|
||||
writeDurableSync(path, payload(state))
|
||||
state.recoveryRecordPresent = false
|
||||
writeDurableSync(path, payload(state))
|
||||
}
|
||||
|
||||
async function runBatched(path) {
|
||||
const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true }
|
||||
state.fenced = true
|
||||
state.surfacePresent = false
|
||||
state.recoveryRecordPresent = false
|
||||
await writeDurableAsync(path, payload(state))
|
||||
}
|
||||
|
||||
try {
|
||||
const legacy = []
|
||||
const batched = []
|
||||
for (let index = 0; index < trials; index += 1) {
|
||||
legacy.push(await measure(() => runLegacy(join(root, `legacy-${index}.json`))))
|
||||
batched.push(await measure(() => runBatched(join(root, `batched-${index}.json`))))
|
||||
}
|
||||
const result = {
|
||||
benchmark: 'legacy-worker-recovery-persistence',
|
||||
fixtureMiB,
|
||||
trials,
|
||||
legacy: {
|
||||
durableWrites: 3,
|
||||
medianDurationMs: median(legacy.map((sample) => sample.durationMs)),
|
||||
medianMaxEventLoopDelayMs: median(legacy.map((sample) => sample.maxEventLoopDelayMs))
|
||||
},
|
||||
batchedAsync: {
|
||||
durableWrites: 1,
|
||||
medianDurationMs: median(batched.map((sample) => sample.durationMs)),
|
||||
medianMaxEventLoopDelayMs: median(batched.map((sample) => sample.maxEventLoopDelayMs))
|
||||
}
|
||||
}
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(result))
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Per-locale renderings that are machine-translation errors for a brand or product name, and get
|
||||
// reverted to the Latin original. Ordinary vocabulary that merely looks like a brand lives in
|
||||
// locale-generic-ui-terms.mjs and stays translated.
|
||||
export const BRAND_MISTRANSLATIONS = {
|
||||
ko: {
|
||||
Codex: ['사본', '코덱스'],
|
||||
Gemini: ['쌍둥이자리'],
|
||||
Claude: ['클로드'],
|
||||
Grok: ['그록'],
|
||||
Orca: ['오르카', '범고래'],
|
||||
Cursor: ['커서'],
|
||||
OpenCode: ['오픈코드'],
|
||||
OpenClaw: ['오픈클로'],
|
||||
OpenClaude: ['오픈클로드'],
|
||||
Antigravity: ['반중력'],
|
||||
Continue: ['계속하다'],
|
||||
Charm: ['매력'],
|
||||
Goose: ['거위'],
|
||||
Pi: ['파이'],
|
||||
'GitHub Copilot': ['GitHub 코파일럿', '코파일럿'],
|
||||
Git: ['힘내'],
|
||||
Discord: ['디스코드'],
|
||||
Linear: ['선형'],
|
||||
Agent: ['에이전트'],
|
||||
Agents: ['에이전트'],
|
||||
agent: ['에이전트'],
|
||||
agents: ['에이전트'],
|
||||
Commit: ['커밋'],
|
||||
Commits: ['커밋'],
|
||||
commit: ['커밋'],
|
||||
commits: ['커밋'],
|
||||
Markdown: ['마크다운', '가격 인하'],
|
||||
markdown: ['마크다운', '가격 인하'],
|
||||
Repo: ['저장소', '레포'],
|
||||
Repos: ['저장소', '레포'],
|
||||
repo: ['저장소', '레포'],
|
||||
repos: ['저장소', '레포'],
|
||||
Terminal: ['터미널'],
|
||||
Terminals: ['터미널'],
|
||||
terminal: ['터미널'],
|
||||
terminals: ['터미널']
|
||||
},
|
||||
zh: {
|
||||
Codex: ['法典'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['克洛德', '克劳德'],
|
||||
Grok: ['格罗克'],
|
||||
Orca: ['虎鲸', '逆戟鲸'],
|
||||
Cursor: ['光标'],
|
||||
OpenCode: ['开放代码'],
|
||||
OpenClaw: ['开爪'],
|
||||
OpenClaude: ['开放克劳德'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['继续'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['鹅'],
|
||||
Pi: ['圆周率'],
|
||||
Droid: ['机器人'],
|
||||
Kimi: ['基米'],
|
||||
// Why: zh-only — 登月计划 is the literal "moon landing programme", not Kimi's vendor. ja and
|
||||
// ko render this keyword their own way, so it must not go in the cross-locale preserve set.
|
||||
moonshot: ['登月计划'],
|
||||
'GitHub Copilot': ['GitHub 副驾驶', '副驾驶'],
|
||||
Bitbucket: ['位桶'],
|
||||
Linear: ['线性', '线形'],
|
||||
Jira: ['吉拉'],
|
||||
Tailscale: ['尾鳞', '尾鱗'],
|
||||
Agent: ['代理', '智能体'],
|
||||
Agents: ['代理', '智能体'],
|
||||
agent: ['代理', '智能体'],
|
||||
agents: ['代理', '智能体'],
|
||||
Commit: ['提交'],
|
||||
Commits: ['提交'],
|
||||
commit: ['提交'],
|
||||
commits: ['提交'],
|
||||
Markdown: ['降价'],
|
||||
markdown: ['降价'],
|
||||
Repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Terminal: ['终端', '端子'],
|
||||
Terminals: ['终端', '端子'],
|
||||
terminal: ['终端', '端子'],
|
||||
terminals: ['终端', '端子'],
|
||||
Bash: ['重击'],
|
||||
PowerShell: ['电源外壳'],
|
||||
REST: ['休息'],
|
||||
HEAD: ['头'],
|
||||
Swift: ['迅速'],
|
||||
Rust: ['锈'],
|
||||
'Claude Code': ['Claude·科德'],
|
||||
'Git AI Author': ['Git AI 作者']
|
||||
},
|
||||
ja: {
|
||||
Codex: ['法典', 'コーデックス'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['クロード'],
|
||||
Grok: ['グロック'],
|
||||
Orca: ['シャチ', '逆戟鲸', 'オルカ'],
|
||||
Cursor: ['カーソル'],
|
||||
OpenCode: ['オープンコード', 'オープン・コード'],
|
||||
OpenClaw: ['オープンクロー'],
|
||||
OpenClaude: ['オープンクロード'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['続ける', '続行'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['ガチョウ', '雁'],
|
||||
Pi: ['円周率'],
|
||||
Droid: ['ロボット', 'ドロイド'],
|
||||
'GitHub Copilot': ['GitHub コパイロット', 'コパイロット'],
|
||||
Discord: ['不和'],
|
||||
Linear: ['線形'],
|
||||
Agent: ['エージェント'],
|
||||
Agents: ['エージェント'],
|
||||
agent: ['エージェント'],
|
||||
agents: ['エージェント'],
|
||||
Commit: ['コミット'],
|
||||
Commits: ['コミット'],
|
||||
commit: ['コミット'],
|
||||
commits: ['コミット'],
|
||||
Markdown: ['マークダウン'],
|
||||
markdown: ['マークダウン'],
|
||||
Hermes: ['エルメス', 'ヘルメス'],
|
||||
PowerShell: ['パワーシェル'],
|
||||
Mermaid: ['マーメイド'],
|
||||
'Claude Code': ['Claude コード'],
|
||||
'VS Code': ['VS コード'],
|
||||
Repo: ['リポジトリ', 'リポ'],
|
||||
Repos: ['リポジトリ', 'リポ'],
|
||||
repo: ['リポジトリ', 'リポ'],
|
||||
repos: ['リポジトリ', 'リポ'],
|
||||
// 端子 is the zh mistranslation and never appears in ja; the ja one is 端末, which the phrase
|
||||
// fixes rewrite to ターミナル rather than back to Latin.
|
||||
Terminal: ['ターミナル'],
|
||||
Terminals: ['ターミナル'],
|
||||
terminal: ['ターミナル'],
|
||||
terminals: ['ターミナル']
|
||||
},
|
||||
es: {
|
||||
Codex: ['códice', 'Códice'],
|
||||
Gemini: ['Géminis'],
|
||||
Claude: ['claudia', 'Claudia'],
|
||||
Orca: ['orca', 'Orcas', 'orcas'],
|
||||
OpenCode: ['código abierto', 'Código abierto'],
|
||||
OpenClaude: ['Openclaude'],
|
||||
Antigravity: ['antigravedad', 'Antigravedad'],
|
||||
'GitHub Copilot': ['Copiloto de GitHub'],
|
||||
Discord: ['discordia'],
|
||||
Linear: ['lineal', 'Lineal'],
|
||||
Jira: ['jira'],
|
||||
Agent: ['Agente', 'agente'],
|
||||
Agents: ['Agentes', 'agentes'],
|
||||
agent: ['agente'],
|
||||
agents: ['agentes'],
|
||||
Commit: ['Confirmación', 'confirmación', 'Confirmar', 'Comprometerse'],
|
||||
Commits: ['Confirmaciones', 'confirmaciones', 'Compromisos', 'compromisos', 'Se compromete'],
|
||||
commit: ['confirmación', 'confirmar', 'comprometerse', 'compromiso'],
|
||||
commits: ['confirmaciones', 'compromisos'],
|
||||
Markdown: ['Reducción', 'reducción', 'Rebaja', 'rebaja', 'rebajas'],
|
||||
markdown: ['reducción', 'rebaja', 'rebajas'],
|
||||
Repo: ['Repositorio', 'repositorio'],
|
||||
Repos: ['Repositorios', 'repositorios'],
|
||||
repo: ['repositorio'],
|
||||
repos: ['repositorios']
|
||||
}
|
||||
}
|
||||
@@ -46,5 +46,16 @@ export const CJK_LATIN_SPACED_TERMS = [
|
||||
'PowerShell',
|
||||
'Git AI Author',
|
||||
'Token',
|
||||
'token'
|
||||
'token',
|
||||
'fast-forward',
|
||||
'Cookie',
|
||||
'Cookies',
|
||||
'upstream',
|
||||
'origin',
|
||||
'Hermes',
|
||||
'DuckDuckGo',
|
||||
'Mermaid',
|
||||
'VS Code',
|
||||
'IDE',
|
||||
'Git'
|
||||
]
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
// Japanese overrides for English values whose translation depends on the call site.
|
||||
export const JA_KEY_OVERRIDES = {
|
||||
// "on" is the toggle state here, but the preposition in the delete confirmation below.
|
||||
'auto.components.github.PRFilterSections.1e9b5244f2': { ja: 'オン' },
|
||||
'auto.components.settings.TerminalPane.29154326bb': { ja: 'オン' },
|
||||
'auto.components.automations.AutomationsPage.1b586f0e2b': { ja: 'の' },
|
||||
// Bare "Open" is the PR state elsewhere; these two are the action, as ko/zh/es render it.
|
||||
'auto.components.browser.pane.BrowserPane.756bfc25c9': { ja: '開く' },
|
||||
'auto.components.right.sidebar.checks.panel.content.7c1f0a2b11': { ja: '開く' }
|
||||
}
|
||||
@@ -1,9 +1,46 @@
|
||||
// Japanese phrase fixes from high-visibility UI audit rounds 1–4.
|
||||
// Why: keep locale-phrase-fixes.mjs under max-lines while preserving repair coverage.
|
||||
|
||||
const JA_SENTENCE_CHAR = '[-ヿ一-龯、。・「」()]'
|
||||
|
||||
// Two-word commands (`git commit`, `orca terminal`) must keep their second word in Latin.
|
||||
const COMMAND_HEAD = '(?<!\\b(?:git|gh|glab|orca|npm|pnpm|npx|yarn|docker|kubectl) )'
|
||||
|
||||
// #12113 stopped new damage; ~700 values still read "terminal を閉じる" and are healed here.
|
||||
// Anchored on adjacent Japanese, so `--agent`, agents.md and "Agent SDK" keep their spelling.
|
||||
const RELOCALIZED_GENERIC_TERMS = [
|
||||
['[Tt]erminals?', 'ターミナル', 'terminal'],
|
||||
['[Cc]ommits?', 'コミット', 'commit'],
|
||||
['[Rr]epos?', 'リポジトリ', 'repo'],
|
||||
// Agent stays Latin in ja; this only normalizes the case.
|
||||
['[Aa]gents?', 'Agent', 'agent'],
|
||||
['[Ww]orktrees?', 'ワークツリー', 'worktree']
|
||||
].flatMap(([latin, katakana, whenEnIncludes]) => [
|
||||
{
|
||||
// The trailing space only goes when Japanese follows, so 和欧間スペース survives before {{…}}.
|
||||
pattern: new RegExp(
|
||||
`(?<=${JA_SENTENCE_CHAR}) ?${COMMAND_HEAD}${latin}(?![-\\w.])(?! [A-Za-z])(?: (?=${JA_SENTENCE_CHAR}))?`,
|
||||
'g'
|
||||
),
|
||||
replacement: katakana,
|
||||
whenEnIncludes
|
||||
},
|
||||
{
|
||||
pattern: new RegExp(
|
||||
`(?<![-\\w])${COMMAND_HEAD}${latin}(?![-\\w.]) ?(?=${JA_SENTENCE_CHAR})`,
|
||||
'g'
|
||||
),
|
||||
replacement: katakana,
|
||||
whenEnIncludes
|
||||
}
|
||||
])
|
||||
|
||||
export const JA_PHRASE_FIXES = [
|
||||
...RELOCALIZED_GENERIC_TERMS,
|
||||
{ pattern: /エージェント/g, replacement: 'Agent', whenEnIncludes: 'agent' },
|
||||
{ pattern: /解雇/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
|
||||
{ pattern: /却下/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
|
||||
{ pattern: /代理人/g, replacement: 'エージェント', whenEnIncludes: 'agent' },
|
||||
{ pattern: /代理人/g, replacement: 'Agent', whenEnIncludes: 'agent' },
|
||||
{ pattern: /支店/g, replacement: 'ブランチ', whenEnIncludes: 'ranch' },
|
||||
{ pattern: /港(?!口)/g, replacement: 'ポート', whenEnIncludes: 'ort' },
|
||||
{ pattern: /会議/g, replacement: 'セッション', whenEnIncludes: 'session' },
|
||||
@@ -198,7 +235,7 @@ export const JA_PHRASE_FIXES = [
|
||||
{ pattern: /電話サイズ/g, replacement: 'スマートフォンサイズ', whenEnIncludes: 'phone' },
|
||||
{
|
||||
pattern: /コンピュータと電話/g,
|
||||
replacement: 'コンピューターとスマートフォン',
|
||||
replacement: 'コンピュータとスマートフォン',
|
||||
whenEnIncludes: 'phone'
|
||||
},
|
||||
{ pattern: /前の問題/g, replacement: 'フロントマター', whenEnIncludes: 'Front Matter' },
|
||||
@@ -235,11 +272,40 @@ export const JA_PHRASE_FIXES = [
|
||||
whenEnIncludes: 'More PR actions'
|
||||
},
|
||||
{ pattern: /アクション/g, replacement: '操作', whenEnIncludes: 'action' },
|
||||
{ pattern: /を選択してください/g, replacement: 'を選択', whenEnIncludes: 'Select' },
|
||||
{ pattern: /を選択してください/g, replacement: 'を選択', whenEnIncludes: 'Choose' },
|
||||
{ pattern: /入力してください/g, replacement: '入力', whenEnIncludes: 'Enter' },
|
||||
{ pattern: /追加してください/g, replacement: '追加', whenEnIncludes: 'Add' },
|
||||
{ pattern: /試してください/g, replacement: '試す', whenEnIncludes: 'Try' },
|
||||
// Orca's host covers SSH hosts and this computer; skipped where the English also says server.
|
||||
{
|
||||
pattern: /(ランタイム)?サーバー/g,
|
||||
replacement: 'ホスト',
|
||||
whenEnMatches: /^(?![\s\S]*\bservers?\b)[\s\S]*\bhosts?\b/i
|
||||
},
|
||||
{ pattern: /上流/g, replacement: 'upstream', whenEnIncludes: 'upstream' },
|
||||
{ pattern: /起源/g, replacement: 'origin', whenEnIncludes: 'origin' },
|
||||
{ pattern: /段階的な変更/g, replacement: 'ステージ済みの変更', whenEnIncludes: 'staged' },
|
||||
{ pattern: /紛争/g, replacement: '競合', whenEnIncludes: 'conflict' },
|
||||
{ pattern: /資格情報/g, replacement: '認証情報', whenEnIncludes: 'credential' },
|
||||
{ pattern: /未知/g, replacement: '不明', whenEnIncludes: 'unknown' },
|
||||
{ pattern: /クッキー/g, replacement: 'Cookie', whenEnIncludes: 'cookie' },
|
||||
{ pattern: /プロフィール/g, replacement: 'プロファイル', whenEnIncludes: 'profile' },
|
||||
{ pattern: /(?<![ンプリ])ロード中/g, replacement: '読み込み中', whenEnIncludes: 'load' },
|
||||
{
|
||||
pattern: /(?<![ンプリ])ロードしています/g,
|
||||
replacement: '読み込んでいます',
|
||||
whenEnIncludes: 'load'
|
||||
},
|
||||
{ pattern: /構成されて/g, replacement: '設定されて', whenEnIncludes: 'configur' },
|
||||
{ pattern: /第一方/g, replacement: 'ファーストパーティ', whenEnIncludes: 'first-party' },
|
||||
{ pattern: /早送り/g, replacement: 'fast-forward', whenEnIncludes: 'fast-forward' },
|
||||
{ pattern: /ファストフォワード/g, replacement: 'fast-forward', whenEnIncludes: 'fast-forward' },
|
||||
{ pattern: /([-ヿ一-龯])\.\.\./g, replacement: '$1…', whenEnMatches: /./ },
|
||||
{ pattern: /([゠-ヿ]) (?=[゠-ヿ])/g, replacement: '$1', whenEnMatches: /./ },
|
||||
{ pattern: /サーバ(?!ー)/g, replacement: 'サーバー', whenEnMatches: /./ },
|
||||
{ pattern: /フォルダ(?!ー)/g, replacement: 'フォルダー', whenEnMatches: /./ },
|
||||
{ pattern: /エディタ(?!ー)/g, replacement: 'エディター', whenEnMatches: /./ },
|
||||
{ pattern: /レンダラ(?!ー)/g, replacement: 'レンダラー', whenEnMatches: /./ },
|
||||
{ pattern: /ブラウザー/g, replacement: 'ブラウザ', whenEnMatches: /./ },
|
||||
{ pattern: /エミュレーター/g, replacement: 'エミュレータ', whenEnMatches: /./ },
|
||||
{ pattern: /コンピューター/g, replacement: 'コンピュータ', whenEnMatches: /./ },
|
||||
{ pattern: /インターフェイス/g, replacement: 'インターフェース', whenEnMatches: /./ },
|
||||
// Why: JP engineers use "Issue" in Latin, not katakana. Runs last so all *→イシュー fixes above normalize to Issue.
|
||||
{ pattern: /イシュー/g, replacement: 'Issue', whenEnIncludes: 'issue' }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// One rendering per English string. 215 sources carried two or more Japanese forms; picks follow
|
||||
// the catalog majority — action labels drop する, completion notices use 〜しました, status labels
|
||||
// are 体言, and デフォルト / フィルター / スコープ / ディストリビューション / 並べ替え win their pairs.
|
||||
export const JA_UNIFIED_VALUES = {
|
||||
"Can't reach {{value0}}": '{{value0}} に接続できません',
|
||||
'Copy Address': 'アドレスをコピー',
|
||||
'Open Externally': '外部で開く',
|
||||
'Show Orca Mobile Button': 'Orca Mobile ボタンを表示',
|
||||
Paste: '貼り付け',
|
||||
'WSL default': 'WSL のデフォルト',
|
||||
'Unable to open the workspace attached to this issue.':
|
||||
'この Issue にリンクされたワークスペースを開けませんでした。',
|
||||
'Failed to connect to project.': 'プロジェクトへの接続に失敗しました。',
|
||||
'Dev-only tools for exercising UI states.': 'UI の状態を確認するための開発者専用ツール。',
|
||||
'Start new workspace': '新規ワークスペースを開始',
|
||||
'Open workspace attached to issue': 'Issue にリンクされたワークスペースを開く',
|
||||
'No one assigned': '担当者なし',
|
||||
'Edit assignees': '担当者を編集',
|
||||
'Edit labels on GitHub': 'GitHub でラベルを編集',
|
||||
'check #': 'チェック #',
|
||||
Started: '開始済み',
|
||||
'Status:': 'ステータス:',
|
||||
'Loading check details…': 'チェックの詳細を読み込んでいます…',
|
||||
'No description provided.': '説明はありません。',
|
||||
'Review comment added.': 'レビューコメントを追加しました。',
|
||||
Copied: 'コピーしました',
|
||||
'No activity yet.': 'まだアクティビティはありません。',
|
||||
Merge: 'マージ',
|
||||
'Create workspace': 'ワークスペースを作成',
|
||||
'Loading labels': 'ラベルを読み込んでいます',
|
||||
Hide: '非表示',
|
||||
Resolve: '解決',
|
||||
'Start workspace': 'ワークスペースを開始',
|
||||
Unassigned: '未割り当て',
|
||||
'Copy prompt': 'プロンプトをコピー',
|
||||
'Failed to copy {{value0}}': '{{value0}} のコピーに失敗しました',
|
||||
'{{value0}} copied': '{{value0}} をコピーしました',
|
||||
'Add project': 'プロジェクトを追加',
|
||||
'Cloning...': 'クローン作成中…',
|
||||
'Add SSH host': 'SSH ホストを追加',
|
||||
Later: '後で',
|
||||
"Don't ask again": '今後表示しない',
|
||||
'Atlassian account settings': 'Atlassian のアカウント設定',
|
||||
'Create a token in': 'でトークンを作成します',
|
||||
'Atlassian API token': 'Atlassian API トークン',
|
||||
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.':
|
||||
'Jira Cloud のサイト URL、Atlassian のメールアドレス、API トークンを使って Issue を閲覧します。',
|
||||
'Connect Jira site': 'Jira サイトに接続',
|
||||
Updated: '更新済み',
|
||||
Owner: 'オーナー',
|
||||
'Connect Jira': 'Jira に接続',
|
||||
'Change the query or clear it.': '検索条件を変更するか、クリアしてください。',
|
||||
'No matching GitHub work': '一致する GitHub の作業はありません',
|
||||
'No GitLab work matches this filter.': 'このフィルターに一致する GitLab の作業はありません。',
|
||||
'No GitLab MRs match this filter.': 'このフィルターに一致する GitLab の MR はありません。',
|
||||
'No GitLab issues match this filter.': 'このフィルターに一致する GitLab の Issue はありません。',
|
||||
Resume: '再開',
|
||||
github: 'GitHub',
|
||||
'Could not build launch command for {{value0}}.':
|
||||
'{{value0}} の起動コマンドを作成できませんでした。',
|
||||
Update: '更新',
|
||||
'Re-check': '再確認',
|
||||
'Checks passed': 'チェックに合格しました',
|
||||
'Copy command': 'コマンドをコピー',
|
||||
Pinned: 'ピン留め済み',
|
||||
'Remove {{value0}} filter': '{{value0}} フィルターを解除',
|
||||
selected: '選択中',
|
||||
'Remove {{value0}}': '{{value0}} を削除',
|
||||
'Last active': '最終アクティブ',
|
||||
'Unpushed commits': '未プッシュのコミット',
|
||||
Ignored: '無視済み',
|
||||
Sort: '並べ替え',
|
||||
Context: 'コンテキスト',
|
||||
'Delete {{value0}}': '{{value0}} を削除',
|
||||
Commits: 'コミット',
|
||||
'Expand details': '詳細を展開',
|
||||
'Uncommitted changes': '未コミットの変更',
|
||||
'No projects match your search.': '検索条件に一致するプロジェクトはありません。',
|
||||
Scope: 'スコープ',
|
||||
'Context copied': 'コンテキストをコピーしました',
|
||||
'Worktree not ready — try again in a moment.':
|
||||
'ワークツリーが準備できていません — しばらくしてから再試行してください。',
|
||||
'Open a workspace before resuming a session.':
|
||||
'セッションを再開する前に、ワークスペースを開いてください。',
|
||||
Run: '実行',
|
||||
'ios simulator': 'iOS シミュレータ',
|
||||
'Kill session {{value0}}': 'セッション {{value0}} を強制終了',
|
||||
Disconnected: '切断済み',
|
||||
'OpenCode Go Usage': 'OpenCode Go の使用量',
|
||||
'Switch to': '切り替え先',
|
||||
'Update failed': '更新に失敗しました',
|
||||
cancelling: 'キャンセル中',
|
||||
Unavailable: '利用不可',
|
||||
'Usage unavailable': '使用量を取得できません',
|
||||
Usage: '使用量',
|
||||
Bundled: '同梱',
|
||||
'Update available': '更新があります',
|
||||
Skipped: 'スキップ済み',
|
||||
'1 update available': '1 件の更新があります',
|
||||
'{{value0}} updates available': '{{value0}} 件の更新があります',
|
||||
Stop: '停止',
|
||||
'Up to date': '最新',
|
||||
'Host path': 'ホストパス',
|
||||
'Open project on SSH host': 'SSH ホスト上のプロジェクトを開く',
|
||||
'Clone from URL': 'URL からクローン',
|
||||
more: '件以上',
|
||||
'Keep hidden': '非表示のままにする',
|
||||
Manual: '手動',
|
||||
Automation: 'オートメーション',
|
||||
Notes: 'ノート',
|
||||
'Board settings': 'ボード設定',
|
||||
'Project on SSH host': 'SSH ホスト上のプロジェクト',
|
||||
'Created by automation': 'オートメーションによって作成',
|
||||
'Open in Orca': 'Orca で開く',
|
||||
'Failed to copy link': 'リンクのコピーに失敗しました',
|
||||
'Delete Worktree': 'ワークツリーを削除',
|
||||
rename: '名前変更',
|
||||
'Create workspace for {{value0}}': '{{value0}} のワークスペースを作成',
|
||||
'Browse folder': 'フォルダーを参照',
|
||||
'{{value0}} failed': '{{value0}} 件失敗',
|
||||
Neutral: '中立',
|
||||
'Set up': 'セットアップ',
|
||||
'Add a persistent machine you can log into over SSH.':
|
||||
'SSH でログインできる永続的なマシンを追加します。',
|
||||
'on the server and paste the printed pairing URL.':
|
||||
'をサーバーで実行し、出力されたペアリング URL を貼り付けます。',
|
||||
'Keep Local Main Up to Date': 'ローカル main を最新の状態に保つ',
|
||||
Saved: '保存済み',
|
||||
Replace: '置換',
|
||||
'Account scope': 'アカウントスコープ',
|
||||
'Agent Permissions': 'Agent の権限',
|
||||
'Show the Orca Mobile button at the top of the left sidebar.':
|
||||
'左サイドバー上部に Orca Mobile ボタンを表示します。',
|
||||
'Enable Orca CLI': 'Orca CLI を有効化',
|
||||
'Registered the Orca CLI in PATH.': 'Orca CLI を PATH に登録しました。',
|
||||
'Failed to load CLI status.': 'CLI ステータスの読み込みに失敗しました。',
|
||||
'Fix PATH': 'PATH を修正',
|
||||
installed: 'インストール済み',
|
||||
cli: 'CLI',
|
||||
'Opened macOS Privacy & Security': 'macOS のプライバシーとセキュリティを開きました',
|
||||
'Could not request permission': '権限をリクエストできませんでした',
|
||||
Shown: '表示中',
|
||||
'Clear all': 'すべてクリア',
|
||||
'Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.':
|
||||
'接続された各 Linear ワークスペースには、アクティブなランタイムが保存したキーが 1 つあります。フルアクセスキーはキー所有者がアクセスできるすべてのチームを対象にできます。制限付きキーはいつでも置き換えられます。',
|
||||
'Add workspace access': 'ワークスペースへのアクセスを追加',
|
||||
'Learn more': '詳しく見る',
|
||||
'Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.':
|
||||
'Gitea の認証情報は設定されていますが、認証できませんでした。トークン、API ベース URL、リポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
|
||||
'Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.':
|
||||
'Azure DevOps の認証情報は設定されていますが、認証できませんでした。トークン、API ベース URL、リポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
|
||||
'Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.':
|
||||
'Bitbucket の認証情報は設定されていますが、認証できませんでした。トークンとリポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
|
||||
'The GitLab CLI is installed but not authenticated. Run this command in a terminal:':
|
||||
'GitLab CLI はインストールされていますが、認証されていません。ターミナルで次のコマンドを実行してください:',
|
||||
'Install GitLab CLI': 'GitLab CLI をインストール',
|
||||
'CLI.': 'CLI。',
|
||||
'The GitHub CLI is installed but not authenticated. Run this command in a terminal:':
|
||||
'GitHub CLI はインストールされていますが、認証されていません。ターミナルで次のコマンドを実行してください:',
|
||||
'Install GitHub CLI': 'GitHub CLI をインストール',
|
||||
'Disconnect {{value0}}': '{{value0}} の接続を解除',
|
||||
'Add Linear access to browse and link issues.':
|
||||
'Linear アクセスを追加して、Issue を閲覧およびリンクします。',
|
||||
'Pull requests and build statuses via Azure DevOps REST API tokens.':
|
||||
'Azure DevOps REST API トークン経由の PR とビルドステータス。',
|
||||
'Pull requests and build statuses via Bitbucket Cloud API tokens.':
|
||||
'Bitbucket Cloud API トークン経由の PR とビルドステータス。',
|
||||
'Reload from Disk': 'ディスクから再読み込み',
|
||||
'Refresh network interfaces': 'ネットワークインターフェースを更新',
|
||||
'CLI arguments': 'CLI 引数',
|
||||
Created: '作成日時',
|
||||
Switch: '切り替え',
|
||||
'Status unavailable': 'ステータスを取得できません',
|
||||
'Checking…': '確認中…',
|
||||
'Updating servers…': 'サーバーを更新しています…',
|
||||
'{{value0}} shortcut': '{{value0}} のショートカット',
|
||||
'Connect {{provider}}': '{{provider}} に接続',
|
||||
'Try again': '再試行',
|
||||
'Windows default': 'Windows のデフォルト',
|
||||
'Loading distributions': 'ディストリビューションを読み込んでいます',
|
||||
'WSL Distribution': 'WSL ディストリビューション',
|
||||
'Default Shell': 'デフォルトのシェル',
|
||||
'Right-click pastes the clipboard. Ctrl+right-click opens the context menu.':
|
||||
'右クリックでクリップボードを貼り付けます。Ctrl+右クリックでコンテキストメニューを開きます。',
|
||||
credentials: '認証情報',
|
||||
display: 'ディスプレイ',
|
||||
setup: 'セットアップ',
|
||||
note: 'メモ',
|
||||
update: '更新',
|
||||
'default branch': 'デフォルトブランチ',
|
||||
target: '対象',
|
||||
'Client default': 'クライアントのデフォルト',
|
||||
'Select distro': 'ディストリビューションを選択',
|
||||
'Sign out': 'サインアウト',
|
||||
'Available on': 'で利用可能',
|
||||
'Update this server manually once to enable remote updates.':
|
||||
'リモート更新を有効にするには、このサーバーを一度手動で更新してください。',
|
||||
Official: '公式',
|
||||
'Open on {{value0}}': '{{value0}} で開く',
|
||||
'Create {{value0}}': '{{value0}} を作成',
|
||||
'{{value0}} #{{value1}} is already open': '{{value0}} #{{value1}} はすでに開いています',
|
||||
'Copy Details': '詳細をコピー',
|
||||
'Clear Notes': 'ノートをクリア',
|
||||
'Fix with AI': 'AI で修正',
|
||||
'AI Fix': 'AI 修正',
|
||||
'Committed on Branch': 'コミット先のブランチ',
|
||||
Changes: '変更点',
|
||||
'Filter files by name': 'ファイル名でフィルター',
|
||||
'Choose the agent and edit the full command input before launch.':
|
||||
'起動前に Agent を選択し、コマンド入力全体を編集してください。',
|
||||
'Unable to resolve the workspace connection.': 'ワークスペースの接続を解決できません。',
|
||||
'Branch has unpushed commits': 'ブランチに未プッシュのコミットがあります',
|
||||
'No enabled AI agents. Configure agents in Settings.':
|
||||
'有効な AI Agent がありません。[設定] で Agent を構成してください。',
|
||||
'Saved AI agent is unavailable. Use Customize launch to choose another agent.':
|
||||
'保存された AI Agent は使用できません。[起動のカスタマイズ] から別の Agent を選択してください。',
|
||||
'Commit failure prompt is empty. Update Source Control AI settings.':
|
||||
'コミット失敗プロンプトが空です。ソース管理の AI 設定を更新してください。',
|
||||
'Create PR': 'PR を作成',
|
||||
'{{value0}} msgs': '{{value0}} 件のメッセージ',
|
||||
'No projects/folders match your search.': '検索に一致するプロジェクト/フォルダーはありません。',
|
||||
Keep: '保持',
|
||||
'Skill command copied and inserted below for review.':
|
||||
'スキルコマンドをコピーし、確認用に以下へ挿入しました。',
|
||||
'Try it out': '試してみる',
|
||||
'1 pending': '保留中 1 件',
|
||||
'Zoom in': 'ズームイン',
|
||||
'Zoom out': 'ズームアウト',
|
||||
Undo: '元に戻す',
|
||||
'{{value0}} failing': '{{value0}} 件失敗',
|
||||
'{{value0}} pending': '{{value0}} 件保留中',
|
||||
'Draft review': '下書きレビュー',
|
||||
'Switch profile': 'プロファイルを切り替え',
|
||||
'Draw on the page, then copy the markup to paste into your agent.':
|
||||
'ページに描き込み、マークアップをコピーして Agent に貼り付けます。',
|
||||
// Grab mode picks a page element and hands it to the AI; 掴む / グラブモード were literal.
|
||||
Grab: '取得',
|
||||
'Grab mode is unavailable in the web client.':
|
||||
'要素の取得モードは Web クライアントでは使用できません。',
|
||||
agent: 'Agent',
|
||||
'{{value0}} workspace{{value1}} connected':
|
||||
'{{value0}} ワークスペース{{value1}} が接続されました',
|
||||
Plugins: 'プラグイン',
|
||||
'Orca will continue to work, but commands such as orca status, orca terminal, and orchestration are unavailable for this session.\n\n{{guidance}}\n\nCause: {{cause}}':
|
||||
'Orca は引き続き使用できますが、このセッションでは orca status や orca terminal などのコマンドとオーケストレーションを利用できません。\n\n{{guidance}}\n\n原因: {{cause}}'
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
import { JA_UNIFIED_VALUES } from './locale-ja-unified-values.mjs'
|
||||
|
||||
// Japanese value overrides from high-visibility UI audit rounds 1–4.
|
||||
// Why: keep locale-value-overrides.mjs under max-lines while preserving exact-match repairs.
|
||||
export const JA_VALUE_OVERRIDES = {
|
||||
const JA_AUDIT_OVERRIDES = {
|
||||
'Retry loading presets': 'プリセットの読み込みを再試行',
|
||||
'Retry Download': 'ダウンロードを再試行',
|
||||
'Enter a reviewer': 'レビュアーを入力',
|
||||
'Choose folder': 'フォルダーを選択',
|
||||
'Choose pet': 'ペットを選択',
|
||||
'Choose a sound': 'サウンドを選択',
|
||||
'Pick your default agent': 'デフォルトのエージェントを選択',
|
||||
'Pick your default agent': 'デフォルトの Agent を選択',
|
||||
'Complete {{artifact_url}}': '{{artifact_url}} を完了',
|
||||
'Control terminals and agents from your phone.':
|
||||
'スマートフォンからターミナルとエージェントを操作',
|
||||
'スマートフォンからターミナルと Agent を操作します。',
|
||||
'Add reviewer': 'レビュアーを追加',
|
||||
Reviewer: 'レビュアー',
|
||||
Reviewers: 'レビュアー',
|
||||
'Fix broken checks': '失敗したチェックを修正',
|
||||
'No broken checks to fix.': '修正が必要な失敗したチェックはありません',
|
||||
'No broken checks to fix.': '修正が必要なチェックはありません。',
|
||||
'Started an AI agent for the broken checks.':
|
||||
'失敗したチェックに対して AI エージェントを開始しました。',
|
||||
'失敗したチェックに対して AI Agent を開始しました。',
|
||||
'Failed to start an AI agent for the broken checks: {{value0}}':
|
||||
'失敗したチェックのため AI エージェントを開始できませんでした: {{value0}}',
|
||||
'失敗したチェックのため AI Agent を開始できませんでした: {{value0}}',
|
||||
'Checks unavailable': 'チェックは利用できません',
|
||||
'Checks pending': '保留中のチェック',
|
||||
checks: 'チェック',
|
||||
@@ -92,7 +94,7 @@ export const JA_VALUE_OVERRIDES = {
|
||||
Launch: '起動',
|
||||
'Launch:': '起動:',
|
||||
'Launch plan': '起動プラン',
|
||||
'Launch agent': 'エージェントを起動',
|
||||
'Launch agent': 'Agent を起動',
|
||||
'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動',
|
||||
Play: '再生',
|
||||
Action: '操作',
|
||||
@@ -148,8 +150,8 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Select a workspace to browse files': 'ファイルを参照するワークスペースを選択',
|
||||
'Select a workspace to search': '検索するワークスペースを選択',
|
||||
'Select a workspace to view changes': '変更を表示するワークスペースを選択',
|
||||
'Choose agent to fix commit failure': 'コミット失敗を修正するエージェントを選択',
|
||||
'Choose an agent for this commit failure': 'このコミット失敗に対するエージェントを選択',
|
||||
'Choose agent to fix commit failure': 'コミット失敗を修正する Agent を選択',
|
||||
'Choose an agent for this commit failure': 'このコミット失敗に対する Agent を選択',
|
||||
'Enter a commit message to commit': 'コミットメッセージを入力してコミット',
|
||||
'Choose parent folder...': '親フォルダーを選択...',
|
||||
'Check for stuck work, stale generated files, failing validation, and anything that needs human attention. Report only actionable issues.':
|
||||
@@ -234,5 +236,313 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Cursor Opacity': 'カーソルの不透明度',
|
||||
'Cursor Shape': 'カーソル形状',
|
||||
'Blinking Cursor': 'カーソルの点滅',
|
||||
'Terminal Cursor': 'Terminal カーソル'
|
||||
'Terminal Cursor': 'ターミナルのカーソル',
|
||||
'Local folders and Git repositories': 'ローカルフォルダーと Git リポジトリ',
|
||||
'Started an AI agent for the conflicts.': '競合の解決のために AI Agent を開始しました。',
|
||||
'Previous conflict': '前の競合',
|
||||
'1 commit ahead of {{value0}}': '{{value0}} より 1 コミット進んでいます',
|
||||
'1 commit behind {{value0}}': '{{value0}} より 1 コミット遅れています',
|
||||
'3 commits ahead': '3 コミット進んでいます',
|
||||
'Fast-forwarded {{branch}} by 1 commit.': '{{branch}} を 1 コミット分 fast-forward しました。',
|
||||
'e.g. cloudflared access ssh --hostname %h': '例: cloudflared access ssh --hostname %h',
|
||||
'Git is not installed': 'Git がインストールされていません',
|
||||
'CLI not installed': 'CLI がインストールされていません',
|
||||
'Locate SDK folder': 'SDK フォルダーを選択',
|
||||
'Not found. Install Android Studio, then create a Virtual Device.':
|
||||
'見つかりません。Android Studio をインストールしてから、仮想デバイスを作成してください。',
|
||||
'ANSI blue color': 'ANSI青色',
|
||||
'MR !{{value0}}': 'MR !{{value0}}',
|
||||
mrs: 'MR',
|
||||
'No human comments.': 'ユーザーによるコメントはありません。',
|
||||
'Any state': 'すべての状態',
|
||||
State: '状態',
|
||||
Health: '健全性',
|
||||
Ordering: '並べ替え',
|
||||
Start: '開始',
|
||||
'Local {{value0}} is behind {{value1}}': 'ローカル {{value0}} は {{value1}} より遅れています',
|
||||
'behind main': 'main より遅れています',
|
||||
'behind (base commit:': '遅れ (ベースコミット:',
|
||||
'No pending todos. You’re all caught up!': '保留中の ToDo はありません。すべて完了しています!',
|
||||
High: '高',
|
||||
Medium: '中',
|
||||
Low: '低',
|
||||
'Split Terminal Down': 'ターミナルを下に分割',
|
||||
'Split Terminal Right': 'ターミナルを右に分割',
|
||||
'Split Up': '上に分割',
|
||||
'Split Down': '下に分割',
|
||||
"Install the Browser Use skill so agents can operate Orca's browser.":
|
||||
'Browser Use スキルをインストールすると、 Agent が Orca のブラウザを操作できるようになります。',
|
||||
'bold ·': '太字 ·',
|
||||
'Duplicate of another issue in this repository': 'このリポジトリ内の別の Issue と重複',
|
||||
'Choose a different issue.': '別の Issue を選択してください。',
|
||||
'No matching issues loaded.': '一致する Issue がありません。',
|
||||
'Duplicate of another issue': '別の Issue と重複',
|
||||
'mentioned this': 'がこの Issue をメンションしました',
|
||||
'closed this': 'がこの Issue をクローズしました',
|
||||
'reopened this': 'がこの Issue を再オープンしました',
|
||||
'moved this': 'がこの Issue を移動しました',
|
||||
'Failed to save setup startup behavior.': 'セットアップ時の起動設定を保存できませんでした。',
|
||||
'Failed to resolve MR base.': 'MR のベースブランチを特定できませんでした。',
|
||||
'Failed to copy context.': 'コンテキストのコピーに失敗しました。',
|
||||
// 3-way split: [this][query]["]. The closing side is already 」, so end the sentence here.
|
||||
'No settings found for "': '検索条件に一致する設定が見つかりませんでした:「',
|
||||
"Doesn't apply to agents where you've overridden launch arguments.":
|
||||
'起動引数を上書きしている Agent には適用されません。',
|
||||
'Check for Server Updates': 'サーバーの更新を確認',
|
||||
'All servers are up to date.': 'すべてのサーバーが最新です。',
|
||||
'Open worktree': 'ワークツリーを開く',
|
||||
'Update this server': 'このサーバーを更新',
|
||||
'Git repository': 'Git リポジトリ',
|
||||
'{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.':
|
||||
'{{host}} は保存済みの SSH ホストではなくなったため、このワークスペースは稼働中のホストに接続されていません。Orca から削除することのみ可能です。リモート上のファイルとブランチはそのまま残ります。',
|
||||
'The app shell could not finish rendering. Retry to remount it, or relaunch Orca if the error persists.':
|
||||
'アプリシェルのレンダリングを完了できませんでした。再マウントを試すか、エラーが解決しない場合は Orca を再起動してください。',
|
||||
'Workspaces are unavailable on a mobile-scope pairing. Reconnect using the browser access link from Settings → Runtime Environments → Share this Orca server.':
|
||||
'モバイル向けペアリングではワークスペースを使用できません。[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] から、ブラウザ用リンクで再接続してください。',
|
||||
'Change this later in Settings → Browser.': '後で [設定] → [ブラウザ] から変更できます。',
|
||||
'Orca Cloud sign-in is not configured': 'Orca Cloud へのサインインが設定されていません',
|
||||
'{{provider}} could not authenticate the credentials available in this environment. Check the {{provider}} login or environment token, then retry.':
|
||||
'{{provider}} はこの環境で利用できる認証情報で認証できませんでした。{{provider}} のログインまたは環境トークンを確認して、再試行してください。',
|
||||
'e.g. ollama run llama3.1 {prompt}': '例: ollama run llama3.1 {prompt}',
|
||||
'e.g. ollama run llama3.1 {{value0}}': '例: ollama run llama3.1 {{value0}}',
|
||||
'Connecting terminal...': 'ターミナルに接続中…',
|
||||
'Starring…': 'スターを付けています…',
|
||||
'self-hosted': 'セルフホスト',
|
||||
'not-authenticated': '未認証',
|
||||
'side-by-side': '左右に並べて',
|
||||
'Side-by-side': '左右に並べて',
|
||||
'Pull requests and commit statuses via the Gitea REST API.':
|
||||
'Gitea REST API 経由の PR とコミットステータス。',
|
||||
Key: 'キー',
|
||||
slug: 'スラッグ',
|
||||
// Rendered as [fragment][var name][fragment] by two cards that share these fragments.
|
||||
'Public repositories are detected from their git remote. Set':
|
||||
'パブリックリポジトリは git リモートから検出されます。設定:',
|
||||
'for private repositories, and set': '(プライベートリポジトリ用)。また、',
|
||||
'only when Orca cannot derive the API base URL from the git remote.':
|
||||
'は、Orca が git リモートから API のベース URL を取得できない場合のみ設定します。',
|
||||
'only when Orca cannot derive the API URL from the remote.':
|
||||
'は、Orca がリモートから API URL を取得できない場合のみ設定します。',
|
||||
Set: '設定:',
|
||||
'. Set': '。設定:',
|
||||
', or set': '、または',
|
||||
and: 'と',
|
||||
// Concatenated: the leading space is part of the string, not padding.
|
||||
' and ': ' と ',
|
||||
' in {{value0}}': '({{value0}})',
|
||||
' • Last scan error: {{value0}}': ' • 最終スキャンエラー: {{value0}}',
|
||||
' vs {{value0}}': ' vs {{value0}}',
|
||||
'When you create a workspace, Orca refreshes the remote base and safely fast-forwards your matching local branch, such as':
|
||||
'ワークスペースを作成すると、Orca はリモートベースを更新し、一致するローカルブランチを安全に fast-forward します。対象は',
|
||||
'. This keeps commands like': '。これにより、',
|
||||
'from comparing against stale history. Orca skips the update if that branch has uncommitted changes or local-only commits.':
|
||||
'などのコマンドが古い履歴と比較されるのを防ぎます。対象のブランチにコミットしていない変更やローカルのみのコミットがある場合、Orca は更新をスキップします。',
|
||||
'Supports skills, file paths, and built-in commands like':
|
||||
'スキル、ファイルパス、および次のような組み込みコマンドに対応しています:',
|
||||
' · {{value0}} external': ' · 外部 {{value0}} 件',
|
||||
'Use your current': '現在のアカウントを使用',
|
||||
Running: '実行中',
|
||||
'Back to runs': '実行一覧に戻る',
|
||||
Failed: '失敗',
|
||||
Unknown: '不明',
|
||||
'Identity file': '秘密鍵ファイル',
|
||||
'Identity File': '秘密鍵ファイル',
|
||||
key: 'キー',
|
||||
failing: '失敗',
|
||||
'Selected host': '選択したホスト',
|
||||
'Copied cleanup payload.': 'クリーンアップ用ペイロードをコピーしました。',
|
||||
'Jump to...': '移動先…',
|
||||
'Start work': '作業を開始',
|
||||
'Remove {{value0}} contained {{value1}} from Orca':
|
||||
'含まれている {{value1}} {{value0}} 件を Orca から削除',
|
||||
'People in {{orgName}} who can collaborate on Orca.':
|
||||
'{{orgName}} で Orca を共同利用できるメンバーです。',
|
||||
'People in your organization who can collaborate on Orca.':
|
||||
'組織内で Orca を共同利用できるメンバーです。',
|
||||
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.':
|
||||
'ソース管理のコミット、PR、ブランチ名、修正操作に使うアクションレシピを追加します。',
|
||||
'Repo fallback for text actions that select Custom command.':
|
||||
'「カスタムコマンド」を選択したテキスト操作に使う、リポジトリ単位のフォールバック。',
|
||||
'Recipe-created runtimes are workspace-owned. Clean up stale entries after crashes, failed creates, or manual recovery.':
|
||||
'レシピで作成されたランタイムはワークスペースに紐づいています。クラッシュ、作成失敗、手動リカバリの後に残った古いエントリをクリーンアップします。',
|
||||
'Failed to register the recipe-created project root on the runtime.':
|
||||
'レシピによって作成されたプロジェクトルートをランタイムに登録できませんでした。',
|
||||
'Update all {{value0}} servers': '{{value0}} 台のサーバーをすべて更新',
|
||||
'This QR code grants limited (mobile) access. To use the full web app, open the browser access link from Settings → Runtime Environments → Share this Orca server → New Link.':
|
||||
'この QR コードでは、モバイル向けの制限付きアクセスのみ利用できます。完全版の Web アプリを使用するには、[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] → [新規リンク] から、ブラウザ用アクセスリンクを開いてください。',
|
||||
'Loading skills...': 'スキルを読み込み中…',
|
||||
'Start agents on your tasks without leaving Orca': 'Orca から離れずに、タスクから Agent を開始',
|
||||
'Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}':
|
||||
'ワークスペースは {{value0}} から作成されましたが、{{value2}} のため Orca はローカル {{value1}} を fast-forward できませんでした',
|
||||
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.':
|
||||
'このワークスペースでは、ターミナル、ブラウザ、またはエディターのレンダリングに失敗しました。再マウントを試してください。',
|
||||
'The dashboard could not finish rendering. Retry to remount it, or reopen it.':
|
||||
'ダッシュボードのレンダリングを完了できませんでした。再マウントを試すか、開き直してください。',
|
||||
|
||||
'Review the prompt before starting an agent.':
|
||||
'Agent を開始する前に、プロンプトを確認してください。',
|
||||
'Use Add Project to enter a path on the selected host.':
|
||||
'[プロジェクトの追加] から、選択したホスト上のパスを入力します。',
|
||||
'Use repo-relative paths like packages/web or apps/api.':
|
||||
'packages/web や apps/api などのリポジトリ相対パスを使用します。',
|
||||
'orca.yaml first, then your local commands.': 'まず orca.yaml、次にローカルのコマンドの順です。',
|
||||
'Rename Orca-created branches from the initial agent task.':
|
||||
'Orca が作成したブランチの名前を、最初の Agent タスクに基づいて変更します。',
|
||||
'Dim files matched by .gitignore in the file explorer.':
|
||||
'ファイルエクスプローラーで、.gitignore に一致するファイルを薄く表示します。',
|
||||
'Command line Orca runs when a text recipe uses Custom command.':
|
||||
'テキストレシピで「カスタムコマンド」を使用したときに Orca が実行するコマンドライン。',
|
||||
'Let programs in the terminal copy to the system clipboard through OSC 52, including over SSH.':
|
||||
'ターミナル内のプログラムが、OSC 52 を使ってシステムのクリップボードにコピーできるようにします(SSH 経由を含む)。',
|
||||
'Choose which optional saved Codex account powers live quota reads.':
|
||||
'ライブクォータの取得に使用する、保存済みの Codex アカウント(任意)を選択します。',
|
||||
'This plugin has no worker process. Its instructional content can still cause actions when you or an agent use it. Review the instructions and commands below before enabling it.':
|
||||
'このプラグインにはワーカープロセスがありません。それでも、ユーザーや Agent が使用したときに、記載された手順が操作を引き起こす可能性があります。有効にする前に、以下の手順とコマンドを確認してください。',
|
||||
'This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.':
|
||||
'保存済みの SSH ホストとその認証情報を、このコンピュータから削除します。リモートのファイルは削除されません。',
|
||||
'Your new worktree is current, but local {{value0}} is {{value1}} {{value2}} behind. AI diffs may miss recent commits.':
|
||||
'新規ワークツリーは最新ですが、ローカル {{value0}} は {{value1}} {{value2}} 遅れています。AI の差分に最近のコミットが反映されない可能性があります。',
|
||||
'This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.':
|
||||
'マージのブロックが解除されるまでに、このチェックには GitHub 上での手動操作(例: ワークフロー実行の承認)が必要です。',
|
||||
'This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.':
|
||||
'未保存の編集がある状態で、このファイルがディスク上で変更されました。保存すると、ディスク上の最新の内容を上書きします。',
|
||||
'Local terminal reveal is unavailable while a remote runtime is active':
|
||||
'リモートランタイムがアクティブな間は、ローカルターミナルの表示は利用できません',
|
||||
'No quick commands saved.': '保存されているクイックコマンドはありません。',
|
||||
'Repository not in Orca': 'このリポジトリは Orca に登録されていません',
|
||||
'host folder not selected': 'ホストフォルダーが選択されていません',
|
||||
'Add a Git repository or folder that already exists on the selected host.':
|
||||
'選択したホストに既に存在する Git リポジトリまたはフォルダーを追加します。',
|
||||
'Add another project from the selected host.': '選択したホストから別のプロジェクトを追加します。',
|
||||
'Enter a host path for the clone destination.': 'クローン先のホスト上のパスを入力します。',
|
||||
'Use a host path to add projects from a remote host.':
|
||||
'ホスト上のパスを指定して、リモートホストからプロジェクトを追加します。',
|
||||
'Use Add Project to enter a host path.':
|
||||
'[プロジェクトの追加] から、ホスト上のパスを入力します。',
|
||||
'Opens as a project on this host · {{value0}}':
|
||||
'このホスト上のプロジェクトとして開きます · {{value0}}',
|
||||
'No project path is available on this host for attachments.':
|
||||
'このホスト上に、添付に使用できるプロジェクトパスがありません。',
|
||||
'Clears them from Orca only. Remote files, worktrees, and branches are left untouched.':
|
||||
'Orca からのみ削除します。リモートのファイル、ワークツリー、ブランチはそのまま残ります。',
|
||||
'Agent location': 'Agent の実行場所',
|
||||
upstream: 'upstream',
|
||||
Upstream: 'upstream',
|
||||
'Commit staged changes': 'ステージ済みの変更をコミット',
|
||||
'Generate the commit message from staged changes.':
|
||||
'ステージ済みの変更からコミットメッセージを生成します。',
|
||||
lan: 'LAN',
|
||||
deploy: 'deploy',
|
||||
'Connect this source to check for Hermes automations in the remote profile.':
|
||||
'このソースに接続すると、リモートプロファイル内の Hermes オートメーションを確認できます。',
|
||||
'Notes sent to active agent.': 'メモをアクティブな Agent に送信しました。',
|
||||
'Review before attaching. Captured page context may include visible site content.':
|
||||
'添付する前に確認してください。取得したページのコンテキストには、表示中のサイトの内容が含まれる場合があります。',
|
||||
'Agent took too long to start. The workspace is ready — paste the {{value0}} when the agent is idle.':
|
||||
'Agent の起動に時間がかかりすぎました。ワークスペースの準備はできています。 Agent がアイドル状態になったら {{value0}} を貼り付けてください。',
|
||||
'Install the Orca CLI before running agent skill setup.':
|
||||
'Agent スキルのセットアップを実行する前に、Orca CLI をインストールしてください。',
|
||||
'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.':
|
||||
'Orca を実行しているコンピュータのターミナルで grok を実行し、起動するまで待ってください。サインインを求められた場合は完了してから、使用状況を再取得してください。チャットメッセージを送る必要はありません。',
|
||||
'Choose an enabled agent before saving.': '保存する前に、有効な Agent を選択してください。',
|
||||
'Selected agent is disabled. Choose an enabled agent before creating.':
|
||||
'選択した Agent は無効です。作成する前に、有効な Agent を選択してください。',
|
||||
'Choose an agent before starting.': '開始する前に、 Agent を選択してください。',
|
||||
'Change this later from the project menu.': '後でプロジェクトメニューから変更できます。',
|
||||
"Orca works with every CLI agent. Choose the one you'll reach for most. Switch any time.":
|
||||
'Orca はすべての CLI Agent で動作します。最もよく使うものを選択してください。いつでも切り替えられます。',
|
||||
'Downloading paused': 'ダウンロード一時停止中',
|
||||
Canceled: 'キャンセル済み',
|
||||
'Thinking...': '思考中…',
|
||||
'No attached worktrees yet': '紐づくワークツリーはまだありません',
|
||||
'Started an AI agent for the commit failure.':
|
||||
'コミット失敗を修正するために AI Agent を開始しました。',
|
||||
'terminals until reset': 'リセットするまでターミナルを維持',
|
||||
'Failed to import repo icon': 'リポジトリアイコンのインポートに失敗しました',
|
||||
'Terminal Panes': 'ターミナルペイン',
|
||||
'Option composes special characters for your keyboard layout.':
|
||||
'Option キーで、キーボードレイアウトに応じた特殊文字を入力します。',
|
||||
'There are local terminals with running processes. Close the window anyway?':
|
||||
'プロセスが実行中のローカルターミナルがあります。このままウィンドウを閉じますか?',
|
||||
'Auto-generates a new name when you leave this text box empty.':
|
||||
'このテキストボックスを空のままにすると、名前が自動生成されます。',
|
||||
'Auto-name workspace from first agent message':
|
||||
'最初の Agent メッセージからワークスペース名を自動生成',
|
||||
'Controls the split divider line between panes in light mode.':
|
||||
'ライトモードでのペイン間の分割線を制御します。',
|
||||
'Controls the split divider line between panes in dark mode.':
|
||||
'ダークモードでのペイン間の分割線を制御します。',
|
||||
'Countdown timer showing time until prompt cache expires (Claude agents).':
|
||||
'プロンプトキャッシュが期限切れになるまでの時間を示すカウントダウンタイマー(Claude Agent )。',
|
||||
'Install and manage experimental Orca plugins.':
|
||||
'実験的機能の Orca プラグインをインストール・管理します。',
|
||||
"Resident memory held by Orca plus the processes under each worktree's terminals.":
|
||||
'Orca が保持する常駐メモリと、各ワークツリーのターミナル配下で実行中のプロセス。',
|
||||
'No local Claude, Codex, or OpenCode usage found yet. The overview will populate after the next agent session writes token logs.':
|
||||
'ローカルの Claude、Codex、OpenCode の使用状況はまだ見つかりません。次の Agent セッションがトークンログを書き込むと、概要に反映されます。',
|
||||
'Choose or add a project before creating a workspace.':
|
||||
'ワークスペースを作成する前に、プロジェクトを選択または追加してください。',
|
||||
'Choose a project to get started.': '開始するには、プロジェクトを選択してください。',
|
||||
'Please enter feedback before submitting.': '送信する前に、フィードバックを入力してください。',
|
||||
'Add a Git project under this folder to attach GitHub or GitLab tasks.':
|
||||
'GitHub または GitLab のタスクを関連付けるには、このフォルダー内に Git プロジェクトを追加してください。',
|
||||
'Enter a valid website URL.': '有効な Web サイトの URL を入力してください。',
|
||||
'Choose a WSL distro before projects can inherit WSL.':
|
||||
'プロジェクトが WSL を継承できるようにするには、先に WSL ディストリビューションを選択してください。',
|
||||
'Choose a different base branch before creating a {{value0}}.':
|
||||
'{{value0}} を作成する前に、別のベースブランチを選択してください。',
|
||||
'Optional. Orca can use your normal Codex login; add accounts only if you want quick switching in Orca.':
|
||||
'任意。Orca は通常の Codex ログインを使用できます。Orca 上ですばやく切り替えたい場合にのみ、アカウントを追加してください。',
|
||||
'Optional. Orca can use your normal Claude login; add accounts only if you want quick switching without moving chat sessions.':
|
||||
'任意。Orca は通常の Claude ログインを使用できます。チャットセッションを移さずにすばやく切り替えたい場合にのみ、アカウントを追加してください。',
|
||||
'Optional. Orca works with your existing provider logins; add accounts only if you want Orca to help switch between them.':
|
||||
'任意。Orca は既存のプロバイダーのログインと連携します。Orca に切り替えを任せたい場合にのみ、アカウントを追加してください。',
|
||||
'Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.':
|
||||
'既定ではこのコンピュータを使用します。対応するプロジェクト、ファイル、ターミナル、プロバイダーチェック、ブラウザ/モバイルの引き継ぎをそのサーバー経由で実行したい場合にのみ、保存済みサーバーを選択してください。',
|
||||
'Window Blur': 'ウィンドウのぼかし',
|
||||
'Medium section heading.': '中サイズのセクション見出し。',
|
||||
'macOS Option key': 'macOS の Option キー',
|
||||
'Failed to add folder on this host': 'このホスト上でのフォルダーの追加に失敗しました',
|
||||
'This workspace is locked by Git. Run git worktree unlock <worktree-path> from its repository, then retry deletion.':
|
||||
'このワークスペースは Git によってロックされています。リポジトリで git worktree unlock <worktree-path> を実行してから、削除を再試行してください。',
|
||||
'This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock <worktree-path> from its repository, then retry deletion.':
|
||||
'このワークスペースは Git によってロックされています。Git の報告: {{value0}}。リポジトリで git worktree unlock <worktree-path> を実行してから、削除を再試行してください。',
|
||||
'Permanently deletes the remote Git worktrees and their branches. Cannot be undone.':
|
||||
'リモートの Git ワークツリーとそのブランチを完全に削除します。この操作は取り消せません。',
|
||||
'Click a card to copy a prompt. Use these in a Linear-linked worktree after the skill is installed.':
|
||||
'カードをクリックするとプロンプトをコピーできます。スキルのインストール後、Linear にリンクしたワークツリーで使用してください。',
|
||||
'Ticket actions work best in a worktree created from Tasks so the issue stays linked as context.':
|
||||
'チケット操作は、タスクから作成したワークツリーで最も効果的です。Issue がコンテキストとしてリンクされたままになります。',
|
||||
'Choose a base branch.': 'ベースブランチを選択してください。',
|
||||
'Enter a {{value0}} title.': '{{value0}} のタイトルを入力してください。',
|
||||
'Enter a valid five-field cron before saving.':
|
||||
'保存する前に、有効な 5 フィールドの cron を入力してください。',
|
||||
'Choose an available workspace before saving.':
|
||||
'保存する前に、使用可能なワークスペースを選択してください。',
|
||||
'Enter a valid advanced schedule before saving.':
|
||||
'保存する前に、有効な詳細スケジュールを入力してください。',
|
||||
'Creating...': '作成中…',
|
||||
'Creating…': '作成中…',
|
||||
'Reopening...': '再オープン中…',
|
||||
'Thinking…': '思考中…',
|
||||
'Hide from sidebar': 'サイドバーから非表示',
|
||||
'Show in sidebar': 'サイドバーに表示',
|
||||
'Recent or tab strip.': '「最近」またはタブストリップ。',
|
||||
// Settings-search keywords: proper nouns keep their canonical spelling, and the rest were
|
||||
// translated in the wrong sense (windows→窓, gitignore→ギティ無視, component→成分).
|
||||
windows: 'Windows',
|
||||
'windows powershell': 'Windows PowerShell',
|
||||
'powershell 7': 'PowerShell 7',
|
||||
neovim: 'Neovim',
|
||||
hermes: 'Hermes',
|
||||
duckduckgo: 'DuckDuckGo',
|
||||
gitignore: 'gitignore',
|
||||
component: 'コンポーネント',
|
||||
compose: '特殊文字の入力',
|
||||
sidekick: 'Sidekick',
|
||||
'api token': 'API トークン',
|
||||
'compare base': '比較ベース',
|
||||
ahead: '進んでいる'
|
||||
}
|
||||
|
||||
export const JA_VALUE_OVERRIDES = { ...JA_AUDIT_OVERRIDES, ...JA_UNIFIED_VALUES }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs'
|
||||
import { JA_KEY_OVERRIDES } from './locale-ja-key-overrides.mjs'
|
||||
import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs'
|
||||
import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs'
|
||||
|
||||
@@ -11,6 +12,9 @@ export function mergeLocaleKeyOverrides(base) {
|
||||
// KO split overrides can share keys with zh/ja repairs; merge per locale.
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
for (const [key, overrides] of Object.entries(JA_KEY_OVERRIDES)) {
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) {
|
||||
merged[key] = { ...merged[key], ...overrides }
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'menu.showMobileButton': {
|
||||
ko: 'Orca 모바일 버튼 표시',
|
||||
zh: '显示 Orca Mobile 按钮',
|
||||
ja: 'Orca モバイル ボタンを表示'
|
||||
ja: 'Orca Mobile ボタンを表示'
|
||||
},
|
||||
'menu.toggleLeftSidebar': {
|
||||
ko: '왼쪽 사이드바 표시/숨기기',
|
||||
@@ -169,7 +169,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.tab.bar.TabBarCreateEntry.b27864279e': {
|
||||
ko: '에이전트 실행',
|
||||
zh: '启动代理',
|
||||
ja: 'エージェントを起動'
|
||||
ja: 'Agent を起動'
|
||||
},
|
||||
'auto.components.sidebar.SidebarNav.c39ab10000': {
|
||||
ko: 'Linear 작업 열기',
|
||||
@@ -343,7 +343,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'settings.appearance.statusBar.kimiToggleDescription': {
|
||||
ko: '활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.',
|
||||
zh: '显示当前工作区的 Kimi 订阅使用情况。',
|
||||
ja: 'Kimi サブスクリプション'
|
||||
ja: 'アクティブなワークスペースの Kimi サブスクリプション使用状況を表示します。'
|
||||
},
|
||||
'auto.components.mobile.MobileHero.cd4e5e816f': {
|
||||
ko: '주머니 속의 워크스페이스.',
|
||||
@@ -433,17 +433,17 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.settings.AgentsPane.9bccf48906': {
|
||||
ko: '에이전트 위치',
|
||||
zh: '代理位置',
|
||||
ja: 'エージェントの場所'
|
||||
ja: 'Agent の場所'
|
||||
},
|
||||
'auto.components.skills.SkillsPage.38e0951c3a': {
|
||||
ko: '에이전트 스킬',
|
||||
zh: '代理技能',
|
||||
ja: 'エージェントのスキル'
|
||||
ja: 'Agent のスキル'
|
||||
},
|
||||
'auto.components.sidebar.SidebarNav.e518f544b1': {
|
||||
ko: '감지된 에이전트 없음',
|
||||
zh: '未检测到代理',
|
||||
ja: 'エージェントが検出されません'
|
||||
ja: 'Agent が検出されません'
|
||||
},
|
||||
'auto.components.onboarding.OnboardingFlow.04ae28d8ca': {
|
||||
ko: '몇 시간 내내 보고 싶은 테마를 선택하세요.',
|
||||
@@ -478,7 +478,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.settings.ExperimentalPane.0277901cf7': {
|
||||
ko: '완료된 에이전트, 차단 질문, 읽지 않은 상태 및 작업 트리 생성 이벤트에 대한 스레드 작업 트리 피드가 있는 에이전트 항목을 왼쪽 사이드바에 추가합니다. 실험적 — 이벤트 모델과 UI가 변경될 수 있습니다.',
|
||||
zh: '将代理条目添加到左侧边栏,其中包含已完成代理、阻塞待办、未读状态和工作树创建事件的线程工作树提要。实验性——事件模型和 UI 可能会改变。',
|
||||
ja: '完了したエージェント、ブロック中の質問、未読状態、ワークツリー作成イベントのスレッドワークツリーフィード付きエージェント項目を左サイドバーに追加します。実験的 — イベントモデルと UI は変更される場合があります。'
|
||||
ja: '完了した Agent、ブロック中の質問、未読状態、ワークツリー作成イベントのスレッドワークツリーフィード付き Agent 項目を左サイドバーに追加します。実験的 — イベントモデルと UI は変更される場合があります。'
|
||||
},
|
||||
'auto.lib.fix.checks.agent.launch.9f00d7df0c': {
|
||||
ko: '검사 프롬프트가 비어 있습니다. 소스 제어 AI 설정을 업데이트하세요.',
|
||||
@@ -488,7 +488,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.TaskPage.d0e3c8f933': {
|
||||
ko: '일치하는 GitHub 작업이 없습니다',
|
||||
zh: '没有匹配的 GitHub 工作项',
|
||||
ja: '一致する GitHub 作業がありません'
|
||||
ja: '一致する GitHub の作業はありません'
|
||||
},
|
||||
'auto.components.TaskPage.2af3ab5c58': {
|
||||
ko: 'Linear에서 열 팀을 하나 선택하세요',
|
||||
@@ -513,7 +513,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.components.settings.IntegrationsPane.33ae9730a8': {
|
||||
ko: 'Linear 액세스를 추가해 이슈를 탐색하고 연결합니다.',
|
||||
zh: '添加 Linear 访问以浏览和链接议题。',
|
||||
ja: 'Linear アクセスを追加してイシューを参照し、リンクします。'
|
||||
ja: 'Linear アクセスを追加して、Issue を閲覧およびリンクします。'
|
||||
},
|
||||
'auto.components.GitHubItemDialog.3ab6ac0fc8': {
|
||||
ko: '선택한 GitHub 이슈 또는 PR을 미리보기하고 편집합니다.',
|
||||
|
||||
@@ -106,6 +106,9 @@ export const SEARCH_KEYWORD_OVERRIDES = {
|
||||
theme: '主题',
|
||||
zoom: '缩放',
|
||||
scale: '比例',
|
||||
// Why: 用法 is "how to use". Keeping the keyword on 用量 also preserves the quota-shaped
|
||||
// query that stopped matching once the labels moved from 使用量 to 使用情况.
|
||||
usage: '用量',
|
||||
proxy: '代理',
|
||||
terminal: '终端',
|
||||
workspace: '工作区',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// CSS that lives in the i18n catalog. MT rewrote a selector to [データスラッシュメニュー] and a keyframe
|
||||
// name to ブラウザフラッシュ, breaking the view in that locale only. Removing these from en.json is
|
||||
// the real fix; until then the policy pins them to English.
|
||||
|
||||
const STYLE_BLOCK =
|
||||
/@(keyframes|media|supports|font-face)\b|[.#][a-zA-Z][\w-]*[^{}]*\{[^{}]*[a-z-]+\s*:|\[[a-z-]+=["'][^"']*["']\]|\{[^{}]*[a-z-]+\s*:\s*[^{}]*[;}]/
|
||||
|
||||
// A selector with no declaration block has no braces for STYLE_BLOCK to key off.
|
||||
const SELECTOR_TOKEN = /^(?:[>+~]|[a-zA-Z.#[][\w.#:()[\]"'=^$|*-]*)$/
|
||||
const LONE_SELECTOR = /^#[a-zA-Z][\w-]*$|^\.[a-zA-Z][\w-]*-[\w-]+$/
|
||||
// One dotted, colon or bracketed token — button.primary, a:hover, wsl.exe, localhost:3000. It is
|
||||
// code whether it names a selector, a file or a host, and a translation breaks all three.
|
||||
const LONE_QUALIFIED = /^[a-zA-Z][\w-]*(?:[.#:][\w-]+|\[[^\]]+\])$/
|
||||
|
||||
function isStandaloneSelector(enValue) {
|
||||
if (/[{}]/.test(enValue)) {
|
||||
return false
|
||||
}
|
||||
const tokens = enValue.trim().split(/\s+/)
|
||||
if (!tokens.every((token) => SELECTOR_TOKEN.test(token))) {
|
||||
return false
|
||||
}
|
||||
// A selector join, not a sentence period: two sentences would otherwise clear the threshold.
|
||||
const marked = tokens.filter((token) => /^[#.][a-zA-Z]|[\w-][.#:][a-zA-Z]|\[/.test(token))
|
||||
if (marked.length >= 2) {
|
||||
return true
|
||||
}
|
||||
return tokens.length === 1 && (LONE_SELECTOR.test(tokens[0]) || LONE_QUALIFIED.test(tokens[0]))
|
||||
}
|
||||
|
||||
export function isStyleValue(enValue) {
|
||||
return STYLE_BLOCK.test(enValue) || isStandaloneSelector(enValue)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { repairTranslatedValue, shouldPreserveEnglishValue } from './locale-translation-policy.mjs'
|
||||
|
||||
const ja = (enValue, localeValue, key = 'auto.test.relocalization') =>
|
||||
repairTranslatedValue({ key, enValue, localeValue, locale: 'ja' })
|
||||
|
||||
describe('locale-translation-policy ja relocalization', () => {
|
||||
it('heals generic terms the brand revert left in Latin mid-sentence', () => {
|
||||
expect(ja('Close terminal?', 'この terminal を閉じますか?')).toBe('このターミナルを閉じますか?')
|
||||
expect(ja('New Terminal', '新規 Terminal')).toBe('新規ターミナル')
|
||||
expect(ja('Stop the agent?', 'この agent を停止しますか?')).toBe('この Agent を停止しますか?')
|
||||
expect(ja('Nothing to commit.', 'commit するものはありません。')).toBe(
|
||||
'コミットするものはありません。'
|
||||
)
|
||||
expect(ja('Open the repo', 'repo を開く')).toBe('リポジトリを開く')
|
||||
})
|
||||
|
||||
it('leaves the second word of a two-word command in Latin', () => {
|
||||
expect(ja('a commit hook or git commit fails', 'コミットフックまたは git commit が失敗')).toBe(
|
||||
'コミットフックまたは git commit が失敗'
|
||||
)
|
||||
expect(ja('Run orca terminal', 'orca terminal を実行')).toBe('orca terminal を実行')
|
||||
expect(ja('Install with pnpm install', 'pnpm install でインストール')).toBe(
|
||||
'pnpm install でインストール'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves identifiers, flags, filenames, and Latin-only labels alone', () => {
|
||||
expect(ja('Pass --agent to select one.', '--agent を渡して選択します。')).toBe(
|
||||
'--agent を渡して選択します。'
|
||||
)
|
||||
expect(ja('Edit agents.md', 'agents.md を編集')).toBe('agents.md を編集')
|
||||
expect(ja('Agent SDK settings', 'Agent SDK の設定')).toBe('Agent SDK の設定')
|
||||
expect(ja('Agent SDK settings', 'この Agent SDK の設定')).toBe('この Agent SDK の設定')
|
||||
expect(ja('Run git commit -m', 'git commit -m を実行')).toBe('git commit -m を実行')
|
||||
})
|
||||
|
||||
it('keeps commands and identifiers in English so they still run', () => {
|
||||
expect(shouldPreserveEnglishValue('pnpm install')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('glab auth login')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue("gh pr list --json number -q '.[0].number'")).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('--model sonnet')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('localhost, 127.0.0.1, *.internal')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('stale-agent-row-{{value0}}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('text-foreground')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('{basePrompt}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('{firstPrompt}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('{assistantMessage}')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('/pricing')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('/signup')).toBe(true)
|
||||
})
|
||||
|
||||
it('repairs translated code examples back to executable values', () => {
|
||||
expect(ja('--model sonnet', '--モデルソネット')).toBe('--model sonnet')
|
||||
expect(
|
||||
ja("gh pr list --json number -q '.[0].number'", "gh pr list --json 番号 -q '.[0].number'")
|
||||
).toBe("gh pr list --json number -q '.[0].number'")
|
||||
expect(ja('{basePrompt}', '{ベースプロンプト}')).toBe('{basePrompt}')
|
||||
expect(ja('/pricing', '/価格設定')).toBe('/pricing')
|
||||
})
|
||||
|
||||
it('keeps style blocks in English because a translated selector breaks the view', () => {
|
||||
expect(shouldPreserveEnglishValue('.pp-card[data-card="starter"] .pp-cta')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('@keyframes browser-flash { to { opacity: 1; } }')).toBe(true)
|
||||
expect(shouldPreserveEnglishValue('.card { background: var(--card); }')).toBe(true)
|
||||
// Why: the declaration requirement is what keeps prose containing braces translatable.
|
||||
expect(shouldPreserveEnglishValue('e.g. {{value0}}')).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes Japanese typography and settled terminology', () => {
|
||||
expect(ja('Loading…', '読み込み中...')).toBe('読み込み中…')
|
||||
expect(ja('Merge conflict', 'マージ紛争')).toBe('マージ競合')
|
||||
// Why: MT inserts a Microsoft-style separator space that Japanese compounds do not use.
|
||||
expect(ja('Status bar', 'ステータス バー')).toBe('ステータスバー')
|
||||
expect(ja('Missing credentials', '資格情報がありません')).toBe('認証情報がありません')
|
||||
expect(ja('Unknown', '未知')).toBe('不明')
|
||||
expect(ja('Restart the server', 'サーバを再起動')).toBe('サーバーを再起動')
|
||||
expect(ja('Open the browser', 'ブラウザーを開く')).toBe('ブラウザを開く')
|
||||
})
|
||||
|
||||
it('leaves ranges and token samples out of the ellipsis rule', () => {
|
||||
expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較')
|
||||
})
|
||||
|
||||
it('spaces the Latin terms the phrase fixes write back in', () => {
|
||||
expect(ja('Clear cookies', 'クッキーを消去')).toBe('Cookie を消去')
|
||||
expect(ja('Safely fast-forward the branch', 'ブランチを安全に早送りします')).toBe(
|
||||
'ブランチを安全に fast-forward します'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses ホスト for host, and leaves サーバー alone when English says server', () => {
|
||||
expect(ja('Host path', 'サーバーパス')).toBe('ホストパス')
|
||||
expect(ja('Enter a path that exists on the selected host.', 'ランタイムサーバー上のパス')).toBe(
|
||||
'ホスト上のパス'
|
||||
)
|
||||
// Why: host and server are deliberately distinct wherever the English uses both.
|
||||
expect(ja('Save the server host', 'サーバーを保存')).toBe('サーバーを保存')
|
||||
})
|
||||
|
||||
it('restores Git vocabulary and brand names the MT localized', () => {
|
||||
expect(ja('Branch upstream', 'ブランチの上流')).toBe('ブランチの upstream')
|
||||
expect(ja('origin has commits', '起源にコミットがあります')).toBe('origin にコミットがあります')
|
||||
expect(ja('Discard all staged changes?', '段階的な変更を破棄しますか?')).toBe(
|
||||
'ステージ済みの変更を破棄しますか?'
|
||||
)
|
||||
expect(ja('Create Hermes automation', 'エルメスオートメーションを作成')).toBe(
|
||||
'Hermes オートメーションを作成'
|
||||
)
|
||||
expect(ja('Delete the worktree', 'worktree を削除')).toBe('ワークツリーを削除')
|
||||
})
|
||||
|
||||
it('keeps class strings, refs, and fixture filenames in English', () => {
|
||||
for (const value of [
|
||||
'size-4 text-muted-foreground',
|
||||
'text-amber-700 dark:text-amber-300',
|
||||
'origin/main',
|
||||
'upstream/main',
|
||||
'login.spec.ts',
|
||||
'bastion.example.com'
|
||||
]) {
|
||||
expect(shouldPreserveEnglishValue(value)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a selector that has no declaration block', () => {
|
||||
for (const value of [
|
||||
'div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta',
|
||||
'.pp-cta',
|
||||
'#dialog',
|
||||
'.pp-card[data-card="starter"] .pp-cta',
|
||||
'button.primary',
|
||||
'a:hover',
|
||||
'input[type="text"]',
|
||||
'localhost:3000'
|
||||
]) {
|
||||
expect(shouldPreserveEnglishValue(value)).toBe(true)
|
||||
}
|
||||
// Why: two ordinary sentences carry two periods, which must not read as selector joins.
|
||||
for (const value of [
|
||||
'Open Settings > Git and try again.',
|
||||
'Show live workspace ports. Click it for workspace-scoped ports and external listeners.',
|
||||
'orca.yaml + local',
|
||||
'.gitignore',
|
||||
'Status:',
|
||||
'Done.',
|
||||
'#{{value0}}'
|
||||
]) {
|
||||
expect(shouldPreserveEnglishValue(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -108,18 +108,18 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
|
||||
enValue: 'Terminals',
|
||||
localeValue: '端子',
|
||||
localeValue: '端末',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Terminals')
|
||||
).toBe('ターミナル')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.skills.SkillsPage.38e0951c3a',
|
||||
enValue: 'Agent Skills',
|
||||
localeValue: 'エージェントのスキル',
|
||||
localeValue: 'Agent のスキル',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('エージェントのスキル')
|
||||
).toBe('Agent のスキル')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
|
||||
|
||||
@@ -4,11 +4,14 @@ import {
|
||||
overlapsCanonicalRendering
|
||||
} from './locale-generic-ui-terms.mjs'
|
||||
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
|
||||
import { BRAND_MISTRANSLATIONS } from './locale-brand-mistranslations.mjs'
|
||||
import { isStyleValue } from './locale-style-values.mjs'
|
||||
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
import { SEARCH_KEYWORD_OVERRIDES } from './locale-search-keyword-overrides.mjs'
|
||||
import { LOCALE_VALUE_OVERRIDES } from './locale-value-overrides.mjs'
|
||||
|
||||
export { BRAND_MISTRANSLATIONS } from './locale-brand-mistranslations.mjs'
|
||||
export { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
export { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
export { SEARCH_KEYWORD_OVERRIDES } from './locale-search-keyword-overrides.mjs'
|
||||
@@ -94,6 +97,7 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'yarn',
|
||||
'Kagi',
|
||||
'kagi',
|
||||
'kimi',
|
||||
'Bitbucket',
|
||||
'bitbucket',
|
||||
'GNOME',
|
||||
@@ -163,163 +167,55 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'lint',
|
||||
'MD',
|
||||
'/home/user/projects',
|
||||
'Claude Code'
|
||||
'Claude Code',
|
||||
// Commands, refs, class strings and code samples: a translated one no longer runs or matches.
|
||||
'pnpm install',
|
||||
'glab auth login',
|
||||
"gh pr list --json number -q '.[0].number'",
|
||||
'--model sonnet',
|
||||
'localhost, 127.0.0.1, *.internal',
|
||||
'packages/web shared/ui',
|
||||
'stale-agent-row-{{value0}}',
|
||||
'text-foreground',
|
||||
'source-control',
|
||||
'combined-branch',
|
||||
'pr-view',
|
||||
'fix-login-flow',
|
||||
'my-project',
|
||||
'serve-sim',
|
||||
'pnpm playwright test',
|
||||
'gh auth login',
|
||||
'size-4 text-muted-foreground',
|
||||
'text-amber-700 dark:text-amber-300',
|
||||
'text-emerald-700 dark:text-emerald-300',
|
||||
'src/renderer packages/ui',
|
||||
'upstream/main',
|
||||
'origin/main',
|
||||
'example.com',
|
||||
'bastion.example.com',
|
||||
'dashboard.spec.ts',
|
||||
'checkout.spec.ts',
|
||||
'login.spec.ts',
|
||||
'untitled.md',
|
||||
'review src/auth',
|
||||
'throw src/auth',
|
||||
// Rendered inside <code> or a font-mono element, so they are code the user copies or types.
|
||||
'{prompt}',
|
||||
'{basePrompt}',
|
||||
'{firstPrompt}',
|
||||
'{assistantMessage}',
|
||||
'/goal',
|
||||
'/pricing',
|
||||
'/signup',
|
||||
'npm run dev',
|
||||
'nbformat',
|
||||
'orca.yaml',
|
||||
'upstream',
|
||||
'LIN-329',
|
||||
'GH #1799',
|
||||
'orca · zsh'
|
||||
])
|
||||
|
||||
export const BRAND_MISTRANSLATIONS = {
|
||||
ko: {
|
||||
Codex: ['사본', '코덱스'],
|
||||
Gemini: ['쌍둥이자리'],
|
||||
Claude: ['클로드'],
|
||||
Grok: ['그록'],
|
||||
Orca: ['오르카', '범고래'],
|
||||
Cursor: ['커서'],
|
||||
OpenCode: ['오픈코드'],
|
||||
OpenClaw: ['오픈클로'],
|
||||
OpenClaude: ['오픈클로드'],
|
||||
Antigravity: ['반중력'],
|
||||
Continue: ['계속하다'],
|
||||
Charm: ['매력'],
|
||||
Goose: ['거위'],
|
||||
Pi: ['파이'],
|
||||
'GitHub Copilot': ['GitHub 코파일럿', '코파일럿'],
|
||||
Git: ['힘내'],
|
||||
Discord: ['디스코드'],
|
||||
Linear: ['선형'],
|
||||
Agent: ['에이전트'],
|
||||
Agents: ['에이전트'],
|
||||
agent: ['에이전트'],
|
||||
agents: ['에이전트'],
|
||||
Commit: ['커밋'],
|
||||
Commits: ['커밋'],
|
||||
commit: ['커밋'],
|
||||
commits: ['커밋'],
|
||||
Markdown: ['마크다운', '가격 인하'],
|
||||
markdown: ['마크다운', '가격 인하'],
|
||||
Repo: ['저장소', '레포'],
|
||||
Repos: ['저장소', '레포'],
|
||||
repo: ['저장소', '레포'],
|
||||
repos: ['저장소', '레포'],
|
||||
Terminal: ['터미널'],
|
||||
Terminals: ['터미널'],
|
||||
terminal: ['터미널'],
|
||||
terminals: ['터미널']
|
||||
},
|
||||
zh: {
|
||||
Codex: ['法典'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['克洛德', '克劳德'],
|
||||
Grok: ['格罗克'],
|
||||
Orca: ['虎鲸', '逆戟鲸'],
|
||||
Cursor: ['光标'],
|
||||
OpenCode: ['开放代码'],
|
||||
OpenClaw: ['开爪'],
|
||||
OpenClaude: ['开放克劳德'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['继续'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['鹅'],
|
||||
Pi: ['圆周率'],
|
||||
Droid: ['机器人'],
|
||||
'GitHub Copilot': ['GitHub 副驾驶', '副驾驶'],
|
||||
Bitbucket: ['位桶'],
|
||||
Linear: ['线性', '线形'],
|
||||
Jira: ['吉拉'],
|
||||
Tailscale: ['尾鳞', '尾鱗'],
|
||||
Agent: ['代理', '智能体'],
|
||||
Agents: ['代理', '智能体'],
|
||||
agent: ['代理', '智能体'],
|
||||
agents: ['代理', '智能体'],
|
||||
Commit: ['提交'],
|
||||
Commits: ['提交'],
|
||||
commit: ['提交'],
|
||||
commits: ['提交'],
|
||||
Markdown: ['降价'],
|
||||
markdown: ['降价'],
|
||||
Repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repo: ['存储库', '仓库', '回购协议', '回购'],
|
||||
repos: ['存储库', '仓库', '回购协议', '回购'],
|
||||
Terminal: ['终端', '端子'],
|
||||
Terminals: ['终端', '端子'],
|
||||
terminal: ['终端', '端子'],
|
||||
terminals: ['终端', '端子'],
|
||||
Bash: ['重击'],
|
||||
PowerShell: ['电源外壳'],
|
||||
REST: ['休息'],
|
||||
HEAD: ['头'],
|
||||
Swift: ['迅速'],
|
||||
Rust: ['锈'],
|
||||
'Claude Code': ['Claude·科德'],
|
||||
'Git AI Author': ['Git AI 作者']
|
||||
},
|
||||
ja: {
|
||||
Codex: ['法典', 'コーデックス'],
|
||||
Gemini: ['双子座'],
|
||||
Claude: ['クロード'],
|
||||
Grok: ['グロック'],
|
||||
Orca: ['シャチ', '逆戟鲸', 'オルカ'],
|
||||
Cursor: ['カーソル'],
|
||||
OpenCode: ['オープンコード', 'オープン・コード'],
|
||||
OpenClaw: ['オープンクロー'],
|
||||
OpenClaude: ['オープンクロード'],
|
||||
Antigravity: ['反重力'],
|
||||
Continue: ['続ける', '続行'],
|
||||
Charm: ['魅力'],
|
||||
Goose: ['ガチョウ', '雁'],
|
||||
Pi: ['円周率'],
|
||||
Droid: ['ロボット', 'ドロイド'],
|
||||
'GitHub Copilot': ['GitHub コパイロット', 'コパイロット'],
|
||||
Discord: ['不和'],
|
||||
Linear: ['線形'],
|
||||
Agent: ['エージェント'],
|
||||
Agents: ['エージェント'],
|
||||
agent: ['エージェント'],
|
||||
agents: ['エージェント'],
|
||||
Commit: ['コミット'],
|
||||
Commits: ['コミット'],
|
||||
commit: ['コミット'],
|
||||
commits: ['コミット'],
|
||||
Markdown: ['マークダウン'],
|
||||
markdown: ['マークダウン'],
|
||||
Repo: ['リポジトリ', 'リポ'],
|
||||
Repos: ['リポジトリ', 'リポ'],
|
||||
repo: ['リポジトリ', 'リポ'],
|
||||
repos: ['リポジトリ', 'リポ'],
|
||||
Terminal: ['ターミナル', '端子'],
|
||||
Terminals: ['ターミナル', '端子'],
|
||||
terminal: ['ターミナル', '端子'],
|
||||
terminals: ['ターミナル', '端子']
|
||||
},
|
||||
es: {
|
||||
Codex: ['códice', 'Códice'],
|
||||
Gemini: ['Géminis'],
|
||||
Claude: ['claudia', 'Claudia'],
|
||||
Orca: ['orca', 'Orcas', 'orcas'],
|
||||
OpenCode: ['código abierto', 'Código abierto'],
|
||||
OpenClaude: ['Openclaude'],
|
||||
Antigravity: ['antigravedad', 'Antigravedad'],
|
||||
'GitHub Copilot': ['Copiloto de GitHub'],
|
||||
Discord: ['discordia'],
|
||||
Linear: ['lineal', 'Lineal'],
|
||||
Jira: ['jira'],
|
||||
Agent: ['Agente', 'agente'],
|
||||
Agents: ['Agentes', 'agentes'],
|
||||
agent: ['agente'],
|
||||
agents: ['agentes'],
|
||||
Commit: ['Confirmación', 'confirmación', 'Confirmar', 'Comprometerse'],
|
||||
Commits: ['Confirmaciones', 'confirmaciones', 'Compromisos', 'compromisos', 'Se compromete'],
|
||||
commit: ['confirmación', 'confirmar', 'comprometerse', 'compromiso'],
|
||||
commits: ['confirmaciones', 'compromisos'],
|
||||
Markdown: ['Reducción', 'reducción', 'Rebaja', 'rebaja', 'rebajas'],
|
||||
markdown: ['reducción', 'rebaja', 'rebajas'],
|
||||
Repo: ['Repositorio', 'repositorio'],
|
||||
Repos: ['Repositorios', 'repositorios'],
|
||||
repo: ['repositorio'],
|
||||
repos: ['repositorios']
|
||||
}
|
||||
}
|
||||
|
||||
export const NATIVE_PICKER_LABELS = {
|
||||
zh: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
|
||||
ko: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
|
||||
@@ -343,6 +239,9 @@ export function shouldPreserveEnglishValue(enValue, key = '') {
|
||||
if (isEnglishOnlyKey(key)) {
|
||||
return true
|
||||
}
|
||||
if (isStyleValue(enValue)) {
|
||||
return true
|
||||
}
|
||||
return NEVER_TRANSLATE_VALUES.has(enValue)
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '壊れた小切手に対して AI エージェントを開始しました。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('失敗したチェックに対して AI エージェントを開始しました。')
|
||||
).toBe('失敗したチェックに対して AI Agent を開始しました。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
|
||||
@@ -88,7 +88,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '電話機からターミナルとエージェントを制御します。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('スマートフォンからターミナルとエージェントを操作')
|
||||
).toBe('スマートフォンからターミナルと Agent を操作します。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.934add88b6',
|
||||
@@ -398,7 +398,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: 'コンピューターと携帯電話で。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('コンピューターとスマートフォンで。')
|
||||
).toBe('コンピュータとスマートフォンで。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.3ab6ac0fc8',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
// #12881 — the status bar item menu renders "<Brand> Usage" for every provider in one dropdown, so
|
||||
// zh has to settle on a single word. Registering it here, not only in the catalog, is what makes a
|
||||
// re-translation pass reproduce the decision instead of re-splitting the labels.
|
||||
//
|
||||
// Scope is the provider usage surfaces, not the word "usage" everywhere: a measured volume
|
||||
// ("Daily usage" 每日使用量) stays 使用量. The toggle descriptions are pinned by the catalog test
|
||||
// instead — LOCALE_VALUE_OVERRIDES keys on the exact English string, so full sentences do not fit.
|
||||
//
|
||||
// ja is split the same way zh was (使用量 vs の使用状況) and is not covered here; ko is uniform.
|
||||
|
||||
// Real catalog keys, so a future zh key override shadowing one of these is caught here.
|
||||
const STATUS_BAR_PROVIDERS = [
|
||||
['auto.components.status.bar.StatusBar.3885eb74d8', 'Claude'],
|
||||
['auto.components.status.bar.StatusBar.c0909c686e', 'Codex'],
|
||||
['auto.components.status.bar.StatusBar.c1df0d67ec', 'Gemini'],
|
||||
['auto.components.status.bar.StatusBar.antigravityUsage', 'Antigravity'],
|
||||
['auto.components.status.bar.StatusBar.8c86cd77b0', 'OpenCode Go'],
|
||||
['auto.components.status.bar.StatusBar.5e59007df4', 'Kimi'],
|
||||
['auto.components.status.bar.StatusBar.3bbf140864', 'MiniMax'],
|
||||
['auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok'],
|
||||
['auto.components.settings.accounts.search.f4a8c2e1b7', 'Grok (xAI)']
|
||||
]
|
||||
|
||||
describe('locale-translation-policy zh status bar usage labels', () => {
|
||||
it('pins every provider usage label to 使用情况', () => {
|
||||
for (const [key, brand] of STATUS_BAR_PROVIDERS) {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key,
|
||||
enValue: `${brand} Usage`,
|
||||
localeValue: `${brand} 使用量`,
|
||||
locale: 'zh'
|
||||
}),
|
||||
brand
|
||||
).toBe(`${brand} 使用情况`)
|
||||
}
|
||||
})
|
||||
|
||||
it('reverts the Kimi transliteration in labels and search keywords', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.3a6c028ea8',
|
||||
enValue: 'Kimi Usage',
|
||||
localeValue: '基米用法',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Kimi 使用情况')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.40e5c3c285',
|
||||
enValue: 'kimi',
|
||||
localeValue: '基米',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('kimi')
|
||||
})
|
||||
|
||||
// Why: NEVER_TRANSLATE_VALUES is cross-locale. Moving the moonshot repair there would rewrite
|
||||
// ja's ムーンショット, so it has to stay a zh-scoped BRAND_MISTRANSLATIONS entry.
|
||||
it('keeps the moonshot keyword repair scoped to zh', () => {
|
||||
const key = 'auto.components.settings.appearance.search.35565867cb'
|
||||
expect(
|
||||
repairTranslatedValue({ key, enValue: 'moonshot', localeValue: '登月计划', locale: 'zh' })
|
||||
).toBe('moonshot')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key,
|
||||
enValue: 'moonshot',
|
||||
localeValue: 'ムーンショット',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('ムーンショット')
|
||||
})
|
||||
|
||||
// Why: the labels moving to 使用情况 removed the only 用量-shaped match, so the search keyword
|
||||
// carries it. 用法 means "how to use".
|
||||
it('keeps 用量 as the zh search keyword for "usage"', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.00a028f25f',
|
||||
enValue: 'usage',
|
||||
localeValue: '用法',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('用量')
|
||||
})
|
||||
})
|
||||
@@ -201,6 +201,12 @@ export const LOCALE_VALUE_OVERRIDES = {
|
||||
'Codex Usage': 'Codex 使用情况',
|
||||
'Claude Usage': 'Claude 使用情况',
|
||||
'Gemini Usage': 'Gemini 使用情况',
|
||||
'Antigravity Usage': 'Antigravity 使用情况',
|
||||
'OpenCode Go Usage': 'OpenCode Go 使用情况',
|
||||
'Kimi Usage': 'Kimi 使用情况',
|
||||
'MiniMax Usage': 'MiniMax 使用情况',
|
||||
'Grok Usage': 'Grok 使用情况',
|
||||
'Grok (xAI) Usage': 'Grok (xAI) 使用情况',
|
||||
'Force Delete Branch': '强制删除分支',
|
||||
'Time agents worked': '代理工作时间',
|
||||
PR: 'PR',
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Synthetic benchmark for native chat's post-incremental renderer hot paths.
|
||||
* Run: pnpm exec tsx config/scripts/native-chat-live-session-benchmark.ts
|
||||
*
|
||||
* This excludes transcript parsing, IPC/remote latency, React, and DOM work. The
|
||||
* 2 KB/message fixtures intentionally stress fallback-key normalization and are
|
||||
* not estimates of average production message size or end-to-end frame latency.
|
||||
*/
|
||||
|
||||
import { deepStrictEqual, strictEqual } from 'node:assert'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import type { NativeChatMessage, NativeChatSession } from '../../src/shared/native-chat-types'
|
||||
import { getVerifiedNativeChatCommands } from '../../src/shared/native-chat-agent-profiles'
|
||||
import { surfaceSkillInvocationUserTurns } from '../../src/shared/native-chat-command-envelope'
|
||||
import { prepareNativeChatLiveMessages } from '../../src/renderer/src/components/native-chat/native-chat-live-message-preparation'
|
||||
import { mergeNativeChatLiveSession } from '../../src/renderer/src/components/native-chat/native-chat-live-status'
|
||||
import {
|
||||
matchingNativeChatUserTexts,
|
||||
selectPendingIndicesRepresentedByUserTexts
|
||||
} from '../../src/renderer/src/components/native-chat/native-chat-pending-occurrence'
|
||||
import { pendingSendsAsMessages } from '../../src/renderer/src/components/native-chat/native-chat-pending'
|
||||
import { assembleNativeChatSession } from '../../src/renderer/src/components/native-chat/native-chat-session-assembler'
|
||||
|
||||
type Operation = (index: number) => number
|
||||
type ExpectedChecksum = (iterations: number) => number
|
||||
type Calibration = { iterations: number; elapsedMs: number; capped: boolean }
|
||||
|
||||
const TARGET_SAMPLE_MS = 50
|
||||
const MAX_ITERATIONS = 16_777_216
|
||||
const ROUNDS = 10
|
||||
let checksum = 0
|
||||
let expectedChecksum = 0
|
||||
let validatedCases = 0
|
||||
let cappedCalibrations = 0
|
||||
let sessionSink: NativeChatSession | null = null
|
||||
let messageArraySink: NativeChatMessage[] | null = null
|
||||
let contentSink = ''
|
||||
let pendingMatchSink: Set<number> | null = null
|
||||
const benchmarkStartedAt = performance.now()
|
||||
|
||||
function proseFixture(count: number, bytes: number, withTurnId: boolean): NativeChatMessage[] {
|
||||
const payload = 'Ab Cd '.repeat(Math.ceil(bytes / 7)).slice(0, bytes)
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `message-${index}`,
|
||||
role: index % 2 === 0 ? ('user' as const) : ('assistant' as const),
|
||||
blocks: [{ type: 'text' as const, text: `${payload}-${index}` }],
|
||||
timestamp: index,
|
||||
source: 'transcript' as const,
|
||||
...(withTurnId ? { turnId: `turn-${index}` } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
function toolFixture(count: number): NativeChatMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `tool-${index}`,
|
||||
role: index % 2 === 0 ? ('assistant' as const) : ('tool' as const),
|
||||
blocks:
|
||||
index % 2 === 0
|
||||
? [
|
||||
{
|
||||
type: 'tool-call' as const,
|
||||
name: 'read',
|
||||
input: { path: `${index}.txt`, context: 'x'.repeat(512) }
|
||||
}
|
||||
]
|
||||
: [{ type: 'tool-result' as const, output: `result-${index}-${'x'.repeat(512)}` }],
|
||||
timestamp: index,
|
||||
source: 'transcript' as const
|
||||
}))
|
||||
}
|
||||
|
||||
function legacySession(messages: NativeChatMessage[]): NativeChatSession {
|
||||
return assembleNativeChatSession({
|
||||
sources: { transcript: messages },
|
||||
sessionId: 'benchmark',
|
||||
agent: 'claude'
|
||||
})
|
||||
}
|
||||
|
||||
function legacyMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSession {
|
||||
const commandNames = new Set(
|
||||
getVerifiedNativeChatCommands('claude').map((command) => command.name)
|
||||
)
|
||||
return legacySession(surfaceSkillInvocationUserTurns(messages, commandNames))
|
||||
}
|
||||
|
||||
function directMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSession {
|
||||
return mergeNativeChatLiveSession({
|
||||
messages: prepareNativeChatLiveMessages(messages, 'claude'),
|
||||
sessionId: 'benchmark',
|
||||
agent: 'claude',
|
||||
hookState: null
|
||||
})
|
||||
}
|
||||
|
||||
function oldEmptyPending(messages: NativeChatMessage[]): NativeChatMessage[] {
|
||||
pendingMatchSink = selectPendingIndicesRepresentedByUserTexts(
|
||||
[],
|
||||
matchingNativeChatUserTexts(messages)
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
function blockContent(message: NativeChatMessage): string {
|
||||
const block = message.blocks[0]
|
||||
if (!block) {
|
||||
return ''
|
||||
}
|
||||
if (block.type === 'text') {
|
||||
return block.text
|
||||
}
|
||||
if (block.type === 'tool-call') {
|
||||
return block.name
|
||||
}
|
||||
if (block.type === 'tool-result') {
|
||||
return block.output
|
||||
}
|
||||
return block.path ?? block.url ?? block.alt ?? ''
|
||||
}
|
||||
|
||||
function messageWeight(message: NativeChatMessage, content: string): number {
|
||||
const idTail = message.id.length > 0 ? message.id.charCodeAt(message.id.length - 1) : 0
|
||||
const contentTail = content.length > 0 ? content.charCodeAt(content.length - 1) : 0
|
||||
return message.id.length + idTail + message.role.charCodeAt(0) + content.length + contentTail
|
||||
}
|
||||
|
||||
function consumeSession(session: NativeChatSession, index: number): number {
|
||||
sessionSink = session
|
||||
messageArraySink = session.messages
|
||||
const message = session.messages[index % session.messages.length]
|
||||
if (!message) {
|
||||
contentSink = ''
|
||||
return session.status.charCodeAt(0)
|
||||
}
|
||||
contentSink = blockContent(message)
|
||||
return session.status.charCodeAt(0) + messageWeight(message, contentSink)
|
||||
}
|
||||
|
||||
function consumePendingOutput(messages: NativeChatMessage[], index: number): number {
|
||||
messageArraySink = messages
|
||||
return (index % 7) + 1
|
||||
}
|
||||
|
||||
function cyclicChecksum(values: readonly number[], iterations: number): number {
|
||||
if (values.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const cycle = values.reduce((sum, value) => sum + value, 0)
|
||||
const fullCycles = Math.floor(iterations / values.length)
|
||||
let total = cycle * fullCycles
|
||||
for (let index = 0; index < iterations % values.length; index += 1) {
|
||||
total += values[index]!
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function sessionExpectedChecksum(session: NativeChatSession): ExpectedChecksum {
|
||||
const statusWeight = session.status.charCodeAt(0)
|
||||
const weights = session.messages.map((message) => {
|
||||
const content = blockContent(message)
|
||||
return messageWeight(message, content)
|
||||
})
|
||||
return (iterations) => statusWeight * iterations + cyclicChecksum(weights, iterations)
|
||||
}
|
||||
|
||||
function pendingExpectedChecksum(iterations: number): number {
|
||||
return cyclicChecksum([1, 2, 3, 4, 5, 6, 7], iterations)
|
||||
}
|
||||
|
||||
function runSample(operation: Operation, expected: ExpectedChecksum, iterations: number): number {
|
||||
let sampleChecksum = 0
|
||||
const startedAt = performance.now()
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
sampleChecksum += operation(index)
|
||||
}
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
checksum += sampleChecksum
|
||||
expectedChecksum += expected(iterations)
|
||||
return elapsedMs
|
||||
}
|
||||
|
||||
function median(samples: number[]): number {
|
||||
return [...samples].sort((left, right) => left - right)[Math.floor(samples.length / 2)]!
|
||||
}
|
||||
|
||||
function calibrate(operation: Operation, expected: ExpectedChecksum): Calibration {
|
||||
let iterations = 1
|
||||
let reachedTarget = false
|
||||
while (true) {
|
||||
const elapsedMs = runSample(operation, expected, iterations)
|
||||
if (elapsedMs >= TARGET_SAMPLE_MS) {
|
||||
if (reachedTarget) {
|
||||
return { iterations, elapsedMs, capped: false }
|
||||
}
|
||||
reachedTarget = true
|
||||
continue
|
||||
}
|
||||
if (iterations >= MAX_ITERATIONS) {
|
||||
return { iterations, elapsedMs, capped: true }
|
||||
}
|
||||
reachedTarget = false
|
||||
iterations = Math.min(iterations * 2, MAX_ITERATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
function benchmark(
|
||||
name: string,
|
||||
baseline: Operation,
|
||||
optimized: Operation,
|
||||
expected: ExpectedChecksum
|
||||
): void {
|
||||
const baselineValue = baseline(0)
|
||||
const optimizedValue = optimized(0)
|
||||
strictEqual(optimizedValue, baselineValue, `${name}: timed arms returned different checksums`)
|
||||
validatedCases += 1
|
||||
const baselineCalibration = calibrate(baseline, expected)
|
||||
const optimizedCalibration = calibrate(optimized, expected)
|
||||
cappedCalibrations += Number(baselineCalibration.capped) + Number(optimizedCalibration.capped)
|
||||
const baselineSamples: number[] = []
|
||||
const optimizedSamples: number[] = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
baselineSamples.push(
|
||||
runSample(baseline, expected, baselineCalibration.iterations) /
|
||||
baselineCalibration.iterations
|
||||
)
|
||||
optimizedSamples.push(
|
||||
runSample(optimized, expected, optimizedCalibration.iterations) /
|
||||
optimizedCalibration.iterations
|
||||
)
|
||||
} else {
|
||||
optimizedSamples.push(
|
||||
runSample(optimized, expected, optimizedCalibration.iterations) /
|
||||
optimizedCalibration.iterations
|
||||
)
|
||||
baselineSamples.push(
|
||||
runSample(baseline, expected, baselineCalibration.iterations) /
|
||||
baselineCalibration.iterations
|
||||
)
|
||||
}
|
||||
}
|
||||
const baselineMs = median(baselineSamples)
|
||||
const optimizedMs = median(optimizedSamples)
|
||||
const speedup = baselineMs / Math.max(optimizedMs, Number.EPSILON)
|
||||
console.log(
|
||||
`${name}\t${baselineCalibration.iterations}\t${optimizedCalibration.iterations}\t${baselineCalibration.elapsedMs.toFixed(1)}\t${optimizedCalibration.elapsedMs.toFixed(1)}\t${baselineMs.toFixed(6)}\t${optimizedMs.toFixed(6)}\t${speedup.toFixed(1)}x`
|
||||
)
|
||||
}
|
||||
|
||||
function benchmarkSessionArms(
|
||||
name: string,
|
||||
baselineSession: () => NativeChatSession,
|
||||
optimizedSession: () => NativeChatSession
|
||||
): void {
|
||||
const expectedSession = baselineSession()
|
||||
deepStrictEqual(optimizedSession(), expectedSession, `${name}: session mismatch`)
|
||||
const expected = sessionExpectedChecksum(expectedSession)
|
||||
benchmark(
|
||||
name,
|
||||
(index) => consumeSession(baselineSession(), index),
|
||||
(index) => consumeSession(optimizedSession(), index),
|
||||
expected
|
||||
)
|
||||
}
|
||||
|
||||
function benchmarkMessageUpdate(name: string, messages: NativeChatMessage[]): void {
|
||||
benchmarkSessionArms(
|
||||
name,
|
||||
() => legacyMessageUpdateSession(messages),
|
||||
() => directMessageUpdateSession(messages)
|
||||
)
|
||||
}
|
||||
|
||||
const prose300 = proseFixture(300, 2_048, false)
|
||||
const fixtures = [
|
||||
['300 x 2KB prose, no turnId', prose300],
|
||||
['300 x 2KB prose, with turnId', proseFixture(300, 2_048, true)],
|
||||
['300 tool-heavy, no turnId', toolFixture(300)],
|
||||
['100 x 2KB prose, no turnId', proseFixture(100, 2_048, false)],
|
||||
['500 x 2KB prose, no turnId', proseFixture(500, 2_048, false)]
|
||||
] as const
|
||||
|
||||
console.log(
|
||||
`Node ${process.version}; ${ROUNDS} alternating interleaved median rounds; ${TARGET_SAMPLE_MS} ms calibration target; ${MAX_ITERATIONS} iteration cap`
|
||||
)
|
||||
console.log(
|
||||
'case\tbaseline iters\toptimized iters\tbaseline cal ms\toptimized cal ms\tbaseline ms/op\toptimized ms/op\tspeedup'
|
||||
)
|
||||
|
||||
for (const [name, messages] of fixtures) {
|
||||
benchmarkMessageUpdate(name, messages)
|
||||
}
|
||||
|
||||
benchmarkSessionArms(
|
||||
'300 x 2KB status-only frame',
|
||||
() => legacySession(prose300),
|
||||
() =>
|
||||
mergeNativeChatLiveSession({
|
||||
messages: prose300,
|
||||
sessionId: 'benchmark',
|
||||
agent: 'claude',
|
||||
hookState: null
|
||||
})
|
||||
)
|
||||
|
||||
deepStrictEqual(pendingSendsAsMessages([], prose300), oldEmptyPending(prose300))
|
||||
benchmark(
|
||||
'300 x 2KB empty pending',
|
||||
(index) => consumePendingOutput(oldEmptyPending(prose300), index),
|
||||
(index) => consumePendingOutput(pendingSendsAsMessages([], prose300), index),
|
||||
pendingExpectedChecksum
|
||||
)
|
||||
|
||||
const combinedSessionExpected = sessionExpectedChecksum(legacyMessageUpdateSession(prose300))
|
||||
benchmark(
|
||||
'300 x 2KB combined',
|
||||
(index) =>
|
||||
consumeSession(legacyMessageUpdateSession(prose300), index) +
|
||||
consumePendingOutput(oldEmptyPending(prose300), index),
|
||||
(index) =>
|
||||
consumeSession(directMessageUpdateSession(prose300), index) +
|
||||
consumePendingOutput(pendingSendsAsMessages([], prose300), index),
|
||||
(iterations) => combinedSessionExpected(iterations) + pendingExpectedChecksum(iterations)
|
||||
)
|
||||
|
||||
strictEqual(checksum, expectedChecksum, 'benchmark checksum accounting drifted')
|
||||
strictEqual(sessionSink?.sessionId, 'benchmark', 'session outputs did not escape')
|
||||
strictEqual(Array.isArray(messageArraySink), true, 'message arrays did not escape')
|
||||
strictEqual(contentSink.length > 0, true, 'message content did not escape')
|
||||
strictEqual(pendingMatchSink instanceof Set, true, 'pending baseline scan did not escape')
|
||||
console.log(
|
||||
`validated=${validatedCases} cases, checksum=${checksum}, capped calibrations=${cappedCalibrations}, runtime=${(
|
||||
performance.now() - benchmarkStartedAt
|
||||
).toFixed(0)} ms`
|
||||
)
|
||||
@@ -104,6 +104,23 @@ describe('orca CLI skill guidance', () => {
|
||||
expect(skill).not.toContain('sk_live_')
|
||||
expect(skill).not.toContain('live_sk_')
|
||||
})
|
||||
|
||||
// Publishing defaults to off, so an agent that follows the unconditional share workflow
|
||||
// just loops on denials. The guide has to teach the opt-in and the recovery.
|
||||
it('teaches the artifact publish opt-in and its recovery path', () => {
|
||||
// Normalized so the assertions survive reflowing the guide's prose.
|
||||
const skill = readSkill().replace(/\s+/gu, ' ')
|
||||
|
||||
expect(skill).toContain('**Publishing is off by default and only a human can turn it on.**')
|
||||
expect(skill).toContain('Settings → Artifacts')
|
||||
expect(skill).toContain('Allow publishing public artifact links')
|
||||
expect(skill).toContain('artifact_sharing_disabled')
|
||||
expect(skill).toContain('There is no CLI or RPC way to grant it')
|
||||
expect(skill).toContain('Do not retry')
|
||||
// The gate is device-wide, and revocation surfaces stay reachable.
|
||||
expect(skill).toContain('every caller on the device, agent or human')
|
||||
expect(skill).toContain('`list`, `unshare`, and `delete` are never gated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca CLI install stub', () => {
|
||||
|
||||
@@ -197,8 +197,13 @@ describe('Electron runtime package contract', () => {
|
||||
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
|
||||
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
|
||||
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
|
||||
expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')")
|
||||
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))")
|
||||
expect(builderConfig).toContain("from: 'out/relay'")
|
||||
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
|
||||
expect(remoteCommands).toContain(
|
||||
"joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')"
|
||||
)
|
||||
|
||||
const assertRelayGate = (steps, publishStepName) => {
|
||||
const names = steps.map((step) => step.name)
|
||||
|
||||
@@ -55,18 +55,35 @@ describe('PR workflow parallelism', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs real-zsh coverage once outside the general shards', () => {
|
||||
it('runs real-shell coverage once outside the general shards', () => {
|
||||
const shellStep = workflow.jobs.shell_contracts.steps.find(
|
||||
(step) => step.name === 'Test real shell contracts'
|
||||
)
|
||||
const shellInstall = workflow.jobs.shell_contracts.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
// Why parsed rather than substring-matched: the step name changes as shells are
|
||||
// added, and `includes('fish')` would also match a comment or a longer package.
|
||||
const aptPackages = (step) =>
|
||||
(step.run?.match(/apt-get install[^\n]*/)?.[0] ?? '')
|
||||
.split(/\s+/)
|
||||
.filter((token) => !['apt-get', 'install', 'sudo', ''].includes(token))
|
||||
.filter((token) => !token.startsWith('-'))
|
||||
const jobsInstallingPackages = Object.entries(workflow.jobs)
|
||||
.filter(([, job]) => (job.steps ?? []).some((step) => aptPackages(step).length > 0))
|
||||
.map(([name]) => name)
|
||||
|
||||
expect(workflow.jobs.test.steps.some((step) => step.name === 'Install zsh')).toBe(false)
|
||||
expect(workflow.jobs.shell_contracts.steps.some((step) => step.name === 'Install zsh')).toBe(
|
||||
true
|
||||
)
|
||||
expect(shellStep).toBeDefined()
|
||||
expect(shellInstall).toBeDefined()
|
||||
// Why the whole workflow, not just the general shards: any other lane installing
|
||||
// these shells would silently start running the real-shell tests twice.
|
||||
expect(jobsInstallingPackages).toEqual(['shell_contracts'])
|
||||
// Why each shell is asserted: the live tests skip themselves when the binary is
|
||||
// missing, so a dropped package silently empties this lane instead of failing it.
|
||||
const shellPackages = workflow.jobs.shell_contracts.steps.flatMap(aptPackages)
|
||||
for (const shell of ['zsh', 'fish']) {
|
||||
expect(shellPackages).toContain(shell)
|
||||
}
|
||||
expect(shellInstall.with['native-runtime']).toBe('node')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(shellStep.run).toContain(testFile)
|
||||
@@ -97,6 +114,24 @@ describe('PR workflow parallelism', () => {
|
||||
expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer')
|
||||
})
|
||||
|
||||
it('smokes managed-hook companions under their supported Node 18 runtime', () => {
|
||||
const steps = workflow.jobs.managed_hook_node18.steps
|
||||
const installIndex = steps.findIndex(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const buildIndex = steps.findIndex((step) => step.run === 'pnpm run build:relay')
|
||||
const node18Index = steps.findIndex(
|
||||
(step) => step.uses === 'actions/setup-node@v6' && step.with['node-version'] === '18'
|
||||
)
|
||||
const smokeIndex = steps.findIndex(
|
||||
(step) => step.run === 'node config/scripts/smoke-managed-hook-runtime-node18.mjs'
|
||||
)
|
||||
|
||||
expect(installIndex).toBeLessThan(buildIndex)
|
||||
expect(buildIndex).toBeLessThan(node18Index)
|
||||
expect(node18Index).toBeLessThan(smokeIndex)
|
||||
})
|
||||
|
||||
it('restores the pnpm store before dependency installation', () => {
|
||||
const steps = dependencyAction.runs.steps
|
||||
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
|
||||
@@ -178,8 +213,14 @@ describe('PR workflow parallelism', () => {
|
||||
'git_compatibility',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
])
|
||||
const verifyStep = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
|
||||
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { platform, release } from 'node:os'
|
||||
import {
|
||||
HEADER_LENGTH,
|
||||
MAX_MESSAGE_SIZE,
|
||||
MessageType,
|
||||
encodeFrame,
|
||||
encodePreparedJsonRpcFrame,
|
||||
prepareJsonRpcPayload,
|
||||
type JsonRpcNotification
|
||||
} from '../../src/relay/protocol'
|
||||
|
||||
type BenchmarkCase = {
|
||||
name: string
|
||||
message: JsonRpcNotification
|
||||
targets: number
|
||||
iterations: number
|
||||
}
|
||||
|
||||
type BenchmarkResult = {
|
||||
name: string
|
||||
targets: number
|
||||
payloadBytes: number
|
||||
legacyMicros: number
|
||||
preparedMicros: number
|
||||
reductionPercent: number
|
||||
speedup: number
|
||||
}
|
||||
|
||||
const SAMPLES = 17
|
||||
let checksum = 0
|
||||
|
||||
function encodeLegacyJsonRpcFrame(message: JsonRpcNotification, id: number, ack: number): Buffer {
|
||||
const payload = Buffer.from(JSON.stringify(message), 'utf8')
|
||||
if (payload.length > MAX_MESSAGE_SIZE) {
|
||||
throw new Error(`Message too large: ${payload.length} bytes`)
|
||||
}
|
||||
return encodeFrame(MessageType.Regular, id, ack, payload)
|
||||
}
|
||||
|
||||
function runLegacy(message: JsonRpcNotification, targets: number, iteration: number): void {
|
||||
const estimated = encodeLegacyJsonRpcFrame(message, 0, 0).length
|
||||
checksum = (checksum + estimated) >>> 0
|
||||
for (let target = 0; target < targets; target++) {
|
||||
consume(encodeLegacyJsonRpcFrame(message, iteration + target + 1, iteration))
|
||||
}
|
||||
}
|
||||
|
||||
function runPrepared(message: JsonRpcNotification, targets: number, iteration: number): void {
|
||||
const payload = prepareJsonRpcPayload(message)
|
||||
checksum = (checksum + HEADER_LENGTH + payload.byteLength) >>> 0
|
||||
for (let target = 0; target < targets; target++) {
|
||||
consume(encodePreparedJsonRpcFrame(payload, iteration + target + 1, iteration))
|
||||
}
|
||||
}
|
||||
|
||||
function consume(frame: Buffer): void {
|
||||
checksum = (checksum + frame.length + frame[0] + frame.at(-1)!) >>> 0
|
||||
}
|
||||
|
||||
function measure(operation: (iteration: number) => void, iterations: number): number {
|
||||
const startedAt = performance.now()
|
||||
for (let iteration = 0; iteration < iterations; iteration++) {
|
||||
operation(iteration)
|
||||
}
|
||||
return ((performance.now() - startedAt) * 1000) / iterations
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
function benchmarkCase(entry: BenchmarkCase): BenchmarkResult {
|
||||
const legacy = (iteration: number): void => runLegacy(entry.message, entry.targets, iteration)
|
||||
const prepared = (iteration: number): void => runPrepared(entry.message, entry.targets, iteration)
|
||||
for (let warmup = 0; warmup < 3; warmup++) {
|
||||
measure(legacy, Math.min(20, entry.iterations))
|
||||
measure(prepared, Math.min(20, entry.iterations))
|
||||
}
|
||||
const legacySamples: number[] = []
|
||||
const preparedSamples: number[] = []
|
||||
for (let sample = 0; sample < SAMPLES; sample++) {
|
||||
if (sample % 2 === 0) {
|
||||
legacySamples.push(measure(legacy, entry.iterations))
|
||||
preparedSamples.push(measure(prepared, entry.iterations))
|
||||
} else {
|
||||
preparedSamples.push(measure(prepared, entry.iterations))
|
||||
legacySamples.push(measure(legacy, entry.iterations))
|
||||
}
|
||||
}
|
||||
const legacyMicros = median(legacySamples)
|
||||
const preparedMicros = median(preparedSamples)
|
||||
return {
|
||||
name: entry.name,
|
||||
targets: entry.targets,
|
||||
payloadBytes: Buffer.byteLength(JSON.stringify(entry.message)),
|
||||
legacyMicros,
|
||||
preparedMicros,
|
||||
reductionPercent: ((legacyMicros - preparedMicros) / legacyMicros) * 100,
|
||||
speedup: legacyMicros / preparedMicros
|
||||
}
|
||||
}
|
||||
|
||||
function terminalData(bytes: number): string {
|
||||
const row = '\u001b[38;5;45mcompile src/renderer/pane.ts\u001b[0m\r\n'
|
||||
return row.repeat(Math.ceil(bytes / row.length)).slice(0, bytes)
|
||||
}
|
||||
|
||||
function watcherEvents(count: number): {
|
||||
kind: string
|
||||
absolutePath: string
|
||||
isDirectory: boolean
|
||||
}[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
kind: index % 7 === 0 ? 'create' : 'update',
|
||||
absolutePath: `/repo/src/features/terminal/generated-${String(index).padStart(4, '0')}.ts`,
|
||||
isDirectory: false
|
||||
}))
|
||||
}
|
||||
|
||||
const ptyMessage: JsonRpcNotification = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'pty.data',
|
||||
params: { id: 'pty-42', data: terminalData(16 * 1024), seq: 18_432, rawLength: 16 * 1024 }
|
||||
}
|
||||
const watcherMessage: JsonRpcNotification = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'fs.changed',
|
||||
params: { events: watcherEvents(256) }
|
||||
}
|
||||
const fileMessage: JsonRpcNotification = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'fs.streamChunk',
|
||||
params: {
|
||||
streamId: 37,
|
||||
seq: 11,
|
||||
data: Buffer.alloc(256 * 1024, 0x61).toString('base64')
|
||||
}
|
||||
}
|
||||
|
||||
const cases: BenchmarkCase[] = [
|
||||
{ name: 'PTY 16KiB', message: ptyMessage, targets: 1, iterations: 800 },
|
||||
{ name: 'PTY 16KiB', message: ptyMessage, targets: 2, iterations: 600 },
|
||||
{ name: 'watcher 256 events', message: watcherMessage, targets: 1, iterations: 400 },
|
||||
{ name: 'watcher 256 events', message: watcherMessage, targets: 2, iterations: 300 },
|
||||
{ name: 'file 256KiB raw', message: fileMessage, targets: 1, iterations: 50 },
|
||||
{ name: 'file 256KiB raw', message: fileMessage, targets: 2, iterations: 35 }
|
||||
]
|
||||
|
||||
const results = cases.map(benchmarkCase)
|
||||
const lines = [
|
||||
`relay JSON payload benchmark: Node ${process.version}, ${platform()} ${release()}, ${SAMPLES} interleaved samples`,
|
||||
'case | targets | JSON bytes | legacy median us/op | prepared median us/op | reduction | speedup',
|
||||
...results.map(
|
||||
(result) =>
|
||||
`${result.name} | ${result.targets} | ${result.payloadBytes} | ` +
|
||||
`${result.legacyMicros.toFixed(2)} | ${result.preparedMicros.toFixed(2)} | ` +
|
||||
`${result.reductionPercent.toFixed(1)}% | ${result.speedup.toFixed(2)}x`
|
||||
),
|
||||
`checksum=${checksum}`,
|
||||
'Limitations: in-process framing only; excludes dispatcher scheduling, sink/network I/O, and tail-GC.',
|
||||
'Legacy is the exact pre-change framing composition retained in this script, not a separate checkout.',
|
||||
'The two-target rows model dispatcher fan-out; watcher batches and file streams are commonly single-target.'
|
||||
]
|
||||
process.stdout.write(`${lines.join('\n')}\n`)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'
|
||||
const knobByFlag = {
|
||||
'--iterations': 'ORCA_AI_VAULT_BENCH_ITERATIONS',
|
||||
'--sessions': 'ORCA_AI_VAULT_BENCH_SESSIONS',
|
||||
'--payload-kib': 'ORCA_AI_VAULT_BENCH_PAYLOAD_KIB',
|
||||
'--keys': 'ORCA_AI_VAULT_BENCH_KEYS',
|
||||
'--cadence-ms': 'ORCA_AI_VAULT_BENCH_CADENCE_MS',
|
||||
'--label': 'ORCA_AI_VAULT_BENCH_LABEL'
|
||||
}
|
||||
|
||||
const env = { ...process.env, ORCA_AI_VAULT_TYPING_BENCH: '1' }
|
||||
const passthroughArgs = []
|
||||
const argv = process.argv.slice(2)
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === '--') {
|
||||
continue
|
||||
}
|
||||
const knob = knobByFlag[argv[index]]
|
||||
if (knob) {
|
||||
env[knob] = argv[++index]
|
||||
} else {
|
||||
passthroughArgs.push(argv[index])
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
npxCommand,
|
||||
[
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/terminal-ai-vault-typing-latency.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...passthroughArgs
|
||||
],
|
||||
{ stdio: 'inherit', env }
|
||||
)
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
@@ -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,64 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { constants } from 'node:fs'
|
||||
import { access, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const PLATFORMS = [
|
||||
'linux-x64',
|
||||
'linux-arm64',
|
||||
'darwin-x64',
|
||||
'darwin-arm64',
|
||||
'win32-x64',
|
||||
'win32-arm64'
|
||||
]
|
||||
const ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
assert.match(process.versions.node, /^18\./, 'This smoke test must run under Node 18')
|
||||
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-managed-hook-node18-'))
|
||||
const originalHome = process.env.HOME
|
||||
const originalUserProfile = process.env.USERPROFILE
|
||||
const originalGetuid = process.getuid
|
||||
process.env.HOME = home
|
||||
process.env.USERPROFILE = home
|
||||
process.getuid = undefined
|
||||
|
||||
try {
|
||||
const runtimes = PLATFORMS.map((platform) => {
|
||||
const artifact = join(ROOT, 'out', 'relay', platform, 'managed-hook-runtime.js')
|
||||
const runtime = require(artifact)
|
||||
assert.equal(typeof runtime.installManagedHooks, 'function', `${platform} installer export`)
|
||||
return runtime
|
||||
})
|
||||
|
||||
const summary = await runtimes[0].installManagedHooks({ agents: ['codex', 'claude'] })
|
||||
assert.deepEqual(summary, { installers: 2, errors: 0 })
|
||||
|
||||
const codexHooks = await readFile(join(home, '.codex', 'hooks.json'), 'utf8')
|
||||
const claudeSettings = await readFile(join(home, '.claude', 'settings.json'), 'utf8')
|
||||
assert.match(codexHooks, /\.orca\/agent-hooks\/codex-hook\.sh/)
|
||||
assert.match(claudeSettings, /\.orca\/agent-hooks\/claude-hook\.sh/)
|
||||
await access(join(home, '.orca', 'agent-hooks', 'codex-hook.sh'), constants.X_OK)
|
||||
await access(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), constants.X_OK)
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = originalHome
|
||||
}
|
||||
if (originalUserProfile === undefined) {
|
||||
delete process.env.USERPROFILE
|
||||
} else {
|
||||
process.env.USERPROFILE = originalUserProfile
|
||||
}
|
||||
process.getuid = originalGetuid
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('Node 18 managed-hook runtime smoke passed for all relay platforms.')
|
||||
@@ -153,6 +153,7 @@ function prepareRelayTree(runDir, nodePtyDir) {
|
||||
for (const filename of [
|
||||
'relay.js',
|
||||
'relay-watcher.js',
|
||||
'relay-ai-vault-service.js',
|
||||
'managed-hook-runtime.js',
|
||||
NODE_PTY_PATCH_FILENAME,
|
||||
'.version'
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree-sort-order-update.ts'
|
||||
import { reuseEqualCatalogRows } from '../../src/renderer/src/store/slices/worktree-catalog-reconciliation.ts'
|
||||
|
||||
const WORKTREE_COUNT = 655
|
||||
const TAB_COUNT = 1_895
|
||||
const REFRESH_COUNT = 10
|
||||
const ROUNDS = 15
|
||||
const BATCHES_PER_ROUND = 25
|
||||
|
||||
function makeFixture() {
|
||||
const worktrees = Array.from({ length: WORKTREE_COUNT }, (_, index) => ({
|
||||
id: `repo-${index % 15}::/workspace/${index}`,
|
||||
sortOrder: WORKTREE_COUNT - index,
|
||||
lastActivityAt: index % 19,
|
||||
isArchived: false
|
||||
}))
|
||||
const tabs = Array.from({ length: TAB_COUNT }, (_, index) => ({
|
||||
worktreeId: worktrees[index % worktrees.length].id,
|
||||
live: index % 3 === 0
|
||||
}))
|
||||
return { worktrees, tabs }
|
||||
}
|
||||
|
||||
function runSidebarProjection(worktrees, tabs) {
|
||||
const liveTabsByWorktree = new Map()
|
||||
for (const tab of tabs) {
|
||||
if (tab.live) {
|
||||
liveTabsByWorktree.set(tab.worktreeId, (liveTabsByWorktree.get(tab.worktreeId) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
const sorted = [...worktrees].sort(
|
||||
(left, right) =>
|
||||
(liveTabsByWorktree.get(right.id) ?? 0) - (liveTabsByWorktree.get(left.id) ?? 0) ||
|
||||
right.lastActivityAt - left.lastActivityAt ||
|
||||
right.sortOrder - left.sortOrder
|
||||
)
|
||||
return sorted.map((worktree) => worktree.id)
|
||||
}
|
||||
|
||||
function runBaseline() {
|
||||
const fixture = makeFixture()
|
||||
const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree]))
|
||||
const requestedIds = fixture.worktrees.map((worktree) => worktree.id)
|
||||
let catalog = fixture.worktrees
|
||||
let publications = 0
|
||||
let persistenceWrites = 0
|
||||
for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) {
|
||||
const now = 1_000_000 + refresh
|
||||
requestedIds.forEach((worktreeId, index) => {
|
||||
metaById.set(worktreeId, {
|
||||
...metaById.get(worktreeId),
|
||||
sortOrder: now - index * 1000
|
||||
})
|
||||
persistenceWrites += 1
|
||||
})
|
||||
const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId)))
|
||||
const reconciled = reuseEqualCatalogRows(catalog, incoming)
|
||||
if (reconciled !== catalog) {
|
||||
catalog = reconciled
|
||||
publications += 1
|
||||
runSidebarProjection(catalog, fixture.tabs)
|
||||
}
|
||||
}
|
||||
return { publications, persistenceWrites }
|
||||
}
|
||||
|
||||
function runCandidate() {
|
||||
const fixture = makeFixture()
|
||||
const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree]))
|
||||
const requestedIds = fixture.worktrees.map((worktree) => worktree.id)
|
||||
let catalog = fixture.worktrees
|
||||
let publications = 0
|
||||
let persistenceWrites = 0
|
||||
for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) {
|
||||
const updates = planWorktreeSortOrderUpdates(
|
||||
requestedIds,
|
||||
(worktreeId) => metaById.get(worktreeId),
|
||||
1_000_000 + refresh
|
||||
)
|
||||
for (const update of updates) {
|
||||
metaById.set(update.worktreeId, {
|
||||
...metaById.get(update.worktreeId),
|
||||
sortOrder: update.sortOrder
|
||||
})
|
||||
}
|
||||
persistenceWrites += updates.length
|
||||
const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId)))
|
||||
const reconciled = reuseEqualCatalogRows(catalog, incoming)
|
||||
if (reconciled !== catalog) {
|
||||
catalog = reconciled
|
||||
publications += 1
|
||||
runSidebarProjection(catalog, fixture.tabs)
|
||||
}
|
||||
}
|
||||
return { publications, persistenceWrites }
|
||||
}
|
||||
|
||||
function measureBatch(run) {
|
||||
let counters
|
||||
const startedAt = performance.now()
|
||||
for (let batch = 0; batch < BATCHES_PER_ROUND; batch += 1) {
|
||||
counters = run()
|
||||
}
|
||||
return { elapsedMs: (performance.now() - startedAt) / BATCHES_PER_ROUND, counters }
|
||||
}
|
||||
|
||||
function measureComparison() {
|
||||
runBaseline()
|
||||
runCandidate()
|
||||
const samples = Array.from({ length: ROUNDS }, (_, round) => {
|
||||
let baseline
|
||||
let candidate
|
||||
if (round % 2 === 0) {
|
||||
baseline = measureBatch(runBaseline)
|
||||
candidate = measureBatch(runCandidate)
|
||||
} else {
|
||||
candidate = measureBatch(runCandidate)
|
||||
baseline = measureBatch(runBaseline)
|
||||
}
|
||||
return { baseline, candidate, ratio: candidate.elapsedMs / baseline.elapsedMs }
|
||||
}).sort((left, right) => left.ratio - right.ratio)
|
||||
return samples[Math.floor(samples.length / 2)]
|
||||
}
|
||||
|
||||
const { baseline, candidate } = measureComparison()
|
||||
if (baseline.counters.publications !== REFRESH_COUNT || candidate.counters.publications !== 0) {
|
||||
throw new Error('Benchmark fixture no longer reproduces the refresh feedback loop')
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Worktree refresh churn: ${WORKTREE_COUNT} worktrees, ${TAB_COUNT} tabs, ${REFRESH_COUNT} refreshes`
|
||||
)
|
||||
console.log(
|
||||
`Before: ${baseline.elapsedMs.toFixed(3)} ms, ${baseline.counters.publications} catalog publications, ${baseline.counters.persistenceWrites} rank writes`
|
||||
)
|
||||
console.log(
|
||||
`After: ${candidate.elapsedMs.toFixed(3)} ms, ${candidate.counters.publications} catalog publications, ${candidate.counters.persistenceWrites} rank writes`
|
||||
)
|
||||
console.log(
|
||||
`Reduction: ${((1 - candidate.elapsedMs / baseline.elapsedMs) * 100).toFixed(1)}% CPU, 100% feedback publications`
|
||||
)
|
||||
@@ -0,0 +1,393 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { posix } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import process from 'node:process'
|
||||
|
||||
import { createJiti } from 'jiti'
|
||||
|
||||
const DEFAULT_SAMPLES = 20
|
||||
const DEFAULT_WARMUPS = 3
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
distro: null,
|
||||
nativeRepo: null,
|
||||
mountedRepo: null,
|
||||
samples: DEFAULT_SAMPLES,
|
||||
warmups: DEFAULT_WARMUPS,
|
||||
loginDelayMs: 0
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = argv[++index]
|
||||
if (!value) {
|
||||
throw new Error(`${arg} requires a value`)
|
||||
}
|
||||
if (arg === '--distro') {
|
||||
options.distro = value
|
||||
} else if (arg === '--native-repo') {
|
||||
options.nativeRepo = value
|
||||
} else if (arg === '--mounted-repo') {
|
||||
options.mountedRepo = value
|
||||
} else if (arg === '--samples') {
|
||||
options.samples = Number(value)
|
||||
} else if (arg === '--warmups') {
|
||||
options.warmups = Number(value)
|
||||
} else if (arg === '--login-delay-ms') {
|
||||
options.loginDelayMs = Number(value)
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
for (const [name, value, minimum] of [
|
||||
['samples', options.samples, 5],
|
||||
['warmups', options.warmups, 0],
|
||||
['login-delay-ms', options.loginDelayMs, 0]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value < minimum || value > 1_000) {
|
||||
throw new Error(`--${name} must be an integer between ${minimum} and 1000`)
|
||||
}
|
||||
}
|
||||
if (!options.nativeRepo || !options.mountedRepo) {
|
||||
throw new Error('--native-repo and --mounted-repo are required')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
env: options.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
const stdout = []
|
||||
const stderr = []
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk))
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk))
|
||||
child.on('error', reject)
|
||||
child.on('close', (status) => {
|
||||
const result = {
|
||||
status,
|
||||
stdout: Buffer.concat(stdout),
|
||||
stderr: Buffer.concat(stderr)
|
||||
}
|
||||
if (status === 0 || options.allowFailure) {
|
||||
resolve(result)
|
||||
} else {
|
||||
reject(new Error(`${command} exited ${status}: ${result.stderr.toString('utf8').trim()}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function wslArgs(distro, args) {
|
||||
return ['-d', distro, '--exec', ...args]
|
||||
}
|
||||
|
||||
function wslShellArgs(distro, args) {
|
||||
return ['-d', distro, '--', ...args]
|
||||
}
|
||||
|
||||
async function resolveDistro(requested) {
|
||||
if (requested) {
|
||||
return requested
|
||||
}
|
||||
const result = await run('wsl.exe', ['--list', '--quiet'], {
|
||||
env: { ...process.env, WSL_UTF8: '1' }
|
||||
})
|
||||
const distro = result.stdout
|
||||
.toString('utf8')
|
||||
.replaceAll('\0', '')
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.find(Boolean)
|
||||
if (!distro) {
|
||||
throw new Error('No WSL distro is installed')
|
||||
}
|
||||
return distro
|
||||
}
|
||||
|
||||
function percentile(samples, value) {
|
||||
const sorted = [...samples].sort((left, right) => left - right)
|
||||
return sorted[Math.ceil((value / 100) * sorted.length) - 1]
|
||||
}
|
||||
|
||||
function summarize(samples) {
|
||||
return {
|
||||
samples: samples.length,
|
||||
medianMs: Number(percentile(samples, 50).toFixed(1)),
|
||||
p95Ms: Number(percentile(samples, 95).toFixed(1)),
|
||||
minMs: Number(Math.min(...samples).toFixed(1)),
|
||||
maxMs: Number(Math.max(...samples).toFixed(1))
|
||||
}
|
||||
}
|
||||
|
||||
function assertRepoPath(path, expectedPrefix) {
|
||||
if (!path.startsWith(expectedPrefix) || path.includes('\0') || path.includes('\n')) {
|
||||
throw new Error(`Unexpected benchmark repository path: ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('This benchmark requires a Windows host with WSL')
|
||||
}
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
assertRepoPath(options.nativeRepo, '/')
|
||||
assertRepoPath(options.mountedRepo, '/mnt/')
|
||||
const distro = await resolveDistro(options.distro)
|
||||
const jiti = createJiti(import.meta.url)
|
||||
const { buildWslLoginShellCommand, escapeWslShCommandForWindows, quotePosixShell } =
|
||||
await jiti.import('../../src/shared/wsl-login-shell-command.ts')
|
||||
|
||||
const loginProbe = buildWslLoginShellCommand(
|
||||
`printf '\\n__ORCA_PATH__%s\\n__ORCA_GIT__%s\\n__ORCA_HOME__%s\\n' "$PATH" "$(command -v git)" "$HOME"`
|
||||
)
|
||||
const probe = await run(
|
||||
'wsl.exe',
|
||||
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(loginProbe)])
|
||||
)
|
||||
const probeText = probe.stdout.toString('utf8')
|
||||
const loginPath = /__ORCA_PATH__(.*)/.exec(probeText)?.[1]?.trim()
|
||||
const gitPath = /__ORCA_GIT__(.*)/.exec(probeText)?.[1]?.trim()
|
||||
const loginHome = /__ORCA_HOME__(.*)/.exec(probeText)?.[1]?.trim()
|
||||
if (!loginPath || !gitPath?.startsWith('/') || !loginHome?.startsWith('/')) {
|
||||
throw new Error(`Could not resolve login-shell Git environment: ${probeText.trim()}`)
|
||||
}
|
||||
const outputMarker = `__ORCA_GIT_OUTPUT_${process.pid}__\n`
|
||||
|
||||
const runGit = async (mode, repo, args) => {
|
||||
const startedAt = performance.now()
|
||||
let result
|
||||
if (mode === 'login') {
|
||||
const command = [
|
||||
`cd ${quotePosixShell(repo)} &&`,
|
||||
`printf ${quotePosixShell(outputMarker)} &&`,
|
||||
`LC_ALL=C LANG=C ${quotePosixShell('git')}`,
|
||||
...args.map(quotePosixShell)
|
||||
].join(' ')
|
||||
const delay = options.loginDelayMs > 0 ? `sleep ${options.loginDelayMs / 1_000}; ` : ''
|
||||
const script = `${delay}${buildWslLoginShellCommand(command)}`
|
||||
result = await run(
|
||||
'wsl.exe',
|
||||
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)])
|
||||
)
|
||||
const markerOffset = result.stdout.indexOf(outputMarker)
|
||||
if (markerOffset < 0) {
|
||||
throw new Error('Login shell did not emit the Git output marker')
|
||||
}
|
||||
result.stdout = result.stdout.subarray(markerOffset + Buffer.byteLength(outputMarker))
|
||||
} else {
|
||||
result = await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/usr/bin/env',
|
||||
`PATH=${loginPath}`,
|
||||
`HOME=${loginHome}`,
|
||||
'LC_ALL=C',
|
||||
'LANG=C',
|
||||
gitPath,
|
||||
'-C',
|
||||
repo,
|
||||
...args
|
||||
])
|
||||
)
|
||||
}
|
||||
return { ...result, elapsedMs: performance.now() - startedAt }
|
||||
}
|
||||
|
||||
const measure = async (label, runArm) => {
|
||||
for (let index = 0; index < options.warmups; index += 1) {
|
||||
await runArm('login')
|
||||
await runArm('fast')
|
||||
}
|
||||
const samples = { login: [], fast: [] }
|
||||
for (let index = 0; index < options.samples; index += 1) {
|
||||
const order = index % 2 === 0 ? ['login', 'fast'] : ['fast', 'login']
|
||||
const results = []
|
||||
for (const mode of order) {
|
||||
const result = await runArm(mode)
|
||||
samples[mode].push(result.elapsedMs)
|
||||
results.push(result)
|
||||
}
|
||||
if (
|
||||
results[0].status !== results[1].status ||
|
||||
!results[0].stdout.equals(results[1].stdout) ||
|
||||
!results[0].stderr.equals(results[1].stderr)
|
||||
) {
|
||||
throw new Error(
|
||||
`${label} correctness mismatch between ${order.join(' and ')} arms:\n` +
|
||||
`${order[0]}=${JSON.stringify({ stdout: results[0].stdout.toString('utf8'), stderr: results[0].stderr.toString('utf8') })}\n` +
|
||||
`${order[1]}=${JSON.stringify({ stdout: results[1].stdout.toString('utf8'), stderr: results[1].stderr.toString('utf8') })}`
|
||||
)
|
||||
}
|
||||
}
|
||||
const login = summarize(samples.login)
|
||||
const fast = summarize(samples.fast)
|
||||
return {
|
||||
login,
|
||||
fast,
|
||||
medianSpeedup: Number((login.medianMs / fast.medianMs).toFixed(2)),
|
||||
medianSavedMs: Number((login.medianMs - fast.medianMs).toFixed(1))
|
||||
}
|
||||
}
|
||||
|
||||
const benchmarkRepo = async (repo) => {
|
||||
const branch = (
|
||||
await runGit('fast', repo, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
|
||||
).stdout
|
||||
.toString('utf8')
|
||||
.trim()
|
||||
const fixtureName = `.orca-wsl-git-shell-benchmark-${process.pid}-${randomUUID()}.txt`
|
||||
const fixturePath = posix.join(repo, fixtureName)
|
||||
const prepareStage = async () => {
|
||||
await runGit('fast', repo, ['reset', '--quiet', '--', fixtureName])
|
||||
await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
'printf "benchmark\\n" > "$1"',
|
||||
'orca-wsl-git-shell-benchmark',
|
||||
fixturePath
|
||||
])
|
||||
)
|
||||
}
|
||||
const cleanupStage = async () => {
|
||||
await runGit('fast', repo, ['reset', '--quiet', '--', fixtureName])
|
||||
await run('wsl.exe', wslArgs(distro, ['/bin/rm', '-f', '--', fixturePath]))
|
||||
}
|
||||
|
||||
const operations = {
|
||||
status: [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'status',
|
||||
'--porcelain=v2',
|
||||
'--branch',
|
||||
'--untracked-files=all'
|
||||
],
|
||||
numstat: ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M'],
|
||||
upstream: [
|
||||
'for-each-ref',
|
||||
'--format=%(refname)%00%(upstream)%00%(upstream:short)',
|
||||
'--count=1',
|
||||
`refs/heads/${branch}`
|
||||
]
|
||||
}
|
||||
const result = {}
|
||||
for (const [name, args] of Object.entries(operations)) {
|
||||
result[name] = await measure(`${repo}:${name}`, (mode) => runGit(mode, repo, args))
|
||||
}
|
||||
const created = await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
'set -C; printf "benchmark\\n" > "$1"',
|
||||
'orca-wsl-git-shell-benchmark',
|
||||
fixturePath
|
||||
]),
|
||||
{ allowFailure: true }
|
||||
)
|
||||
if (created.status !== 0) {
|
||||
throw new Error(`Could not exclusively create benchmark fixture: ${fixturePath}`)
|
||||
}
|
||||
try {
|
||||
result.stageRefresh = await measure(`${repo}:stage-refresh`, async (mode) => {
|
||||
await prepareStage()
|
||||
const startedAt = performance.now()
|
||||
await runGit('login', repo, ['add', '--', fixtureName])
|
||||
const status = await runGit(mode, repo, operations.status)
|
||||
const numstat = await runGit(mode, repo, [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'diff',
|
||||
'-z',
|
||||
'--cached',
|
||||
'--numstat',
|
||||
'-M'
|
||||
])
|
||||
return {
|
||||
status: 0,
|
||||
stdout: Buffer.concat([status.stdout, numstat.stdout]),
|
||||
stderr: Buffer.concat([status.stderr, numstat.stderr]),
|
||||
elapsedMs: performance.now() - startedAt
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
await cleanupStage()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const verifyProductionRunner = async (repo) => {
|
||||
const windowsRepo = (
|
||||
await run('wsl.exe', wslArgs(distro, ['/usr/bin/wslpath', '-w', repo]))
|
||||
).stdout
|
||||
.toString('utf8')
|
||||
.trim()
|
||||
const args = [
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'status',
|
||||
'--porcelain=v2',
|
||||
'--branch',
|
||||
'--untracked-files=all'
|
||||
]
|
||||
const [{ gitExecFileAsync }, { getWslGitReadEnvironment }, expected] = await Promise.all([
|
||||
jiti.import('../../src/main/git/runner.ts'),
|
||||
jiti.import('../../src/main/git/wsl-git-read-environment.ts'),
|
||||
runGit('fast', repo, args)
|
||||
])
|
||||
await getWslGitReadEnvironment(distro)
|
||||
const startedAt = performance.now()
|
||||
const actual = await gitExecFileAsync(args, {
|
||||
cwd: windowsRepo,
|
||||
preferWslDirectGit: true,
|
||||
wslDistro: distro
|
||||
})
|
||||
if (
|
||||
!Buffer.from(actual.stdout).equals(expected.stdout) ||
|
||||
!Buffer.from(actual.stderr).equals(expected.stderr)
|
||||
) {
|
||||
throw new Error(`Production runner output mismatch for ${windowsRepo}`)
|
||||
}
|
||||
return {
|
||||
windowsRepo,
|
||||
environmentPrimed: true,
|
||||
elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
|
||||
outputBytes: expected.stdout.length
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
distro,
|
||||
gitPath,
|
||||
gitVersion: (await runGit('fast', options.nativeRepo, ['--version'])).stdout
|
||||
.toString('utf8')
|
||||
.trim(),
|
||||
samples: options.samples,
|
||||
warmups: options.warmups,
|
||||
injectedLoginDelayMs: options.loginDelayMs,
|
||||
loginProbePreambleBytes: Buffer.byteLength(probeText.split('__ORCA_PATH__', 1)[0]),
|
||||
guestProcessShape: {
|
||||
login: 'sh -> interactive login shell -> git',
|
||||
fast: 'env -> git'
|
||||
},
|
||||
native: await benchmarkRepo(options.nativeRepo),
|
||||
mounted: await benchmarkRepo(options.mountedRepo),
|
||||
productionRunnerVerification: {
|
||||
native: await verifyProductionRunner(options.nativeRepo),
|
||||
mounted: await verifyProductionRunner(options.mountedRepo)
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Minimal `electron` module for wsl-hook-relay-reattach-benchmark.mjs, which loads the real
|
||||
* src/main/ipc/pty.ts under plain node. Only the surfaces that main's PTY graph touches are
|
||||
* implemented; everything else stays undefined so an unexpected dependency fails loudly.
|
||||
*/
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const userDataPath = process.env.ORCA_USER_DATA_PATH ?? tmpdir()
|
||||
|
||||
export const BrowserWindow = undefined
|
||||
|
||||
export const app = {
|
||||
isPackaged: false,
|
||||
getVersion: () => '0.0.0-wsl-relay-benchmark',
|
||||
getName: () => 'orca-wsl-relay-benchmark',
|
||||
getPath: (name) => {
|
||||
if (name === 'userData') {
|
||||
return userDataPath
|
||||
}
|
||||
if (name === 'temp') {
|
||||
return tmpdir()
|
||||
}
|
||||
if (name === 'home') {
|
||||
return homedir()
|
||||
}
|
||||
return join(userDataPath, name)
|
||||
},
|
||||
on: () => {},
|
||||
once: () => {},
|
||||
whenReady: () => Promise.resolve()
|
||||
}
|
||||
|
||||
export const ipcMain = {
|
||||
handle: () => {},
|
||||
handleOnce: () => {},
|
||||
on: () => {},
|
||||
once: () => {},
|
||||
removeHandler: () => {},
|
||||
removeAllListeners: () => {}
|
||||
}
|
||||
|
||||
export const powerMonitor = { on: () => {}, once: () => {} }
|
||||
export const nativeTheme = { shouldUseDarkColors: false, on: () => {} }
|
||||
export const shell = { openPath: async () => '', openExternal: async () => {} }
|
||||
export const dialog = { showMessageBox: async () => ({ response: 0 }) }
|
||||
export const safeStorage = { isEncryptionAvailable: () => false }
|
||||
export const clipboard = { readText: () => '', writeText: () => {} }
|
||||
export const screen = { getPrimaryDisplay: () => ({ workAreaSize: { width: 0, height: 0 } }) }
|
||||
export const session = { defaultSession: undefined }
|
||||
|
||||
export default {
|
||||
BrowserWindow,
|
||||
app,
|
||||
ipcMain,
|
||||
powerMonitor,
|
||||
nativeTheme,
|
||||
shell,
|
||||
dialog,
|
||||
safeStorage,
|
||||
clipboard,
|
||||
screen,
|
||||
session
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { createJiti } from 'jiti'
|
||||
|
||||
const DEFAULT_SAMPLES = 20
|
||||
const PANE_KEY = 'wsl-relay-bench:11111111-1111-4111-8111-111111111111'
|
||||
const WSL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
const TERMINAL_COLS = 80
|
||||
const TERMINAL_ROWS = 24
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { distro: null, samples: DEFAULT_SAMPLES }
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const arg = argv[index]
|
||||
if (arg === '--distro') {
|
||||
options.distro = argv[++index] ?? null
|
||||
} else if (arg === '--samples') {
|
||||
options.samples = Number(argv[++index])
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(options.samples) || options.samples < 5 || options.samples > 100) {
|
||||
throw new Error('--samples must be an integer between 5 and 100')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
const stdout = []
|
||||
const stderr = []
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk))
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk))
|
||||
child.on('error', reject)
|
||||
child.on('close', (status) => {
|
||||
const result = {
|
||||
status,
|
||||
stdout: Buffer.concat(stdout).toString('utf8'),
|
||||
stderr: Buffer.concat(stderr).toString('utf8')
|
||||
}
|
||||
if (status === 0 || options.allowFailure) {
|
||||
resolve(result)
|
||||
} else {
|
||||
reject(new Error(`${command} exited ${status}: ${result.stderr.trim()}`))
|
||||
}
|
||||
})
|
||||
child.stdin.end(options.input)
|
||||
})
|
||||
}
|
||||
|
||||
function wslArgs(distro, args) {
|
||||
return ['-d', distro, '--exec', ...args]
|
||||
}
|
||||
|
||||
async function resolveDistro(requested) {
|
||||
if (requested) {
|
||||
return requested
|
||||
}
|
||||
const listed = await run('wsl.exe', ['--list', '--quiet'], {
|
||||
env: { ...process.env, WSL_UTF8: '1' }
|
||||
})
|
||||
const distro = listed.stdout
|
||||
.replaceAll('\0', '')
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.find(Boolean)
|
||||
if (!distro) {
|
||||
throw new Error('No WSL distro is installed')
|
||||
}
|
||||
return distro
|
||||
}
|
||||
|
||||
async function readGuestFile(distro, path) {
|
||||
const result = await run('wsl.exe', wslArgs(distro, ['/bin/cat', path]), {
|
||||
allowFailure: true
|
||||
})
|
||||
return result.status === 0 ? result.stdout : null
|
||||
}
|
||||
|
||||
async function waitFor(description, probe, timeoutMs = 30_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const value = await probe()
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${description}`)
|
||||
}
|
||||
|
||||
function parseEndpoint(contents) {
|
||||
const port = Number(/ORCA_AGENT_HOOK_PORT=['"]?(\d+)/.exec(contents)?.[1])
|
||||
const token = /ORCA_AGENT_HOOK_TOKEN=['"]?([^'"\r\n]+)/.exec(contents)?.[1]
|
||||
return Number.isInteger(port) && port > 0 && token ? { port, token } : null
|
||||
}
|
||||
|
||||
async function freeGuestPort(distro) {
|
||||
const result = await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/usr/bin/python3',
|
||||
'-c',
|
||||
'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()'
|
||||
])
|
||||
)
|
||||
const port = Number(result.stdout.trim())
|
||||
if (!Number.isInteger(port) || port <= 0) {
|
||||
throw new Error(`Could not reserve a guest port: ${result.stdout}`)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
async function startStallingGuestServer(distro) {
|
||||
const source = [
|
||||
'import socket,sys,threading',
|
||||
'stop=threading.Event()',
|
||||
'threading.Thread(target=lambda:(sys.stdin.buffer.read(),stop.set()),daemon=True).start()',
|
||||
'server=socket.socket()',
|
||||
'server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)',
|
||||
'server.bind(("127.0.0.1",0))',
|
||||
'server.listen()',
|
||||
'server.settimeout(0.1)',
|
||||
'print(f"READY {server.getsockname()[1]}",flush=True)',
|
||||
'held=[]',
|
||||
'while not stop.is_set():',
|
||||
' try:',
|
||||
' client,_=server.accept(); held.append(client); print("ACCEPT",flush=True)',
|
||||
' except TimeoutError: pass',
|
||||
'for client in held: client.close()',
|
||||
'server.close()'
|
||||
].join('\n')
|
||||
const child = spawn('wsl.exe', wslArgs(distro, ['/usr/bin/python3', '-u', '-c', source]), {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
let output = ''
|
||||
let accepted = 0
|
||||
child.stdout.on('data', (chunk) => {
|
||||
output += chunk.toString('utf8')
|
||||
accepted += chunk.toString('utf8').split('ACCEPT').length - 1
|
||||
})
|
||||
const port = await waitFor('stalling WSL listener', async () => {
|
||||
const match = /READY (\d+)/.exec(output)
|
||||
return match ? Number(match[1]) : null
|
||||
})
|
||||
return {
|
||||
port,
|
||||
accepted: () => accepted,
|
||||
stop: async () => {
|
||||
child.stdin.end()
|
||||
await new Promise((resolve) => child.once('close', resolve))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeEndpoint(distro, path, endpoint) {
|
||||
const contents = [
|
||||
`ORCA_AGENT_HOOK_PORT=${endpoint.port}`,
|
||||
`ORCA_AGENT_HOOK_TOKEN=${endpoint.token}`,
|
||||
'ORCA_AGENT_HOOK_ENV=benchmark',
|
||||
'ORCA_AGENT_HOOK_VERSION=1',
|
||||
''
|
||||
].join('\n')
|
||||
await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
'umask 077; mkdir -p "$(dirname "$1")"; cat > "$1"',
|
||||
'orca-wsl-benchmark',
|
||||
path
|
||||
]),
|
||||
{ input: contents }
|
||||
)
|
||||
}
|
||||
|
||||
async function invokeHook(distro, scriptPath, endpointPath) {
|
||||
const startedAt = performance.now()
|
||||
const result = await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/usr/bin/env',
|
||||
`PATH=${WSL_PATH}`,
|
||||
`ORCA_AGENT_HOOK_ENDPOINT=${endpointPath}`,
|
||||
`ORCA_PANE_KEY=${PANE_KEY}`,
|
||||
'ORCA_TAB_ID=wsl-relay-bench',
|
||||
'ORCA_WORKTREE_ID=wsl-relay-bench',
|
||||
'/bin/sh',
|
||||
scriptPath
|
||||
]),
|
||||
{ input: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'benchmark' }) }
|
||||
)
|
||||
return { status: result.status, elapsedMs: performance.now() - startedAt }
|
||||
}
|
||||
|
||||
/** Captures the PTY controller that registerPtyHandlers installs; every other runtime
|
||||
* call the spawn path makes is a no-op so main's real lifecycle ordering still runs. */
|
||||
function createRuntimeStub() {
|
||||
let controller = null
|
||||
const own = {
|
||||
setPtyController: (next) => {
|
||||
controller = next
|
||||
}
|
||||
}
|
||||
// Why no `has: () => true`: feature detection (`'x' in runtime`, typeof checks) must
|
||||
// not claim methods exist when they are only auto-stubbed getters.
|
||||
const runtime = new Proxy(own, {
|
||||
get: (target, property) => {
|
||||
if (property in target) {
|
||||
return target[property]
|
||||
}
|
||||
return typeof property === 'string' && property !== 'then' ? () => undefined : undefined
|
||||
}
|
||||
})
|
||||
return { runtime, getController: () => controller }
|
||||
}
|
||||
|
||||
function createRendererWindowStub() {
|
||||
const webContents = {
|
||||
id: 1,
|
||||
on: () => {},
|
||||
once: () => {},
|
||||
removeListener: () => {},
|
||||
send: () => {},
|
||||
isDestroyed: () => false,
|
||||
session: { on: () => {} }
|
||||
}
|
||||
return { webContents, on: () => {}, once: () => {}, isDestroyed: () => false }
|
||||
}
|
||||
|
||||
function percentile(samples, percentileValue) {
|
||||
const sorted = [...samples].sort((left, right) => left - right)
|
||||
return sorted[Math.ceil((percentileValue / 100) * sorted.length) - 1]
|
||||
}
|
||||
|
||||
function summarize(samples) {
|
||||
return {
|
||||
samples: samples.length,
|
||||
medianMs: Number(percentile(samples, 50).toFixed(1)),
|
||||
p95Ms: Number(percentile(samples, 95).toFixed(1)),
|
||||
minMs: Number(Math.min(...samples).toFixed(1)),
|
||||
maxMs: Number(Math.max(...samples).toFixed(1))
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('This benchmark requires a Windows host with WSL')
|
||||
}
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const distro = await resolveDistro(options.distro)
|
||||
const nodeVersion = await run(
|
||||
'wsl.exe',
|
||||
wslArgs(distro, [
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
'for node_bin in "$(command -v node 2>/dev/null || true)" "$HOME/.local/bin/node"; do [ -x "$node_bin" ] || continue; "$node_bin" -p process.versions.node && exit 0; done; exit 1'
|
||||
]),
|
||||
{ allowFailure: true }
|
||||
)
|
||||
if (nodeVersion.status !== 0 || Number(nodeVersion.stdout.trim().split('.')[0]) < 18) {
|
||||
throw new Error(`WSL distro '${distro}' needs Node.js 18 or newer to run Orca's relay`)
|
||||
}
|
||||
|
||||
const bundleDir = join(process.cwd(), 'out', 'relay', 'wsl')
|
||||
const bundlePath = join(bundleDir, 'wsl-agent-hook-relay.js')
|
||||
const versionPath = join(bundleDir, '.version')
|
||||
if (!existsSync(bundlePath) || !existsSync(versionPath)) {
|
||||
await run(process.execPath, [join('config', 'scripts', 'build-relay.mjs')], {
|
||||
cwd: process.cwd()
|
||||
})
|
||||
}
|
||||
|
||||
// Why: main's PTY graph reads app paths at import time; keep it inside a disposable directory.
|
||||
let userDataDir
|
||||
let ptyIpc
|
||||
let cleanupPaths = []
|
||||
let manager = null
|
||||
let staller = null
|
||||
try {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-wsl-relay-bench-'))
|
||||
process.env.ORCA_USER_DATA_PATH = userDataDir
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
alias: {
|
||||
electron: fileURLToPath(
|
||||
new URL('./wsl-hook-relay-reattach-benchmark-electron-stub.mjs', import.meta.url)
|
||||
)
|
||||
}
|
||||
})
|
||||
// Why sequential: concurrent jiti.import calls can each instantiate the module graph, and two
|
||||
// copies of wsl-hook-relay-manager.ts would leave pty.ts refreshing a singleton we never see.
|
||||
ptyIpc = await jiti.import('../../src/main/ipc/pty.ts')
|
||||
const { WslHookRelayManager, wslHookRelayManager } = await jiti.import(
|
||||
'../../src/main/agent-hooks/wsl-hook-relay-manager.ts'
|
||||
)
|
||||
const { ensureWslHookRelayForReattach } = await jiti.import(
|
||||
'../../src/main/agent-hooks/wsl-hook-relay-reattach.ts'
|
||||
)
|
||||
const { codexHookService } = await jiti.import('../../src/main/codex/hook-service.ts')
|
||||
const { MANAGED_AGENT_HOOK_TARGETS } = await jiti.import(
|
||||
'../../src/shared/managed-agent-hook-targets.ts'
|
||||
)
|
||||
const { toWindowsWslPath } = await jiti.import('../../src/shared/wsl-paths.ts')
|
||||
const { FLOATING_TERMINAL_WORKTREE_ID } = await jiti.import('../../src/shared/constants.ts')
|
||||
|
||||
// Why: if jiti ever returns a second manager module, our monkey-patch would miss the singleton
|
||||
// that ensureWslHookRelayForReattach (loaded via pty.ts) closes over — fail closed now.
|
||||
const singletonProbe = []
|
||||
const previousEnsure = wslHookRelayManager.ensureForDistro.bind(wslHookRelayManager)
|
||||
wslHookRelayManager.ensureForDistro = (probedDistro) => {
|
||||
singletonProbe.push(probedDistro)
|
||||
}
|
||||
ensureWslHookRelayForReattach(
|
||||
{ isReattach: true, wslDistro: '__bench-singleton-probe__' },
|
||||
null
|
||||
)
|
||||
wslHookRelayManager.ensureForDistro = previousEnsure
|
||||
if (singletonProbe.length !== 1 || singletonProbe[0] !== '__bench-singleton-probe__') {
|
||||
throw new Error(
|
||||
'jiti duplicated the WSL hook-relay manager graph; reattach patch would not observe pty.ts'
|
||||
)
|
||||
}
|
||||
|
||||
const guestHome = (
|
||||
await run('wsl.exe', wslArgs(distro, ['/bin/sh', '-c', 'printf %s "$HOME"']))
|
||||
).stdout.trim()
|
||||
const instanceKey = `bench-${process.pid}-${Date.now().toString(36)}`
|
||||
const benchmarkRoot = `${guestHome}/.orca-wsl/benchmarks/${instanceKey}`
|
||||
const scriptPath = `${benchmarkRoot}/.orca/agent-hooks/codex-hook.sh`
|
||||
const endpointPath = `${guestHome}/.orca-wsl/agent-hooks/instance-${instanceKey}/endpoint.env`
|
||||
cleanupPaths = [benchmarkRoot, `${guestHome}/.orca-wsl/agent-hooks/instance-${instanceKey}`]
|
||||
if (cleanupPaths.some((cleanupPath) => !cleanupPath.startsWith(`${guestHome}/.orca-wsl/`))) {
|
||||
throw new Error('Refusing to use an unexpected guest cleanup path')
|
||||
}
|
||||
const disabledTuiAgents = MANAGED_AGENT_HOOK_TARGETS.filter(
|
||||
(target) => target.tuiAgent !== 'codex'
|
||||
).map((target) => target.tuiAgent)
|
||||
const bundleVersion = readFileSync(versionPath, 'utf8').trim()
|
||||
const warnings = []
|
||||
const relayRefreshes = []
|
||||
let delivered = 0
|
||||
|
||||
// Why: pty.ts refreshes through the production singleton, so route that singleton at the
|
||||
// benchmark-scoped manager instead of calling the reattach helper from here — a removed or
|
||||
// mislocated integration call in pty.ts must fail this benchmark.
|
||||
wslHookRelayManager.ensureForDistro = (refreshedDistro) => {
|
||||
relayRefreshes.push(refreshedDistro)
|
||||
manager?.ensureForDistro(refreshedDistro)
|
||||
}
|
||||
|
||||
const { runtime, getController } = createRuntimeStub()
|
||||
// Why hooks off: fresh WSL spawn still runs buildPtyHostEnv, which calls ensureForDistro when
|
||||
// hooks are on. This bench isolates the reattach call site (ensureWslHookRelayForReattach);
|
||||
// the manager's own hooks gate lives behind the ensureForDistro patch above, so it stays out
|
||||
// of the measurement — `manager` below resolves its own (enabled) managed-hook settings.
|
||||
ptyIpc.registerPtyHandlers(
|
||||
createRendererWindowStub(),
|
||||
runtime,
|
||||
undefined,
|
||||
() => ({ agentStatusHooksEnabled: false }),
|
||||
undefined,
|
||||
undefined,
|
||||
{}
|
||||
)
|
||||
const ptyController = getController()
|
||||
if (typeof ptyController?.spawn !== 'function') {
|
||||
throw new Error('registerPtyHandlers did not install a runtime PTY controller')
|
||||
}
|
||||
const survivingSessionId = `orca-wsl-relay-bench-${instanceKey}`
|
||||
const spawnSurvivingPty = () =>
|
||||
ptyController.spawn({
|
||||
cols: TERMINAL_COLS,
|
||||
rows: TERMINAL_ROWS,
|
||||
// Why floating: without a worktree root main drops the requested cwd, and the UNC path is
|
||||
// what makes the local provider launch this PTY inside the distro under test.
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
cwd: toWindowsWslPath(guestHome, distro),
|
||||
sessionId: survivingSessionId
|
||||
})
|
||||
|
||||
const createManager = async (token) => {
|
||||
const preferredPort = await freeGuestPort(distro)
|
||||
return new WslHookRelayManager({
|
||||
platform: () => 'win32',
|
||||
remoteHooksEnabled: () => true,
|
||||
hookCoordsEnv: () => ({
|
||||
ORCA_AGENT_HOOK_PORT: String(preferredPort),
|
||||
ORCA_AGENT_HOOK_TOKEN: token,
|
||||
ORCA_AGENT_HOOK_ENV: 'benchmark',
|
||||
ORCA_AGENT_HOOK_VERSION: '1'
|
||||
}),
|
||||
instanceKey: () => instanceKey,
|
||||
resolveBundle: () => ({ jsPath: bundlePath, version: bundleVersion }),
|
||||
listDistros: async () => [distro],
|
||||
ingest: () => {
|
||||
delivered++
|
||||
},
|
||||
installHooks: async (sftp) => [
|
||||
await codexHookService.installRemote(sftp, benchmarkRoot, {
|
||||
codexHomeDir: `${benchmarkRoot}/codex-home`,
|
||||
deferTrustUntilConfigToml: true
|
||||
})
|
||||
],
|
||||
managedHookSettings: () => ({
|
||||
agentCmdOverrides: { codex: '/bin/true' },
|
||||
disabledTuiAgents
|
||||
}),
|
||||
pluginSources: () => ({}),
|
||||
warn: (message) => warnings.push(message),
|
||||
transientRetryDelayMs: 100
|
||||
})
|
||||
}
|
||||
|
||||
manager = await createManager('before-restart-token')
|
||||
manager.ensureForDistro(distro)
|
||||
await waitFor('initial relay and generated Codex hook', async () => {
|
||||
const [endpoint, script] = await Promise.all([
|
||||
readGuestFile(distro, endpointPath),
|
||||
readGuestFile(distro, scriptPath)
|
||||
])
|
||||
return endpoint?.includes('before-restart-token') && script?.includes('--max-time')
|
||||
})
|
||||
|
||||
const survivingPty = await spawnSurvivingPty()
|
||||
if (relayRefreshes.length > 0) {
|
||||
throw new Error(`Fresh WSL spawn refreshed the relay: ${relayRefreshes.join(', ')}`)
|
||||
}
|
||||
|
||||
manager.disposeAll()
|
||||
manager = null
|
||||
|
||||
staller = await startStallingGuestServer(distro)
|
||||
await writeEndpoint(distro, endpointPath, {
|
||||
port: staller.port,
|
||||
token: 'stale-token'
|
||||
})
|
||||
|
||||
await invokeHook(distro, scriptPath, endpointPath)
|
||||
const staleSamples = []
|
||||
for (let index = 0; index < options.samples; index++) {
|
||||
staleSamples.push((await invokeHook(distro, scriptPath, endpointPath)).elapsedMs)
|
||||
}
|
||||
|
||||
delivered = 0
|
||||
manager = await createManager('after-restart-token')
|
||||
// Reattach the surviving WSL PTY through main's real spawn path — no direct helper call.
|
||||
// Why delta: only the reattach phase should record ensureForDistro; length alone can false-
|
||||
// green if an earlier phase already refreshed (or if a future hooks-on change reintroduces it).
|
||||
const refreshesBeforeReattach = relayRefreshes.length
|
||||
const reattachedPty = await spawnSurvivingPty()
|
||||
if (reattachedPty.id !== survivingPty.id) {
|
||||
throw new Error(
|
||||
`Expected to reattach PTY ${survivingPty.id}, main spawned ${reattachedPty.id}`
|
||||
)
|
||||
}
|
||||
const reattachRefreshes = relayRefreshes.slice(refreshesBeforeReattach)
|
||||
if (reattachRefreshes.length !== 1 || reattachRefreshes[0] !== distro) {
|
||||
throw new Error(
|
||||
`PTY reattach did not refresh the relay for '${distro}': before=${refreshesBeforeReattach} all=[${relayRefreshes.join(', ')}]`
|
||||
)
|
||||
}
|
||||
const refreshedEndpoint = await waitFor('reattached relay endpoint rewrite', async () => {
|
||||
const contents = await readGuestFile(distro, endpointPath)
|
||||
const parsed = contents ? parseEndpoint(contents) : null
|
||||
return parsed && parsed.token === 'after-restart-token' ? parsed : null
|
||||
})
|
||||
|
||||
await invokeHook(distro, scriptPath, endpointPath)
|
||||
await waitFor('refreshed hook warmup delivery', async () => (delivered >= 1 ? true : null))
|
||||
delivered = 0
|
||||
const refreshedSamples = []
|
||||
for (let index = 0; index < options.samples; index++) {
|
||||
refreshedSamples.push((await invokeHook(distro, scriptPath, endpointPath)).elapsedMs)
|
||||
}
|
||||
await waitFor('all refreshed hooks to reach the host', async () =>
|
||||
delivered >= options.samples ? true : null
|
||||
)
|
||||
|
||||
const stale = summarize(staleSamples)
|
||||
const refreshed = summarize(refreshedSamples)
|
||||
const result = {
|
||||
distro,
|
||||
endpointPath,
|
||||
endpointPortAfterReattach: refreshedEndpoint.port,
|
||||
reattachedPtyId: reattachedPty.id,
|
||||
relayRefreshes,
|
||||
reattachRefreshes,
|
||||
stale,
|
||||
refreshed,
|
||||
medianSpeedup: Number((stale.medianMs / refreshed.medianMs).toFixed(1)),
|
||||
stalledConnections: staller.accepted(),
|
||||
delivered,
|
||||
warnings
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
|
||||
if (stale.medianMs < 1_000) {
|
||||
throw new Error(`Stale endpoint did not exercise the timeout path: ${stale.medianMs}ms`)
|
||||
}
|
||||
if (refreshed.p95Ms >= 1_000) {
|
||||
throw new Error(`Refreshed endpoint p95 regressed: ${refreshed.p95Ms}ms`)
|
||||
}
|
||||
if (delivered !== options.samples) {
|
||||
throw new Error(`Expected ${options.samples} delivered hooks, received ${delivered}`)
|
||||
}
|
||||
} finally {
|
||||
manager?.disposeAll()
|
||||
await staller?.stop()
|
||||
ptyIpc?.killAllPty?.()
|
||||
if (userDataDir) {
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
}
|
||||
for (const cleanupPath of cleanupPaths) {
|
||||
await run('wsl.exe', wslArgs(distro, ['/bin/rm', '-rf', '--', cleanupPath]), {
|
||||
allowFailure: true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -12,6 +12,7 @@
|
||||
"../src/main/agent-hooks/local-agent-cli-presence.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
@@ -35,6 +36,7 @@
|
||||
"../src/main/codex/codex-hook-trust-grant.ts",
|
||||
"../src/main/codex/codex-managed-trust-reconciliation.ts",
|
||||
"../src/main/codex/codex-process-exit-deadline.ts",
|
||||
"../src/main/codex/codex-state-db.ts",
|
||||
"../src/main/codex/codex-trust-config-rollback.ts",
|
||||
"../src/main/codex/codex-trust-grant-telemetry.ts",
|
||||
"../src/main/codex/codex-trust-grant-host.ts",
|
||||
@@ -77,6 +79,7 @@
|
||||
// project's serve spec; the module has no imports, so listing it pulls in nothing else.
|
||||
"../src/main/startup/serve-mode-argv.ts",
|
||||
"../src/main/runtime/runtime-metadata.ts",
|
||||
"../src/main/sqlite/sync-database.ts",
|
||||
"../src/main/win32-utils.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
|
||||
"../src/main/ipc/worktree-metadata-merge.ts",
|
||||
"../src/main/ipc/worktree-path-comparison.ts",
|
||||
"../src/main/wsl-availability.ts",
|
||||
"../src/main/wsl-distro-list-output.ts",
|
||||
"../src/main/wsl-distro-retry.ts",
|
||||
"../src/main/wsl.ts",
|
||||
|
||||
@@ -16,7 +16,8 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
|
||||
execArgv: ['--no-experimental-webstorage'],
|
||||
// Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds.
|
||||
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
|
||||
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
|
||||
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
|
||||
include: [
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
# AI Vault process isolation architecture and implementation plan
|
||||
|
||||
Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete.
|
||||
|
||||
Last updated: 2026-08-09.
|
||||
|
||||
## Decision summary
|
||||
|
||||
AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary.
|
||||
|
||||
The target has three rules:
|
||||
|
||||
1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process.
|
||||
2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data.
|
||||
3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer.
|
||||
|
||||
This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified.
|
||||
|
||||
The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal.
|
||||
|
||||
## Goals
|
||||
|
||||
- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host.
|
||||
- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active.
|
||||
- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time.
|
||||
- The service can be restarted without restarting the app, runtime, relay, or terminals.
|
||||
- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible.
|
||||
- Mixed-version remote peers continue to work.
|
||||
- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity.
|
||||
- Rewriting the scanner or changing session discovery semantics during the process migration.
|
||||
- Moving xterm into another renderer.
|
||||
- Adding a process per window, worktree, repository, or SSH request.
|
||||
- Removing the old-relay compatibility fallback in the same change.
|
||||
- Making pagination a public wire dependency before mixed-version fallback behavior exists.
|
||||
|
||||
## Current architecture and coupling
|
||||
|
||||
| Path | Current execution | Remaining coupling |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main |
|
||||
| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host |
|
||||
| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process |
|
||||
| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay |
|
||||
| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm |
|
||||
|
||||
The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API.
|
||||
|
||||
The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign.
|
||||
|
||||
## Feature-parity contract
|
||||
|
||||
The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics.
|
||||
|
||||
| Capability | Required behavior after migration | Owner |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters |
|
||||
| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today |
|
||||
| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection |
|
||||
| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router |
|
||||
| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them |
|
||||
| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation |
|
||||
| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing |
|
||||
| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate |
|
||||
| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate |
|
||||
| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result |
|
||||
| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration |
|
||||
| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success |
|
||||
| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling |
|
||||
| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports |
|
||||
|
||||
Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow.
|
||||
|
||||
No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list.
|
||||
|
||||
## Target topology
|
||||
|
||||
```text
|
||||
Desktop / runtime host
|
||||
|
||||
Integrated renderer
|
||||
├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon
|
||||
└─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process
|
||||
│
|
||||
└─ host merge + compatibility routing
|
||||
|
||||
SSH execution host
|
||||
|
||||
Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar
|
||||
└────────────────── PTY handler/event loop
|
||||
```
|
||||
|
||||
The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar.
|
||||
|
||||
## Process ownership
|
||||
|
||||
- Desktop: one Vault service per Electron main process, shared by every window and workspace.
|
||||
- Orca runtime: one Vault service per runtime host process, shared by all connected clients.
|
||||
- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests.
|
||||
- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration.
|
||||
- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop.
|
||||
|
||||
## Service responsibilities
|
||||
|
||||
The service owns:
|
||||
|
||||
- Source discovery and filesystem metadata reads.
|
||||
- Transcript streaming and parsing for all supported agents.
|
||||
- OpenCode SQLite reads and its nested worker lifecycle.
|
||||
- In-memory and persisted session parse caches.
|
||||
- The bounded title index and transcript title resolution.
|
||||
- On-demand Claude/OMP subagent listing and full first-user-prompt extraction.
|
||||
- Scan parse concurrency and cooperative cancellation.
|
||||
- Result construction, deduplication, sorting, and internal paging.
|
||||
- Per-request metrics that contain counts and timings, not session content.
|
||||
|
||||
The router owns:
|
||||
|
||||
- Electron IPC, runtime RPC, or relay method registration.
|
||||
- Authentication and connection ownership already enforced by that transport.
|
||||
- Public argument validation and execution-host selection.
|
||||
- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration.
|
||||
- Multi-host result merging.
|
||||
- Request coalescing and mapping caller cancellation to the service request.
|
||||
- Validation of service responses before publishing them across a public boundary.
|
||||
- Compatibility fallback selection.
|
||||
- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation.
|
||||
|
||||
The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication.
|
||||
|
||||
## Internal process protocol
|
||||
|
||||
Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol.
|
||||
|
||||
Parent to service:
|
||||
|
||||
```ts
|
||||
type ServiceRequest =
|
||||
| { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions }
|
||||
| { type: 'request'; id: number; operation: 'scan'; options: ScanOptions }
|
||||
| { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] }
|
||||
| { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest }
|
||||
| { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest }
|
||||
| { type: 'invalidate'; paths: string[]; generation: number }
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'shutdown' }
|
||||
```
|
||||
|
||||
Service to parent:
|
||||
|
||||
```ts
|
||||
type ServiceResponse =
|
||||
| { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities }
|
||||
| { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics }
|
||||
| { type: 'result'; id: number; operation: 'titles'; value: TitlesResult }
|
||||
| { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult }
|
||||
| { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult }
|
||||
| { type: 'invalidated'; generation: number }
|
||||
| { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean }
|
||||
| { type: 'fatal'; code: ServiceErrorCode; message: string }
|
||||
```
|
||||
|
||||
Protocol rules:
|
||||
|
||||
- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch.
|
||||
- Use monotonically increasing safe-integer request IDs scoped to the child lifetime.
|
||||
- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth.
|
||||
- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes.
|
||||
- Coalesce equivalent scans. Replace an older queued background scan with the newest one.
|
||||
- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does.
|
||||
- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy.
|
||||
- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck.
|
||||
- Continue returning the last good renderer result when a refresh fails.
|
||||
- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state.
|
||||
- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented.
|
||||
|
||||
The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers.
|
||||
|
||||
## Lifecycle and supervision
|
||||
|
||||
### Startup
|
||||
|
||||
- Start lazily on the first scan or title request.
|
||||
- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader.
|
||||
- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows.
|
||||
- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready.
|
||||
- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child.
|
||||
|
||||
### Steady state
|
||||
|
||||
- Reuse the process and caches across calls.
|
||||
- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency.
|
||||
- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal.
|
||||
- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive.
|
||||
- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper.
|
||||
|
||||
### Faults and restart
|
||||
|
||||
- Reject the active request on exit, disconnect, malformed protocol, or timeout.
|
||||
- Retry queued work in a new process only when it is safe and has not been cancelled.
|
||||
- Back off at 250 ms, 1 second, then 5 seconds.
|
||||
- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt.
|
||||
- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely.
|
||||
|
||||
## Resource and quality-of-service policy
|
||||
|
||||
The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness.
|
||||
|
||||
- CPU: below-normal process priority, scan concurrency eight, one scan at a time.
|
||||
- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry.
|
||||
- Queue: 16 calls, coalescing, background replacement, explicit priority.
|
||||
- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior.
|
||||
- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve.
|
||||
- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending.
|
||||
- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap.
|
||||
|
||||
Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation.
|
||||
|
||||
## Renderer publication
|
||||
|
||||
`ai-vault-session-refresh.ts` should stop applying a completed result immediately.
|
||||
|
||||
Add `ai-vault-session-publication-gate.ts` with this behavior:
|
||||
|
||||
1. Cache the validated result immediately so another caller can reuse it.
|
||||
2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`.
|
||||
3. Otherwise retain only the newest pending result and wait for quiet.
|
||||
4. Publish after 1 second even if input continues, using a transition and one bounded page.
|
||||
5. Cancel a pending publication when the scope, host, or component request token changes.
|
||||
|
||||
Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel.
|
||||
|
||||
A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization.
|
||||
|
||||
## Desktop and runtime implementation
|
||||
|
||||
Create these concrete modules:
|
||||
|
||||
- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown.
|
||||
- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy.
|
||||
- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions.
|
||||
- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution.
|
||||
- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes.
|
||||
- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy.
|
||||
|
||||
Reuse the scanner and cache modules without moving their business logic. Replace imports in:
|
||||
|
||||
- `src/main/ai-vault/cached-session-list.ts`
|
||||
- `src/main/ai-vault/session-title-resolver.ts`
|
||||
- `src/main/ipc/ai-vault-subagent-list.ts`
|
||||
- `src/main/ai-vault/session-first-user-prompt-read.ts`
|
||||
- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete
|
||||
- `src/main/ipc/ai-vault.ts`
|
||||
- `src/main/runtime/rpc/methods/ai-vault.ts`
|
||||
|
||||
Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period.
|
||||
|
||||
Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration.
|
||||
|
||||
## SSH relay sidecar implementation
|
||||
|
||||
Create:
|
||||
|
||||
- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader.
|
||||
- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter.
|
||||
- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports.
|
||||
|
||||
Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process.
|
||||
|
||||
`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness.
|
||||
|
||||
If the sidecar cannot start or crashes:
|
||||
|
||||
- Return a normal `AiVaultListResult` containing a host issue and no new sessions.
|
||||
- Do not scan inline in the relay.
|
||||
- Keep all PTYs, reconnect state, and other relay methods available.
|
||||
- Allow the next manual refresh to cross the restart circuit breaker once.
|
||||
|
||||
Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals.
|
||||
|
||||
## Remote compatibility
|
||||
|
||||
The first implementation adds no public method, stream opcode, required field, or changed result meaning.
|
||||
|
||||
- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar.
|
||||
- New desktop with current relay: calls the same methods and receives the same result shape.
|
||||
- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback.
|
||||
- Old desktop with new relay: unknown internal sidecar details never cross the wire.
|
||||
|
||||
Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability.
|
||||
|
||||
The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision.
|
||||
|
||||
## Folder workspaces, WSL, and paths
|
||||
|
||||
- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata.
|
||||
- Continue using `path.join` and the existing host-platform path adapters.
|
||||
- Keep Windows batch and shell selection unrelated to terminal-shell preference.
|
||||
- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows.
|
||||
- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths.
|
||||
- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript.
|
||||
- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The service is a performance and reliability boundary, not a privilege boundary.
|
||||
|
||||
- It has the same OS user and must access the same agent stores.
|
||||
- Do not expose its IPC channel to renderer code or the network.
|
||||
- The parent passes validated options; the service validates again before filesystem access.
|
||||
- Do not log prompts, titles, transcript paths, environment values, or raw service messages.
|
||||
- Preserve symlink and allowed-root checks for title reads.
|
||||
- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further.
|
||||
|
||||
## Packaging
|
||||
|
||||
Desktop packaging changes:
|
||||
|
||||
- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`.
|
||||
- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`.
|
||||
- Reuse the existing unpacked entry-path pattern used by other forked processes.
|
||||
- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down.
|
||||
|
||||
Relay packaging changes:
|
||||
|
||||
- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture.
|
||||
- Include it in `.version` content hashing and deploy completeness assertions.
|
||||
- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together.
|
||||
- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier.
|
||||
|
||||
## Observability
|
||||
|
||||
Add spans and counters for:
|
||||
|
||||
- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit.
|
||||
- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls.
|
||||
- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count.
|
||||
- Parent/child serialization duration and serialized bytes.
|
||||
- Child RSS and heap-used snapshots at request completion.
|
||||
- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count.
|
||||
- Relay sidecar failures separately from PTY and relay-loop health.
|
||||
|
||||
Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs.
|
||||
|
||||
## Verification matrix
|
||||
|
||||
The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable.
|
||||
|
||||
| Layer | Required coverage |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts |
|
||||
| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ |
|
||||
| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush |
|
||||
| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown |
|
||||
| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP |
|
||||
| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior |
|
||||
| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions |
|
||||
| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state |
|
||||
| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency |
|
||||
| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata |
|
||||
|
||||
The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
| Failure | User-visible Vault behavior | Terminal behavior |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
||||
| Service fails to start | Keep last result and show a retryable host issue | Unchanged |
|
||||
| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged |
|
||||
| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged |
|
||||
| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged |
|
||||
| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged |
|
||||
| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available |
|
||||
| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged |
|
||||
| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains |
|
||||
|
||||
## Benchmark methodology
|
||||
|
||||
The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home.
|
||||
|
||||
Each iteration:
|
||||
|
||||
1. Keeps the Vault panel mounted in both arms.
|
||||
2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home.
|
||||
3. Alternates arm order to reduce warm-up bias.
|
||||
4. Runs paced raw-mode terminal echo typing as the control.
|
||||
5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment.
|
||||
6. Verifies the newest seeded title is visible, proving the full scan and UI path completed.
|
||||
|
||||
The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key.
|
||||
|
||||
Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory.
|
||||
|
||||
## Current measurements
|
||||
|
||||
Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work.
|
||||
|
||||
Pre-implementation parity baseline:
|
||||
|
||||
- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts.
|
||||
- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests.
|
||||
- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes.
|
||||
|
||||
### Representative pass
|
||||
|
||||
Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | -------------------: | ------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms |
|
||||
| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms |
|
||||
| Worst timer drift | 14.2 ms | 24.5 ms |
|
||||
| Worst frame gap | 9.4 ms | 24.9 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 |
|
||||
| Refresh duration p50 / max | — | 103.5 / 204.2 ms |
|
||||
|
||||
There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration.
|
||||
|
||||
### Stress pass
|
||||
|
||||
Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | ------------------: | -------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms |
|
||||
| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms |
|
||||
| Worst timer drift | 9.3 ms | 43.6 ms |
|
||||
| Worst frame gap | 9.4 ms | 50.0 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 240 | 0 / 240 |
|
||||
| Refresh duration p50 / max | — | 276.3 / 453.5 ms |
|
||||
|
||||
The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer.
|
||||
|
||||
### Measurement limits
|
||||
|
||||
- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior.
|
||||
- The benchmark covers the local service path, not the currently unisolated SSH relay path.
|
||||
- Raw shell echo is a stable latency probe, not a full-screen TUI workload.
|
||||
- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost.
|
||||
|
||||
## Post-implementation A/B
|
||||
|
||||
Run the same build with the rollout switch off and on, alternating launch order:
|
||||
|
||||
```bash
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment
|
||||
```
|
||||
|
||||
Run at least five representative and five stress passes on:
|
||||
|
||||
- macOS Apple Silicon.
|
||||
- Windows x64 with a folder workspace and a WSL-backed source present.
|
||||
- Ubuntu 20.04-compatible x64 packaging/runtime.
|
||||
- A 4-core or smaller machine or constrained CI runner.
|
||||
|
||||
Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy.
|
||||
|
||||
## Implemented result
|
||||
|
||||
The implementation now matches the target topology:
|
||||
|
||||
- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback.
|
||||
- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry.
|
||||
- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue.
|
||||
- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes.
|
||||
- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second.
|
||||
- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker.
|
||||
|
||||
Local validation on 2026-08-09:
|
||||
|
||||
- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully.
|
||||
- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane.
|
||||
- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone.
|
||||
- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled.
|
||||
|
||||
Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence.
|
||||
|
||||
| Metric | Worker control | Worker refresh | Process control | Process refresh |
|
||||
| --------------------- | -------------: | -------------: | --------------: | --------------: |
|
||||
| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms |
|
||||
| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms |
|
||||
| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms |
|
||||
| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms |
|
||||
| Long tasks over 50 ms | 0 | 0 | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 |
|
||||
| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms |
|
||||
|
||||
The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness.
|
||||
|
||||
Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Correctness:
|
||||
|
||||
- Zero missing terminal echoes.
|
||||
- The latest seeded Vault session is visibly published in every treatment arm.
|
||||
- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner.
|
||||
- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics.
|
||||
|
||||
Performance:
|
||||
|
||||
- Treatment parse p95 is no more than 10 ms above its paired control.
|
||||
- Treatment render p95 is no more than 15 ms above its paired control.
|
||||
- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms.
|
||||
- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run.
|
||||
- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes.
|
||||
|
||||
Isolation:
|
||||
|
||||
- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon.
|
||||
- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods.
|
||||
- The service respects queue, timeout, idle, and heap bounds.
|
||||
- Packaged app and relay artifacts contain and launch the correct entry for every supported platform.
|
||||
|
||||
## Phased implementation plan
|
||||
|
||||
### Phase 0 — measurement harness (completed in this change)
|
||||
|
||||
- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes.
|
||||
- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`.
|
||||
- Capture representative and stress baselines.
|
||||
|
||||
Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes.
|
||||
|
||||
### Phase 1 — desktop and runtime service behind a switch
|
||||
|
||||
- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor.
|
||||
- Reuse current scanner/cache implementations.
|
||||
- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled.
|
||||
- Keep the worker path as the disabled-switch fallback.
|
||||
- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution.
|
||||
- Add E2E fault injection that kills the service during active terminal typing.
|
||||
|
||||
Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets.
|
||||
|
||||
### Phase 2 — make the desktop/runtime process the default
|
||||
|
||||
- Enable the process by default in development, E2E, and canary builds.
|
||||
- Collect process RSS, restart, timeout, and latency telemetry without content.
|
||||
- Validate Windows, Linux, WSL, and constrained-runner results.
|
||||
- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle.
|
||||
|
||||
Exit: no unexplained crash or timeout increase and all acceptance gates green.
|
||||
|
||||
### Phase 3 — SSH relay sidecar
|
||||
|
||||
- Add the relay sidecar entry and supervisor.
|
||||
- Remove scanner/parser/cache imports from the relay handler.
|
||||
- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test.
|
||||
- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests.
|
||||
- Make inline relay scanning impossible; sidecar failure returns a host issue.
|
||||
|
||||
Exit: remote typing and fault gates green; current public relay methods unchanged.
|
||||
|
||||
### Phase 4 — renderer quiet publication
|
||||
|
||||
- Add the shared quiet-publication gate and React transition.
|
||||
- Retain only the newest pending result per scope/host.
|
||||
- Add supersession, starvation timeout, unmount, and rapid-host-switch tests.
|
||||
- Re-run the Electron benchmark with large result counts and active filtering/grouping.
|
||||
|
||||
Exit: no Vault-attributable renderer long task over 50 ms in the representative gate.
|
||||
|
||||
### Phase 5 — hardening and cleanup
|
||||
|
||||
- Remove the worker implementation after one stable release if the kill switch was unused.
|
||||
- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry.
|
||||
- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate.
|
||||
- Document operational recovery and service diagnostics.
|
||||
|
||||
Exit: kill switch and dead worker code removed only after evidence supports it.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data.
|
||||
- Renderer: disable quiet publication and apply the validated complete result directly.
|
||||
- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails.
|
||||
- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion.
|
||||
|
||||
## Confidence and remaining questions
|
||||
|
||||
Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation.
|
||||
|
||||
The remaining implementation questions are deliberately narrow:
|
||||
|
||||
1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on.
|
||||
2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry.
|
||||
3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets.
|
||||
4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop.
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 16m">
|
||||
<title>downloads: 16m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 18m">
|
||||
<title>downloads: 18m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">16m</text>
|
||||
<text x="90" y="14">16m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">18m</text>
|
||||
<text x="90" y="14">18m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 244 KiB |
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
|
||||
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
|
||||
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
|
||||
|
||||
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android :** [Télécharger l'APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
- **Android :** [Télécharger l'APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
|
||||
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Baixar APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
- **Android:** [Baixar APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -217,6 +217,12 @@ export const electronViteConfig: UserConfig = {
|
||||
'session-scanner-opencode-sqlite-worker-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts'
|
||||
),
|
||||
'session-scanner-worker-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-worker-entry.ts'
|
||||
),
|
||||
'session-scanner-service-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-service-entry.ts'
|
||||
),
|
||||
// Why: libuv spawns processes inline on the calling loop, so the port
|
||||
// scan's probe commands run on a worker thread instead of the UI one.
|
||||
'port-scan-command-worker-entry': resolve(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
minimum-release-age=4320
|
||||
@@ -93,12 +93,6 @@
|
||||
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/terminal-settings.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 514, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/mock-server.ts"],
|
||||
"rules": {
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.41",
|
||||
"version": "0.0.43",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 11
|
||||
"versionCode": 12
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+106
-95
@@ -80,6 +80,7 @@ import {
|
||||
import { shouldResolveHostedReviewStartPoint } from '../../../src/tasks/hosted-review-start-point'
|
||||
import { getLinkedWorkItemSuggestedName } from '../../../src/tasks/mobile-workspace-name'
|
||||
import {
|
||||
dropFailedGitHubRepoSlugEntries,
|
||||
filterGitHubProjectRowsForRepos,
|
||||
findRepoForGitHubProjectRepository,
|
||||
type GitHubRepoSlugCacheEntry
|
||||
@@ -132,6 +133,9 @@ import {
|
||||
resolveVisibleTaskProvider,
|
||||
type TaskProvider
|
||||
} from '../../../src/tasks/mobile-task-providers'
|
||||
import { hasSettledHostRepoList } from '../../../src/tasks/host-repo-list'
|
||||
import { useHostRepoList } from '../../../src/tasks/use-host-repo-list'
|
||||
import { isHostedTaskRepo, reconcileRepoSelection } from '../../../src/tasks/hosted-repo-selection'
|
||||
import {
|
||||
extractLinearIssueReadItems,
|
||||
type LinearMobileIssue
|
||||
@@ -1118,22 +1122,6 @@ async function mapWithConcurrency<T, R>(
|
||||
return results
|
||||
}
|
||||
|
||||
function isHostedTaskRepo(repo: RepoSummary): boolean {
|
||||
return repo.kind !== 'folder'
|
||||
}
|
||||
|
||||
function reconcileRepoSelection(
|
||||
repos: RepoSummary[],
|
||||
persisted: string[] | null | undefined
|
||||
): Set<string> {
|
||||
if (!persisted || persisted.length === 0) {
|
||||
return new Set()
|
||||
}
|
||||
const availableIds = new Set(repos.filter(isHostedTaskRepo).map((repo) => repo.id))
|
||||
const selected = persisted.filter((id) => availableIds.has(id))
|
||||
return selected.length === 0 ? new Set() : new Set(selected)
|
||||
}
|
||||
|
||||
function createLinearTask(issue: LinearIssue): TaskItem {
|
||||
return {
|
||||
key: `linear:${issue.workspaceId ?? 'workspace'}:${issue.id}`,
|
||||
@@ -2084,10 +2072,22 @@ export default function MobileTasksScreen() {
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const reposRef = useRef<RepoSummary[]>([])
|
||||
const loadGenerationRef = useRef(0)
|
||||
const taskResumeRef = useRef<TaskResumeState>({})
|
||||
const [repos, setRepos] = useState<RepoSummary[]>([])
|
||||
const repoList = useHostRepoList<RepoSummary>(
|
||||
client,
|
||||
client && connState === 'connected'
|
||||
? async () => {
|
||||
const response = await client.sendRequest('repo.list')
|
||||
if (!isSuccess(response)) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return (response.result as { repos: RepoSummary[] }).repos
|
||||
}
|
||||
: null
|
||||
)
|
||||
const repos = repoList.state.repos
|
||||
const { ensureLoaded: repoListEnsureLoaded, reload: repoListReload } = repoList
|
||||
const [provider, setProvider] = useState<TaskProvider>('github')
|
||||
const [visibleProviders, setVisibleProviders] = useState<TaskProvider[]>(() =>
|
||||
normalizeVisibleTaskProviders(undefined)
|
||||
@@ -2378,13 +2378,17 @@ export default function MobileTasksScreen() {
|
||||
) as RepoSummary | null,
|
||||
[activeGitHubProjectHost, githubRepoSlugCache, hostedRepos]
|
||||
)
|
||||
// Why: `every` is vacuously true on an empty repo list, so readiness has to ask
|
||||
// the resource whether that list is real yet. Otherwise the board renders
|
||||
// "No project items" for a board whose repos simply have not arrived.
|
||||
const githubProjectRepoSlugReady = useMemo(
|
||||
() =>
|
||||
hasSettledHostRepoList(repoList.state) &&
|
||||
hostedRepos.every((repo) => {
|
||||
const cached = githubRepoSlugCache[repo.id]
|
||||
return cached !== undefined && cached.path === repo.path
|
||||
}),
|
||||
[githubRepoSlugCache, hostedRepos]
|
||||
[githubRepoSlugCache, hostedRepos, repoList.state]
|
||||
)
|
||||
const visibleGitHubProjectRows = useMemo(
|
||||
() =>
|
||||
@@ -2488,13 +2492,10 @@ export default function MobileTasksScreen() {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const result = response.result as GitHubOwnerRepo | null
|
||||
return {
|
||||
repoId: repo.id,
|
||||
path: repo.path,
|
||||
repository: result
|
||||
}
|
||||
return { repoId: repo.id, entry: { path: repo.path, repository: result } }
|
||||
} catch {
|
||||
return { repoId: repo.id, path: repo.path, repository: null }
|
||||
// Cached so readiness settles; `failed` marks it for retry on refresh.
|
||||
return { repoId: repo.id, entry: { path: repo.path, repository: null, failed: true } }
|
||||
}
|
||||
}).then((entries) => {
|
||||
if (cancelled) {
|
||||
@@ -2503,7 +2504,7 @@ export default function MobileTasksScreen() {
|
||||
setGithubRepoSlugCache((current) => {
|
||||
const next = { ...current }
|
||||
for (const entry of entries) {
|
||||
next[entry.repoId] = { path: entry.path, repository: entry.repository }
|
||||
next[entry.repoId] = entry.entry
|
||||
}
|
||||
return next
|
||||
})
|
||||
@@ -2621,8 +2622,31 @@ export default function MobileTasksScreen() {
|
||||
|
||||
// Why: task-loading effects use this as a stale-client guard, so the ref
|
||||
// must be current before those passive effects can run after commit.
|
||||
const resetGitHubItemsState = useCallback(() => {
|
||||
setGithubRepoSources({})
|
||||
setGithubPages([])
|
||||
setGithubCurrentPage(0)
|
||||
setGithubTotalCount(null)
|
||||
setGithubSourceErrors([])
|
||||
setGithubSourceFallbacks([])
|
||||
}, [])
|
||||
|
||||
// Why: Expo reuses this screen for the next host, so an effect reset runs a
|
||||
// render too late and the previous host's rows show under the new one. The
|
||||
// repo list resets itself; these are the other client-scoped caches.
|
||||
const [boundClient, setBoundClient] = useState(client)
|
||||
if (boundClient !== client) {
|
||||
setBoundClient(client)
|
||||
setItems([])
|
||||
setGithubRepoSlugCache({})
|
||||
resetGitHubItemsState()
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
clientRef.current = client
|
||||
// Why: ref writes belong in the commit phase. Doing this during render would
|
||||
// leak out of a concurrent render React later abandons.
|
||||
repoSelectionHydratedRef.current = false
|
||||
}, [client])
|
||||
|
||||
const persistTaskResumeState = useCallback(
|
||||
@@ -2877,7 +2901,7 @@ export default function MobileTasksScreen() {
|
||||
// pair but must not receive the newer task-specific method calls.
|
||||
setTasksSupportState({ kind: 'unsupported', client })
|
||||
setItems([])
|
||||
setGithubPages([])
|
||||
resetGitHubItemsState()
|
||||
setGithubProjectTable(null)
|
||||
setShowLinearWorkspacePicker(false)
|
||||
setShowLinearTeamPicker(false)
|
||||
@@ -3035,32 +3059,26 @@ export default function MobileTasksScreen() {
|
||||
setProvider(resolveVisibleTaskProvider(provider, visibleProviders))
|
||||
}, [provider, visibleProviders])
|
||||
|
||||
const loadRepos = useCallback(async (): Promise<RepoSummary[]> => {
|
||||
if (!client || connState !== 'connected') {
|
||||
return []
|
||||
// Selection follows the list rather than the fetch, so it reconciles the same
|
||||
// way no matter which caller triggered the load.
|
||||
useEffect(() => {
|
||||
if (repoList.state.status !== 'loaded') {
|
||||
return
|
||||
}
|
||||
const response = await client.sendRequest('repo.list')
|
||||
if (!isSuccess(response)) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const result = response.result as { repos: RepoSummary[] }
|
||||
reposRef.current = result.repos
|
||||
setRepos(result.repos)
|
||||
if (!repoSelectionHydratedRef.current) {
|
||||
repoSelectionHydratedRef.current = true
|
||||
setSelectedRepoIds(reconcileRepoSelection(result.repos, defaultRepoSelectionRef.current))
|
||||
} else {
|
||||
setSelectedRepoIds((current) => {
|
||||
if (current.size === 0) {
|
||||
return current
|
||||
}
|
||||
const availableIds = new Set(result.repos.filter(isHostedTaskRepo).map((repo) => repo.id))
|
||||
const next = new Set([...current].filter((id) => availableIds.has(id)))
|
||||
return next.size === current.size ? current : next
|
||||
})
|
||||
setSelectedRepoIds(reconcileRepoSelection(repos, defaultRepoSelectionRef.current))
|
||||
return
|
||||
}
|
||||
return result.repos
|
||||
}, [client, connState])
|
||||
setSelectedRepoIds((current) => {
|
||||
if (current.size === 0) {
|
||||
return current
|
||||
}
|
||||
const availableIds = new Set(repos.filter(isHostedTaskRepo).map((repo) => repo.id))
|
||||
const next = new Set([...current].filter((id) => availableIds.has(id)))
|
||||
return next.size === current.size ? current : next
|
||||
})
|
||||
}, [repoList.state.status, repos])
|
||||
|
||||
const loadLinearContext = useCallback(async (): Promise<void> => {
|
||||
if (!client || connState !== 'connected' || !tasksSupported) {
|
||||
@@ -3251,24 +3269,26 @@ export default function MobileTasksScreen() {
|
||||
}
|
||||
try {
|
||||
if (provider !== 'github' || githubMode !== 'items') {
|
||||
setGithubPages([])
|
||||
setGithubCurrentPage(0)
|
||||
setGithubTotalCount(null)
|
||||
setGithubSourceErrors([])
|
||||
setGithubSourceFallbacks([])
|
||||
}
|
||||
if (provider === 'github' && githubMode === 'project') {
|
||||
setItems([])
|
||||
return
|
||||
resetGitHubItemsState()
|
||||
}
|
||||
if (provider === 'linear' && !linearConnected) {
|
||||
setItems([])
|
||||
return
|
||||
}
|
||||
const currentRepos = reposRef.current.length > 0 ? reposRef.current : await loadRepos()
|
||||
// Why: Linear issues do not need the repo list, only the composer does, so
|
||||
// start the fetch either way but never make Linear wait on it.
|
||||
const repoListRequest = repoListEnsureLoaded()
|
||||
void repoListRequest.catch(() => {})
|
||||
const currentRepos = provider === 'linear' ? [] : await repoListRequest
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
// Why: project mode fetches no work items, but its rows are matched against
|
||||
// the repo list, so it must not return before loadRepos() has run.
|
||||
if (provider === 'github' && githubMode === 'project') {
|
||||
setItems([])
|
||||
return
|
||||
}
|
||||
if (provider === 'github' || provider === 'gitlab') {
|
||||
const supportedRepos = currentRepos.filter(isHostedTaskRepo)
|
||||
const queriedRepos =
|
||||
@@ -3280,11 +3300,7 @@ export default function MobileTasksScreen() {
|
||||
return
|
||||
}
|
||||
setItems([])
|
||||
setGithubPages([])
|
||||
setGithubCurrentPage(0)
|
||||
setGithubTotalCount(null)
|
||||
setGithubSourceErrors([])
|
||||
setGithubSourceFallbacks([])
|
||||
resetGitHubItemsState()
|
||||
return
|
||||
}
|
||||
if (provider === 'github') {
|
||||
@@ -3320,11 +3336,6 @@ export default function MobileTasksScreen() {
|
||||
return
|
||||
}
|
||||
if (provider === 'gitlab' && gitlabView === 'todos') {
|
||||
setGithubPages([])
|
||||
setGithubCurrentPage(0)
|
||||
setGithubTotalCount(null)
|
||||
setGithubSourceErrors([])
|
||||
setGithubSourceFallbacks([])
|
||||
const response = await requestClient.sendRequest('gitlab.todos', {
|
||||
repo: `id:${queriedRepos[0]!.id}`
|
||||
})
|
||||
@@ -3341,11 +3352,6 @@ export default function MobileTasksScreen() {
|
||||
)
|
||||
return
|
||||
}
|
||||
setGithubPages([])
|
||||
setGithubCurrentPage(0)
|
||||
setGithubTotalCount(null)
|
||||
setGithubSourceErrors([])
|
||||
setGithubSourceFallbacks([])
|
||||
const results = await mapWithConcurrency(
|
||||
queriedRepos,
|
||||
GITHUB_REPO_CONCURRENCY,
|
||||
@@ -3429,8 +3435,7 @@ export default function MobileTasksScreen() {
|
||||
return
|
||||
}
|
||||
setItems([])
|
||||
setGithubSourceErrors([])
|
||||
setGithubSourceFallbacks([])
|
||||
resetGitHubItemsState()
|
||||
setError(err instanceof Error ? err.message : 'Failed to load tasks')
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
@@ -3451,7 +3456,9 @@ export default function MobileTasksScreen() {
|
||||
linearConnected,
|
||||
linearFilter,
|
||||
linearOrderBy,
|
||||
loadRepos,
|
||||
// resetGitHubItemsState is useCallback([]), so its identity never changes
|
||||
// and listing it here would only cost a line against the max-lines budget.
|
||||
repoListEnsureLoaded,
|
||||
provider,
|
||||
selectedLinearTeamIds,
|
||||
selectedLinearWorkspaceId,
|
||||
@@ -3890,6 +3897,19 @@ export default function MobileTasksScreen() {
|
||||
tasksSupported
|
||||
])
|
||||
|
||||
// Why: a refresh must re-read the host, not replay the cached list, or a repo
|
||||
// added since this screen mounted can never appear.
|
||||
const refreshTasks = useCallback(() => {
|
||||
void repoListReload().catch(() => {})
|
||||
void loadTasks({ silent: true })
|
||||
}, [loadTasks, repoListReload])
|
||||
|
||||
const refreshGitHubProject = useCallback(() => {
|
||||
setGithubRepoSlugCache(dropFailedGitHubRepoSlugEntries)
|
||||
refreshTasks()
|
||||
void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch })
|
||||
}, [appliedGithubProjectSearch, loadGitHubProjectTable, refreshTasks])
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskStateHydrated) {
|
||||
return
|
||||
@@ -8120,22 +8140,15 @@ export default function MobileTasksScreen() {
|
||||
if (!isSuccess(response)) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
setRepos((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === repo.id
|
||||
? { ...candidate, issueSourcePreference: preference }
|
||||
: candidate
|
||||
)
|
||||
)
|
||||
reposRef.current = reposRef.current.map((candidate) =>
|
||||
candidate.id === repo.id ? { ...candidate, issueSourcePreference: preference } : candidate
|
||||
)
|
||||
// Why: the host owns issueSourcePreference, so re-read the list instead of
|
||||
// patching the cached copy and hoping the two stay in step.
|
||||
await repoListReload().catch(() => {})
|
||||
await loadTasks({ silent: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update issue source')
|
||||
}
|
||||
},
|
||||
[client, loadTasks, taskUiReady]
|
||||
[client, loadTasks, repoListReload, taskUiReady]
|
||||
)
|
||||
|
||||
const renderCommentComposer = (args: {
|
||||
@@ -8691,10 +8704,10 @@ export default function MobileTasksScreen() {
|
||||
return
|
||||
}
|
||||
if (provider === 'github' && githubMode === 'project') {
|
||||
void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch })
|
||||
refreshGitHubProject()
|
||||
return
|
||||
}
|
||||
void loadTasks({ silent: true })
|
||||
refreshTasks()
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} color={taskUiReady ? colors.textSecondary : colors.textMuted} />
|
||||
@@ -9278,9 +9291,7 @@ export default function MobileTasksScreen() {
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]}
|
||||
refreshing={githubProjectLoading}
|
||||
onRefresh={() =>
|
||||
void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch })
|
||||
}
|
||||
onRefresh={refreshGitHubProject}
|
||||
renderItem={({ item: entry }) => {
|
||||
if (entry.type === 'group') {
|
||||
return (
|
||||
@@ -9485,7 +9496,7 @@ export default function MobileTasksScreen() {
|
||||
}
|
||||
contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]}
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => void loadTasks({ silent: true })}
|
||||
onRefresh={refreshTasks}
|
||||
renderItem={({ item: entry }) => {
|
||||
if (entry.type === 'section') {
|
||||
return (
|
||||
@@ -9584,7 +9595,7 @@ export default function MobileTasksScreen() {
|
||||
}
|
||||
contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]}
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => void loadTasks({ silent: true })}
|
||||
onRefresh={refreshTasks}
|
||||
ListFooterComponent={
|
||||
provider === 'github' && githubMode === 'items' && githubCanShowPagination ? (
|
||||
<View style={styles.paginationFooter}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, Switch } from 'react-native'
|
||||
import { View, Text, Pressable, Switch } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
@@ -9,7 +9,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { colors, spacing } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import type { HostProfile } from '../src/transport/types'
|
||||
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
|
||||
@@ -17,6 +17,7 @@ import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
|
||||
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
|
||||
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
|
||||
import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles'
|
||||
import {
|
||||
loadTerminalAutocompleteEnabled,
|
||||
loadTerminalTextScale,
|
||||
@@ -380,92 +381,3 @@ export default function TerminalSettingsScreen() {
|
||||
</GestureHandlerRootView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: 0
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: spacing.xl
|
||||
},
|
||||
groupHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
groupDescription: {
|
||||
fontSize: typography.bodySize - 1,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
sectionTopGap: {
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
inputGroupGap: {
|
||||
marginTop: spacing.xl
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
padding: spacing.md
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowSublabel: {
|
||||
fontSize: typography.bodySize - 2,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
}
|
||||
})
|
||||
|
||||
+56
-13
@@ -2,8 +2,9 @@
|
||||
#
|
||||
# Builds the prebuilt iOS workspace, signs it with the distribution identity
|
||||
# imported into the CI keychain plus an explicit App Store provisioning profile
|
||||
# fetched via the App Store Connect API key, then uploads the .ipa to
|
||||
# TestFlight. All Apple credentials come from CI env vars
|
||||
# fetched via the App Store Connect API key, uploads the .ipa, then distributes
|
||||
# the processed build to external TestFlight testers. Credentials come from CI
|
||||
# env vars
|
||||
# (see .github/workflows/mobile-ios-release.yml) so nothing secret lives in the
|
||||
# repo.
|
||||
#
|
||||
@@ -30,6 +31,7 @@ SCHEME = "Orca"
|
||||
BUNDLE_ID = "com.stably.orca.mobile"
|
||||
TESTFLIGHT_GROUPS = ["peeps"].freeze
|
||||
DEFAULT_TESTFLIGHT_CHANGELOG = "Latest Orca Mobile updates and fixes.".freeze
|
||||
TESTFLIGHT_PROCESSING_TIMEOUT_SECONDS = 20 * 60
|
||||
|
||||
# App Store version states in which the version "train" is terminally closed to
|
||||
# new TestFlight build uploads (altool rejects with 90186 "train ... is
|
||||
@@ -74,6 +76,11 @@ def current_mobile_version(config)
|
||||
config.fetch("expo").fetch("version")
|
||||
end
|
||||
|
||||
def testflight_changelog
|
||||
changelog = ENV.fetch("TESTFLIGHT_CHANGELOG", "").strip
|
||||
changelog.empty? ? DEFAULT_TESTFLIGHT_CHANGELOG : changelog
|
||||
end
|
||||
|
||||
# True when either state field reports a terminally closed train. `respond_to?`
|
||||
# guards the newer field, which older spaceship versions do not define.
|
||||
def closed_state?(app_store_version)
|
||||
@@ -112,11 +119,6 @@ rescue StandardError => error
|
||||
nil
|
||||
end
|
||||
|
||||
def testflight_changelog
|
||||
changelog = ENV.fetch("TESTFLIGHT_CHANGELOG", "").strip
|
||||
changelog.empty? ? DEFAULT_TESTFLIGHT_CHANGELOG : changelog
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
desc "Resolve the iOS release version and next TestFlight build number"
|
||||
lane :prepare_release_version do |options|
|
||||
@@ -178,8 +180,8 @@ platform :ios do
|
||||
UI.message("Prepared Orca Mobile iOS #{version} (#{build_number})")
|
||||
end
|
||||
|
||||
desc "Build, sign, upload, and share Orca Mobile on TestFlight"
|
||||
lane :release do
|
||||
desc "Build, sign, and upload Orca Mobile to App Store Connect / TestFlight"
|
||||
lane :build_and_upload do
|
||||
api_key = app_store_connect_api_key_from_env
|
||||
|
||||
team_id = ENV.fetch("APPLE_TEAM_ID")
|
||||
@@ -221,15 +223,56 @@ platform :ios do
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
# Why: fastlane can only assign external TestFlight groups after Apple
|
||||
# finishes processing the uploaded build.
|
||||
# Distribution runs separately so a processing timeout can be retried
|
||||
# against this exact build without uploading another binary.
|
||||
skip_waiting_for_build_processing: true,
|
||||
distribute_external: false,
|
||||
)
|
||||
end
|
||||
|
||||
desc "Wait for and distribute an uploaded build to external TestFlight testers"
|
||||
lane :distribute_testflight do |options|
|
||||
version = options[:version].to_s.strip
|
||||
build_number = options[:build_number].to_s.strip
|
||||
UI.user_error!("version is required") if version.empty?
|
||||
UI.user_error!("build_number is required") if build_number.empty?
|
||||
|
||||
api_key = app_store_connect_api_key_from_env
|
||||
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
|
||||
UI.user_error!("Could not find App Store Connect app #{BUNDLE_ID}") unless app
|
||||
|
||||
available_groups = app.get_beta_groups.map(&:name)
|
||||
missing_groups = TESTFLIGHT_GROUPS - available_groups
|
||||
unless missing_groups.empty?
|
||||
UI.user_error!(
|
||||
"Missing TestFlight group(s): #{missing_groups.join(', ')}. " \
|
||||
"Create them in App Store Connect or update TESTFLIGHT_GROUPS.",
|
||||
)
|
||||
end
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
app_identifier: BUNDLE_ID,
|
||||
app_version: version,
|
||||
build_number: build_number,
|
||||
distribute_only: true,
|
||||
skip_waiting_for_build_processing: false,
|
||||
wait_processing_timeout_duration: TESTFLIGHT_PROCESSING_TIMEOUT_SECONDS,
|
||||
distribute_external: true,
|
||||
groups: TESTFLIGHT_GROUPS,
|
||||
notify_external_testers: true,
|
||||
# Why: fastlane requires release notes when distributing to external
|
||||
# TestFlight testers, even for CI-triggered smoke releases.
|
||||
changelog: testflight_changelog,
|
||||
)
|
||||
end
|
||||
|
||||
desc "Build, upload, and distribute Orca Mobile to external TestFlight testers"
|
||||
lane :release do
|
||||
build_and_upload
|
||||
|
||||
config = load_mobile_app_config
|
||||
distribute_testflight(
|
||||
version: current_mobile_version(config),
|
||||
build_number: config.fetch("expo").fetch("ios").fetch("buildNumber"),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@
|
||||
"expo-status-bar": "^55.0.6",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react-native": "^1.14.0",
|
||||
"mermaid": "11.16.0",
|
||||
"mermaid": "11.16.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"react-native": "^0.83.9",
|
||||
|
||||
Generated
+241
-130
File diff suppressed because it is too large
Load Diff
@@ -157,28 +157,12 @@ const RESET_SNAPSHOT = {
|
||||
}
|
||||
} as const
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderAccountsRoute(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AccountsScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AccountsScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('Accounts route did not render')
|
||||
}
|
||||
@@ -229,7 +213,6 @@ describe('accounts route Codex reset credit', () => {
|
||||
let storedValues: Map<string, string>
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
resetCodexResetAttemptJournalForTests()
|
||||
storedValues = new Map()
|
||||
dependencies.alert.mockReset()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { CodexResetCreditAction } from './CodexResetCreditAction'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
@@ -19,35 +19,19 @@ const summary = {
|
||||
expiryLabel: 'Expires in 5d'
|
||||
}
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(CodexResetCreditAction, {
|
||||
summary,
|
||||
scopeLabel: 'dev@example.com on the host',
|
||||
busy,
|
||||
disabled,
|
||||
onPress: vi.fn()
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(CodexResetCreditAction, {
|
||||
summary,
|
||||
scopeLabel: 'dev@example.com on the host',
|
||||
busy,
|
||||
disabled,
|
||||
onPress: vi.fn()
|
||||
})
|
||||
)
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('Reset action did not render')
|
||||
}
|
||||
@@ -55,10 +39,6 @@ function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer {
|
||||
}
|
||||
|
||||
describe('CodexResetCreditAction', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('exposes a 44pt touch target and enabled accessibility state', () => {
|
||||
const renderer = renderAction(false, false)
|
||||
const button = renderer.root.findByType('Pressable')
|
||||
|
||||
@@ -78,7 +78,6 @@ describe('HostProtocolGate', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
nativeTestState.openUrl.mockClear()
|
||||
nativeTestState.platform.OS = 'ios'
|
||||
probeMounts.count = 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HostProfile } from '../transport/types'
|
||||
import { MobileHomeQuickActions } from './MobileHomeQuickActions'
|
||||
|
||||
@@ -37,10 +37,6 @@ function host(id: string, name: string, endpoint: string): HostProfile {
|
||||
describe('MobileHomeQuickActions', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileHostCard } from './MobileHostCard'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
@@ -30,10 +30,6 @@ function suppressRendererDeprecation() {
|
||||
describe('MobileHostCard', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionVerdict } from '../transport/connection-health'
|
||||
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types'
|
||||
@@ -39,10 +39,6 @@ const loaded: HostWorktreeInfo = {
|
||||
describe('MobileHostCard', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -38,7 +38,6 @@ describe('MobileMarkdown file links', () => {
|
||||
const onOpenFile = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
onOpenFile.mockClear()
|
||||
openURL.mockClear()
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Linking } from 'react-native'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { MobilePrComposeForm, type PrComposePrefill } from './pr-sidebar/MobilePrComposeForm'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
client: RpcClient | null
|
||||
worktreeId: string
|
||||
prefill: PrComposePrefill
|
||||
// Head branch — enables the base≠head guard and the "from <branch>" hint.
|
||||
head?: string | null
|
||||
onClose: () => void
|
||||
onCreated: (url: string, warning?: string) => void
|
||||
}
|
||||
|
||||
// BottomDrawer wrapper around the inline compose form, for full-screen roots
|
||||
// (source-control modals). The PR sidebar empty-state renders MobilePrComposeForm
|
||||
// inline instead, since a BottomDrawer overlay nested in a ScrollView clips it.
|
||||
export function MobilePrComposeSheet({
|
||||
visible,
|
||||
client,
|
||||
worktreeId,
|
||||
prefill,
|
||||
head,
|
||||
onClose,
|
||||
onCreated
|
||||
}: Props) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
{/* Why: key on visible + prefill fields so reopening (or a new prefill)
|
||||
remounts the form with fresh initial values; the previous sheet reset
|
||||
its fields via an effect on the same signals. */}
|
||||
<MobilePrComposeForm
|
||||
key={`${visible}:${prefill.title}:${prefill.base}:${prefill.body}`}
|
||||
client={client}
|
||||
worktreeId={worktreeId}
|
||||
prefill={prefill}
|
||||
head={head}
|
||||
onCancel={onClose}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
export function openMobilePrUrl(url: string): void {
|
||||
// Why: Linking.openURL rejects when iOS/Android can't open the URL (no app,
|
||||
// bad scheme, etc.). Without a catch that surfaces as LogBox "Uncaught
|
||||
// (in promise) Error: Unable to open URL…" over the PR screen.
|
||||
void Linking.openURL(url).catch(() => {})
|
||||
}
|
||||
@@ -58,7 +58,6 @@ describe('NewWorktreeModal repo list', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
setCachedRepos('host-1', repos)
|
||||
})
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ describe('PickerModal accessibility', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
|
||||
throw new Error(String(args[0]))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import { WorktreeAgentList } from './WorktreeAgentList'
|
||||
|
||||
@@ -39,10 +39,6 @@ function agent(paneKey: string, parentPaneKey: string | null = null): RuntimeWor
|
||||
describe('WorktreeAgentList', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -127,7 +127,6 @@ describe('memoized worktree rows', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
agentSpinnerRender.mockClear()
|
||||
agentStateDotRender.mockClear()
|
||||
})
|
||||
|
||||
@@ -51,14 +51,12 @@ function mountedDrawer(renderer: ReactTestRenderer) {
|
||||
|
||||
describe('BottomDrawer close lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
const originalConsoleError = console.error
|
||||
vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const message = args[0]
|
||||
if (
|
||||
typeof message === 'string' &&
|
||||
(message.includes('react-test-renderer is deprecated') ||
|
||||
message.includes('The current testing environment is not configured to support act'))
|
||||
message.includes('The current testing environment is not configured to support act')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropT
|
||||
'mimo-code': require('../../../src/shared/agent-icons/mimo-code.png'),
|
||||
ante: require('../../../src/shared/agent-icons/ante.png'),
|
||||
trae: require('../../../src/shared/agent-icons/trae.png'),
|
||||
'prime-agent': require('../../../src/shared/agent-icons/prime-agent.png'),
|
||||
gemini: require('../../../src/shared/agent-icons/gemini.png'),
|
||||
antigravity: require('../../../src/shared/agent-icons/antigravity.png'),
|
||||
goose: require('../../../src/shared/agent-icons/goose.png'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
@@ -35,10 +35,6 @@ vi.mock('./AgentIcons', () => ({
|
||||
describe('MobileAgentIcon OMP gradient', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Linking } from 'react-native'
|
||||
|
||||
export function openMobilePrUrl(url: string): void {
|
||||
// Prevent unhandled rejections when no app accepts the URL.
|
||||
void Linking.openURL(url).catch(() => {})
|
||||
}
|
||||
@@ -38,11 +38,7 @@ type Props = {
|
||||
onCreated: (url: string, warning?: string) => void
|
||||
}
|
||||
|
||||
// PR compose form body: title/body/base/draft with AI prefill (git.generate
|
||||
// PullRequestFields), submitting via createMobilePr. Renders a plain View so it
|
||||
// can sit inline inside the PR sidebar's existing ScrollView (a BottomDrawer
|
||||
// overlay trapped in a ScrollView clips the form). The BottomDrawer wrapper
|
||||
// MobilePrComposeSheet reuses this body at full-screen roots.
|
||||
// A plain View keeps the compose form usable inside the PR sidebar ScrollView.
|
||||
export function MobilePrComposeForm({
|
||||
client,
|
||||
worktreeId,
|
||||
|
||||
@@ -32,18 +32,6 @@ vi.mock('./pr-comments-styles', () => ({ prCommentsStyles: {} }))
|
||||
vi.mock('./mobile-pr-sidebar-styles', () => ({ mobilePrSidebarStyles: {} }))
|
||||
vi.mock('../../theme/mobile-theme', () => ({ colors: { textSecondary: '#999' } }))
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function comment(id: number): PRComment {
|
||||
return {
|
||||
id,
|
||||
@@ -65,14 +53,9 @@ function detailsWithComments(count: number): GitHubWorkItemDetails {
|
||||
|
||||
async function renderComments(details: GitHubWorkItemDetails): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(PRCommentsSection, { details, prState: 'open' }))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(PRCommentsSection, { details, prState: 'open' }))
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('PRCommentsSection did not render')
|
||||
}
|
||||
@@ -107,7 +90,6 @@ describe('PRCommentsSection', () => {
|
||||
afterEach(() => {
|
||||
renderer?.unmount()
|
||||
renderer = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resets pagination only when the user chooses a different audience filter', async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user