mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
Merge structured-tui-adoption into structured-session-eviction
Four conflicts, two of them mine to own:
- Both branches independently grew structured-agent-session-attach-{orchestration,context}.
Kept this branch's pair: it types the subscriber surface structurally instead of
importing the concrete class, so attach stays testable without building a host.
The host keeps serialize + trackAttach around it, because the module deliberately
does neither.
- The readable restorer collided head-on. Adoption added an orphan reap before
restore; this branch had already removed the startup RESUME entirely. Both are
right: reap first, then restore read-only. The resume-era operationId/now went
with the resume.
The wedged-profile migration tests asserted the record ends 'live' with acquire
called once. That was the eager startup spawn this branch removed, so they now
assert what actually matters: the lease ends acquirable — proved by running
evaluateAgentSessionAcquisition, not by reading fields — and a HOLD is what drives
the acquire. The orphan-ordering test keeps its property by taking a hold: stop the
orphan first, then acquire, never the reverse.
structured-agent-session-host.ts hit 313 lines, so the mutation surface (send,
cancel, respondToPrompt, setOption, readOptions and the shared admit-and-run) moves
to its own module. Split, not squeezed.
This commit is contained in:
@@ -12,3 +12,13 @@
|
||||
/src/cli/bundled-skill-guides.ts text eol=lf
|
||||
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
|
||||
/resources/plugins/** text eol=lf
|
||||
# pnpm hashes every patch byte-for-byte, so a CRLF checkout breaks the install.
|
||||
/config/patches/*.patch -text
|
||||
# The xterm bundle hunks also make a diff nobody can read; review the hand-written
|
||||
# source patch under xterm-src/ instead. The sibling patches stay diffable.
|
||||
/config/patches/@xterm__xterm@*.patch -diff
|
||||
/config/patches/xterm-src/*.patch text eol=lf
|
||||
# Generated wrapper fixtures: collapse them in the PR diff so they stop drowning
|
||||
# the reviewable change, and pin LF because they are compared byte-for-byte.
|
||||
# Not -diff: the shell diff is the review surface when a wrapper does change.
|
||||
/src/main/__fixtures__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf
|
||||
|
||||
@@ -21,6 +21,7 @@ runs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
id: default-node
|
||||
if: inputs.node-version == ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -28,6 +29,7 @@ runs:
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup requested Node.js
|
||||
id: requested-node
|
||||
if: inputs.node-version != ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -63,15 +65,27 @@ runs:
|
||||
rm -rf node_modules
|
||||
fi
|
||||
|
||||
# Why --frozen-lockfile: re-resolving pulls ~62 MB of registry packuments per job
|
||||
# (measured) to recompute what the lockfile already pins, and the `git diff` guard
|
||||
# below fails the run whenever that recomputation would have changed anything. The
|
||||
# guard stays so a stale lockfile still fails by name rather than by resolver error.
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install \
|
||||
--no-frozen-lockfile \
|
||||
--prefer-frozen-lockfile=false \
|
||||
--ignore-scripts
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
git diff --exit-code package.json pnpm-lock.yaml
|
||||
|
||||
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
|
||||
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
|
||||
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
|
||||
# Node version, and the patch whose contents the build has to match.
|
||||
- name: Restore compiled native modules
|
||||
if: inputs.native-runtime != 'none'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
key: native-modules-${{ runner.os }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch') }}
|
||||
|
||||
- name: Prepare native runtime
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
|
||||
@@ -52,8 +52,3 @@ Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Re
|
||||
- [ ] Self-reviewed for correctness, security, and performance
|
||||
- [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
|
||||
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred)
|
||||
|
||||
## Author
|
||||
|
||||
- X / Twitter: @your_handle
|
||||
<!-- Optional but appreciated -->
|
||||
|
||||
@@ -68,7 +68,15 @@ function writeGithubOutput(totals) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePullRequest({ owner, repo, pullNumber, token, totals, fetchImpl = fetch }) {
|
||||
export async function updatePullRequest({
|
||||
owner,
|
||||
repo,
|
||||
pullNumber,
|
||||
token,
|
||||
totals,
|
||||
fetchImpl = fetch,
|
||||
sleepImpl = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}) {
|
||||
const headers = githubHeaders(token)
|
||||
const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`
|
||||
const response = await fetchImpl(url, { headers })
|
||||
@@ -87,14 +95,22 @@ async function updatePullRequest({ owner, repo, pullNumber, token, totals, fetch
|
||||
return 0
|
||||
}
|
||||
|
||||
const update = await fetchImpl(url, {
|
||||
const updateRequest = {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ body: nextBody })
|
||||
})
|
||||
}
|
||||
let update
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
update = await fetchImpl(url, updateRequest)
|
||||
if (update.ok || ![500, 502, 503, 504].includes(update.status) || attempt === 2) {
|
||||
break
|
||||
}
|
||||
await sleepImpl(1000 * 2 ** attempt)
|
||||
}
|
||||
if (update.status === 403) {
|
||||
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
|
||||
return 0
|
||||
|
||||
@@ -3,7 +3,10 @@ export const LOC_BLOCK_END = '<!-- /orca-pr-loc -->'
|
||||
export const LOC_HANDS_OFF_COMMENT =
|
||||
'<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->'
|
||||
|
||||
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|e2e|tests)(?:\/|$)/i
|
||||
// Why the __-wrapped names: they are unambiguous test-data markers, so a dir
|
||||
// cannot be one by accident. Bare `fixtures/` is deliberately absent — it reads
|
||||
// as a plausible prod module name, and this classifier bills every PR.
|
||||
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|__fixtures__|__snapshots__|e2e|tests)(?:\/|$)/i
|
||||
const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i
|
||||
|
||||
export function isTestPath(path) {
|
||||
|
||||
+54
-28
@@ -46,16 +46,19 @@ jobs:
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux.
|
||||
@@ -70,18 +73,23 @@ jobs:
|
||||
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
- name: Build Electron app for E2E
|
||||
- name: Build E2E outputs
|
||||
env:
|
||||
VITE_EXPOSE_STORE: 'true'
|
||||
run: |
|
||||
npx electron-vite build --mode e2e
|
||||
pnpm run build:web-from-renderer
|
||||
pnpm run build:relay
|
||||
|
||||
- name: Upload E2E build output
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-build-out
|
||||
path: out/
|
||||
# Why: build-relay.mjs writes each relay's marker as `out/relay/<platform>/.version`,
|
||||
# and upload-artifact drops dotfiles by default — consumers then fail SSH specs with
|
||||
# "local relay build is missing its version marker".
|
||||
include-hidden-files: true
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -126,7 +134,7 @@ jobs:
|
||||
# scripts still need the Linux toolchain even though this shard reuses
|
||||
# the prebuilt Electron output.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 zsh
|
||||
|
||||
# Why: Electron on Linux needs an X display even when the app
|
||||
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
|
||||
@@ -134,16 +142,19 @@ jobs:
|
||||
- name: Install xvfb
|
||||
run: sudo apt-get install -y xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
|
||||
@@ -174,7 +185,7 @@ jobs:
|
||||
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
|
||||
# launches but never creates a BrowserWindow.
|
||||
- name: Run E2E tests (${{ matrix.shard_name }})
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
|
||||
# Why: Playwright retains traces/screenshots only on failure. Uploading
|
||||
# them as an artifact makes post-mortem debugging on CI possible without
|
||||
@@ -193,7 +204,9 @@ jobs:
|
||||
needs: build
|
||||
if: inputs.test_files != ''
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can
|
||||
# pay a container image build plus ~22 serial SSH tests on top of the changed specs.
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -202,17 +215,23 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
# Why ripgrep: Quick Open's bounded host-side search requires rg instead of an
|
||||
# unbounded inventory fallback; the paired fixture exercises that real boundary.
|
||||
# Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this
|
||||
# lane now receives those specs from pr.yml's SSH source mapping.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy
|
||||
run: |
|
||||
@@ -240,8 +259,12 @@ jobs:
|
||||
echo "Changed startup-readiness specs are owned by the dedicated live lane."
|
||||
exit 0
|
||||
fi
|
||||
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1)
|
||||
if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'; then
|
||||
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay")
|
||||
# Second clause: a spec that reads ORCA_E2E_SSH_DOCKER test.skip()s itself without it, so
|
||||
# naming only one trigger silently skipped every other Docker-SSH spec in this lane.
|
||||
# The first clause stays because that spec needs Docker without referencing the variable.
|
||||
if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' \
|
||||
|| grep -l 'ORCA_E2E_SSH_DOCKER' "${TEST_FILES[@]}" >/dev/null 2>&1; then
|
||||
E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1)
|
||||
fi
|
||||
E2E_PROJECT_ARGS=()
|
||||
@@ -279,17 +302,20 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb zsh
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
cache: pnpm
|
||||
|
||||
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
|
||||
# stays consistent across workflows even while this job is ubuntu-latest.
|
||||
|
||||
@@ -32,6 +32,14 @@ jobs:
|
||||
runs-on: macos-26
|
||||
# Archive + upload is ~30–40m when healthy; 90m leaves margin for setup.
|
||||
timeout-minutes: 90
|
||||
|
||||
env:
|
||||
# Why: this job and ios-distribute resolve gems ~25 minutes apart, so both
|
||||
# must install the committed Gemfile.lock exactly. Frozen turns a lockfile
|
||||
# drift into a setup failure instead of two different fastlane versions in
|
||||
# one release.
|
||||
BUNDLE_FROZEN: 'true'
|
||||
|
||||
outputs:
|
||||
release_version: ${{ steps.release_metadata.outputs.version }}
|
||||
build_number: ${{ steps.release_metadata.outputs.build_number }}
|
||||
@@ -149,6 +157,10 @@ jobs:
|
||||
# margin without allowing App Store Connect polling to hang for hours.
|
||||
timeout-minutes: 30
|
||||
|
||||
env:
|
||||
# Same fastlane as ios-build, or fail before touching App Store Connect.
|
||||
BUNDLE_FROZEN: 'true'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
@@ -12,11 +12,21 @@ on:
|
||||
# Why: the mobile terminal link parsers are conformance-tested against
|
||||
# these shared fixtures; desktop-side fixture edits must re-run this suite.
|
||||
- 'src/shared/terminal-file-link-conformance.ts'
|
||||
# Why: this job holds the only checks that load the Fastfile, so edits to
|
||||
# it or to the release workflow it guards must re-run them.
|
||||
- '.github/workflows/mobile.yml'
|
||||
- '.github/workflows/mobile-ios-release.yml'
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
# Why: an unfrozen bundler silently re-resolves when Gemfile.lock drifts
|
||||
# from the Gemfile, which is how the release jobs could land on different
|
||||
# fastlane versions in the first place. Fail here instead.
|
||||
BUNDLE_FROZEN: 'true'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
@@ -30,10 +40,15 @@ jobs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup Ruby
|
||||
# bundler-cache installs mobile/Gemfile.lock, so this job is also what
|
||||
# proves the pinned fastlane the release workflow depends on still
|
||||
# resolves — before a release run finds out.
|
||||
- name: Setup Ruby and fastlane
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
working-directory: mobile
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
@@ -64,6 +79,20 @@ jobs:
|
||||
- name: Test iOS release version resolution
|
||||
run: ruby fastlane/ios_release_version_test.rb
|
||||
|
||||
- name: Test TestFlight lane arguments
|
||||
run: ruby fastlane/fastfile_testflight_arguments_test.rb
|
||||
|
||||
# Why: nothing else in CI loads the Fastfile, so a syntax error, a broken
|
||||
# require, or an undefined constant only surfaces mid-release — the
|
||||
# ios-distribute job failed every run for six days that way. `lanes` just
|
||||
# loads and lists, so it needs no App Store Connect credentials and makes
|
||||
# no network calls to Apple.
|
||||
- name: Smoke-check the Fastfile
|
||||
env:
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_OPT_OUT_USAGE: '1'
|
||||
run: bundle exec fastlane lanes
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
|
||||
@@ -23,14 +23,20 @@ jobs:
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
# Why no checkout: the Files API already has per-file additions/deletions.
|
||||
# Why the default branch and never pull/<n>/head: this job holds a write-scoped
|
||||
# GITHUB_TOKEN, so it may only execute reviewed code. A PR that edits these
|
||||
# scripts takes effect once merged.
|
||||
- name: Count test vs non-test LoC
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
TRUSTED_REF: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for script in pr-test-loc-table.mjs pr-test-loc-summary.mjs; do
|
||||
gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=pull/${{ github.event.pull_request.number }}/head" \
|
||||
gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=${TRUSTED_REF}" \
|
||||
--jq .content | base64 --decode > "$RUNNER_TEMP/${script}"
|
||||
done
|
||||
node "$RUNNER_TEMP/pr-test-loc-summary.mjs" --update-pr "${{ github.event.pull_request.number }}"
|
||||
node "$RUNNER_TEMP/pr-test-loc-summary.mjs" --update-pr "$PR_NUMBER"
|
||||
|
||||
+162
-15
@@ -24,7 +24,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why blob:none: full history is needed for the merge-base diff, but historical
|
||||
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
|
||||
# few this job actually reads on demand.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
@@ -122,7 +126,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why blob:none: full history is needed for the merge-base diff, but historical
|
||||
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
|
||||
# few this job actually reads on demand.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- name: Reject new root-level files and folders
|
||||
@@ -142,7 +150,19 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- run: pnpm typecheck
|
||||
# Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets
|
||||
# the next run skip unchanged files. CI threw it away each time. The key is per-SHA
|
||||
# so each run saves its own; restore-keys inherit the newest prior graph to diff against.
|
||||
- name: Cache TypeScript incremental state
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: config/*.tsbuildinfo
|
||||
key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-
|
||||
tsbuildinfo-${{ runner.os }}-
|
||||
|
||||
- run: pnpm run typecheck
|
||||
|
||||
git_compatibility:
|
||||
name: Git compatibility
|
||||
@@ -156,19 +176,34 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the
|
||||
# same binary on every PR for minutes of runner time. The key carries the version
|
||||
# because that is the only input; the sha256 assertion below still guards the
|
||||
# tarball on the miss path that actually builds.
|
||||
- name: Cache baseline Git build
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/orca-git-compat/git-2.25.5
|
||||
key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5
|
||||
|
||||
- name: Verify Git binary compatibility matrix
|
||||
run: |
|
||||
pids=()
|
||||
(
|
||||
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
|
||||
source="$RUNNER_TEMP/git-2.25.5"
|
||||
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
|
||||
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
|
||||
| sha256sum --check
|
||||
mkdir -p "$source"
|
||||
tar -xzf "$archive" -C "$source" --strip-components=1
|
||||
make -C "$source" -j"$(nproc)" \
|
||||
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
|
||||
source="$HOME/.cache/orca-git-compat/git-2.25.5"
|
||||
if [ ! -x "$source/git" ]; then
|
||||
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
|
||||
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
|
||||
| sha256sum --check
|
||||
mkdir -p "$source"
|
||||
tar -xzf "$archive" -C "$source" --strip-components=1
|
||||
make -C "$source" -j"$(nproc)" \
|
||||
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
|
||||
# Why: the linked binaries are what the next run needs; the objects that
|
||||
# produced them are most of the tree and would bloat the cache entry.
|
||||
find "$source" -name '*.o' -delete
|
||||
fi
|
||||
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
@@ -194,9 +229,42 @@ jobs:
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
xterm_patch_sync:
|
||||
name: xterm patch sync
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: the check rebuilds xterm.js from a pinned upstream commit. Caching the
|
||||
# npm metadata and the shallow clone turns a ~4 min cold run into well under a
|
||||
# minute; the key is the manifest, so a commit or toolchain bump invalidates it.
|
||||
- name: Restore upstream xterm build inputs
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
${{ runner.temp }}/xterm-patch-build/upstream/.git
|
||||
key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
|
||||
|
||||
- name: Verify xterm patches match the pinned upstream build
|
||||
env:
|
||||
WORK_DIR: ${{ runner.temp }}/xterm-patch-build
|
||||
run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$WORK_DIR"
|
||||
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
runs-on: ubuntu-latest
|
||||
# Why: this job's cost is almost entirely package download, and a stalled mirror has
|
||||
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
|
||||
# is generous; it exists so a wedge fails the job instead of holding the whole run
|
||||
# open for the 6h GitHub default — which also blocks `gh run rerun --failed`.
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# Why: the suites below gate their live fish tests on the binary, which is
|
||||
# right on a developer machine and wrong here — this job is a required check
|
||||
@@ -224,20 +292,46 @@ jobs:
|
||||
# non-zero and would red this required check over something unrelated to the
|
||||
# PR. Every fish outcome is judged by the version gate below instead, so only
|
||||
# the zsh install (which has no such gate) stays fatal here.
|
||||
sudo apt-get update || true
|
||||
# Why retry only here: adding the PPA is the network-flaky step, and the
|
||||
# version gate below is fatal, so a transient Launchpad blip would
|
||||
# otherwise red a required check on PRs unrelated to shells.
|
||||
# Why -n: add-apt-repository refreshes every configured repo on its own. With
|
||||
# an update on each side of it this step refreshed them three times over, and
|
||||
# the Azure archive mirror alone costs ~15-30s a pass. The PPA index is the
|
||||
# only thing the repo list gains here, and the single update below fetches it.
|
||||
# Why bound acquisition: measured on a *passing* run, this step spent 40s
|
||||
# fetching 11.4 MB of index and then 2m17s fetching 8.9 MB of packages at
|
||||
# 65 kB/s — it is dominated by download throughput, not by work. apt applies
|
||||
# no wall-clock bound to a stalled mirror, so a slow Launchpad or archive
|
||||
# host wedges the step for tens of minutes. This job is a required check, so
|
||||
# a wedge holds the entire run open and blocks `gh run rerun --failed`.
|
||||
# Bounded timeouts plus retries turn an unbounded hang into a fast, legible
|
||||
# failure. Set in apt.conf.d rather than on each command line so the two
|
||||
# invocations below stay exactly as pr-workflow-parallelism.test.mjs parses
|
||||
# them. Retries are 1, not 3: a first attempt at these bounds already multiplied
|
||||
# 30s x 3 retries across every index file into a ~15 minute stall on a dead
|
||||
# mirror, which is worse than failing once and moving on.
|
||||
sudo tee /etc/apt/apt.conf.d/99-orca-shell-contracts >/dev/null <<'APTCONF'
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
Acquire::Retries "1";
|
||||
APTCONF
|
||||
for attempt in 1 2 3; do
|
||||
sudo add-apt-repository -y ppa:fish-shell/release-4 && break
|
||||
sudo add-apt-repository -y -n ppa:fish-shell/release-4 && break
|
||||
echo "add-apt-repository attempt ${attempt} failed; retrying" >&2
|
||||
sudo add-apt-repository -y -r ppa:fish-shell/release-4 || true
|
||||
sudo add-apt-repository -y -n -r ppa:fish-shell/release-4 || true
|
||||
sleep 5
|
||||
done
|
||||
sudo apt-get update || true
|
||||
# Why a wall-clock bound on each command: apt's Acquire timeouts are per-connection,
|
||||
# so a dead mirror costs timeout x retries x every index file. Measured: the archive
|
||||
# mirror stalled with zero bytes and the step burned 14m26s before the job bound
|
||||
# killed it. `timeout` is the only thing that bounds the command as a whole.
|
||||
# The update is already tolerant by design (see above), so bounding it just caps
|
||||
# what a dead mirror can cost before the install runs against whatever index exists.
|
||||
timeout 120 sudo apt-get update || true
|
||||
# Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the
|
||||
# first install command in this step to prove the lane really installs them.
|
||||
sudo apt-get install -y zsh fish
|
||||
timeout 300 sudo apt-get install -y zsh fish
|
||||
|
||||
# Separate from the install so the failure names the contract, not an apt error.
|
||||
# ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds
|
||||
@@ -270,7 +364,12 @@ jobs:
|
||||
src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
|
||||
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/main/shell-startup-feature-channel.test.ts \
|
||||
src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
@@ -309,7 +408,12 @@ jobs:
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/shell-startup-feature-channel.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
@@ -325,7 +429,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why blob:none: full history is needed for the merge-base diff, but historical
|
||||
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
|
||||
# few this job actually reads on demand.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
@@ -459,14 +567,19 @@ jobs:
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts
|
||||
src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts
|
||||
src/main/cli/wsl-cli-powershell-boundary.test.ts
|
||||
src/main/orca-profiles/profile-index-store.test.ts
|
||||
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
|
||||
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
|
||||
src/shared/secure-file-fsync-flags.test.ts
|
||||
|
||||
# Why the :parallel variant: identical to build:release except the three
|
||||
# electron-vite targets overlap instead of running back to back. The Linux package
|
||||
# job already packages and smoke-tests an AppImage built that way.
|
||||
- name: Build package inputs
|
||||
run: pnpm run build:release
|
||||
run: pnpm run build:release:parallel
|
||||
|
||||
- name: Prepare Electron native runtime
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
@@ -495,7 +608,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why blob:none: full history is needed for the merge-base diff, but historical
|
||||
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
|
||||
# few this job actually reads on demand.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- name: Filter changed E2E specs
|
||||
@@ -509,6 +626,33 @@ jobs:
|
||||
if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then
|
||||
TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)"
|
||||
fi
|
||||
# Why: the Docker-SSH specs only ever ran when someone edited a spec, so four
|
||||
# pane-restore regressions shipped from SSH source edits that touched no test.
|
||||
# Unit tests are excluded because they prove the same source without Docker.
|
||||
# ssh-startup-exec-readiness.spec.ts is listed to route e2e.yml's dedicated
|
||||
# ssh-docker-watcher-isolation lane, which filters it out of changed-e2e.
|
||||
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(src/main/ssh/|src/main/providers/ssh-|src/main/ipc/(ssh-|pty)|src/relay/|src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty))'; then
|
||||
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts' \
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts' \
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' \
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts' \
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' | sort -u)"
|
||||
fi
|
||||
# Why: same lesson as the SSH specs above. The IME e2e suite asserts real pty bytes and
|
||||
# real overlay geometry, but only ran when someone edited a spec, so every IME fix in the
|
||||
# 1.4.18x window shipped without it - including one whose diagnosis hardware later refuted.
|
||||
# config/patches is listed because the terminal's input handling lives in the xterm patch.
|
||||
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(config/patches/|src/renderer/src/components/terminal-pane/(terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))'; then
|
||||
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
|
||||
'tests/e2e/terminal-cjk-ime-committed-text.spec.ts' \
|
||||
'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts' \
|
||||
'tests/e2e/terminal-ime-exact-byte.spec.ts' \
|
||||
'tests/e2e/terminal-korean-composing-chord-order.spec.ts' \
|
||||
'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts' \
|
||||
'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts' \
|
||||
'tests/e2e/terminal-korean-preedit-visibility.spec.ts' | sort -u)"
|
||||
fi
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
@@ -537,6 +681,7 @@ jobs:
|
||||
- root_directory_guard
|
||||
- typecheck
|
||||
- git_compatibility
|
||||
- xterm_patch_sync
|
||||
- shell_contracts
|
||||
- test
|
||||
- managed_hook_node18
|
||||
@@ -559,6 +704,7 @@ jobs:
|
||||
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
@@ -570,6 +716,7 @@ jobs:
|
||||
"$ROOT_DIRECTORY_GUARD" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
|
||||
@@ -105,8 +105,12 @@ docs/**
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/remote-wire-compatibility.md
|
||||
!docs/reference/renderer-agent-status-performance.md
|
||||
!docs/reference/ssh-host-key-verification.md
|
||||
!docs/reference/ssh-reconnect-source-recovery.md
|
||||
!docs/reference/windows-setup-shell.md
|
||||
!docs/reference/worktree-scan-fingerprint.md
|
||||
!docs/reference/wsl-command-execution.md
|
||||
!docs/reference/xterm-patch-regeneration.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
@@ -148,3 +152,8 @@ tests/tools/benchmarks/results/terminal-pipeline-*.json
|
||||
|
||||
# Old release trees the cross-version wire harness extracts on demand
|
||||
tests/e2e/.cross-version-checkouts/
|
||||
|
||||
# Bundler's install path for the mobile release toolchain (mobile/Gemfile.lock
|
||||
# IS committed). Also keeps oxfmt/oxlint, which honor this file, from walking
|
||||
# vendored gems.
|
||||
/mobile/vendor/
|
||||
|
||||
@@ -34,6 +34,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
||||
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
|
||||
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
|
||||
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
|
||||
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
|
||||
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
|
||||
|
||||
## SSH Use Case
|
||||
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -238,9 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
|
||||
- **WeChat:** Scan to join the Orca community WeChat group 7.
|
||||
- **WeChat:** Scan to join the Orca community WeChat group 7. If it is full, use group 8.
|
||||
|
||||
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 QR code for the Orca community" width="160" />
|
||||
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 QR code for the Orca community" width="160" />
|
||||
<img src="docs/assets/wechat-qr-group8.jpg" alt="WeChat group 8 QR code for the Orca community" width="160" />
|
||||
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
|
||||
@@ -17,12 +17,13 @@ type OutputChunk = Rollup.OutputChunk
|
||||
// graph still resolves.
|
||||
|
||||
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
|
||||
// forked daemon, parcel-watcher and computer sidecars, and the CLI-run
|
||||
// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run
|
||||
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
'computer-sidecar',
|
||||
'wsl-transcript-fs-process-entry',
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'codex/codex-app-server-grant-entry'
|
||||
] as const
|
||||
|
||||
@@ -129,6 +129,9 @@ module.exports = {
|
||||
'!out/**/*.test.js',
|
||||
// Why: Vite's manifest is only used to project the paired web client.
|
||||
'!out/renderer/.vite{,/**/*}',
|
||||
// Why: out/electron-dev caches `pnpm dev`'s per-branch Electron.app copies (~270MB each).
|
||||
// CI never creates it, but packaging on a machine that has run dev would pack them all.
|
||||
'!out/electron-dev{,/**/*}',
|
||||
'!electron.vite.config.{js,ts,mjs,cjs}',
|
||||
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
|
||||
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
|
||||
@@ -189,6 +192,7 @@ module.exports = {
|
||||
'out/main/hermes/**',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/session-scanner-service-entry.js',
|
||||
'out/main/wsl-transcript-fs-process-entry.js',
|
||||
'out/main/session-scanner-opencode-sqlite-worker-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
|
||||
@@ -29,7 +29,6 @@ inline src/main/git/repo.ts
|
||||
inline src/main/git/runner.ts
|
||||
inline src/main/git/status.ts
|
||||
inline src/main/git/worktree.ts
|
||||
inline src/main/github/client.ts
|
||||
inline src/main/github/pr-refresh-coordinator.ts
|
||||
inline src/main/github/project-view.ts
|
||||
inline src/main/github/project-view/mutations.ts
|
||||
@@ -65,7 +64,6 @@ inline src/main/runtime/orca-runtime-files.ts
|
||||
inline src/main/runtime/orca-runtime-git.ts
|
||||
inline src/main/runtime/orca-runtime.test.ts
|
||||
inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/orchestration/db.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/rpc/methods/terminal.ts
|
||||
inline src/main/runtime/runtime-rpc.ts
|
||||
@@ -99,7 +97,6 @@ inline src/renderer/src/components/UpdateCard.tsx
|
||||
inline src/renderer/src/components/WorktreeJumpPalette.tsx
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
|
||||
inline src/renderer/src/components/automations/AutomationsPage.tsx
|
||||
inline src/renderer/src/components/browser-pane/BrowserPane.tsx
|
||||
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
|
||||
inline src/renderer/src/components/editor/EditorContent.tsx
|
||||
inline src/renderer/src/components/editor/IpynbViewer.tsx
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schemaNote": "Consumed by config/scripts/regenerate-xterm-patches.mjs. See docs/reference/xterm-patch-regeneration.md.",
|
||||
"upstream": {
|
||||
"repository": "https://github.com/xtermjs/xterm.js.git",
|
||||
"commit": "53a98a720ae4a973e384fa2440880d09537132f3",
|
||||
"commitSource": "bin/publish.js stamps package.json.commit before npm publish, so the published tarball names its own commit. The generator asserts the two agree."
|
||||
},
|
||||
"sourcemaps": {
|
||||
"policy": "include",
|
||||
"why": "terminal-ime-xterm-transaction-events.test.ts reads lib/*.map and asserts the mapped Version.ts matches the runtime version, so the maps have to move with the bundles rather than be dropped. Patching them costs ~5.8MB of the emitted patch; the alternative, deleting them, is only available again once nothing reads them."
|
||||
},
|
||||
"toolchain": {
|
||||
"why": "Pinned by the upstream package-lock at the commit above. The generator asserts these resolve as expected so a silent upstream resolution change surfaces as a toolchain error rather than a mystery patch diff.",
|
||||
"esbuild": "0.28.1",
|
||||
"webpack": "5.107.0",
|
||||
"terser": "5.47.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260521.1"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"name": "@xterm/xterm",
|
||||
"version": "6.1.0-beta.287",
|
||||
"packageDir": ".",
|
||||
"versionStampFile": "src/common/Version.ts",
|
||||
"sourcePatch": "config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch",
|
||||
"patch": "config/patches/@xterm__xterm@6.1.0-beta.287.patch",
|
||||
"generatedPaths": ["lib/"],
|
||||
"build": [{ "cwd": ".", "command": "npm", "args": ["run", "package"] }]
|
||||
}
|
||||
],
|
||||
"forbiddenBuildScripts": {
|
||||
"why": "`npm run setup` runs a development esbuild (minify:false), so calling it after the packaging build overwrites lib/*.mjs with an unminified bundle and a mismatched map. Publish order is: stamp Version.ts, then `npm run package` only.",
|
||||
"scripts": ["setup", "presetup", "postsetup", "esbuild", "esbuild-watch", "dev"]
|
||||
}
|
||||
}
|
||||
+188
-24
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-14",
|
||||
"updatedAt": "2026-08-18",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -943,18 +943,27 @@
|
||||
"providers": ["lan", "tailscale", "cloud-relay"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["lan", "tailscale", "cloud-relay"],
|
||||
"coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, direct-winner cancellation, credential-gated Relay presentation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca-cloud/pull/96"],
|
||||
"invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Relay recovery presentation starts only for a failed active Relay or a dial backed by an eligible credential, and remains pending through a genuine failed-dial cooldown. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.",
|
||||
"oracle": "Inject deterministic relay close codes, missing and expired credentials, delayed credential reads, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, keep Relay presentation absent when no socket can open, clear it across lifecycle races, retain it through real dial cooldowns, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.",
|
||||
"coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, continuous-outage escalation, persistent pairing-rejection escalation, direct-winner cancellation, credential-gated Relay presentation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca-cloud/pull/96",
|
||||
"https://linear.app/stably/issue/STA-4587",
|
||||
"https://linear.app/stably/issue/STA-4681"
|
||||
],
|
||||
"invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Relay recovery presentation starts only for a failed active Relay or a dial backed by an eligible credential, remains pending through a genuine failed-dial cooldown, and exposes the supervisor's consecutive failed-dial count so a continuous outage escalates to unreachable. A relay credential the desktop keeps refusing escalates to re-pair once its transient-rejection budget is spent, and only an authenticated session clears that verdict. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.",
|
||||
"oracle": "Inject deterministic relay close codes, missing and expired credentials, delayed credential reads, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, keep Relay presentation absent when no socket can open, clear it across lifecycle races, retain it through real dial cooldowns, establish Relay through the supervisor and require its active close plus 11 failed replacements to publish reconnect attempt 12 and a Relay-unreachable verdict, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins. Drive a supervisor-established Relay through four simulated hours of E2EE rejections and require the re-pair verdict, require it to survive a background/foreground cycle, require two rejections followed by acceptance to never latch it, and require a plain transport outage to still read Relay-unreachable.",
|
||||
"commands": [
|
||||
"pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts",
|
||||
"pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts mobile/src/transport/mobile-relay-outage-escalation.test.ts mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/mobile-relay-runtime-failover.test.ts mobile/src/transport/connection-health.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot"
|
||||
],
|
||||
"testFiles": [
|
||||
"mobile/src/transport/mobile-direct-endpoint-probe.test.ts",
|
||||
"mobile/src/transport/mobile-relay-reconnect-controller.test.ts",
|
||||
"mobile/src/transport/mobile-endpoint-supervisor.test.ts",
|
||||
"mobile/src/transport/mobile-relay-outage-escalation.test.ts",
|
||||
"mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts",
|
||||
"mobile/src/transport/stable-logical-rpc-client.test.ts",
|
||||
"mobile/src/transport/mobile-relay-runtime-failover.test.ts",
|
||||
"mobile/src/transport/connection-health.test.ts",
|
||||
"src/shared/mobile-relay-close-codes.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
@@ -981,6 +990,43 @@
|
||||
"a genuine failed Relay dial remains pending through its retry cooldown"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "mobile/src/transport/mobile-relay-outage-escalation.test.ts",
|
||||
"assertions": [
|
||||
"a supervisor-established Relay close plus 11 failed replacements publish reconnect attempt 12",
|
||||
"the existing connection classifier escalates the continuous outage to Can't connect via Relay"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts",
|
||||
"assertions": [
|
||||
"a persistent E2EE rejection escalates to Pairing invalid — re-pair with your desktop",
|
||||
"the re-pair verdict survives a background/foreground cycle",
|
||||
"rejections inside the transient budget followed by acceptance never latch it",
|
||||
"a plain transport outage still reads Can't connect via Relay"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "mobile/src/transport/connection-health.test.ts",
|
||||
"assertions": [
|
||||
"a rejected pairing outranks a pending Relay recovery",
|
||||
"a rejected pairing never overrides a live connection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "mobile/src/transport/stable-logical-rpc-client.test.ts",
|
||||
"assertions": [
|
||||
"supervisor Relay attempts overlay but never replace a larger active-session retry count",
|
||||
"count-only changes notify connection-path subscribers and clearing recovery restores the physical count"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "mobile/src/transport/mobile-relay-runtime-failover.test.ts",
|
||||
"assertions": [
|
||||
"a fresh credential starts Relay presentation with the controller's current count",
|
||||
"missing or expired credentials never open a Relay socket"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/mobile-relay-close-codes.test.ts",
|
||||
"assertions": ["HOST_OFFLINE maps to self-healing full-jitter recovery"]
|
||||
@@ -988,13 +1034,13 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-16",
|
||||
"date": "2026-08-18",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts",
|
||||
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts mobile/src/transport/mobile-relay-outage-escalation.test.ts mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/mobile-relay-runtime-failover.test.ts mobile/src/transport/connection-health.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.23,
|
||||
"summary": "Three focused mobile transport files passed with 54 assertions."
|
||||
"durationSeconds": 1.25,
|
||||
"summary": "Eight focused mobile transport files passed with 109 assertions, including production-lifecycle outage escalation, persistent pairing-rejection escalation, and logical count publication."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-16",
|
||||
@@ -1016,11 +1062,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The prior external-signal HOST_OFFLINE policy fails the retry oracle, and the prior serial direct probe fails the first-authenticated-endpoint timing oracle. Both pass with the candidate behavior."
|
||||
"evidence": "Banking the pairing rejection after registerFailure's gated early return — the pre-fix placement — leaves the four-hour E2EE oracle on 'Connecting via Relay…' and reds 7 assertions while the 17 others stay green; moving it ahead of the gate turns them green. The pre-fix production-lifecycle oracle establishes Relay through the supervisor, then completes one active close plus 11 replacement failures while the public retry count remains 0; the candidate publishes 12 and reaches the existing Relay-unreachable verdict. The prior external-signal HOST_OFFLINE policy and serial direct probe also fail their respective retry and first-authenticated-endpoint timing oracles."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "All configured direct candidates start in one turn, the first authenticated candidate wins after 100 ms in the deterministic test, and every losing client is closed. A physical-device radio and battery budget is still required before promotion."
|
||||
"evidence": "The production diff only projects existing supervisor state — the failure count and a consecutive-rejection latch — through local setters and subscribers; it adds no call to a Relay dial, timer, socket, probe, or wire API, and the four screens read both recovery inputs from one host subscription instead of two. The deterministic outage asserts exactly one successful establishment plus 11 failed replacements at the escalation boundary. All configured direct candidates start in one turn, the first authenticated candidate wins after 100 ms, and every losing client is closed."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive CI passes or 14 days of soak history.",
|
||||
@@ -1512,6 +1558,124 @@
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if index-only churn can emit worktrees:changed, or if structural add/remove/HEAD/lock changes fail to converge."
|
||||
},
|
||||
{
|
||||
"id": "runtime.multi-client-view-isolation",
|
||||
"title": "A client's workspace create navigates only the caller unless follow is explicit",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "runtime-platform",
|
||||
"layer": "runtime-client-event-authority",
|
||||
"surfaces": [
|
||||
"paired desktop client",
|
||||
"paired web client",
|
||||
"headed Orca desktop server",
|
||||
"headless orca serve",
|
||||
"worktree.create with activate",
|
||||
"runtime.clientEvents stream"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "orca-server"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "orca-server"],
|
||||
"coverageNotes": "A real two-client integration harness pairs two authenticated devices to one OrcaRuntimeService over WebSockets with E2EE and drives worktree.create through the real RPC dispatcher, in both headed-notifier and headless orca-serve shapes. The headless case is a modeled `orca serve` runtime shape — no notifier, no attached window — not a CLI bootstrap, and the in-process reveal non-regression calls createManagedWorktree directly. A headed Electron journey runs the desktop app as the remote server with two independently paired web clients in separate partitions and asserts each client's rendered active worktree. Windows and Linux are covered by the platform-neutral contract only; the reported field topology was a Windows server with mixed-OS clients.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/issues/11027"],
|
||||
"invariant": "View intent belongs to the connection that requested it. With no explicit navigation target, a workspace create arriving over a paired desktop/web client connection activates only that caller; it must not emit an activateWorktree client event to sibling connections or drive the host renderer. An explicit navigation of host/clients/all remains an allowed, tested follow intent. Shared model state — repo/worktree catalogs, terminals, agent output — must keep propagating to every client unchanged. In-process and CLI creates keep notifying the host and all clients so a headless server's only viewer still gets its reveal.",
|
||||
"oracle": "notifyActivateWorktree takes its navigation target as a required parameter, so a create call site that forgets to forward it is a compile error rather than a silent fallback to broadcasting — mutation testing confirmed all six call sites (folder, git, and remote/SSH branches, startup and no-startup each) are killed by typecheck. Behaviorally: two paired clients subscribe to runtime.clientEvents. Client A creates a workspace with activate:true; a reposChanged fence emitted after the create must be the next frame each client sees, proving no activation frame was interleaved, while worktreesChanged still arrives on both streams. The host notifier's activateWorktree is asserted not called and the host's mirrored selection is unchanged. Repeated ordinary worktree.activate calls by A are the negative control. The same file asserts the in-process create still reaches host and clients, headed and headless. The Electron journey asserts the observer's data-rendered-active-worktree-id is unchanged while the created workspace still appears in its sidebar.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/multi-client-navigation-isolation.integration.test.ts src/main/runtime/rpc/methods/worktree-create-navigation.test.ts",
|
||||
"pnpm run test:e2e:multi-client-navigation -- tests/e2e/multi-client-navigation-isolation.spec.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/multi-client-navigation-isolation.integration.test.ts",
|
||||
"src/main/runtime/rpc/methods/worktree-create-navigation.test.ts",
|
||||
"tests/e2e/multi-client-navigation-isolation.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/multi-client-navigation-isolation.integration.test.ts",
|
||||
"assertions": [
|
||||
"keeps a paired client workspace create-with-activate off every other client and the host",
|
||||
"still reveals to every client when a paired caller asks for all-surface navigation",
|
||||
"does not replay a missed create activation to a reconnecting observer",
|
||||
"keeps create activation caller-scoped on a headless orca serve host",
|
||||
"still reveals a host-originated create-with-activate on the host and every client",
|
||||
"keeps worktree navigation local to each paired runtime client by default",
|
||||
"routes explicit host and paired-client follow intent without changing the default"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/worktree-create-navigation.test.ts",
|
||||
"assertions": [
|
||||
"resolves create activation from the paired runtime client kind",
|
||||
"resolves create activation from the paired mobile client kind",
|
||||
"honors an explicit follow navigation on create",
|
||||
"keeps an explicit all-surface reveal from a paired caller"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/multi-client-navigation-isolation.spec.ts",
|
||||
"assertions": [
|
||||
"keeps a paired client workspace create-with-agent off the other client and the host",
|
||||
"keeps two paired browser clients and the host on independent worktrees"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-18",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/multi-client-navigation-isolation.integration.test.ts src/main/runtime/rpc/methods/worktree-create-navigation.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 6,
|
||||
"summary": "Eleven paired-runtime contracts pass, including the headless orca-serve shape, the in-process reveal non-regression, and the CLI-shaped explicit all-surface reveal. With the caller scoping removed the observer's stream carried activateWorktree instead of the fence."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-18",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run test:e2e:multi-client-navigation -- tests/e2e/multi-client-navigation-isolation.spec.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 80,
|
||||
"summary": "Headed Orca desktop server with two paired web clients: the observer stayed on its own worktree while the creator's workspace appeared in its sidebar. With the fix disabled the same journey failed with the observer rendering the created workspace."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 120,
|
||||
"scope": "two-client WebSocket integration contracts plus one headed Electron two-web-client journey"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "New contracts. The integration file is fence-ordered rather than timing-based and passed repeatedly locally; the Electron journey passed twice locally on macOS. CI and soak history have not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Both layers were observed red on origin/main 4ec6bbf588 before any fix: the observer client received ['worktreesChanged', 'activateWorktree'] instead of the fence, and the Electron observer's data-rendered-active-worktree-id resolved to the workspace the other client had just created. Both went green with the caller scoping and red again when only resolveRuntimeNavigationTarget's clientKind input was removed."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "The change adds one enum resolution per create request and two boolean checks before an existing fan-out. No new listeners, subscriptions, provider calls, or timers; the client-event fan-out itself is unchanged in shape and frequency."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect CI soak history for the Electron journey on macOS plus at least one Linux run.",
|
||||
"Add a live Windows-server topology run matching the reported field setup.",
|
||||
"Extend the oracle to mobile observers and to a client with two windows open on one connection."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live Windows or Linux server run yet; the reported field topology was a Windows 11 server.",
|
||||
"Mobile observers are covered only by the platform-neutral clientKind contract, not by a live phone journey.",
|
||||
"Host-originated CLI creates deliberately still reveal on every client; that broader authority question is untouched by this gate.",
|
||||
"Mobile creates deliberately keep the all-surface reveal: a phone has no renderer to provision repo setup/default tabs off the activation event.",
|
||||
"The Electron journey drives the store create action, so the composer's automatic self-navigation on create is proved by worktree-creation-flow.test.ts and worktree.spec.ts rather than by this journey.",
|
||||
"Headless coverage is a modeled runtime shape; CLI bootstrap and argument parsing for orca serve are not exercised here.",
|
||||
"Host-first rollout: the fix is enforced by the host, so a NEW client paired to an OLD always-on server still sees the original cross-client jump. Shipping clients alone fixes nothing for anyone; the runtime/server must be updated. Nothing breaks under that skew — the old host simply keeps its old behavior — but release notes and support triage must check the host version, not the client version. No skew combination is worse than pre-fix main: an old CLI is recognized by its cliProvenanceRequest marker so its --activate reveal survives an updated host.",
|
||||
"A future contributor could still hardcode a wrong navigation target at a call site: the required object key makes omission a compile error, but not substitution. Only the folder/no-startup branch has a behavioral test that would catch it; adding one for the remote/SSH branch needs new SSH provider scaffolding and was judged not worth the surface.",
|
||||
"Out of scope, host-only and unreachable from the product UI: a paired caller that explicitly requests terminal.create with presentation 'focused' still navigates the host renderer (the Orca client always sends 'background'); agent-session already downgrades that presentation for paired callers.",
|
||||
"The remote/SSH create branch (createManagedRemoteWorktree) has compile-time propagation coverage but no behavioral navigation test; the folder branch is covered by the integration suite and the git branch by the Electron journey.",
|
||||
"Out of scope: browser.tabCreate/tabSwitch carry no paired-client navigation policy, so they can change the host's active browser tab when the host is already on that worktree. They never cross worktrees, and the paired web client sends activate:true deliberately."
|
||||
],
|
||||
"demotionRule": "Demote if a create, activate, or reveal path can move a viewer that did not request it, or if scoping regresses the in-process/CLI reveal that a headless server's only remote viewer depends on."
|
||||
},
|
||||
{
|
||||
"id": "runtime.headless-desktop-promotion-continuity",
|
||||
"title": "Headless serve opens its desktop without replacing live terminal sessions",
|
||||
@@ -2640,19 +2804,19 @@
|
||||
"invariant": "A current remote browser stream that closes must retry transient failures on one counted, capped backoff chain. Cancellation, pane or runtime supersession, missing pages, and permanent failures stop that chain without stale state mutation. A stream that stays healthy refills the budget; a silent or flapping stream cannot retry forever. Exhaustion and every non-retrying failure leave an actionable Reconnect control, and manual reconnect creates a fresh stream without resurrecting stale subscriptions.",
|
||||
"oracle": "With one fake clock and exact pane, operation, stream, page, and runtime identities, drop a confirmed stream and require the counted delay sequence, one active chain, stale-token rejection, exact cancellation, full cleanup, and either a new live subscription or one stopped state with Reconnect after exhaustion. Inject transport error without close, close racing rejection, never-ready subscriptions, ready-then-end flapping, viewport supersession, missing pages, and permanent failures. In a separate paired client against an isolated headless Orca host, paint one remote frame, disconnect the owning runtime, require the bounded-retry notice and Reconnect control, reconnect, click once, and require a different frame object URL from the recovered stream.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/paired-remote-browser-stream-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts",
|
||||
"src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts",
|
||||
"src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts",
|
||||
"src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts",
|
||||
"src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts",
|
||||
"src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts",
|
||||
"src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts",
|
||||
"src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts",
|
||||
"tests/e2e/paired-remote-browser-stream-reconnect.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts",
|
||||
"file": "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts",
|
||||
"assertions": [
|
||||
"transient failures retry until recovery on counted delays",
|
||||
"budget exhaustion stops exactly once",
|
||||
@@ -2661,7 +2825,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts",
|
||||
"file": "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts",
|
||||
"assertions": [
|
||||
"dropped, silent, and flapping streams converge on a bounded retry or actionable stop",
|
||||
"stale, superseded, missing-page, and permanent-failure work cannot mutate the current pane",
|
||||
@@ -2683,7 +2847,7 @@
|
||||
"date": "2026-08-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.36,
|
||||
"summary": "Four focused files passed 61 deterministic error, status, scheduler, lifecycle, in-flight ownership, stale-rejection, liveness, and cleanup tests."
|
||||
@@ -9092,7 +9256,7 @@
|
||||
"oracle": "A quarter-circle Claude task title authorizes without a provider lookup only on the exact PTY incarnation carrying verified managed-Claude launch identity; the same title in a bare pane, with an unverified launch hint, or after PTY incarnation replacement is refused while current Claude activity remains a working signal. Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/quarter-circle-title-send-authorization.test.ts",
|
||||
@@ -9105,7 +9269,7 @@
|
||||
"src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
|
||||
"src/main/daemon/pty-subprocess.test.ts",
|
||||
"src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts",
|
||||
"src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx"
|
||||
"src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -9175,7 +9339,7 @@
|
||||
"assertions": ["selected active-agent note sends retain guarded paste and submit routing"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx",
|
||||
"file": "src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx",
|
||||
"assertions": [
|
||||
"browser annotation send content routes through review-notes send so existing agent sessions remain selectable",
|
||||
"both browser annotation send surfaces wire the shared menu content"
|
||||
@@ -9196,7 +9360,7 @@
|
||||
"date": "2026-07-11",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx",
|
||||
"result": "passed",
|
||||
"durationSeconds": 9.58,
|
||||
"summary": "Ten focused test files passed (1183 tests), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing."
|
||||
|
||||
@@ -15,7 +15,13 @@ export const OXLINT_SCANS = [
|
||||
},
|
||||
{
|
||||
label: 'type-aware code quality',
|
||||
args: ['--type-aware', '--config', 'config/oxlint-code-quality-type-aware.json']
|
||||
args: ['--type-aware', '--config', 'config/oxlint-code-quality-type-aware.json'],
|
||||
// Why: type-aware rules need the types to RESOLVE, and mobile's live only in
|
||||
// mobile/node_modules. Linting them under the root config turns every such import into an
|
||||
// `error` type, which `no-redundant-type-constituents` then reports as a real finding. The
|
||||
// package script scopes this scan to `src config tests` for the same reason; the changed-file
|
||||
// gate has to agree with it or it invents findings the repo cannot reproduce.
|
||||
excludes: (file) => file === 'mobile' || file.startsWith('mobile/')
|
||||
},
|
||||
{
|
||||
label: 'React Doctor',
|
||||
@@ -195,7 +201,12 @@ export function main(
|
||||
|
||||
let failures = 0
|
||||
for (const scan of OXLINT_SCANS) {
|
||||
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
|
||||
const scanned = scan.excludes ? files.filter((file) => !scan.excludes(file)) : files
|
||||
if (scanned.length === 0) {
|
||||
console.log(`${scan.label}: 0 new finding(s) across 0 changed file(s).`)
|
||||
continue
|
||||
}
|
||||
const diagnostics = runOxlintScan(root, scan, scanned).filter((diagnostic) =>
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root)
|
||||
)
|
||||
for (const diagnostic of diagnostics) {
|
||||
@@ -203,7 +214,7 @@ export function main(
|
||||
}
|
||||
failures += diagnostics.length
|
||||
console.log(
|
||||
`${scan.label}: ${diagnostics.length} new finding(s) across ${files.length} changed file(s).`
|
||||
`${scan.label}: ${diagnostics.length} new finding(s) across ${scanned.length} changed file(s).`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,4 +51,19 @@ describe('changed-code quality line matching', () => {
|
||||
expect(scan.args).not.toContain('--config')
|
||||
expect(scan.args).not.toContain('--disable-nested-config')
|
||||
})
|
||||
|
||||
// Why: the type-aware scan DOES pin the root config, and mobile's types resolve only from
|
||||
// mobile/node_modules. Linting mobile under the root config makes every such import an `error`
|
||||
// type, which no-redundant-type-constituents reports as a real finding — findings the package
|
||||
// script cannot reproduce, because it scopes this scan to `src config tests`.
|
||||
it('keeps the type-aware scan off mobile, whose types the root config cannot resolve', () => {
|
||||
const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'type-aware code quality')
|
||||
|
||||
expect(scan.args).toContain('--type-aware')
|
||||
expect(scan.excludes('mobile/src/session/use-mobile-structured-attachments.test.ts')).toBe(true)
|
||||
expect(scan.excludes('mobile')).toBe(true)
|
||||
expect(scan.excludes('src/main/runtime/orca-runtime.ts')).toBe(false)
|
||||
// A path that merely starts with the same letters is not inside the package.
|
||||
expect(scan.excludes('mobile-shim/src/a.ts')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +47,18 @@ describe('computer-use skill guidance', () => {
|
||||
expect(skill).not.toContain('`result.elements`')
|
||||
})
|
||||
|
||||
it('explains how JSON and pretty output handle screenshots', () => {
|
||||
expect(bundledGuide).toBeDefined()
|
||||
|
||||
for (const skill of [readFileSync(guidePath, 'utf8'), bundledGuide]) {
|
||||
expect(skill).toContain('request screenshots by default unless `--no-screenshot`')
|
||||
expect(skill).toContain('A successful `--json` capture')
|
||||
expect(skill).toContain('`result.screenshot.path`')
|
||||
expect(skill).toContain('inline base64 `result.screenshot.data`')
|
||||
expect(skill).toContain('Pretty output does not save')
|
||||
}
|
||||
})
|
||||
|
||||
it('requires atomic modifier-click actions in the source and bundled guide', () => {
|
||||
expect(bundledGuide).toBeDefined()
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Why this module exists: `out/electron-dev` accumulates one ~270MB copy of Electron.app per
|
||||
// (branch title x Electron version x bundle layout). The runner only ever clears the directory it is
|
||||
// about to rebuild, so siblings from renamed branches and past upgrades are never reclaimed --
|
||||
// measured at 143 directories / 38GB across one developer's worktrees.
|
||||
|
||||
// Measured from BUILD START, not from last activity: a directory's mtime only changes when a
|
||||
// top-level entry is created, so it freezes once `<app>.app` appears and the 276MB copy, helper
|
||||
// compiles and signing that follow never refresh it. So this is a hard cliff -- a build still
|
||||
// running after this long becomes eligible for deletion by a concurrent instance. Full builds
|
||||
// measured at 130-200s, so the margin is 5-10x.
|
||||
export const IN_PROGRESS_WINDOW_MS = 15 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Which cached bundle directories are safe to reclaim.
|
||||
*
|
||||
* Three things are protected, and every one of them is a directory some other process still needs:
|
||||
*
|
||||
* - the bundle this run is about to use;
|
||||
* - a bundle a live dev instance is running from. Deleting it can crash that instance mid-session,
|
||||
* and developers routinely run several at once;
|
||||
* - a bundle still being copied. Between `mkdirSync` and the marker write it exists, has no marker,
|
||||
* and cannot appear in the process table because its instance has not launched yet -- so a
|
||||
* concurrently starting instance would otherwise delete it mid-copy.
|
||||
*
|
||||
* `processTable` is raw `ps` output, searched for each candidate rather than parsed into paths.
|
||||
* Parsing was tried twice and failed twice in the same dangerous direction -- a regex that missed a
|
||||
* live process yielded "nothing is running" and deleted its bundle. Searching for a known absolute
|
||||
* directory is immune to spaces and shell-significant characters in the path, and the trailing
|
||||
* slash keeps `<dir>2` from being mistaken for `<dir>`.
|
||||
*/
|
||||
export function isDevBundleInUse(dir, processTable) {
|
||||
// Both spellings: macOS realpaths /tmp to /private/tmp, and `ps` preserves whatever spelling the
|
||||
// process was launched with. A mismatch would read as "not running" and delete a live bundle.
|
||||
// Checking both can only ever protect more, which is the safe direction.
|
||||
const alternate = dir.startsWith('/private/') ? dir.slice('/private'.length) : `/private${dir}`
|
||||
return processTable.includes(`${dir}/`) || processTable.includes(`${alternate}/`)
|
||||
}
|
||||
|
||||
export function selectStaleDevBundleDirs({ bundles, currentDir, processTable, nowMs }) {
|
||||
return bundles
|
||||
.filter(({ dir, hasMarker, mtimeMs }) => {
|
||||
if (dir === currentDir || isDevBundleInUse(dir, processTable)) {
|
||||
return false
|
||||
}
|
||||
const buildInFlight = !hasMarker && nowMs - mtimeMs < IN_PROGRESS_WINDOW_MS
|
||||
return !buildInFlight
|
||||
})
|
||||
.map(({ dir }) => dir)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
IN_PROGRESS_WINDOW_MS,
|
||||
isDevBundleInUse,
|
||||
selectStaleDevBundleDirs
|
||||
} from './dev-electron-bundle-cache.mjs'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
const ROOT = '/repo/out/electron-dev'
|
||||
const A = `${ROOT}/aaaaaaaaaaaa`
|
||||
const B = `${ROOT}/bbbbbbbbbbbb`
|
||||
const C = `${ROOT}/cccccccccccc`
|
||||
|
||||
/** A finished bundle: marker present, copied long ago. */
|
||||
function settled(dir: string) {
|
||||
return { dir, hasMarker: true, mtimeMs: NOW - IN_PROGRESS_WINDOW_MS * 2 }
|
||||
}
|
||||
|
||||
/** A `ps -Awwo command=` line for a dev instance running out of `dir`. */
|
||||
function psLine(dir: string, appName = 'Orca: some-branch') {
|
||||
return `${dir}/${appName}.app/Contents/MacOS/Electron --remote-debugging-port=9333`
|
||||
}
|
||||
|
||||
describe('dev-electron-bundle-cache', () => {
|
||||
it('reclaims siblings while keeping the current bundle', () => {
|
||||
expect(
|
||||
selectStaleDevBundleDirs({
|
||||
bundles: [settled(A), settled(B), settled(C)],
|
||||
currentDir: B,
|
||||
processTable: '',
|
||||
nowMs: NOW
|
||||
})
|
||||
).toEqual([A, C])
|
||||
})
|
||||
|
||||
it('never reclaims a bundle a live process is running from', () => {
|
||||
// Deleting a bundle out from under a live Electron process can crash it mid-session, and
|
||||
// developers routinely run several dev instances at once.
|
||||
expect(
|
||||
selectStaleDevBundleDirs({
|
||||
bundles: [settled(A), settled(B), settled(C)],
|
||||
currentDir: B,
|
||||
processTable: psLine(A),
|
||||
nowMs: NOW
|
||||
})
|
||||
).toEqual([C])
|
||||
})
|
||||
|
||||
it('protects a live bundle whose path contains spaces', () => {
|
||||
// Regression: an extraction regex using \S* could not cross a space, so any developer with a
|
||||
// space in their checkout path got "nothing is live" and had the running bundle deleted.
|
||||
const spaced = '/Users/me/My Projects/orca/out/electron-dev/aaaaaaaaaaaa'
|
||||
const other = '/Users/me/My Projects/orca/out/electron-dev/bbbbbbbbbbbb'
|
||||
expect(
|
||||
selectStaleDevBundleDirs({
|
||||
bundles: [settled(spaced), settled(other)],
|
||||
currentDir: C,
|
||||
processTable: psLine(spaced, 'Orca: my branch'),
|
||||
nowMs: NOW
|
||||
})
|
||||
).toEqual([other])
|
||||
})
|
||||
|
||||
it('does not treat a sibling as live just because it shares a prefix', () => {
|
||||
// Boundary: `<dir>2` being live must not protect `<dir>`. Without the trailing slash in the
|
||||
// needle, `A` would be found inside `A2`'s path and wrongly spared.
|
||||
const a2 = `${A}2`
|
||||
expect(
|
||||
selectStaleDevBundleDirs({
|
||||
bundles: [settled(A), settled(a2)],
|
||||
currentDir: C,
|
||||
processTable: psLine(a2),
|
||||
nowMs: NOW
|
||||
})
|
||||
).toEqual([A])
|
||||
})
|
||||
|
||||
it('never deletes a bundle that is still being copied', () => {
|
||||
// Between mkdirSync and the marker write there is a ~270MB copy taking tens of seconds, during
|
||||
// which the directory exists, has no marker, and cannot appear in `ps` because its instance has
|
||||
// not launched yet. A concurrently starting instance would otherwise delete it mid-copy.
|
||||
const inFlight = { dir: A, hasMarker: false, mtimeMs: NOW - 30_000 }
|
||||
expect(
|
||||
selectStaleDevBundleDirs({
|
||||
bundles: [inFlight, settled(B)],
|
||||
currentDir: C,
|
||||
processTable: '',
|
||||
nowMs: NOW
|
||||
})
|
||||
).toEqual([B])
|
||||
})
|
||||
|
||||
it('reclaims a marker-less bundle once it is too old to be in flight', () => {
|
||||
// A build that crashed partway leaves the same signature; only age separates it from the case
|
||||
// above, otherwise abandoned debris would be protected forever.
|
||||
const abandoned = { dir: A, hasMarker: false, mtimeMs: NOW - IN_PROGRESS_WINDOW_MS - 1 }
|
||||
expect(
|
||||
selectStaleDevBundleDirs({
|
||||
bundles: [abandoned],
|
||||
currentDir: C,
|
||||
processTable: '',
|
||||
nowMs: NOW
|
||||
})
|
||||
).toEqual([A])
|
||||
})
|
||||
|
||||
it('matches a live bundle across the /tmp and /private/tmp spellings', () => {
|
||||
// macOS realpaths /tmp to /private/tmp, and `ps` preserves whatever spelling the process was
|
||||
// launched with. A mismatch would read as "not running" and delete a live bundle.
|
||||
const priv = '/private/tmp/wt/out/electron-dev/aaaaaaaaaaaa'
|
||||
const plain = '/tmp/wt/out/electron-dev/aaaaaaaaaaaa'
|
||||
expect(isDevBundleInUse(priv, psLine(plain))).toBe(true)
|
||||
expect(isDevBundleInUse(plain, psLine(priv))).toBe(true)
|
||||
expect(isDevBundleInUse(priv, psLine('/private/tmp/wt/out/electron-dev/bbbbbbbbbbbb'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('still honours the path boundary across spellings', () => {
|
||||
const priv = '/private/tmp/wt/out/electron-dev/aaaaaaaaaaaa'
|
||||
expect(isDevBundleInUse(priv, psLine('/tmp/wt/out/electron-dev/aaaaaaaaaaaa2'))).toBe(false)
|
||||
})
|
||||
|
||||
it('reclaims nothing when every directory is current or live', () => {
|
||||
expect(
|
||||
selectStaleDevBundleDirs({
|
||||
bundles: [settled(A), settled(B)],
|
||||
currentDir: A,
|
||||
processTable: psLine(B),
|
||||
nowMs: NOW
|
||||
})
|
||||
).toEqual([])
|
||||
expect(
|
||||
selectStaleDevBundleDirs({ bundles: [], currentDir: A, processTable: '', nowMs: NOW })
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
// Why this module exists: the dev runner re-signs its copied Electron.app ad-hoc, so the bundle's
|
||||
// designated requirement is a bare cdhash derived from its contents. macOS Keychain ACLs key off
|
||||
// that requirement. Patching any branch-varying value into Info.plist therefore gave every branch a
|
||||
// different code identity, and safeStorage re-prompted for a password on each one.
|
||||
//
|
||||
// The invariant: every value patched here must be CONSTANT across branches and worktrees, so all dev
|
||||
// bundles share one cdhash and one Keychain ACL entry. Patching a key is fine; varying it is not.
|
||||
// Per-branch Dock names come from the .app directory name, which is not part of the signature.
|
||||
|
||||
export const DEV_BUNDLE_ID = 'com.stablyai.orca.dev'
|
||||
export const DEV_HELPER_BUNDLE_ID = `${DEV_BUNDLE_ID}.helper`
|
||||
// Why a constant display name rather than none: leaving the stock value makes every dev
|
||||
// notification and System Settings > Notifications row read "Electron", indistinguishable from any
|
||||
// other Electron app. A fixed name keeps that legible without reintroducing per-branch drift.
|
||||
export const DEV_BUNDLE_DISPLAY_NAME = 'Orca Dev'
|
||||
|
||||
/** Info.plist patches for the app bundle. Values must not vary per branch — see above. */
|
||||
export function getDevBundlePlistPatches() {
|
||||
return [
|
||||
{ key: 'CFBundleIdentifier', value: DEV_BUNDLE_ID },
|
||||
{ key: 'CFBundleName', value: DEV_BUNDLE_DISPLAY_NAME },
|
||||
{ key: 'CFBundleDisplayName', value: DEV_BUNDLE_DISPLAY_NAME }
|
||||
]
|
||||
}
|
||||
|
||||
/** Info.plist patches for the embedded Electron Helper bundle. */
|
||||
export function getDevHelperPlistPatches() {
|
||||
return [{ key: 'CFBundleIdentifier', value: DEV_HELPER_BUNDLE_ID }]
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDevInstanceIdentity } from '../../src/main/startup/dev-instance-identity'
|
||||
import {
|
||||
DEV_BUNDLE_DISPLAY_NAME,
|
||||
DEV_BUNDLE_ID,
|
||||
DEV_HELPER_BUNDLE_ID,
|
||||
getDevBundlePlistPatches,
|
||||
getDevHelperPlistPatches
|
||||
} from './dev-electron-bundle-identity.mjs'
|
||||
|
||||
const BRANCH_ENV_KEYS = [
|
||||
'ORCA_DEV_DOCK_TITLE',
|
||||
'ORCA_DEV_BRANCH',
|
||||
'ORCA_DEV_INSTANCE_LABEL',
|
||||
'ORCA_DEV_WORKTREE_NAME'
|
||||
] as const
|
||||
|
||||
/** Collect the patch set as it would be computed on a given branch. */
|
||||
function patchesUnder(dockTitle: string, branch: string) {
|
||||
// Restores individual keys rather than reassigning process.env: that swaps Node's native env
|
||||
// object for a plain one, which stops coercing assigned values to strings (`env.X = 5` stays a
|
||||
// number). Vitest reuses a worker across files, so every later test would inherit the plain object.
|
||||
const saved = BRANCH_ENV_KEYS.map((key) => [key, process.env[key]] as const)
|
||||
Object.assign(process.env, {
|
||||
ORCA_DEV_DOCK_TITLE: dockTitle,
|
||||
ORCA_DEV_BRANCH: branch,
|
||||
ORCA_DEV_INSTANCE_LABEL: branch,
|
||||
ORCA_DEV_WORKTREE_NAME: branch
|
||||
})
|
||||
try {
|
||||
return [...getDevBundlePlistPatches(), ...getDevHelperPlistPatches()]
|
||||
} finally {
|
||||
for (const [key, value] of saved) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('dev-electron-bundle-identity', () => {
|
||||
it('leaves process.env untouched, including its object identity', () => {
|
||||
const envBefore = process.env
|
||||
const snapshot = { ...process.env }
|
||||
patchesUnder('Orca: some-branch', 'some-branch')
|
||||
expect(process.env).toBe(envBefore)
|
||||
expect({ ...process.env }).toEqual(snapshot)
|
||||
})
|
||||
|
||||
it('patches a stable bundle id for the app and its helper', () => {
|
||||
expect(getDevHelperPlistPatches()).toEqual([
|
||||
{ key: 'CFBundleIdentifier', value: DEV_HELPER_BUNDLE_ID }
|
||||
])
|
||||
expect(DEV_HELPER_BUNDLE_ID.startsWith(`${DEV_BUNDLE_ID}.`)).toBe(true)
|
||||
})
|
||||
|
||||
it('gives the dev app a legible display name so notifications are not just "Electron"', () => {
|
||||
const byKey = Object.fromEntries(
|
||||
getDevBundlePlistPatches().map((patch) => [patch.key, patch.value])
|
||||
)
|
||||
expect(byKey.CFBundleName).toBe(DEV_BUNDLE_DISPLAY_NAME)
|
||||
expect(byKey.CFBundleDisplayName).toBe(DEV_BUNDLE_DISPLAY_NAME)
|
||||
expect(DEV_BUNDLE_DISPLAY_NAME).not.toBe('Electron')
|
||||
})
|
||||
|
||||
it('keeps the bundle display name in step with the name safeStorage keys off', () => {
|
||||
// Two independently hardcoded 'Orca Dev' strings: this one names the bundle (notifications,
|
||||
// System Settings), and getDevInstanceIdentity().appName drives app.setName, which decides the
|
||||
// Keychain service name. Drift would split the two without anything else failing.
|
||||
expect(DEV_BUNDLE_DISPLAY_NAME).toBe(getDevInstanceIdentity(true, {}).appName)
|
||||
})
|
||||
|
||||
it('produces byte-identical patches on two different branches', () => {
|
||||
// The invariant the whole fix rests on. Info.plist is inside the signature seal, so any
|
||||
// branch-derived value moves the ad-hoc cdhash — and macOS Keychain ACLs match on that cdhash,
|
||||
// which is what made every branch re-prompt for a password.
|
||||
//
|
||||
// Compared across two simulated branch environments rather than pattern-matched against
|
||||
// suspicious substrings: a denylist only catches branches whose names happen to contain the
|
||||
// banned words, and would miss the likeliest regression of all — re-adding
|
||||
// `{ key: 'CFBundleName', value: title }` for an ordinary branch like "fix-login-crash".
|
||||
expect(patchesUnder('Orca: fix-login-crash', 'fix-login-crash')).toEqual(
|
||||
patchesUnder('Orca: perf-2', 'perf-2')
|
||||
)
|
||||
expect(patchesUnder('Orca: dev', 'main')).toEqual(
|
||||
patchesUnder('Orca: some-worktree @ feature/x', 'feature/x')
|
||||
)
|
||||
})
|
||||
|
||||
it('leaks no branch, worktree, or title text into any patched value', () => {
|
||||
const branch = 'fix-login-crash'
|
||||
const worktree = 'Orca-safe-storage-lock'
|
||||
for (const patch of patchesUnder(`Orca: ${branch}`, branch)) {
|
||||
expect(patch.value).not.toContain(branch)
|
||||
expect(patch.value).not.toContain(worktree)
|
||||
expect(typeof patch.value).toBe('string')
|
||||
expect(patch.value.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -75,6 +75,25 @@ describe('electron-builder config', () => {
|
||||
expect(packs('out/main/examples/index.js')).toBe(true)
|
||||
})
|
||||
|
||||
// Why: out/electron-dev holds `pnpm dev`'s cached Electron.app copies (~270MB per branch).
|
||||
// CI never creates it, so only a local package would have hit this -- silently, as bulk.
|
||||
it('keeps cached dev Electron bundles out of app.asar', () => {
|
||||
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
|
||||
matcher.prependPattern('**/*')
|
||||
const isPacked = matcher.createFilter()
|
||||
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
|
||||
|
||||
for (const devBundlePath of [
|
||||
'out/electron-dev/1a2b3c4d5e6f/Orca: dev.app/Contents/MacOS/Electron',
|
||||
'out/electron-dev/1a2b3c4d5e6f/orca-dev-electron-app.json'
|
||||
]) {
|
||||
expect(packs(devBundlePath)).toBe(false)
|
||||
}
|
||||
// The real build outputs sit beside it under out/ and must still ship.
|
||||
expect(packs('out/main/index.js')).toBe(true)
|
||||
expect(packs('out/renderer/index.html')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps runtime resources available through extraResources', () => {
|
||||
const bundledPluginResources = expect.objectContaining({
|
||||
from: 'resources/plugins/launch',
|
||||
@@ -188,6 +207,14 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('unpacks the replaceable WSL transcript filesystem process entry', async () => {
|
||||
const entryFilename = 'wsl-transcript-fs-process-entry.js'
|
||||
expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`)
|
||||
|
||||
const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8')
|
||||
expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`))
|
||||
})
|
||||
|
||||
// Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is
|
||||
// invisible to it and a packed worker entry fails closed — dropping every
|
||||
// OpenCode session in packaged builds while dev stays green. Three legs must
|
||||
|
||||
@@ -6,6 +6,11 @@ import { tmpdir } from 'node:os'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEV_BUNDLE_ID,
|
||||
DEV_HELPER_BUNDLE_ID,
|
||||
getDevHelperPlistPatches
|
||||
} from './dev-electron-bundle-identity.mjs'
|
||||
import {
|
||||
BOOTSTRAP_FATAL_LOG_ENV_VAR,
|
||||
BOOTSTRAP_FATAL_LOG_FILE_NAME,
|
||||
@@ -201,10 +206,13 @@ describe('Electron Vite output contract', () => {
|
||||
})
|
||||
|
||||
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
|
||||
expect(devRunner).toContain('const helperBundleId = `${bundleId}.helper`')
|
||||
// Asserted on the values rather than the source text: the ids moved into
|
||||
// dev-electron-bundle-identity.mjs so every dev bundle signs to one cdhash.
|
||||
expect(DEV_HELPER_BUNDLE_ID).toBe(`${DEV_BUNDLE_ID}.helper`)
|
||||
expect(getDevHelperPlistPatches()).toEqual([
|
||||
{ key: 'CFBundleIdentifier', value: DEV_HELPER_BUNDLE_ID }
|
||||
])
|
||||
expect(devRunner).toContain("'Electron Helper.app',")
|
||||
expect(devRunner).toContain(
|
||||
"setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)"
|
||||
)
|
||||
expect(devRunner).toContain('setPlistValue(helperPlistPath, key, value)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,9 @@ describe('Git binary compatibility PR gate', () => {
|
||||
)
|
||||
|
||||
expect(step?.run).toContain('git-2.25.5.tar.gz')
|
||||
// Why asserted: the sha256 check only runs on the build path, so a cached binary
|
||||
// must come from a key that pins the same version the tarball line declares.
|
||||
expect(step?.run).toContain('if [ ! -x "$source/git" ]; then')
|
||||
expect(step?.run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf')
|
||||
expect(step?.run).toContain('ORCA_GIT_COMPAT_BINARY="$source/git"')
|
||||
expect(step?.run).toContain('alpine/git:edge-2.38.1|2.38.1')
|
||||
@@ -20,4 +23,21 @@ describe('Git binary compatibility PR gate', () => {
|
||||
expect(step?.run).toContain('pids+=("$!")')
|
||||
expect(step?.run).toContain('wait "$pid" || status=1')
|
||||
})
|
||||
|
||||
it('restores the baseline Git build before the matrix runs', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const steps = workflow.jobs.git_compatibility.steps
|
||||
const cacheIndex = steps.findIndex((step) => step.name === 'Cache baseline Git build')
|
||||
const matrixIndex = steps.findIndex(
|
||||
(step) => step.name === 'Verify Git binary compatibility matrix'
|
||||
)
|
||||
|
||||
expect(cacheIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(cacheIndex).toBeLessThan(matrixIndex)
|
||||
// The cached path and the build path must be the same directory or the guard
|
||||
// above would rebuild on every run while still reporting a cache hit.
|
||||
expect(steps[cacheIndex].with.path).toBe('~/.cache/orca-git-compat/git-2.25.5')
|
||||
expect(steps[matrixIndex].run).toContain('source="$HOME/.cache/orca-git-compat/git-2.25.5"')
|
||||
expect(steps[cacheIndex].with.key).toContain('2.25.5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,8 +15,9 @@ import { surfaceSkillInvocationUserTurns } from '../../src/shared/native-chat-co
|
||||
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
|
||||
matchingNativeChatUserRows,
|
||||
selectPendingIndicesRepresentedByUserRows,
|
||||
type NativeChatUserRow
|
||||
} 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'
|
||||
@@ -36,6 +37,7 @@ let sessionSink: NativeChatSession | null = null
|
||||
let messageArraySink: NativeChatMessage[] | null = null
|
||||
let contentSink = ''
|
||||
let pendingMatchSink: Set<number> | null = null
|
||||
let userRowSink: readonly NativeChatUserRow[] | null = null
|
||||
const benchmarkStartedAt = performance.now()
|
||||
|
||||
function proseFixture(count: number, bytes: number, withTurnId: boolean): NativeChatMessage[] {
|
||||
@@ -94,10 +96,10 @@ function directMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSe
|
||||
}
|
||||
|
||||
function oldEmptyPending(messages: NativeChatMessage[]): NativeChatMessage[] {
|
||||
pendingMatchSink = selectPendingIndicesRepresentedByUserTexts(
|
||||
[],
|
||||
matchingNativeChatUserTexts(messages)
|
||||
)
|
||||
// An empty queue makes the renderer skip candidate-row construction entirely, so the
|
||||
// row scan is the whole cost here — keep it escaping and let the matcher take its exit.
|
||||
userRowSink = matchingNativeChatUserRows(messages)
|
||||
pendingMatchSink = selectPendingIndicesRepresentedByUserRows([], [])
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -328,6 +330,7 @@ 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')
|
||||
strictEqual(Array.isArray(userRowSink), true, 'user row scan did not escape')
|
||||
console.log(
|
||||
`validated=${validatedCases} cases, checksum=${checksum}, capped calibrations=${cappedCalibrations}, runtime=${(
|
||||
performance.now() - benchmarkStartedAt
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
@@ -125,6 +125,54 @@ describe('PR E2E gate contract', () => {
|
||||
expect(filterStep.run).toContain('set -euo pipefail')
|
||||
})
|
||||
|
||||
it('maps SSH source edits onto the Docker-backed specs they can break', () => {
|
||||
// Why: the Docker-SSH specs self-skip without ORCA_E2E_SSH_DOCKER, and the only
|
||||
// trigger used to be "someone edited a spec" — four pane-restore regressions shipped
|
||||
// through that hole. Each mapped spec must exist, or the lane runs an empty file list.
|
||||
const sshSourceAuthorities = [
|
||||
'src/main/ssh/',
|
||||
'src/main/providers/ssh-',
|
||||
'src/main/ipc/(ssh-|pty)',
|
||||
'src/relay/',
|
||||
'src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty)'
|
||||
]
|
||||
for (const authority of sshSourceAuthorities) {
|
||||
expect(filterStep.run).toContain(authority)
|
||||
}
|
||||
|
||||
const mappedSpecs = [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
]
|
||||
for (const spec of mappedSpecs) {
|
||||
expect(filterStep.run).toContain(`'${spec}'`)
|
||||
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
|
||||
// Why: a spec that stops reading the flag would silently run without Docker.
|
||||
if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') {
|
||||
expect(readFileSync(join(projectDir, spec), 'utf8'), spec).toContain('ORCA_E2E_SSH_DOCKER')
|
||||
}
|
||||
}
|
||||
|
||||
// Why: unit tests next to the mapped sources prove the same code without Docker.
|
||||
expect(filterStep.run).toContain("grep -Ev '\\.test\\.tsx?$'")
|
||||
|
||||
// Why: startup readiness is filtered out of changed-e2e, so listing it is only
|
||||
// meaningful while it still routes the dedicated Docker lane.
|
||||
expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toContain(
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts'
|
||||
)
|
||||
|
||||
// Why: this lane can now pay a Docker image build plus serial SSH specs.
|
||||
expect(e2eWorkflow.jobs['changed-e2e']['timeout-minutes']).toBeGreaterThanOrEqual(45)
|
||||
const changedInstall = e2eWorkflow.jobs['changed-e2e'].steps.find((step) =>
|
||||
step.name.startsWith('Install native build')
|
||||
)
|
||||
expect(changedInstall.run).toContain('openssh-client')
|
||||
})
|
||||
|
||||
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
|
||||
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
|
||||
|
||||
@@ -4,7 +4,11 @@ import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { listPullFiles, nextLink } from '../../.github/scripts/pr-test-loc-summary.mjs'
|
||||
import {
|
||||
listPullFiles,
|
||||
nextLink,
|
||||
updatePullRequest
|
||||
} from '../../.github/scripts/pr-test-loc-summary.mjs'
|
||||
import {
|
||||
LOC_HANDS_OFF_COMMENT,
|
||||
isTestPath,
|
||||
@@ -15,6 +19,11 @@ import {
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const locScript = join(projectDir, '.github/scripts/pr-test-loc-summary.mjs')
|
||||
const locWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/pr-test-loc.yml'), 'utf8')
|
||||
)
|
||||
const locJob = locWorkflow.jobs.loc
|
||||
const locStep = locJob.steps[0]
|
||||
const tempDirs = []
|
||||
|
||||
function runLoc(args, { env } = {}) {
|
||||
@@ -41,6 +50,17 @@ describe('PR test LoC summary', () => {
|
||||
expect(isTestPath('src/main/foo.ts')).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies __fixtures__ and __snapshots__ data of any extension', () => {
|
||||
expect(isTestPath('src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt')).toBe(
|
||||
true
|
||||
)
|
||||
expect(isTestPath('src/shared/__fixtures__/trace.json')).toBe(true)
|
||||
expect(isTestPath('src/main/runtime/orchestration/__snapshots__/run.test.ts.snap')).toBe(true)
|
||||
// Why: only the __-wrapped names count; a bare `fixtures` dir stays prod.
|
||||
expect(isTestPath('src/main/daemon/fixtures/ratatui-tui.py')).toBe(false)
|
||||
expect(isTestPath('src/main/my__fixtures__helper.ts')).toBe(false)
|
||||
})
|
||||
|
||||
it('sums GitHub pull-file additions and deletions', () => {
|
||||
const totals = sumChangedFiles([
|
||||
{ filename: 'src/app.ts', additions: 4, deletions: 1 },
|
||||
@@ -101,6 +121,41 @@ describe('PR test LoC summary', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('retries transient GitHub errors when updating the PR body', async () => {
|
||||
const responses = [
|
||||
{ ok: false, status: 503, statusText: 'Service Unavailable' },
|
||||
{ ok: true, status: 200, statusText: 'OK' }
|
||||
]
|
||||
const requests = []
|
||||
const result = await updatePullRequest({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
pullNumber: 14656,
|
||||
token: 'test-token',
|
||||
totals: {
|
||||
test: { files: 1, added: 2, deleted: 0 },
|
||||
nonTest: { files: 0, added: 0, deleted: 0 }
|
||||
},
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (requests.length === 1) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ body: '## Description\n' })
|
||||
}
|
||||
}
|
||||
return responses.shift()
|
||||
},
|
||||
sleepImpl: async () => {}
|
||||
})
|
||||
|
||||
expect(result).toBe(0)
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(requests[1].options.method).toBe('PATCH')
|
||||
expect(requests[2].options.method).toBe('PATCH')
|
||||
})
|
||||
|
||||
it('colors added cells green and deleted or negative-net cells red', () => {
|
||||
const block = renderLocBlock({
|
||||
test: { files: 1, added: 2, deleted: 5 },
|
||||
@@ -178,20 +233,36 @@ describe('PR test LoC summary', () => {
|
||||
})
|
||||
|
||||
it('is a no-checkout GitHub-hosted PR workflow', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/pr-test-loc.yml'), 'utf8')
|
||||
)
|
||||
const locJob = workflow.jobs.loc
|
||||
const serialized = JSON.stringify(workflow)
|
||||
|
||||
expect(locJob['runs-on']).toBe('ubuntu-latest')
|
||||
expect(locJob.steps).toHaveLength(1)
|
||||
expect(locJob.steps[0].run).toContain('gh api')
|
||||
expect(locJob.steps[0].run).toContain('pr-test-loc-table.mjs')
|
||||
expect(locJob.steps[0].run).toContain('pr-test-loc-summary.mjs')
|
||||
expect(locJob.steps[0].run).toContain('--update-pr')
|
||||
expect(workflow.permissions['pull-requests']).toBe('write')
|
||||
expect(serialized).not.toContain('actions/checkout')
|
||||
expect(serialized).not.toContain('self-hosted')
|
||||
expect(locStep.run).toContain('gh api')
|
||||
expect(locStep.run).toContain('pr-test-loc-table.mjs')
|
||||
expect(locStep.run).toContain('pr-test-loc-summary.mjs')
|
||||
expect(locStep.run).toContain('--update-pr')
|
||||
expect(locWorkflow.permissions['pull-requests']).toBe('write')
|
||||
expect(JSON.stringify(locWorkflow)).not.toContain('actions/checkout')
|
||||
expect(JSON.stringify(locWorkflow)).not.toContain('self-hosted')
|
||||
})
|
||||
|
||||
// The write-scoped GITHUB_TOKEN makes any PR-authored code a privilege escalation.
|
||||
it('executes only default-branch script code, never pull-request head code', () => {
|
||||
const serialized = JSON.stringify(locWorkflow)
|
||||
|
||||
expect(locStep.env.TRUSTED_REF).toBe('${{ github.event.repository.default_branch }}')
|
||||
expect(locStep.run).toContain('?ref=${TRUSTED_REF}')
|
||||
expect(serialized).not.toContain('pull_request_target')
|
||||
expect(serialized).not.toContain('pull/')
|
||||
expect(serialized).not.toContain('pull_request.head')
|
||||
expect(serialized).not.toContain('/merge')
|
||||
})
|
||||
|
||||
it('passes event data through env instead of interpolating it into the shell', () => {
|
||||
expect(locStep.env.PR_NUMBER).toBe('${{ github.event.pull_request.number }}')
|
||||
expect(locStep.run).toContain('--update-pr "$PR_NUMBER"')
|
||||
expect(locStep.run).not.toContain('${{')
|
||||
})
|
||||
|
||||
it('fails the step when a script download fails instead of running a truncated file', () => {
|
||||
expect(locStep.run).toContain('set -euo pipefail')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,9 @@ const shellContractFiles = [
|
||||
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
'src/main/shell-startup-feature-channel.test.ts',
|
||||
'src/main/zsh-scoped-histfile.live-shell.test.ts',
|
||||
'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts',
|
||||
'src/shared/posix-command-path-lookup.test.ts'
|
||||
]
|
||||
const patchedNodePtyContractFiles = [
|
||||
@@ -95,6 +98,60 @@ describe('PR workflow parallelism', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('refreshes the apt index once while adding the fish PPA', () => {
|
||||
const installStep = workflow.jobs.shell_contracts.steps.find(
|
||||
(step) => step.name === 'Install zsh and fish'
|
||||
)
|
||||
// Comment lines mention both commands by name, so count the executed ones only.
|
||||
const commands = installStep.run
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('#'))
|
||||
.join('\n')
|
||||
const updates = commands.match(/apt-get update/g) ?? []
|
||||
// Anchored to the start of a line so the retry message that names the command in
|
||||
// prose is not mistaken for an invocation of it.
|
||||
const addRepoCalls = commands
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^(sudo\s+)?add-apt-repository\b/.test(line))
|
||||
|
||||
// add-apt-repository refreshes every configured repo unless told not to, so an
|
||||
// update on each side of it made this step pay for three full passes.
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(addRepoCalls.length).toBeGreaterThan(0)
|
||||
for (const call of addRepoCalls) {
|
||||
expect(call.split(/\s+/)).toContain('-n')
|
||||
}
|
||||
// The one remaining update has to come after the PPA is on the list, or the fish
|
||||
// index it exists to fetch would not be there yet.
|
||||
expect(commands.lastIndexOf('add-apt-repository')).toBeLessThan(
|
||||
commands.indexOf('apt-get update')
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds the shell lane so a stalled apt mirror cannot hold the run open', () => {
|
||||
const job = workflow.jobs.shell_contracts
|
||||
// A passing run of this job takes ~4.5 minutes, almost all of it package download.
|
||||
// Without a job bound a stalled mirror runs to GitHub's 6h default, and because this
|
||||
// is a required check it holds the whole run open and blocks `gh run rerun --failed`.
|
||||
expect(job['timeout-minutes']).toBeGreaterThan(0)
|
||||
expect(job['timeout-minutes']).toBeLessThanOrEqual(30)
|
||||
|
||||
const installStep = job.steps.find((step) => step.name === 'Install zsh and fish')
|
||||
// apt applies no wall-clock bound to a stalled mirror on its own. These turn an
|
||||
// unbounded hang into a bounded, retried, legible failure.
|
||||
expect(installStep.run).toMatch(/Acquire::http::Timeout/)
|
||||
expect(installStep.run).toMatch(/Acquire::https::Timeout/)
|
||||
expect(installStep.run).toMatch(/Acquire::Retries/)
|
||||
// Retries multiply: a first attempt at 30s x 3 retries across every index file turned
|
||||
// a dead mirror into a ~15 minute stall. One attempt, then move on.
|
||||
expect(installStep.run).toMatch(/Acquire::Retries "1"/)
|
||||
// Acquire timeouts are per-connection, so they cannot bound the command as a whole.
|
||||
// Only a wall-clock bound can, and both apt invocations need one.
|
||||
expect(installStep.run).toMatch(/timeout \d+ sudo apt-get update/)
|
||||
expect(installStep.run).toMatch(/timeout \d+ sudo apt-get install/)
|
||||
})
|
||||
|
||||
it('keeps every real-zsh test in the dedicated shell lane', () => {
|
||||
const discoveredFiles = globSync(testFilePatterns)
|
||||
.filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8')))
|
||||
@@ -170,7 +227,12 @@ describe('PR workflow parallelism', () => {
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) {
|
||||
for (const jobName of [
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'xterm_patch_sync'
|
||||
]) {
|
||||
expect(installFor(jobName).with, jobName).toBeUndefined()
|
||||
}
|
||||
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
|
||||
@@ -183,7 +245,12 @@ describe('PR workflow parallelism', () => {
|
||||
const dependencyInstall = dependencyAction.runs.steps.find(
|
||||
(step) => step.name === 'Install dependencies'
|
||||
)
|
||||
expect(dependencyInstall.run).toContain('--no-frozen-lockfile')
|
||||
// Why frozen: re-resolving the graph costs a minute per job and the `git diff`
|
||||
// guard below already fails the run when the lockfile is stale, so the slow
|
||||
// resolution can never legitimately change anything.
|
||||
expect(dependencyInstall.run).toContain('--frozen-lockfile')
|
||||
expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile')
|
||||
expect(dependencyInstall.run).toContain('git diff --exit-code package.json pnpm-lock.yaml')
|
||||
expect(dependencyInstall.run).toContain('--ignore-scripts')
|
||||
expect(dependencyInstall.run).not.toContain('--os=')
|
||||
expect(dependencyInstall.run).not.toContain('--cpu=')
|
||||
@@ -210,12 +277,63 @@ describe('PR workflow parallelism', () => {
|
||||
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
||||
})
|
||||
|
||||
it('restores compiled native modules after the install that strips them', () => {
|
||||
const steps = dependencyAction.runs.steps
|
||||
const installIndex = steps.findIndex((step) => step.name === 'Install dependencies')
|
||||
const cacheIndex = steps.findIndex((step) => step.name === 'Restore compiled native modules')
|
||||
const prepareIndex = steps.findIndex((step) => step.name === 'Prepare native runtime')
|
||||
|
||||
// `--ignore-scripts` leaves no build/, so a restore before the install would be
|
||||
// overwritten and one after the rebuild would never save a hit.
|
||||
expect(installIndex).toBeLessThan(cacheIndex)
|
||||
expect(cacheIndex).toBeLessThan(prepareIndex)
|
||||
expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
|
||||
// Native artifacts are ABI-bound: a key missing either dimension serves a build
|
||||
// that cannot load, and ensure-native-runtime would recompile it anyway.
|
||||
expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
|
||||
expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
|
||||
expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
|
||||
// No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
|
||||
expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reuses TypeScript incremental state across typecheck runs', () => {
|
||||
const steps = workflow.jobs.typecheck.steps
|
||||
const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state')
|
||||
const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck')
|
||||
|
||||
expect(cacheIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(cacheIndex).toBeLessThan(checkIndex)
|
||||
expect(steps[cacheIndex].with.path).toBe('config/*.tsbuildinfo')
|
||||
// Why restore-keys matter here: an exact-key miss is the normal case (the key is
|
||||
// per-SHA), so without them the cache would never once be read.
|
||||
expect(steps[cacheIndex].with['restore-keys']).toBeTruthy()
|
||||
// The buildinfo is only reusable while the compiler options that produced it hold.
|
||||
expect(steps[cacheIndex].with.key).toContain(
|
||||
"hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json')"
|
||||
)
|
||||
})
|
||||
|
||||
it('checks out full history without historical blobs', () => {
|
||||
const fullHistoryCheckouts = Object.values(workflow.jobs)
|
||||
.flatMap((job) => job.steps ?? [])
|
||||
.filter(
|
||||
(step) => step.uses?.startsWith('actions/checkout@') && step.with?.['fetch-depth'] === 0
|
||||
)
|
||||
|
||||
expect(fullHistoryCheckouts.length).toBeGreaterThan(0)
|
||||
for (const checkout of fullHistoryCheckouts) {
|
||||
expect(checkout.with.filter).toBe('blob:none')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps verify as the aggregate required check', () => {
|
||||
expect(workflow.jobs.verify.needs).toEqual([
|
||||
'static_analysis',
|
||||
'root_directory_guard',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'xterm_patch_sync',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'managed_hook_node18',
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
|
||||
const MANIFEST_RELATIVE_PATH = path.join('config', 'patches', 'xterm-upstream.json')
|
||||
|
||||
/**
|
||||
* Flags pnpm@10 passes to `git diff` in its own `diffFolders()`. A patch built
|
||||
* with anything else is a patch pnpm may re-diff differently on the next
|
||||
* `pnpm patch-commit`, so the byte-comparison gate would never settle.
|
||||
*/
|
||||
export const PNPM_DIFF_FLAGS = [
|
||||
'-c',
|
||||
'core.safecrlf=false',
|
||||
'diff',
|
||||
'--src-prefix=a/',
|
||||
'--dst-prefix=b/',
|
||||
'--ignore-cr-at-eol',
|
||||
'--irreversible-delete',
|
||||
'--full-index',
|
||||
'--no-index',
|
||||
'--text',
|
||||
'--no-ext-diff',
|
||||
'--no-color'
|
||||
]
|
||||
|
||||
/**
|
||||
* The same formatting as PNPM_DIFF_FLAGS minus `--no-index`, so a diff taken
|
||||
* inside the upstream checkout is byte-comparable with the emitted patch.
|
||||
*/
|
||||
export const CHECKOUT_DIFF_FLAGS = PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index')
|
||||
|
||||
/** Blanks the vars pnpm blanks so user and system git config cannot reach the diff. */
|
||||
export function pnpmDiffEnvironment(baseEnvironment = process.env) {
|
||||
return {
|
||||
...baseEnvironment,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
HOME: '',
|
||||
XDG_CONFIG_HOME: '',
|
||||
USERPROFILE: ''
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function trimSurroundingSlashes(value) {
|
||||
return value[0] === '/' || value.endsWith('/') ? value.replace(/^\/|\/$/g, '') : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduces pnpm's post-processing of the raw `git diff` output: strip the two
|
||||
* scratch folder prefixes, drop a trailing no-newline marker, and remove
|
||||
* .DS_Store entries a macOS run would otherwise smuggle in.
|
||||
*/
|
||||
export function normalizePnpmDiff(stdout, folderA, folderB) {
|
||||
const a = folderA.replace(/\\/g, '/')
|
||||
const b = folderB.replace(/\\/g, '/')
|
||||
return stdout
|
||||
.replace(new RegExp(`(a|b)(${escapeRegExp(`/${trimSurroundingSlashes(a)}/`)})`, 'g'), '$1/')
|
||||
.replace(new RegExp(`(a|b)${escapeRegExp(`/${trimSurroundingSlashes(b)}/`)}`, 'g'), '$1/')
|
||||
.replace(new RegExp(escapeRegExp(`${a}/`), 'g'), '')
|
||||
.replace(new RegExp(escapeRegExp(`${b}/`), 'g'), '')
|
||||
.replace(/\n\\ No newline at end of file\n$/, '\n')
|
||||
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]+?(?=^diff --git)/gm, '')
|
||||
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]*$/gm, '')
|
||||
}
|
||||
|
||||
/** Splits a patch into one entry per `diff --git` stanza, keeping the raw text. */
|
||||
export function splitPatchEntries(patchText) {
|
||||
return patchText
|
||||
.split(/^(?=diff --git )/m)
|
||||
.filter((entry) => entry.startsWith('diff --git '))
|
||||
.map((text) => {
|
||||
const header = text.slice(0, text.indexOf('\n'))
|
||||
const match = /^diff --git a\/(.+) b\/\1$/.exec(header)
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported diff header (renames are not supported): ${header}`)
|
||||
}
|
||||
return { path: match[1], text }
|
||||
})
|
||||
}
|
||||
|
||||
export function selectPatchEntries(patchText, matches) {
|
||||
return splitPatchEntries(patchText)
|
||||
.filter((entry) => matches(entry.path))
|
||||
.map((entry) => entry.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** The hand-editable half of a patch: everything under `src/`. */
|
||||
export function sourceHunks(patchText) {
|
||||
return selectPatchEntries(patchText, (file) => file.startsWith('src/'))
|
||||
}
|
||||
|
||||
/**
|
||||
* The source patch and the emitted patch are the same edits diffed two ways, so
|
||||
* they must produce the same bytes. A source hunk the emitted patch cannot carry
|
||||
* would be deleted by the next `--write`, so this fails instead of shipping one.
|
||||
*/
|
||||
export function assertSourceDerivationsAgree(checkoutSource, patchText) {
|
||||
const checkout = sourceHunks(checkoutSource)
|
||||
const emitted = sourceHunks(patchText)
|
||||
if (checkout === emitted) {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
'The checkout diff and the emitted patch disagree on a source file.',
|
||||
` from checkout: [${splitPatchEntries(checkout)
|
||||
.map((e) => e.path)
|
||||
.join(', ')}]`,
|
||||
` from patch: [${splitPatchEntries(emitted)
|
||||
.map((e) => e.path)
|
||||
.join(', ')}]`,
|
||||
` first difference at character ${firstDifferenceIndex(checkout, emitted)}`,
|
||||
'',
|
||||
'A hunk the emitted patch cannot name is never installed, so it cannot ship',
|
||||
'here; upstream .npmignore strips `src/**/*.test.ts`.'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
export function stampVersionSource(source, version) {
|
||||
const stamped = source.replace(
|
||||
/export const XTERM_VERSION = '[^']+';/,
|
||||
`export const XTERM_VERSION = '${version}';`
|
||||
)
|
||||
if (stamped === source && !source.includes(`'${version}'`)) {
|
||||
throw new Error('Version stamp file does not declare XTERM_VERSION')
|
||||
}
|
||||
return stamped
|
||||
}
|
||||
|
||||
/**
|
||||
* The published tarball names the commit it was built from, so a version bump
|
||||
* that forgets the manifest fails here instead of producing a patch against the
|
||||
* wrong tree.
|
||||
*/
|
||||
export function assertPublishedCommit(publishedPackageJson, packageEntry, upstreamCommit) {
|
||||
if (publishedPackageJson.version !== packageEntry.version) {
|
||||
throw new Error(
|
||||
`${packageEntry.name}: registry served ${publishedPackageJson.version}, manifest pins ${packageEntry.version}`
|
||||
)
|
||||
}
|
||||
if (publishedPackageJson.commit !== upstreamCommit) {
|
||||
throw new Error(
|
||||
[
|
||||
`${packageEntry.name}@${packageEntry.version} was published from commit`,
|
||||
` ${publishedPackageJson.commit ?? '(absent)'}`,
|
||||
`but ${MANIFEST_RELATIVE_PATH} pins`,
|
||||
` ${upstreamCommit}`,
|
||||
'Update upstream.commit in the manifest to the published commit, then rerun with --write.'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Guards the publish-order trap: a dev esbuild pass would silently de-minify lib/*.mjs. */
|
||||
export function assertBuildStepsAllowed(manifest) {
|
||||
const forbidden = new Set(manifest.forbiddenBuildScripts?.scripts ?? [])
|
||||
for (const packageEntry of manifest.packages) {
|
||||
for (const step of packageEntry.build) {
|
||||
const script = step.command === 'npm' && step.args[0] === 'run' ? step.args[1] : undefined
|
||||
if (script !== undefined && forbidden.has(script)) {
|
||||
throw new Error(
|
||||
`${packageEntry.name}: build step \`npm run ${script}\` is forbidden. ${manifest.forbiddenBuildScripts.why}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `delete` was dropped with the code that implemented it: accepting a policy
|
||||
// nothing honours would silently ship maps that do not match the bundle.
|
||||
export const SOURCEMAP_POLICIES = new Set(['include'])
|
||||
|
||||
/** An unrecognised policy is a manifest bug, not a default to fall through to. */
|
||||
export function assertSourcemapPolicy(manifest) {
|
||||
const policy = manifest.sourcemaps?.policy
|
||||
if (!SOURCEMAP_POLICIES.has(policy)) {
|
||||
throw new Error(
|
||||
`sourcemaps.policy must be one of ${[...SOURCEMAP_POLICIES].join(', ')}, got ${JSON.stringify(policy)}`
|
||||
)
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
/**
|
||||
* pnpm keys the patched package directory and the lockfile entry by the
|
||||
* sha256 of the patch file itself, so a regenerated patch that leaves
|
||||
* pnpm-lock.yaml alone fails `--frozen-lockfile` on every machine but the
|
||||
* author's.
|
||||
*/
|
||||
export function patchHash(patchText) {
|
||||
return createHash('sha256').update(patchText, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function lockfilePatchHashPattern(packageKey) {
|
||||
// Unscoped keys such as `node-pty@1.1.0` are emitted unquoted.
|
||||
return new RegExp(`(^ '?${escapeRegExp(packageKey)}'?:\\n hash: )([0-9a-f]{64})$`, 'm')
|
||||
}
|
||||
|
||||
export function readLockfilePatchHash(lockfileText, packageKey) {
|
||||
const match = lockfilePatchHashPattern(packageKey).exec(lockfileText)
|
||||
if (!match) {
|
||||
throw new Error(`pnpm-lock.yaml has no patchedDependencies entry for '${packageKey}'`)
|
||||
}
|
||||
return match[2]
|
||||
}
|
||||
|
||||
function lockfileResolutionHashPattern(packageKey) {
|
||||
const separator = packageKey.lastIndexOf('@')
|
||||
const name = escapeRegExp(packageKey.slice(0, separator))
|
||||
const version = escapeRegExp(packageKey.slice(separator + 1))
|
||||
// Two spellings: `name@version(patch_hash=…)` in dependency keys, and a bare
|
||||
// `: version(patch_hash=…)` under `version:` and in resolved dependency maps.
|
||||
return new RegExp(`(?:${name}@|: )${version}\\(patch_hash=([0-9a-f]{64})\\)`, 'g')
|
||||
}
|
||||
|
||||
/**
|
||||
* pnpm repeats the hash inside every resolution key that depends on the patched
|
||||
* package, not just in `patchedDependencies`. Updating one and not the other leaves
|
||||
* a lockfile that installs on a warm store and drifts on a cold one, which is CI.
|
||||
*/
|
||||
export function readLockfileResolutionHashes(lockfileText, packageKey) {
|
||||
return Array.from(
|
||||
lockfileText.matchAll(lockfileResolutionHashPattern(packageKey)),
|
||||
(match) => match[1]
|
||||
)
|
||||
}
|
||||
|
||||
export function lockfilePatchHashIsStale(lockfileText, packageKey, hash) {
|
||||
return (
|
||||
readLockfilePatchHash(lockfileText, packageKey) !== hash ||
|
||||
readLockfileResolutionHashes(lockfileText, packageKey).some((value) => value !== hash)
|
||||
)
|
||||
}
|
||||
|
||||
export function updateLockfilePatchHash(lockfileText, packageKey, hash) {
|
||||
readLockfilePatchHash(lockfileText, packageKey)
|
||||
return lockfileText
|
||||
.replace(lockfilePatchHashPattern(packageKey), `$1${hash}`)
|
||||
.replace(lockfileResolutionHashPattern(packageKey), (match, current) =>
|
||||
match.replace(`patch_hash=${current}`, `patch_hash=${hash}`)
|
||||
)
|
||||
}
|
||||
|
||||
export function firstDifferenceIndex(left, right) {
|
||||
const limit = Math.min(left.length, right.length)
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return left.length === right.length ? -1 : limit
|
||||
}
|
||||
|
||||
export function formatCheckFailure({ name, patchPath, committed, regenerated }) {
|
||||
const index = firstDifferenceIndex(committed, regenerated)
|
||||
const committedFiles = splitPatchEntries(committed).map((entry) => entry.path)
|
||||
const regeneratedFiles = splitPatchEntries(regenerated).map((entry) => entry.path)
|
||||
return [
|
||||
`${name}: ${patchPath} is not what the pinned upstream build produces.`,
|
||||
` committed: ${Buffer.byteLength(committed)} bytes, files [${committedFiles.join(', ')}]`,
|
||||
` regenerated: ${Buffer.byteLength(regenerated)} bytes, files [${regeneratedFiles.join(', ')}]`,
|
||||
` first difference at character ${index}`,
|
||||
'',
|
||||
'The bundle hunks are generated. Do not edit them. Change the source patch',
|
||||
'instead and regenerate both files:',
|
||||
'',
|
||||
' node config/scripts/regenerate-xterm-patches.mjs --write',
|
||||
'',
|
||||
'See docs/reference/xterm-patch-regeneration.md.'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// npm ships as `npm.cmd` on Windows, and execFile applies no PATHEXT and refuses
|
||||
// a `.cmd` target without a shell (CVE-2024-27980). git and tar are real .exe.
|
||||
const WINDOWS_SHIM_COMMANDS = new Set(['npm', 'npx', 'pnpm', 'yarn'])
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const shim = process.platform === 'win32' && WINDOWS_SHIM_COMMANDS.has(command)
|
||||
return execFileSync(shim ? `${command}.cmd` : command, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
shell: shim,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
function listFilesRelative(root, base = root) {
|
||||
const files = []
|
||||
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
||||
const absolute = path.join(base, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...listFilesRelative(root, absolute))
|
||||
} else if (entry.isFile()) {
|
||||
files.push(path.relative(root, absolute))
|
||||
}
|
||||
}
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
function sameBytes(left, right) {
|
||||
return (
|
||||
statSync(left).size === statSync(right).size && readFileSync(left).equals(readFileSync(right))
|
||||
)
|
||||
}
|
||||
|
||||
function fetchPristinePackage(packageEntry, workDir) {
|
||||
const target = path.join(workDir, 'pristine', packageEntry.name.replace(/[@/]/g, '_'))
|
||||
rmSync(target, { recursive: true, force: true })
|
||||
mkdirSync(target, { recursive: true })
|
||||
const spec = `${packageEntry.name}@${packageEntry.version}`
|
||||
const output = run('npm', ['pack', spec, '--pack-destination', target, '--silent'], {
|
||||
cwd: workDir
|
||||
})
|
||||
const tarball = path.join(target, output.trim().split('\n').at(-1).trim())
|
||||
run('tar', ['xzf', tarball, '-C', target])
|
||||
return path.join(target, 'package')
|
||||
}
|
||||
|
||||
function hasCommit(root, commit) {
|
||||
try {
|
||||
return run('git', ['cat-file', '-t', commit], { cwd: root, stdio: 'pipe' }).trim() === 'commit'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUpstreamCheckout(manifest, workDir) {
|
||||
const root = path.join(workDir, 'upstream')
|
||||
const { repository, commit } = manifest.upstream
|
||||
if (!existsSync(path.join(root, '.git'))) {
|
||||
mkdirSync(root, { recursive: true })
|
||||
run('git', ['init', '--quiet'], { cwd: root })
|
||||
run('git', ['remote', 'add', 'origin', repository], { cwd: root })
|
||||
}
|
||||
if (!hasCommit(root, commit)) {
|
||||
run('git', ['fetch', '--depth=1', 'origin', commit], { cwd: root, stdio: 'inherit' })
|
||||
}
|
||||
run('git', ['checkout', '--quiet', '--detach', commit], { cwd: root })
|
||||
run('git', ['reset', '--quiet', '--hard', commit], { cwd: root })
|
||||
return root
|
||||
}
|
||||
|
||||
function ensureDependencies(upstreamRoot, manifest) {
|
||||
const lockfile = path.join(upstreamRoot, 'package-lock.json')
|
||||
const stamp = path.join(upstreamRoot, 'node_modules', '.orca-xterm-install-stamp')
|
||||
const want = `${manifest.upstream.commit}\n${statSync(lockfile).size}\n`
|
||||
if (existsSync(stamp) && readFileSync(stamp, 'utf8') === want) {
|
||||
return
|
||||
}
|
||||
run('npm', ['ci'], {
|
||||
cwd: upstreamRoot,
|
||||
env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', PUPPETEER_SKIP_DOWNLOAD: '1' }
|
||||
})
|
||||
assertToolchain(upstreamRoot, manifest)
|
||||
writeFileSync(stamp, want)
|
||||
}
|
||||
|
||||
function assertToolchain(upstreamRoot, manifest) {
|
||||
const expected = manifest.toolchain
|
||||
for (const [name, version] of Object.entries(expected)) {
|
||||
if (name === 'why') {
|
||||
continue
|
||||
}
|
||||
const installed = path.join(upstreamRoot, 'node_modules', name, 'package.json')
|
||||
if (!existsSync(installed)) {
|
||||
throw new Error(
|
||||
`Upstream install is missing ${name}. The pinned toolchain is no longer resolvable; see the tsgo note in docs/reference/xterm-patch-regeneration.md.`
|
||||
)
|
||||
}
|
||||
const actual = JSON.parse(readFileSync(installed, 'utf8')).version
|
||||
if (actual !== version) {
|
||||
throw new Error(
|
||||
`Upstream ${name} resolved to ${actual}, manifest expects ${version}. Update the toolchain block only together with a verified rebuild.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The published src/ must equal the pinned commit's src/ apart from the version
|
||||
* stamp publish.js rewrites. If it does not, the manifest points at the wrong
|
||||
* commit and every hunk below would be nonsense.
|
||||
*/
|
||||
function assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry) {
|
||||
const stampFile = packageEntry.versionStampFile
|
||||
const sourceRoot = path.join(pristineDir, 'src')
|
||||
const drifted = listFilesRelative(sourceRoot)
|
||||
.map((relative) => path.join('src', relative))
|
||||
.filter((relative) => relative !== stampFile)
|
||||
.filter(
|
||||
(relative) =>
|
||||
!sameBytes(
|
||||
path.join(pristineDir, relative),
|
||||
path.join(upstreamRoot, packageEntry.packageDir, relative)
|
||||
)
|
||||
)
|
||||
if (drifted.length > 0) {
|
||||
throw new Error(
|
||||
`Published src/ does not match ${packageEntry.packageDir} at the pinned commit: ${drifted.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function buildPackage(upstreamRoot, packageEntry, manifest) {
|
||||
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
|
||||
for (const directory of ['lib', 'out', 'out-esbuild']) {
|
||||
rmSync(path.join(packageRoot, directory), { recursive: true, force: true })
|
||||
}
|
||||
const stampPath = path.join(packageRoot, packageEntry.versionStampFile)
|
||||
writeFileSync(
|
||||
stampPath,
|
||||
stampVersionSource(readFileSync(stampPath, 'utf8'), packageEntry.version)
|
||||
)
|
||||
assertBuildStepsAllowed(manifest)
|
||||
for (const step of packageEntry.build) {
|
||||
run(step.command, step.args, { cwd: path.join(packageRoot, step.cwd), stdio: 'inherit' })
|
||||
}
|
||||
}
|
||||
|
||||
/** Proves the pinned toolchain still reproduces the untouched published bundles. */
|
||||
function assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry) {
|
||||
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
|
||||
const drifted = listFilesRelative(pristineDir)
|
||||
.filter((relative) =>
|
||||
packageEntry.generatedPaths.some((prefix) => toPosix(relative).startsWith(prefix))
|
||||
)
|
||||
.filter(
|
||||
(relative) => !sameBytes(path.join(pristineDir, relative), path.join(packageRoot, relative))
|
||||
)
|
||||
if (drifted.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Rebuilding ${packageEntry.name}@${packageEntry.version} from the pinned commit did not reproduce the published bundles:`,
|
||||
...drifted.map((relative) => ` ${relative}`),
|
||||
'',
|
||||
'Refusing to emit a patch. Either the toolchain drifted or the build ran in the',
|
||||
'wrong order (a dev `npm run setup` pass de-minifies lib/*.mjs).'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function toPosix(value) {
|
||||
return value.split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, destination) {
|
||||
rmSync(destination, { recursive: true, force: true })
|
||||
cpSync(pristineDir, destination, { recursive: true })
|
||||
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
|
||||
for (const relative of listFilesRelative(pristineDir)) {
|
||||
// package.json carries the registry's version/commit stamp, which the build
|
||||
// tree has no way to reproduce and which we never want to patch.
|
||||
if (relative === 'package.json') {
|
||||
continue
|
||||
}
|
||||
const built = path.join(packageRoot, relative)
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`Published file has no build-tree counterpart: ${relative}`)
|
||||
}
|
||||
copyFileSync(built, path.join(destination, relative))
|
||||
}
|
||||
}
|
||||
|
||||
function diffFolders(folderA, folderB) {
|
||||
let stdout
|
||||
try {
|
||||
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 512 * 1024 * 1024,
|
||||
env: pnpmDiffEnvironment(),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
} catch (error) {
|
||||
// `git diff --no-index` exits 1 whenever it finds differences.
|
||||
if (error.status !== 1 || error.stderr?.length > 0) {
|
||||
throw error
|
||||
}
|
||||
stdout = error.stdout
|
||||
}
|
||||
return normalizePnpmDiff(stdout, folderA, folderB)
|
||||
}
|
||||
|
||||
/** The source of truth for the hand-written half: what the checkout itself holds. */
|
||||
function diffCheckoutSource(packageRoot) {
|
||||
return run('git', [...CHECKOUT_DIFF_FLAGS, '--', 'src/'], {
|
||||
cwd: packageRoot,
|
||||
env: pnpmDiffEnvironment(),
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
}
|
||||
|
||||
function regeneratePackage(packageEntry, manifest, context) {
|
||||
const { workDir, repoRoot } = context
|
||||
const pristineDir = fetchPristinePackage(packageEntry, workDir)
|
||||
const published = JSON.parse(readFileSync(path.join(pristineDir, 'package.json'), 'utf8'))
|
||||
assertPublishedCommit(published, packageEntry, manifest.upstream.commit)
|
||||
|
||||
const upstreamRoot = ensureUpstreamCheckout(manifest, workDir)
|
||||
ensureDependencies(upstreamRoot, manifest)
|
||||
assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry)
|
||||
|
||||
buildPackage(upstreamRoot, packageEntry, manifest)
|
||||
assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry)
|
||||
|
||||
run('git', ['reset', '--quiet', '--hard', manifest.upstream.commit], { cwd: upstreamRoot })
|
||||
run('git', ['apply', '--whitespace=nowarn', path.join(repoRoot, packageEntry.sourcePatch)], {
|
||||
cwd: path.join(upstreamRoot, packageEntry.packageDir)
|
||||
})
|
||||
buildPackage(upstreamRoot, packageEntry, manifest)
|
||||
|
||||
const patchedDir = path.join(workDir, 'patched', packageEntry.name.replace(/[@/]/g, '_'))
|
||||
overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, patchedDir)
|
||||
|
||||
// Leave the checkout diffable: the pinned commit plus the source patch, with
|
||||
// no publish-time version stamp mixed in, so `git diff` there is the source
|
||||
// patch and nothing else.
|
||||
run('git', ['checkout', '--', packageEntry.versionStampFile], {
|
||||
cwd: path.join(upstreamRoot, packageEntry.packageDir)
|
||||
})
|
||||
|
||||
const source = diffCheckoutSource(path.join(upstreamRoot, packageEntry.packageDir))
|
||||
const patch = diffFolders(pristineDir, patchedDir)
|
||||
assertSourceDerivationsAgree(source, patch)
|
||||
return { patch, source }
|
||||
}
|
||||
|
||||
export function regenerateXtermPatches({
|
||||
mode,
|
||||
repoRoot = DEFAULT_REPO_ROOT,
|
||||
workDir = path.join(tmpdir(), 'orca-xterm-patch-build'),
|
||||
log = console.info
|
||||
} = {}) {
|
||||
const manifest = JSON.parse(readFileSync(path.join(repoRoot, MANIFEST_RELATIVE_PATH), 'utf8'))
|
||||
assertBuildStepsAllowed(manifest)
|
||||
assertSourcemapPolicy(manifest)
|
||||
mkdirSync(workDir, { recursive: true })
|
||||
|
||||
const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml')
|
||||
let lockfile = readFileSync(lockfilePath, 'utf8')
|
||||
let lockfileChanged = false
|
||||
|
||||
const failures = []
|
||||
for (const packageEntry of manifest.packages) {
|
||||
const shortCommit = manifest.upstream.commit.slice(0, 12)
|
||||
log(`${packageEntry.name}@${packageEntry.version}: regenerating from ${shortCommit}`)
|
||||
const { patch: regenerated, source: canonicalSource } = regeneratePackage(
|
||||
packageEntry,
|
||||
manifest,
|
||||
{ workDir, repoRoot }
|
||||
)
|
||||
const patchPath = path.join(repoRoot, packageEntry.patch)
|
||||
const sourcePatchPath = path.join(repoRoot, packageEntry.sourcePatch)
|
||||
const packageKey = `${packageEntry.name}@${packageEntry.version}`
|
||||
const hash = patchHash(regenerated)
|
||||
|
||||
if (mode === 'write') {
|
||||
writeFileSync(patchPath, regenerated)
|
||||
writeFileSync(sourcePatchPath, canonicalSource)
|
||||
log(` wrote ${packageEntry.patch} (${Buffer.byteLength(regenerated)} bytes)`)
|
||||
log(` wrote ${packageEntry.sourcePatch} (${Buffer.byteLength(canonicalSource)} bytes)`)
|
||||
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
|
||||
lockfile = updateLockfilePatchHash(lockfile, packageKey, hash)
|
||||
lockfileChanged = true
|
||||
log(` updated pnpm-lock.yaml patch hash to ${hash}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
|
||||
const stale = Array.from(
|
||||
new Set(readLockfileResolutionHashes(lockfile, packageKey).filter((v) => v !== hash))
|
||||
)
|
||||
failures.push(
|
||||
[
|
||||
`${packageKey}: pnpm-lock.yaml records a stale patch hash.`,
|
||||
` patchedDependencies: ${readLockfilePatchHash(lockfile, packageKey)}`,
|
||||
` resolution keys: ${stale.length > 0 ? stale.join(', ') : 'in sync'}`,
|
||||
` patch: ${hash}`,
|
||||
'',
|
||||
'pnpm keys the patched package by the sha256 of the patch file, so',
|
||||
'`pnpm install --frozen-lockfile` will fail. Rerun with --write.'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
const committed = readFileSync(patchPath, 'utf8')
|
||||
if (committed !== regenerated) {
|
||||
failures.push(
|
||||
formatCheckFailure({
|
||||
name: packageEntry.name,
|
||||
patchPath: packageEntry.patch,
|
||||
committed,
|
||||
regenerated
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
const committedSource = readFileSync(sourcePatchPath, 'utf8')
|
||||
if (committedSource !== canonicalSource) {
|
||||
failures.push(
|
||||
formatCheckFailure({
|
||||
name: packageEntry.name,
|
||||
patchPath: packageEntry.sourcePatch,
|
||||
committed: committedSource,
|
||||
regenerated: canonicalSource
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
log(` in sync (${Buffer.byteLength(regenerated)} bytes)`)
|
||||
}
|
||||
|
||||
if (lockfileChanged) {
|
||||
writeFileSync(lockfilePath, lockfile)
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(failures.join('\n\n'))
|
||||
}
|
||||
}
|
||||
|
||||
const USAGE =
|
||||
'Usage: regenerate-xterm-patches.mjs [--check | --write] [--work-dir=<path>]\n' +
|
||||
' --check (default) verifies the shipped patches match the pinned upstream build;\n' +
|
||||
' --write regenerates them from config/patches/xterm-src/. Build outside this repo:\n' +
|
||||
' tsc otherwise walks up into our node_modules. See\n' +
|
||||
' docs/reference/xterm-patch-regeneration.md.'
|
||||
|
||||
function main(argv) {
|
||||
if (argv.includes('--help') || argv.includes('-h')) {
|
||||
console.info(USAGE)
|
||||
return
|
||||
}
|
||||
// --check is the default, so an unrecognised flag would otherwise silently run a full
|
||||
// upstream build instead of whatever the caller meant.
|
||||
const known = (v) =>
|
||||
!v.startsWith('-') || ['--write', '--check'].includes(v) || v.startsWith('--work-dir=')
|
||||
const unknown = argv.filter((value) => !known(value))
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`Unknown option: ${unknown.join(', ')}\n${USAGE}`)
|
||||
}
|
||||
const write = argv.includes('--write')
|
||||
const check = argv.includes('--check') || !write
|
||||
if (write && argv.includes('--check')) {
|
||||
throw new Error('Pass either --write or --check, not both')
|
||||
}
|
||||
const workDirArgument = argv.find((value) => value.startsWith('--work-dir='))
|
||||
regenerateXtermPatches({
|
||||
mode: write ? 'write' : 'check',
|
||||
workDir: workDirArgument ? path.resolve(workDirArgument.slice('--work-dir='.length)) : undefined
|
||||
})
|
||||
if (check) {
|
||||
console.info('xterm patches are in sync with the pinned upstream build.')
|
||||
}
|
||||
}
|
||||
|
||||
// realpathSync so a symlinked checkout path still registers as a direct run.
|
||||
const invokedPath = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : null
|
||||
if (invokedPath === import.meta.url) {
|
||||
try {
|
||||
main(process.argv.slice(2))
|
||||
} catch (error) {
|
||||
console.error(`\n${error.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CHECKOUT_DIFF_FLAGS,
|
||||
PNPM_DIFF_FLAGS,
|
||||
assertBuildStepsAllowed,
|
||||
assertPublishedCommit,
|
||||
assertSourceDerivationsAgree,
|
||||
assertSourcemapPolicy,
|
||||
firstDifferenceIndex,
|
||||
formatCheckFailure,
|
||||
lockfilePatchHashIsStale,
|
||||
normalizePnpmDiff,
|
||||
patchHash,
|
||||
pnpmDiffEnvironment,
|
||||
readLockfilePatchHash,
|
||||
readLockfileResolutionHashes,
|
||||
sourceHunks,
|
||||
splitPatchEntries,
|
||||
stampVersionSource,
|
||||
updateLockfilePatchHash
|
||||
} from './regenerate-xterm-patches.mjs'
|
||||
|
||||
// Only the tests need to slice the generated half out of a patch; the generator
|
||||
// reads `generatedPaths` directly where it compares against the pristine build.
|
||||
function generatedHunks(patchText, generatedPaths) {
|
||||
return splitPatchEntries(patchText)
|
||||
.filter((entry) => generatedPaths.some((prefix) => entry.path.startsWith(prefix)))
|
||||
.map((entry) => entry.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
|
||||
const MANIFEST_PATH = path.join(REPO_ROOT, 'config', 'patches', 'xterm-upstream.json')
|
||||
const temporaryDirectories = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
async function createDirectory() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'orca-xterm-patch-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function writeTree(root, files) {
|
||||
for (const [relative, contents] of Object.entries(files)) {
|
||||
const target = path.join(root, relative)
|
||||
await mkdir(path.dirname(target), { recursive: true })
|
||||
await writeFile(target, contents)
|
||||
}
|
||||
}
|
||||
|
||||
/** The three exported diff pieces, composed the way the generator composes them. */
|
||||
function diffFolders(folderA, folderB) {
|
||||
let stdout
|
||||
try {
|
||||
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
|
||||
encoding: 'utf8',
|
||||
env: pnpmDiffEnvironment(),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.status !== 1) {
|
||||
throw error
|
||||
}
|
||||
stdout = error.stdout
|
||||
}
|
||||
return normalizePnpmDiff(stdout, folderA, folderB)
|
||||
}
|
||||
|
||||
const PRISTINE = {
|
||||
'src/Widget.ts': 'export function widget(): number {\n return 1\n}\n',
|
||||
'src/Other.ts': 'export const other = 0\n',
|
||||
'lib/widget.js': 'function widget(){return 1}\n',
|
||||
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAA"}\n',
|
||||
'package.json': '{\n "name": "@scope/widget"\n}\n'
|
||||
}
|
||||
|
||||
const PATCHED = {
|
||||
...PRISTINE,
|
||||
'src/Widget.ts': 'export function widget(): number {\n return 2\n}\n',
|
||||
'lib/widget.js': 'function widget(){return 2}\n',
|
||||
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAC"}\n'
|
||||
}
|
||||
|
||||
describe('pnpm diff format', () => {
|
||||
it('keeps the exact git flags pnpm uses, so patches survive `pnpm patch-commit`', () => {
|
||||
expect(PNPM_DIFF_FLAGS).toEqual([
|
||||
'-c',
|
||||
'core.safecrlf=false',
|
||||
'diff',
|
||||
'--src-prefix=a/',
|
||||
'--dst-prefix=b/',
|
||||
'--ignore-cr-at-eol',
|
||||
'--irreversible-delete',
|
||||
'--full-index',
|
||||
'--no-index',
|
||||
'--text',
|
||||
'--no-ext-diff',
|
||||
'--no-color'
|
||||
])
|
||||
})
|
||||
|
||||
it('blanks the config-bearing environment variables', () => {
|
||||
const environment = pnpmDiffEnvironment({ PATH: '/usr/bin', HOME: '/Users/someone' })
|
||||
expect(environment).toMatchObject({
|
||||
PATH: '/usr/bin',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
HOME: '',
|
||||
XDG_CONFIG_HOME: '',
|
||||
USERPROFILE: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('strips both scratch folder prefixes from headers and index lines', async () => {
|
||||
const root = await createDirectory()
|
||||
const folderA = path.join(root, 'pristine')
|
||||
const folderB = path.join(root, 'patched')
|
||||
await writeTree(folderA, PRISTINE)
|
||||
await writeTree(folderB, PATCHED)
|
||||
|
||||
const patch = diffFolders(folderA, folderB)
|
||||
|
||||
expect(patch).not.toContain(root)
|
||||
expect(patch).toContain('diff --git a/lib/widget.js b/lib/widget.js')
|
||||
expect(patch).toContain('--- a/src/Widget.ts')
|
||||
expect(patch).toContain('+++ b/src/Widget.ts')
|
||||
})
|
||||
|
||||
it('drops a trailing no-newline marker and .DS_Store entries', () => {
|
||||
const withMarker = 'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n'
|
||||
expect(normalizePnpmDiff(withMarker, '/a', '/b')).toBe(
|
||||
'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n'
|
||||
)
|
||||
|
||||
const withJunk = [
|
||||
'diff --git a/.DS_Store b/.DS_Store\n',
|
||||
'index 000..111\n',
|
||||
'Binary files differ\n',
|
||||
'diff --git a/lib/x.js b/lib/x.js\n',
|
||||
'@@ -1 +1 @@\n-a\n+b\n'
|
||||
].join('')
|
||||
expect(normalizePnpmDiff(withJunk, '/a', '/b')).toBe(
|
||||
'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('patch entry splitting', () => {
|
||||
it('separates hand-edited source hunks from generated bundle hunks', async () => {
|
||||
const root = await createDirectory()
|
||||
const folderA = path.join(root, 'pristine')
|
||||
const folderB = path.join(root, 'patched')
|
||||
await writeTree(folderA, PRISTINE)
|
||||
await writeTree(folderB, PATCHED)
|
||||
const patch = diffFolders(folderA, folderB)
|
||||
|
||||
expect(splitPatchEntries(patch).map((entry) => entry.path)).toEqual([
|
||||
'lib/widget.js',
|
||||
'lib/widget.js.map',
|
||||
'src/Widget.ts'
|
||||
])
|
||||
expect(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path)).toEqual([
|
||||
'src/Widget.ts'
|
||||
])
|
||||
expect(splitPatchEntries(generatedHunks(patch, ['lib/'])).map((entry) => entry.path)).toEqual([
|
||||
'lib/widget.js',
|
||||
'lib/widget.js.map'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects renames rather than emitting a header it cannot round-trip', () => {
|
||||
expect(() => splitPatchEntries('diff --git a/old.ts b/new.ts\n')).toThrow(
|
||||
/renames are not supported/
|
||||
)
|
||||
})
|
||||
|
||||
it('concatenating the two halves reproduces the whole patch', async () => {
|
||||
const root = await createDirectory()
|
||||
const folderA = path.join(root, 'pristine')
|
||||
const folderB = path.join(root, 'patched')
|
||||
await writeTree(folderA, PRISTINE)
|
||||
await writeTree(folderB, PATCHED)
|
||||
const patch = diffFolders(folderA, folderB)
|
||||
|
||||
expect(generatedHunks(patch, ['lib/']) + sourceHunks(patch)).toBe(patch)
|
||||
})
|
||||
})
|
||||
|
||||
describe('round-trip stability', () => {
|
||||
it('re-diffing an applied patch yields the identical patch', async () => {
|
||||
const root = await createDirectory()
|
||||
const folderA = path.join(root, 'pristine')
|
||||
const folderB = path.join(root, 'patched')
|
||||
await writeTree(folderA, PRISTINE)
|
||||
await writeTree(folderB, PATCHED)
|
||||
const patch = diffFolders(folderA, folderB)
|
||||
|
||||
const replay = path.join(root, 'replay')
|
||||
await writeTree(replay, PRISTINE)
|
||||
const patchFile = path.join(root, 'round-trip.patch')
|
||||
await writeFile(patchFile, patch)
|
||||
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
|
||||
|
||||
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
|
||||
PATCHED['lib/widget.js']
|
||||
)
|
||||
expect(diffFolders(folderA, replay)).toBe(patch)
|
||||
})
|
||||
|
||||
it('applying only the source half leaves the bundle untouched', async () => {
|
||||
const root = await createDirectory()
|
||||
const folderA = path.join(root, 'pristine')
|
||||
const folderB = path.join(root, 'patched')
|
||||
await writeTree(folderA, PRISTINE)
|
||||
await writeTree(folderB, PATCHED)
|
||||
const patchFile = path.join(root, 'src.patch')
|
||||
await writeFile(patchFile, sourceHunks(diffFolders(folderA, folderB)))
|
||||
|
||||
const replay = path.join(root, 'replay')
|
||||
await writeTree(replay, PRISTINE)
|
||||
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
|
||||
|
||||
expect(await readFile(path.join(replay, 'src/Widget.ts'), 'utf8')).toBe(
|
||||
PATCHED['src/Widget.ts']
|
||||
)
|
||||
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
|
||||
PRISTINE['lib/widget.js']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// `--write` rewrites the source patch from the emitted patch, so a hunk the
|
||||
// emitted patch cannot name would delete itself on the next run.
|
||||
describe('source derivation agreement', () => {
|
||||
const publishedEntry = [
|
||||
'diff --git a/src/browser/Types.ts b/src/browser/Types.ts',
|
||||
'index 1111111..2222222 100644',
|
||||
'--- a/src/browser/Types.ts',
|
||||
'+++ b/src/browser/Types.ts',
|
||||
'@@ -1 +1,2 @@',
|
||||
' interface ICompositionHelper {',
|
||||
'+ handleCompositionInput(data: string): boolean;',
|
||||
''
|
||||
].join('\n')
|
||||
const unpublishedEntry = [
|
||||
'diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts',
|
||||
'index 3333333..4444444 100644',
|
||||
'--- a/src/browser/TestUtils.test.ts',
|
||||
'+++ b/src/browser/TestUtils.test.ts',
|
||||
'@@ -1 +1,2 @@',
|
||||
' class MockCompositionHelper {',
|
||||
'+ public handleCompositionInput(): boolean { return false; }',
|
||||
''
|
||||
].join('\n')
|
||||
it('fails when the source patch carries a file the emitted patch cannot', () => {
|
||||
expect(() =>
|
||||
assertSourceDerivationsAgree(publishedEntry + unpublishedEntry, publishedEntry)
|
||||
).toThrow(/disagree on a source file/)
|
||||
})
|
||||
|
||||
it('fails when the two derivations disagree on a file', () => {
|
||||
expect(() =>
|
||||
assertSourceDerivationsAgree(publishedEntry.replace('boolean;', 'void;'), publishedEntry)
|
||||
).toThrow(/disagree on a source file/)
|
||||
})
|
||||
|
||||
it('diffs the checkout with pnpm formatting so the two halves stay comparable', () => {
|
||||
expect(CHECKOUT_DIFF_FLAGS).toEqual(PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index'))
|
||||
expect(CHECKOUT_DIFF_FLAGS).toContain('--full-index')
|
||||
expect(CHECKOUT_DIFF_FLAGS).not.toContain('--no-index')
|
||||
})
|
||||
})
|
||||
|
||||
describe('manifest guards', () => {
|
||||
const packageEntry = { name: '@xterm/xterm', version: '6.1.0-beta.287' }
|
||||
const commit = '53a98a720ae4a973e384fa2440880d09537132f3'
|
||||
|
||||
it('accepts a tarball that names the pinned commit', () => {
|
||||
const published = { version: '6.1.0-beta.287', commit }
|
||||
expect(() => assertPublishedCommit(published, packageEntry, commit)).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails when a version bump moved the upstream commit', () => {
|
||||
const published = { version: '6.1.0-beta.287', commit: 'f'.repeat(40) }
|
||||
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(
|
||||
/was published from commit[\s\S]*Update upstream\.commit/
|
||||
)
|
||||
})
|
||||
|
||||
it('fails when the registry serves a different version than the manifest pins', () => {
|
||||
const published = { version: '6.1.0-beta.288', commit }
|
||||
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(/registry served/)
|
||||
})
|
||||
|
||||
it('fails when the tarball carries no commit stamp at all', () => {
|
||||
expect(() =>
|
||||
assertPublishedCommit({ version: '6.1.0-beta.287' }, packageEntry, commit)
|
||||
).toThrow(/\(absent\)/)
|
||||
})
|
||||
|
||||
it('refuses a build step that would de-minify the bundle', () => {
|
||||
const manifest = {
|
||||
forbiddenBuildScripts: { why: 'dev esbuild', scripts: ['setup'] },
|
||||
packages: [
|
||||
{
|
||||
name: '@xterm/xterm',
|
||||
build: [
|
||||
{ cwd: '.', command: 'npm', args: ['run', 'setup'] },
|
||||
{ cwd: '.', command: 'npm', args: ['run', 'package'] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(() => assertBuildStepsAllowed(manifest)).toThrow(/`npm run setup` is forbidden/)
|
||||
})
|
||||
|
||||
it('refuses a sourcemap policy it does not implement', () => {
|
||||
expect(assertSourcemapPolicy({ sourcemaps: { policy: 'include' } })).toBe('include')
|
||||
// `delete` named a code path that no longer exists; accepting it would ship
|
||||
// maps that do not match the bundle.
|
||||
expect(() => assertSourcemapPolicy({ sourcemaps: { policy: 'delete' } })).toThrow(
|
||||
/must be one of include, got "delete"/
|
||||
)
|
||||
expect(() => assertSourcemapPolicy({ sourcemaps: { policy: 'exclude' } })).toThrow(
|
||||
/must be one of include, got "exclude"/
|
||||
)
|
||||
expect(() => assertSourcemapPolicy({})).toThrow(/got undefined/)
|
||||
})
|
||||
|
||||
it('stamps the published version into the version source', () => {
|
||||
const source = "export const XTERM_VERSION = '6.0.0';\n"
|
||||
expect(stampVersionSource(source, '6.1.0-beta.287')).toBe(
|
||||
"export const XTERM_VERSION = '6.1.0-beta.287';\n"
|
||||
)
|
||||
expect(() => stampVersionSource('export const OTHER = 1\n', '6.1.0')).toThrow(/XTERM_VERSION/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lockfile coupling', () => {
|
||||
const lockfile = [
|
||||
'patchedDependencies:',
|
||||
" '@xterm/xterm@6.1.0-beta.287':",
|
||||
` hash: ${'0'.repeat(64)}`,
|
||||
' path: config/patches/@xterm__xterm@6.1.0-beta.287.patch',
|
||||
' node-pty@1.1.0:',
|
||||
` hash: ${'1'.repeat(64)}`,
|
||||
' path: config/patches/node-pty@1.1.0.patch',
|
||||
'snapshots:',
|
||||
` '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=${'0'.repeat(64)}))':`,
|
||||
` '@xterm/xterm': 6.1.0-beta.287(patch_hash=${'0'.repeat(64)})`,
|
||||
` node-pty@1.1.0(patch_hash=${'1'.repeat(64)}):`,
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
it('hashes the patch the way pnpm keys the store directory', () => {
|
||||
expect(patchHash('diff --git a/x b/x\n')).toBe(
|
||||
createHash('sha256').update('diff --git a/x b/x\n').digest('hex')
|
||||
)
|
||||
})
|
||||
|
||||
it('reads quoted and unquoted package keys', () => {
|
||||
expect(readLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287')).toBe('0'.repeat(64))
|
||||
expect(readLockfilePatchHash(lockfile, 'node-pty@1.1.0')).toBe('1'.repeat(64))
|
||||
})
|
||||
|
||||
it('rewrites only the targeted entry', () => {
|
||||
const updated = updateLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287', 'a'.repeat(64))
|
||||
expect(readLockfilePatchHash(updated, '@xterm/xterm@6.1.0-beta.287')).toBe('a'.repeat(64))
|
||||
expect(readLockfilePatchHash(updated, 'node-pty@1.1.0')).toBe('1'.repeat(64))
|
||||
expect(updated.split('\n')).toHaveLength(lockfile.split('\n').length)
|
||||
})
|
||||
|
||||
// pnpm repeats the hash in every resolution key. Rewriting only patchedDependencies
|
||||
// installs fine on a warm store and drifts on a cold one, so it fails in CI only.
|
||||
it('rewrites the resolution keys as well as patchedDependencies', () => {
|
||||
const key = '@xterm/xterm@6.1.0-beta.287'
|
||||
expect(readLockfileResolutionHashes(lockfile, key)).toEqual(['0'.repeat(64), '0'.repeat(64)])
|
||||
|
||||
const updated = updateLockfilePatchHash(lockfile, key, 'a'.repeat(64))
|
||||
|
||||
expect(readLockfileResolutionHashes(updated, key)).toEqual(['a'.repeat(64), 'a'.repeat(64)])
|
||||
expect(readLockfileResolutionHashes(updated, 'node-pty@1.1.0')).toEqual(['1'.repeat(64)])
|
||||
expect(updated).not.toContain('0'.repeat(64))
|
||||
})
|
||||
|
||||
it('reports a lockfile stale in its resolution keys alone', () => {
|
||||
const key = '@xterm/xterm@6.1.0-beta.287'
|
||||
const halfUpdated = lockfile.replace(`hash: ${'0'.repeat(64)}`, `hash: ${'a'.repeat(64)}`)
|
||||
|
||||
expect(readLockfilePatchHash(halfUpdated, key)).toBe('a'.repeat(64))
|
||||
expect(lockfilePatchHashIsStale(halfUpdated, key, 'a'.repeat(64))).toBe(true)
|
||||
expect(lockfilePatchHashIsStale(lockfile, key, '0'.repeat(64))).toBe(false)
|
||||
})
|
||||
|
||||
it('fails loudly when the package is not patched at all', () => {
|
||||
expect(() => readLockfilePatchHash(lockfile, '@xterm/addon-webgl@0.20.0-beta.286')).toThrow(
|
||||
/no patchedDependencies entry/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('check-mode reporting', () => {
|
||||
it('points at the source patch instead of the bundle', () => {
|
||||
const message = formatCheckFailure({
|
||||
name: '@xterm/xterm',
|
||||
patchPath: 'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
|
||||
committed: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n',
|
||||
regenerated: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+c\n'
|
||||
})
|
||||
expect(message).toContain('Do not edit them')
|
||||
expect(message).toContain('--write')
|
||||
expect(message).toContain('docs/reference/xterm-patch-regeneration.md')
|
||||
expect(message).toContain('files [lib/x.js]')
|
||||
})
|
||||
|
||||
it('locates the first differing character', () => {
|
||||
expect(firstDifferenceIndex('abc', 'abd')).toBe(2)
|
||||
expect(firstDifferenceIndex('abc', 'abc')).toBe(-1)
|
||||
expect(firstDifferenceIndex('abc', 'abcd')).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
// These run without network or a build, so ordinary `pnpm test` catches the two
|
||||
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
|
||||
describe('committed xterm patch artifacts', () => {
|
||||
it('records the lockfile hash pnpm derives from the patch file', async () => {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
|
||||
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
|
||||
for (const packageEntry of manifest.packages) {
|
||||
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
|
||||
const key = `${packageEntry.name}@${packageEntry.version}`
|
||||
expect(readLockfilePatchHash(lockfile, key)).toBe(patchHash(patch))
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the source patch and the full patch equal on every source file', async () => {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
|
||||
for (const packageEntry of manifest.packages) {
|
||||
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
|
||||
const source = await readFile(path.join(REPO_ROOT, packageEntry.sourcePatch), 'utf8')
|
||||
expect(sourceHunks(source)).toBe(sourceHunks(patch))
|
||||
expect(generatedHunks(patch, packageEntry.generatedPaths)).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('pins a full upstream commit and a buildable package entry', async () => {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
|
||||
expect(manifest.upstream.commit).toMatch(/^[0-9a-f]{40}$/)
|
||||
expect(manifest.packages.length).toBeGreaterThan(0)
|
||||
expect(() => assertBuildStepsAllowed(manifest)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -36,12 +36,34 @@ describe('release E2E dispatch contract', () => {
|
||||
})
|
||||
|
||||
it('includes the paired-runtime web client in the shared E2E build artifact', () => {
|
||||
const buildStep = e2eWorkflow.jobs.build.steps.find(
|
||||
(step) => step.name === 'Build Electron app for E2E'
|
||||
)
|
||||
const buildStep = e2eWorkflow.jobs.build.steps.find((step) => step.name === 'Build E2E outputs')
|
||||
|
||||
expect(buildStep.run).toContain('electron-vite build --mode e2e')
|
||||
expect(buildStep.env.VITE_EXPOSE_STORE).toBe('true')
|
||||
expect(buildStep.run).toContain('pnpm run build:web-from-renderer')
|
||||
expect(buildStep.run).toContain('pnpm run build:relay')
|
||||
})
|
||||
|
||||
it('hands the built relay artifact to every E2E run command', () => {
|
||||
const uploadStep = e2eWorkflow.jobs.build.steps.find(
|
||||
(step) => step.name === 'Upload E2E build output'
|
||||
)
|
||||
|
||||
expect(uploadStep.with.name).toBe('e2e-build-out')
|
||||
expect(uploadStep.with.path).toBe('out/')
|
||||
|
||||
for (const [jobName, runStepName] of [
|
||||
['e2e', 'Run E2E tests (${{ matrix.shard_name }})'],
|
||||
['changed-e2e', 'Run changed E2E specs']
|
||||
]) {
|
||||
const job = e2eWorkflow.jobs[jobName]
|
||||
const downloadStep = job.steps.find((step) => step.name === 'Download E2E build output')
|
||||
const runStep = job.steps.find((step) => step.name === runStepName)
|
||||
|
||||
expect(job.needs).toBe('build')
|
||||
expect(downloadStep.with.name).toBe('e2e-build-out')
|
||||
expect(downloadStep.with.path).toBe('out/')
|
||||
expect(runStep.run).toContain('ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay"')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,12 @@ import net from 'node:net'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
|
||||
import { isDevBundleInUse, selectStaleDevBundleDirs } from './dev-electron-bundle-cache.mjs'
|
||||
import {
|
||||
DEV_BUNDLE_ID,
|
||||
getDevBundlePlistPatches,
|
||||
getDevHelperPlistPatches
|
||||
} from './dev-electron-bundle-identity.mjs'
|
||||
|
||||
// Why: Electron-based hosts (e.g. Claude Code, VS Code) set
|
||||
// ELECTRON_RUN_AS_NODE=1 in their terminal environment. If this leaks into
|
||||
@@ -110,6 +116,60 @@ function sanitizeMacAppBundleName(value) {
|
||||
)
|
||||
}
|
||||
|
||||
function getDevBundleProcessTable() {
|
||||
// Not pgrep: macOS pgrep has no -a (a Linux procps extension) and silently prints bare PIDs,
|
||||
// which reads as "nothing is running" and deletes a live bundle. -ww keeps the command column
|
||||
// from being truncated. The raw text is searched directly; see dev-electron-bundle-cache.mjs
|
||||
// for why it is deliberately not parsed into paths.
|
||||
try {
|
||||
return execFileSync('/bin/ps', ['-Awwo', 'command='], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000
|
||||
})
|
||||
} catch {
|
||||
// Treating a failure as "nothing live" would risk deleting a running bundle, so skip pruning.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function pruneStaleDevBundles(distDir) {
|
||||
const root = path.dirname(distDir)
|
||||
let bundles
|
||||
try {
|
||||
bundles = readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => {
|
||||
const dir = path.join(root, entry.name)
|
||||
return {
|
||||
dir,
|
||||
hasMarker: existsSync(path.join(dir, 'orca-dev-electron-app.json')),
|
||||
mtimeMs: getMtimeMs(dir)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (bundles.length <= 1) {
|
||||
return
|
||||
}
|
||||
const processTable = getDevBundleProcessTable()
|
||||
if (processTable === null) {
|
||||
return
|
||||
}
|
||||
const stale = selectStaleDevBundleDirs({
|
||||
bundles,
|
||||
currentDir: distDir,
|
||||
processTable,
|
||||
nowMs: Date.now()
|
||||
})
|
||||
for (const dir of stale) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function prepareMacDevElectronApp() {
|
||||
if (process.platform !== 'darwin') {
|
||||
return
|
||||
@@ -128,9 +188,9 @@ function prepareMacDevElectronApp() {
|
||||
|
||||
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
|
||||
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
|
||||
// v10: add the keyboard-layout helper. A stale copy only emits extra fields
|
||||
// the parser ignores, so narrowing its schema needs no bump.
|
||||
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v10'
|
||||
// v11: stop patching the branch title into Info.plist so every dev bundle signs to one cdhash.
|
||||
// A stale copy only emits extra fields the parser ignores, so narrowing its schema needs no bump.
|
||||
const bundleLayoutVersion = 'stable-cdhash-dock-name-from-bundle-dir-v11'
|
||||
const hash = createHash('sha1')
|
||||
.update(
|
||||
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
|
||||
@@ -138,8 +198,9 @@ function prepareMacDevElectronApp() {
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
const distDir = path.join(repoRoot, 'out', 'electron-dev', hash)
|
||||
// Why: macOS Dock hover uses the bundle's filesystem display name for
|
||||
// electron-vite's direct binary launch path, even when Info.plist is patched.
|
||||
// Why: macOS Dock hover uses the bundle's filesystem display name for electron-vite's direct
|
||||
// binary launch path. This is what carries the per-branch name now that Info.plist no longer does,
|
||||
// and it sits outside the code signature, so varying it does not disturb the cdhash.
|
||||
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
|
||||
const appPath = path.join(distDir, appBundleName)
|
||||
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
|
||||
@@ -153,24 +214,37 @@ function prepareMacDevElectronApp() {
|
||||
// once, macOS may route a notification click to the other instance —
|
||||
// Electron drops clicks for notification ids it didn't create, so the
|
||||
// click is lost, not misdirected.
|
||||
const bundleId = 'com.stablyai.orca.dev'
|
||||
const helperBundleId = `${bundleId}.helper`
|
||||
const bundleId = DEV_BUNDLE_ID
|
||||
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
|
||||
// Why the patches are in the marker: bundleLayoutVersion alone does not cover them, so a cache
|
||||
// built before a patch value changed would be reused and keep presenting the old identity.
|
||||
const expectedMarker = JSON.stringify(
|
||||
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
|
||||
{
|
||||
title,
|
||||
appBundleName,
|
||||
bundleId,
|
||||
sourceAppPath,
|
||||
electronVersion,
|
||||
bundleLayoutVersion,
|
||||
plistPatches: [...getDevBundlePlistPatches(), ...getDevHelperPlistPatches()]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
const executablePath = path.join(appPath, 'Contents', 'MacOS', 'Electron')
|
||||
// Split by consequence: without this Chromium blank-crashes, so it gates whether the bundle can
|
||||
// run at all. The keyboard-layout helper below is optional -- swiftc builds it non-fatally, so on
|
||||
// a Mac without full Xcode it is simply absent and only a keyboard feature degrades.
|
||||
const chromiumResourcePath = path.join(
|
||||
appPath,
|
||||
'Contents',
|
||||
'Frameworks',
|
||||
'Electron Framework.framework',
|
||||
'Resources',
|
||||
'icudtl.dat'
|
||||
)
|
||||
const requiredResourcePaths = [
|
||||
path.join(
|
||||
appPath,
|
||||
'Contents',
|
||||
'Frameworks',
|
||||
'Electron Framework.framework',
|
||||
'Resources',
|
||||
'icudtl.dat'
|
||||
),
|
||||
chromiumResourcePath,
|
||||
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout')
|
||||
]
|
||||
|
||||
@@ -194,6 +268,30 @@ function prepareMacDevElectronApp() {
|
||||
}
|
||||
|
||||
if (copiedAppIsUsable()) {
|
||||
pruneStaleDevBundles(distDir)
|
||||
process.env.ELECTRON_EXEC_PATH = executablePath
|
||||
return
|
||||
}
|
||||
|
||||
// Why this guard: a rebuild replaces this exact directory, and making the marker conditional on a
|
||||
// successful sign means an unsigned bundle is a permanent cache miss -- so every later run would
|
||||
// reach this rmSync while another instance is still running from it, deleting its app mid-session.
|
||||
// Reusing what is there matches what the runner did before the marker became conditional.
|
||||
const rebuildProcessTable = getDevBundleProcessTable()
|
||||
if (
|
||||
rebuildProcessTable !== null &&
|
||||
isDevBundleInUse(distDir, rebuildProcessTable) &&
|
||||
// Why a runnability check: a bundle missing Chromium's resources blank-crashes, and its
|
||||
// orphaned crashpad helper still matches the process table -- so without this the runner would
|
||||
// reuse a broken bundle forever and never rebuild itself out. Deliberately narrower than
|
||||
// copiedAppIsUsable, which also demands the optional keyboard-layout helper: gating on that
|
||||
// would strand every Mac without swiftc back on the rmSync path this guard exists to avoid.
|
||||
existsSync(executablePath) &&
|
||||
existsSync(chromiumResourcePath)
|
||||
) {
|
||||
console.warn(
|
||||
`[orca-dev] Another dev instance is running from this bundle; reusing it instead of rebuilding. Quit the other instance (or delete ${distDir}) to force a rebuild.`
|
||||
)
|
||||
process.env.ELECTRON_EXEC_PATH = executablePath
|
||||
return
|
||||
}
|
||||
@@ -214,10 +312,17 @@ function prepareMacDevElectronApp() {
|
||||
'Contents',
|
||||
'Info.plist'
|
||||
)
|
||||
setPlistValue(plistPath, 'CFBundleName', title)
|
||||
setPlistValue(plistPath, 'CFBundleDisplayName', title)
|
||||
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
|
||||
setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)
|
||||
// Why every value here is constant: Info.plist is inside the signature seal, so a branch-varying
|
||||
// value (these keys used to carry the branch title) changed the ad-hoc cdhash per branch, and
|
||||
// macOS Keychain ACLs match on that cdhash — every branch read as a different app and re-prompted.
|
||||
// Patching these keys is fine; varying them is not. The Dock takes its label from the .app
|
||||
// directory name (see appBundleName), which is outside the signature, so per-branch names survive.
|
||||
for (const { key, value } of getDevBundlePlistPatches()) {
|
||||
setPlistValue(plistPath, key, value)
|
||||
}
|
||||
for (const { key, value } of getDevHelperPlistPatches()) {
|
||||
setPlistValue(helperPlistPath, key, value)
|
||||
}
|
||||
|
||||
// Why: the notification-status helper reads the app's real macOS
|
||||
// notification authorization (UNUserNotificationCenter has no Electron
|
||||
@@ -268,14 +373,24 @@ function prepareMacDevElectronApp() {
|
||||
// An ad-hoc re-sign restores delivery, the permission prompt, and the
|
||||
// notification-settings deep link for dev builds. Non-fatal: a signing
|
||||
// failure should not block `pnpm dev`.
|
||||
let signed = true
|
||||
try {
|
||||
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath])
|
||||
} catch (error) {
|
||||
signed = false
|
||||
console.warn(
|
||||
`[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}`
|
||||
)
|
||||
}
|
||||
writeFileSync(markerPath, expectedMarker, 'utf8')
|
||||
// Why only when signed: the marker is what marks this bundle reusable. Writing it after a failed
|
||||
// sign cached an unsigned bundle permanently -- the warning above scrolled past once and every
|
||||
// later launch silently reused it, losing notification delivery and the stable cdhash that keeps
|
||||
// safeStorage from re-prompting. Leaving it unwritten costs a re-copy per launch until signing
|
||||
// works, and still does not block `pnpm dev`.
|
||||
if (signed) {
|
||||
writeFileSync(markerPath, expectedMarker, 'utf8')
|
||||
}
|
||||
pruneStaleDevBundles(distDir)
|
||||
process.env.ELECTRON_EXEC_PATH = executablePath
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// The three projects overlap heavily in src/shared but have no build dependency on
|
||||
// each other, so tsc can check them concurrently instead of in a `&&` chain.
|
||||
const projects = ['tsconfig.node.json', 'tsconfig.tc.cli.json', 'tsconfig.tc.web.json']
|
||||
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const tsc = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url))
|
||||
|
||||
// Why serialize on a single-core runner: three tsc processes there thrash rather than overlap.
|
||||
const concurrent = availableParallelism() > 1
|
||||
|
||||
function checkProject(project) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [tsc, '--noEmit', '-p', `config/${project}`], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
child.on('error', reject)
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`tsc ${project} exited with signal ${signal}`))
|
||||
} else if (code !== 0) {
|
||||
reject(new Error(`tsc ${project} exited with code ${code}`))
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let failures = []
|
||||
if (concurrent) {
|
||||
const results = await Promise.allSettled(projects.map(checkProject))
|
||||
failures = results.filter((result) => result.status === 'rejected').map((result) => result.reason)
|
||||
} else {
|
||||
for (const project of projects) {
|
||||
try {
|
||||
await checkProject(project)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
console.error(failure.message ?? failure)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ describe('packaged skills CLI PR gates', () => {
|
||||
const smokeStep = job.steps.find((step) => step.name === 'Smoke packaged CLI')
|
||||
|
||||
expect(job['runs-on']).toBe('windows-2022')
|
||||
expect(buildStep.run).toBe('pnpm run build:release')
|
||||
expect(buildStep.run).toBe('pnpm run build:release:parallel')
|
||||
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(packageStep.run).toContain('electron-builder')
|
||||
expect(packageStep.run).toContain('--dir')
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
|
||||
const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
|
||||
|
||||
function runWsl(distro, script, options = {}) {
|
||||
return execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-se'], {
|
||||
return execFileSync('wsl.exe', ['-d', distro, '--exec', 'bash', '-se'], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
input: script,
|
||||
|
||||
@@ -86,10 +86,6 @@ 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
|
||||
@@ -139,15 +135,16 @@ async function main() {
|
||||
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 { buildWslLoginShellCommand, 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)])
|
||||
wslArgs(distro, ['/bin/sh', '-lc', loginProbe])
|
||||
)
|
||||
const probeText = probe.stdout.toString('utf8')
|
||||
const loginPath = /__ORCA_PATH__(.*)/.exec(probeText)?.[1]?.trim()
|
||||
@@ -172,7 +169,7 @@ async function main() {
|
||||
const script = `${delay}${buildWslLoginShellCommand(command)}`
|
||||
result = await run(
|
||||
'wsl.exe',
|
||||
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)])
|
||||
wslArgs(distro, ['/bin/sh', '-lc', script])
|
||||
)
|
||||
const markerOffset = result.stdout.indexOf(outputMarker)
|
||||
if (markerOffset === -1) {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"../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/agent-hooks/posix-hook-command.ts",
|
||||
"../src/main/agent-hooks/runtime-home-hook-command.ts",
|
||||
"../src/main/agent-hooks/windows-powershell-hook-launcher.ts",
|
||||
"../src/main/amp/agent-status-plugin-source.ts",
|
||||
@@ -39,6 +40,7 @@
|
||||
"../src/main/codex/codex-config-settings-upsert.ts",
|
||||
"../src/main/codex/codex-home-paths.ts",
|
||||
"../src/main/codex/codex-managed-home-resource-copy-marker.ts",
|
||||
"../src/main/codex/codex-path-observation.ts",
|
||||
"../src/main/codex/codex-hook-identity.ts",
|
||||
"../src/main/codex/codex-hook-trust-grant.ts",
|
||||
"../src/main/codex/codex-managed-trust-reconciliation.ts",
|
||||
@@ -54,6 +56,7 @@
|
||||
"../src/main/codex/config-settings-baseline.ts",
|
||||
"../src/main/codex/config-settings-conflict-resolution.ts",
|
||||
"../src/main/codex/config-settings-promotion.ts",
|
||||
"../src/main/codex/config-settings-promotion-write-target.ts",
|
||||
"../src/main/codex/config-sync-stall.ts",
|
||||
"../src/main/codex/config-toml-deprecated-hook-flag.ts",
|
||||
"../src/main/codex/config-toml-key-path.ts",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
// NOT ENFORCED YET, and deliberately so: tests/ has never been typechecked, so this currently
|
||||
// reports ~198 errors across ~94 files. Wiring it into `typecheck` or CI today would just paint
|
||||
// the whole build red. It earns its place anyway — pointing it at the SSH reconnect spec found
|
||||
// two real bugs a passing suite had hidden (a targetId read off the wrong type, and a required
|
||||
// argument omitted), because that spec had never actually executed. Run `pnpm typecheck:e2e`,
|
||||
// fix a file, repeat; when the count reaches zero, add it to the `typecheck` chain.
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
// Why: specs import renderer and main modules directly, so the ambient declarations those
|
||||
// modules rely on (vite client types, build-time defines) have to be in the program too.
|
||||
"include": [
|
||||
"../tests/**/*.ts",
|
||||
"../tests/**/*.tsx",
|
||||
"../src/renderer/src/env.d.ts",
|
||||
"../src/types/**/*"
|
||||
],
|
||||
"exclude": ["../tests/e2e/.cross-version-checkouts/**"],
|
||||
"compilerOptions": {
|
||||
// Why: specs run browser code inside page.evaluate, so the DOM lib has to be visible here
|
||||
// even though the harness itself is Node.
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@renderer/*": ["../src/renderer/src/*"],
|
||||
"@/*": ["../src/renderer/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 21m">
|
||||
<title>downloads: 21m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 23m">
|
||||
<title>downloads: 23m</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">21m</text>
|
||||
<text x="90" y="14">21m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">23m</text>
|
||||
<text x="90" y="14">23m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
Binary file not shown.
|
After Width: | Height: | Size: 389 KiB |
@@ -235,9 +235,10 @@ yay -S stably-orca-bin
|
||||
|
||||
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
|
||||
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
|
||||
- **微信:** 扫码加入 Orca 社区微信第 7 群。
|
||||
- **微信:** 扫码加入 Orca 社区微信第 7 群。如果第 7 群已满,请使用第 8 群。
|
||||
|
||||
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 社区微信第 7 群二维码" width="160" />
|
||||
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 社区微信第 7 群二维码" width="160" />
|
||||
<img src="../assets/wechat-qr-group8.jpg" alt="Orca 社区微信第 8 群二维码" width="160" />
|
||||
|
||||
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
|
||||
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
|
||||
|
||||
@@ -118,3 +118,42 @@ The harness covers the terminal stream and the structured agent-session surface.
|
||||
**not** cover the session-tab sync channel, legacy agent-session publications, file or Git
|
||||
RPCs, mobile/E2EE framing, or the relay transport. A change on those paths still needs its
|
||||
own reasoning against the three rules above.
|
||||
|
||||
## Worked example: `agentWait` on terminal and worker reads
|
||||
|
||||
`terminal.show`, `orchestration.workerShow` and `orchestration.federationShow` carry an
|
||||
optional `agentWait` naming a pane parked on a prompt only a human can answer. It is Rule 1 —
|
||||
a new optional field — but it has a second state that Rule 1 alone does not describe, and
|
||||
getting that wrong turns a skew into a false "nothing is blocked".
|
||||
|
||||
- **present object** — this pane is waiting, with the evidence that proved it.
|
||||
- **present `null`** — the host evaluated this pane and nothing proves a wait.
|
||||
- **absent** — the host never evaluated it: it predates the field, the worker identity was
|
||||
unverifiable, the pane was unreadable, or the agent probe did not answer in time.
|
||||
|
||||
A new client against an old host sees the field absent, which is why absence must read as
|
||||
*unknown* and never as *not waiting*. Collapsing absent into `null` at any hop — including a
|
||||
convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable
|
||||
from a healthy idle worker, which is the exact failure the field exists to remove.
|
||||
|
||||
An old client against a new host ignores the key, as Rule 1 allows. New members added to
|
||||
`RuntimeTerminalWaitBlockedReason` are also Rule 1: no consumer switches exhaustively on it,
|
||||
and both the CLI and worker-start interpolate it as an opaque string.
|
||||
|
||||
## Known debt: JSON-RPC errors drop Node's string code
|
||||
|
||||
An error raised on an SSH host crosses the relay as JSON-RPC, and
|
||||
`ssh-channel-multiplexer` rebuilds it with the TRANSPORT's numeric `code`. Node's
|
||||
string code — `'ENOENT'`, `'EACCES'` — does not survive, so a caller on this side
|
||||
cannot ask what kind of failure it was.
|
||||
|
||||
`isENOENT` in `src/main/ipc/filesystem-path-containment.ts` pays for that by also
|
||||
matching Node's canonical message text, which is what makes remote worktree creation
|
||||
work. The cost is that a host can make an unrelated failure read as "absent" by
|
||||
putting that sentence in a message.
|
||||
|
||||
The exit is Rule 1: carry the original string code in a new optional field on the
|
||||
error payload and read that instead. An old host omits it and the message match still
|
||||
covers them; once hosts that send it are the floor, the message match can be deleted
|
||||
rather than lived with at its ~10 call sites. Narrowing `isENOENT` back to `.code`
|
||||
without doing this reinstates the bug — the transport has already overwritten it.
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
# SSH host key verification (STA-4319)
|
||||
|
||||
Revised after security and migration review. Where a first draft was wrong, the correction is kept
|
||||
visible rather than quietly edited out — the reasoning matters for anyone changing this later.
|
||||
|
||||
## The defect
|
||||
|
||||
`src/main/ssh/ssh-connection.ts:1184` installs a `hostVerifier` that records a SHA-256 fingerprint
|
||||
and then `return true`. Every ssh2 connection accepts every host key. There is no `known_hosts`
|
||||
consult, no trust record, and no change detection anywhere in `src/main/ssh/`. There is exactly one
|
||||
ssh2 `Client` construction site, so the fix has a single chokepoint.
|
||||
|
||||
Scope is per-connection, not per-feature: one `SshConnection` per target serves exec, SFTP, port
|
||||
forwarding, the filesystem watcher and relay deploy.
|
||||
|
||||
### Threat model, corrected
|
||||
|
||||
Traffic is still encrypted, so a passive observer gets nothing. The exposure is an **active**
|
||||
attacker who can redirect the connection — ARP/DNS spoofing, hostile Wi-Fi, a hijacked internal name.
|
||||
|
||||
Three corrections to the first draft:
|
||||
|
||||
- **Jump hosts are NOT the worst case; they are already safe.** `shouldUseSystemSshTransport`
|
||||
(`ssh-transport-selection.ts:71-91`) returns true for exactly the conditions under which
|
||||
`resolveEffectiveProxy` (`ssh-proxy-command.ts:17-38`) returns a proxy — the two branch on the same
|
||||
inputs in the same order — and `attemptConnect` returns unconditionally after the system probe
|
||||
(`ssh-connection.ts:670-673`). So ProxyJump/ProxyCommand go through OpenSSH and are already
|
||||
verified. The ssh2 proxy-spawn at `:697` is effectively unreachable. Good news for migration, and
|
||||
the first draft's motivating example was simply wrong.
|
||||
- **Agent forwarding was overstated.** `agentForward` is gated on the user's `ForwardAgent yes`
|
||||
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent *auth*, whose
|
||||
signatures bind the session id and cannot be replayed onward. The risk applies to users who opted
|
||||
into `ForwardAgent`, not everyone.
|
||||
- **Credential theft was understated, and the relay claim was backwards.** `isAgentFallbackError`
|
||||
treats *any* auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
|
||||
publickey walks the user to the password prompt (`ssh-connection.ts:844`) and the private-key
|
||||
**passphrase** prompt (`:834`), and `cachedPassword` is replayed without prompting on every
|
||||
reconnect (`:709`). Meanwhile the relay upload matters less than assumed — the attacker already
|
||||
owns their machine. The real client-side impact is the **return** direction: the attacker becomes
|
||||
the host our workspace trusts, driving relay protocol frames, landing SFTP content in local
|
||||
worktrees, and feeding agent-hook payloads in.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Read the user's `known_hosts`; write only to our own store
|
||||
|
||||
Consult the user's real `known_hosts` as a trust source — most developers already have their hosts
|
||||
there from `ssh` and `git`, which is the entire migration story. Do **not** write to it: that file is
|
||||
shared with every other SSH tool on the machine, and appending brings line-endings, permissions,
|
||||
concurrent writers, and a corruption blast radius well beyond us.
|
||||
|
||||
Two consequences to own rather than discover:
|
||||
|
||||
- **Revocation does not propagate into our store.** `ssh-keygen -R host` clears `known_hosts` but not
|
||||
our record. That is survivable for the ordinary rotation, because a `known_hosts` MATCH is now
|
||||
decided before our store's mismatch — running the remedy we print and reconnecting works, which it
|
||||
did not when the store was consulted first. What it does not cure is a host we only ever knew
|
||||
ourselves, never written to `known_hosts`: there is no `ssh-keygen -R` for that one, so the
|
||||
rejection names the store file directly. The "forget" action (D5) replaces that with a button; its
|
||||
helper is deliberately absent until then, since an exported API nothing can reach is unverified in
|
||||
production. Mismatch messaging must keep naming which source disagreed.
|
||||
- **`ssh -G` on the HOME-divergent `-F` path suppresses `/etc/ssh/ssh_config`**
|
||||
(`ssh-g-config-resolution.ts:44-52`), hiding site-wide `StrictHostKeyChecking yes` and
|
||||
`GlobalKnownHostsFile`. On that path we must fail **strict**, never laxer than `ssh` would.
|
||||
|
||||
There is no `ssh`-only way out of this: `-F /dev/null` does NOT invert the exclusion, it reports
|
||||
built-in defaults, so a probe built on it looks permissive on every machine. Verified against
|
||||
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — *could*
|
||||
the site config be restricting host keys — where anything ambiguous (unreadable, an unresolvable
|
||||
`Include`, the directive present at all) keeps the refusal. Only a site config that demonstrably
|
||||
says nothing about host keys clears it, which is what stops the rule punishing every devcontainer,
|
||||
`su` shell and Nix shell.
|
||||
|
||||
### D2. Ask `ssh -G`, do not reimplement config resolution
|
||||
|
||||
`ssh -G` reports `userknownhostsfile`, `globalknownhostsfile`, `stricthostkeychecking`,
|
||||
`checkhostip`, `hostkeyalgorithms`, `fingerprinthash`, `hashknownhosts`, `updatehostkeys` and
|
||||
**`hostkeyalias`** — with `Match` and `Include` already applied. `resolveWithSshG` exists and simply
|
||||
does not read them yet.
|
||||
|
||||
`userknownhostsfile` is a space-separated list on one line, may contain `~`, and may contain
|
||||
double-quoted paths with spaces. When `ssh -G` is unavailable (no `ssh`, non-zero exit, >5s timeout)
|
||||
fall back to `~/.ssh/known_hosts` + `known_hosts2` — never to accept.
|
||||
|
||||
`HostKeyAlias` must be honoured: users tunnelling bastions through `localhost:port` depend on it and
|
||||
would otherwise hit spurious mismatches. It appears nowhere in `src/main/ssh/` today.
|
||||
|
||||
**Lookup key.** Config resolution uses `configHost || label` (`ssh-connection.ts:660`) while ssh2
|
||||
dials `effectiveHost` (`ssh-connection-utils.ts:188`). The `known_hosts` lookup must use
|
||||
`HostKeyAlias` if set, else the **resolved hostname** — keying on the Orca label would miss every
|
||||
existing entry.
|
||||
|
||||
**Two ordered lookup passes, not one candidate set.** Verified against OpenSSH 10.2p1: a non-default
|
||||
port looks up `[host]:port` first, and if that finds nothing it retries the **bare** host. Crucially,
|
||||
on that second pass a wrong key is downgraded to `unknown` rather than reported as changed. So the
|
||||
passes are `[['[host]:port'], ['host']]`, and the fallback pass can only yield `match` or `unknown`.
|
||||
Collapsing them into one set would give a spurious first-contact prompt to anyone who has a bare
|
||||
line and connects on a non-default port; treating the fallback as authoritative would raise a false
|
||||
change-of-key alarm.
|
||||
|
||||
**The entry condition to that second pass is the part that bites.** ssh runs it only when the
|
||||
port-qualified lookup matched no plain entry of ANY key type — not "no match". Gating it on
|
||||
"no match and no same-type mismatch" reaches the bare line when an off-port entry of another type
|
||||
exists, and returns `match` where ssh prints `IDENTIFICATION HAS CHANGED`: an accept-a-changed-key
|
||||
path, reproduced live. And the observations from each pass must not leak into the other, or an entry
|
||||
found only on the fallback refuses a host ssh accepts as first contact.
|
||||
|
||||
**`HostKeyAlias` suppresses the port entirely.** ssh looks the alias up bare and never brackets it,
|
||||
so an alias gets ONE pass regardless of port. Combined with the rule above, a stale `[alias]:port`
|
||||
line would otherwise block the bare lookup ssh actually performs — turning the bastion case this
|
||||
feature cites `HostKeyAlias` for into a hard failure.
|
||||
|
||||
**Hashed entries hash the candidate form, not the bare host** — `[example.com]:2222` is what gets
|
||||
HMAC'd for a bracketed entry, so each candidate must be hashed separately.
|
||||
|
||||
**Multiple files union.** Any exact hit in any file wins; a disagreeing entry in another file does
|
||||
not make it a mismatch. Confirmed live in both orderings.
|
||||
|
||||
**A `@cert-authority` line whose key equals the presented plain host key is not a match** — a CA line
|
||||
only validates certificates. A normal line alongside it still decides. But ssh's verdict for a
|
||||
CA-covered host presenting a plain key is `HOST_NEW`, not a failure: it connects. See D4.
|
||||
|
||||
### D3. Six outcomes, and type scoping is only safe with algorithm ordering
|
||||
|
||||
`match | mismatch | revoked | ca-only | unknown-type-known-host | unknown`.
|
||||
|
||||
Mismatch is scoped to the same key type: a host with only an RSA entry that presents ed25519 is not
|
||||
"changed". Without scoping we would false-alarm nearly every RSA-era user on their first upgraded
|
||||
connect, training them to dismiss the one warning that matters.
|
||||
|
||||
> **Corrected against a live client.** The premise above is wrong about OpenSSH, though the
|
||||
> conclusion survives. `check_key_in_hostkeys` is not type-scoped at all: ANY non-marker entry for
|
||||
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — known_hosts
|
||||
> holding only `ssh-rsa` against an ed25519-only server prints `IDENTIFICATION HAS CHANGED` and
|
||||
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the *situation* via
|
||||
> `order_hostkeyalgs`, and hard-fails when the situation arises anyway. Our split into `mismatch`
|
||||
> and `unknown-type-known-host` therefore only chooses the wording — both refuse, which is ssh's
|
||||
> action. What the ordering below buys us is what it buys ssh: the situation mostly never arises.
|
||||
|
||||
**But scoping alone is a downgrade vector, and this is the correction that most changes the design.**
|
||||
OpenSSH is safe here only because `order_hostkeyalgs()` reorders the client's proposed host-key
|
||||
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the *client's* order
|
||||
priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first
|
||||
regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a
|
||||
friendly first-contact prompt instead of a hard failure.
|
||||
|
||||
Therefore: **set ssh2's `algorithms.serverHostKey` to lead with the key types already known for that
|
||||
host.** Type scoping without algorithm ordering is not a safe design.
|
||||
|
||||
And when the presented type is unknown *while other types are known for this host*, that is
|
||||
`unknown-type-known-host` — never a plain TOFU prompt. It must say we already hold a different key
|
||||
for this host.
|
||||
|
||||
### D4. Outcomes
|
||||
|
||||
- **match** → connect silently.
|
||||
- **unknown** → trust-on-first-use (see the phasing below for whether that is silent or prompted).
|
||||
- **mismatch** → hard fail, no override in the failure surface.
|
||||
- **revoked** → hard fail, always.
|
||||
- **ca-only** → ~~hard fail~~ **REVERSED: treated as first contact.** See below.
|
||||
- **unknown-type-known-host** → treat as suspicious, not first contact.
|
||||
|
||||
`StrictHostKeyChecking` is honoured: `no`/`off` accepts unknown but **never persists** and still
|
||||
hard-fails changed and revoked; `accept-new` persists silently; `yes` denies unknown.
|
||||
|
||||
> **`ssh -G` does not report the spelling the user wrote.** StrictHostKeyChecking is rendered through
|
||||
> `fmt_multistate_int`, which prints the first entry of `multistate_strict_hostkey`, and that table
|
||||
> lists true/false before yes/no. So `yes` arrives as `true`, `no` and `off` both as `false`; only
|
||||
> `ask` and `accept-new` pass through unchanged. Matching on `yes`/`no`/`off` matches nothing a real
|
||||
> config can produce. `UpdateHostKeys` has the same shape (`true`, not `yes`).
|
||||
|
||||
**ca-only, reversed after review.** The rejection was stricter than ssh, and the blast radius was
|
||||
mispriced. An SSH CA user holds ONE line — very often `@cert-authority *` — which matches every
|
||||
candidate, so EVERY target failed, not just CA-signed ones, including on-demand runtime VMs, and
|
||||
`StrictHostKeyChecking=no` did not help. `ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1` is read from the
|
||||
process environment, which an Electron app launched from the Dock or Start Menu does not have, so
|
||||
the documented escape was unreachable for exactly the people who needed it. And OpenSSH's own
|
||||
verdict for a CA-covered host presenting a plain key is `HOST_NEW`: it connects. ssh2 cannot
|
||||
validate certificates at all, so refusing conceded nothing ssh was not already conceding.
|
||||
|
||||
The residual risk is accepted, not resolved: for a CA-protected host we take a plain key we cannot
|
||||
tie to the CA. Certificate support is Phase 2 work. The `ca-only` outcome is still produced and
|
||||
carried through the decision so the log shows a CA line was involved.
|
||||
|
||||
**An unreadable known_hosts connects but records nothing.** A file that EXISTS and will not open is
|
||||
the absence of evidence, and the common trigger is not exotic — a Windows OneDrive Known Folder Move
|
||||
placeholder while offline fails with a cloud-file error, not ENOENT. Refusing there broke an
|
||||
ordinary corporate laptop while blaming a config file that was fine, and was asymmetric with our own
|
||||
store, which degrades to "nothing trusted" and connects. ssh warns and treats the host as unknown;
|
||||
so do we — but we write no record, so a first contact we could not check never becomes durable
|
||||
trust. An ABSENT file is not this case: that is the normal state for a fresh profile and genuinely
|
||||
means nothing is known.
|
||||
|
||||
### D5. Recovery must not live in the failure dialog
|
||||
|
||||
A "forget this host key" button *in* the mismatch dialog is D4's rejected "trust anyway" with one
|
||||
extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it
|
||||
shows the stored fingerprint so the user is choosing knowingly.
|
||||
|
||||
Offer it only when **our** store is what disagreed; when `known_hosts` disagrees, forgetting our
|
||||
record cannot unblock the connect. Messages, written to avoid naming internals:
|
||||
|
||||
> **Ours disagreed** — "The host key for `build-01` changed since you last connected from Orca. If you
|
||||
> rebuilt or reprovisioned this machine, this is expected." → *Forget the saved key* / *Cancel*
|
||||
|
||||
> **`known_hosts` disagreed** — "The host key for `build-01` does not match the entry in
|
||||
> `~/.ssh/known_hosts`. `ssh` and `git` will refuse this host too. Run `ssh-keygen -R build-01`." →
|
||||
> no button, because a button would not help.
|
||||
|
||||
### D6. Never prompt on a background reconnect
|
||||
|
||||
A prompt only means something when a human initiated the connect. `userInitiated` does not exist on
|
||||
the connect path today and must be threaded through `connect → attemptConnect → doSsh2Connect`,
|
||||
defaulting **false**.
|
||||
|
||||
Two traps: `useAutomationDispatchEvents.ts:203` and `pty-connection.ts:857` reach `ssh:connect`
|
||||
without a human click — automation must pass `false`, but **terminal-pane focus reconnects must count
|
||||
as user-initiated** or terminals die silently. And the denial string must avoid "authentication
|
||||
failed"/"permission denied", or `isAgentFallbackError`/`isAuthError`
|
||||
(`ssh-connection-utils.ts:46-61`) misclassifies it and the reconnect ladder retries a decision that
|
||||
will never change.
|
||||
|
||||
### D7. Fail closed — three known fail-open shapes
|
||||
|
||||
1. The existing generation/disposed guard at `:1185` has the fail-open shape today: skip recording,
|
||||
still `return true`. Post-fix that branch must **deny**.
|
||||
2. A synchronous throw inside the verifier may not be caught by ssh2 — wrap and `verify(false)`.
|
||||
3. Any non-`undefined` return accepts immediately (see Traps).
|
||||
|
||||
Plus: no prompt channel registered → deny (the load-bearing default lives in `doSsh2Connect`, not in
|
||||
IPC, so a caller that forgets to wire it cannot accidentally accept); no window → deny; timeout →
|
||||
deny; dialog dismissed → deny.
|
||||
|
||||
### D8. Store shape and scope
|
||||
|
||||
Accepted keys are scoped to **host + port + key type**, not target id — aliases point at different
|
||||
machines, two targets can name one machine, and a re-created target must not lose trust.
|
||||
|
||||
The store is a **dedicated file**, not the main persistence blob (`persistence.ts:7088`): a settings
|
||||
restore or rollback must not silently reset trust. Accept and mismatch events are logged.
|
||||
|
||||
`hostKeyFingerprint` is now security-relevant *and* wire-relevant — it is an isolation namespace sent
|
||||
to the host (`ssh-relay-session.ts:1298`, `managed-hook-owner-identity.ts:187`). It is `undefined` on
|
||||
the system transport, so **no trust logic may key off it**, and its format must not change (see
|
||||
Traps).
|
||||
|
||||
## Phasing — ship the defence before the dialog
|
||||
|
||||
Review made the case that the riskiest part of this change is not the security model but the modal.
|
||||
Startup restore fires eager connects for *all* previously-active targets in parallel (`App.tsx:1041`)
|
||||
with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs
|
||||
outliving the timeout that already deferred them. Runtime-owned ephemeral VMs
|
||||
(`ephemeral-vm-runtime-ssh.ts:31`) dial a freshly provisioned host with a brand-new key on every
|
||||
launch. Paired-web connects run on the *host desktop* (`runtime/rpc/methods/ssh.ts:32`), so the
|
||||
dialog would open on someone else's screen while the web user watches a spinner.
|
||||
|
||||
**Phase 1 — no new modal.** Consult `known_hosts` + our store. `match` connects. `unknown` persists
|
||||
silently with `accept-new` semantics and a passive notification naming the host and fingerprint.
|
||||
`mismatch` (same type) and `revoked` hard-fail. This is the entire MITM defence with zero prompts,
|
||||
zero startup storms and zero web hang.
|
||||
|
||||
**Phase 2** — the TOFU dialog, `StrictHostKeyChecking` honouring, `ca-only`, `userInitiated`
|
||||
plumbing, and the D5 settings surface.
|
||||
|
||||
Carve-outs required before Phase 1 ships:
|
||||
|
||||
- **Runtime-owned ephemeral targets are exempt from persistence** — a new key every launch is
|
||||
expected, not suspicious, and recording one would accumulate a row per launch that eventually
|
||||
reads as a spurious change. Implemented via `target.owner?.type === 'on-demand-runtime'`.
|
||||
- **RPC-originated connects: NOT needed in Phase 1, required in Phase 2.** The review asked for
|
||||
these to fail fast rather than leave a paired-web user watching a spinner for the 120s prompt
|
||||
timeout. That hang is only reachable if a prompt exists, and Phase 1 has none — the decision
|
||||
function is pinned by a test asserting it never returns `prompt`. An RPC connect therefore behaves
|
||||
exactly like a local one: it accepts and records on first contact, or fails immediately with the
|
||||
host-key reason. Adding a fail-fast path now would introduce a failure mode for a hang that cannot
|
||||
occur. It becomes load-bearing the moment the dialog lands, and is listed in Phase 2.
|
||||
|
||||
Worth noting for Phase 2: `runtime/rpc/methods/ssh.ts` already swallows the specific error and
|
||||
rethrows `getPublicSshError(status)`, so a web client sees a generic failure rather than the
|
||||
host-key reason. Pre-existing, but it means the Phase 2 message will not reach the web user
|
||||
without a change there too.
|
||||
|
||||
## Traps
|
||||
|
||||
Each of these makes the fix silently do nothing. All confirmed in our tree.
|
||||
|
||||
1. **An `async` verifier defeats it entirely.** ssh2 does
|
||||
`const ret = hashCb(key, verify); if (ret !== undefined) verify(ret)`. An async function returns a
|
||||
Promise — not `undefined`, and truthy — so ssh2 accepts before our callback settles.
|
||||
2. **Do not set ssh2's `hostHash`.** It hands the callback a hex digest and discards the raw blob we
|
||||
must compare — and it would change `hostKeyFingerprint`'s format, which is a cross-version state
|
||||
break, not a local refactor.
|
||||
3. **The existing test mock calls `hostVerifier(key)` with one argument** and ignores the return
|
||||
(`ssh-connection.test.ts:86-91`). Under an async verifier every connect test there breaks. The
|
||||
mock must change — flagged deliberately, not rewritten silently.
|
||||
4. **Validate the blob**: embedded algorithm name must match the line's key-type field; reject empty
|
||||
decodes, empty salts, and hashed entries whose hash is not 20 bytes.
|
||||
5. **`ssh-relay-live-connect.test.ts:59`** constructs a connection with no credential callback —
|
||||
headless with no prompt channel must deny, not hang.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope, corrected:** IPv6 literals and `[host]:port` bracket parsing. Review was right that this
|
||||
is a *parser* requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
|
||||
`unknown`, which is the prompt-training harm D3 exists to avoid.
|
||||
|
||||
**Out of scope, with consequences stated:**
|
||||
- **`CheckHostIP`** — OpenSSH defaults it off; we form candidates from the hostname only.
|
||||
- **WSL** — `src/main/ssh/` has no WSL awareness; a distro's `known_hosts` is unreachable, so WSL
|
||||
users get first-contact treatment for hosts they already verified.
|
||||
- **`UpdateHostKeys`** — we read it and use nothing, so we never learn a rotated key, which makes D5
|
||||
the routine path for key rotation rather than an exception.
|
||||
- **Moving SFTP to the system transport** — correct direction, separate change.
|
||||
|
||||
## Test plan
|
||||
|
||||
**Parser** (against the file format, not our code's shape): plain lines, `host,host2` lists,
|
||||
`[host]:port` used only when port ≠ 22, IPv6 literals, hashed `|1|salt|hash` with a real computable
|
||||
vector, `@revoked`, `@cert-authority`, `*`/`?` globs, `!` negation vetoing a whole line, unrecognised
|
||||
`@marker` skipping the line, malformed lines skipped not fatal, multiple keys per host, CRLF, blank
|
||||
lines, comments, user file and global file disagreeing.
|
||||
|
||||
**Decision function**: all six outcomes; type scoping; revocation resolved before match regardless of
|
||||
line order; every `StrictHostKeyChecking` value; `no`/`off` never persists.
|
||||
|
||||
**Algorithm ordering**: `algorithms.serverHostKey` leads with types on file — the test that makes D3
|
||||
safe rather than merely scoped.
|
||||
|
||||
**Wiring**: unknown persists (Phase 1) without a prompt; match never notifies; mismatch fails with no
|
||||
accept path; revoked fails; background reconnect denies; aborted connect settles pending verify
|
||||
false; no prompt channel denies; runtime-owned targets are exempt; the denial string does not match
|
||||
`isAuthError`; and — catching the worst regression — **the verifier returns nothing**, so a refactor
|
||||
to `async` reddens a test rather than reaching a user.
|
||||
|
||||
**Checked against a live client, not just the file format.** Two assumptions the design leans on were
|
||||
verified by running an OpenSSH 10.2p1 client against a real `sshd` on `127.0.0.1:2222` and recording
|
||||
its verdict:
|
||||
|
||||
- **The bare-host fallback pass never reports a change.** With `StrictHostKeyChecking=accept-new`, a
|
||||
bare line holding a *different* key, dialed on a non-default port, made ssh connect and append a
|
||||
new `[127.0.0.1]:2222` line — first contact, no `IDENTIFICATION HAS CHANGED`. Reporting `mismatch`
|
||||
on that pass would refuse hosts ssh connects to happily, and would have looked like the cautious
|
||||
choice.
|
||||
- **`unknown-type-known-host` is ssh's own behaviour.** known_hosts holding `ssh-rsa` while the
|
||||
server offers ed25519 makes ssh print `IDENTIFICATION HAS CHANGED` and refuse. So the rejection is
|
||||
neither stricter nor laxer than ssh — and treating it as first contact, which a naive type-scoped
|
||||
lookup does, is the laxer mistake. It also means `ssh-keygen -R` is the right remedy to name there.
|
||||
|
||||
## What Phase 1 shipped, and what review changed
|
||||
|
||||
The design above survived implementation. Every defect found afterwards was in the wiring, and the
|
||||
pattern is worth recording because it repeats: **each one made us either blind or unusable, never
|
||||
subtly wrong.**
|
||||
|
||||
Fixed after review:
|
||||
|
||||
1. **Our own store was type-downgradable.** The inline lookup filtered by key type first and could
|
||||
only answer match/mismatch/unknown, so a record of a *different* type read as `unknown`. D3's
|
||||
downgrade, applied to the records we create ourselves. Stored types now also feed the algorithm
|
||||
ordering — without that the guard is only half present.
|
||||
2. **We keyed on the Orca label.** `ssh -G` echoes its own argument back as `hostname` when no Host
|
||||
block matches, so for a manual target `resolved.hostname` *is* the label — the one name D2
|
||||
forbids. We consulted no entries at all.
|
||||
3. **A refused key still walked the credential ladder.** ssh2 reports a denial as a generic auth
|
||||
failure, so we went on to prompt for the passphrase and hand it to the host we had just refused.
|
||||
Rejections are now a typed error recognised before any fallback.
|
||||
4. **Fail-closed nearly became fail-always.** "No readable known_hosts" counted a *missing* file the
|
||||
same as an unreadable one, so a profile that had never connected — everyone's first run — would
|
||||
have been refused, and the suite passed only because dev machines have a `known_hosts`.
|
||||
5. **Ephemeral runtimes were refused for a policy they cannot satisfy.** The carve-out sat below the
|
||||
incomplete-sources check, so a HOME-divergent environment turned on-demand runtimes off entirely.
|
||||
|
||||
A second review round, run against a live OpenSSH client and sshd rather than against the source,
|
||||
found five more — and the pattern held: the two that mattered most were both cases where we refused
|
||||
a host `ssh` connects to, and the worst single defect was that **`StrictHostKeyChecking` had never
|
||||
been read correctly at all**, so a config saying `yes` was silently accepted AND persisted. See the
|
||||
D2/D3/D4 corrections above. The lesson worth keeping: every one of these was invisible to unit tests
|
||||
that fed the code the value a human writes, rather than the value the tool emits.
|
||||
|
||||
## Action items (STA-4319)
|
||||
|
||||
**Where the message actually lands.** Traced end to end, because a rejection the user cannot read is
|
||||
a half-shipped feature. Fixed in this branch: the settings card clamped it to one line with no
|
||||
tooltip, and the terminal reconnect overlay never asked for it at all. Still open:
|
||||
|
||||
- **The "Remote Hosts" status bar shows only `Error`.** `SshTargetStatusRow` does not receive the
|
||||
error, so the status bar is a dead end for the most likely place a user notices the failure.
|
||||
- **"Connect again" is the wrong advice for a decision that will never change.** The terminal overlay
|
||||
now prints the reason underneath, but its call to action still invites an action that cannot
|
||||
succeed. Telling a permanent rejection from a transient fault in the renderer needs a typed reason
|
||||
on the wire rather than a string — a remote-wire-compatibility decision, so deliberately deferred.
|
||||
- **Toasts carry Electron's `Error invoking remote method 'ssh:connect':` prefix.** The repo has
|
||||
strippers for exactly this; no SSH call site uses one. Also worth noting sonner auto-dismisses in
|
||||
4s, which is short for a message ending in a command the user is meant to copy.
|
||||
|
||||
**Before Phase 2:**
|
||||
|
||||
- **`UpdateHostKeys` (out of scope above, now the highest-value gap).** We read it and use nothing,
|
||||
so a rotated key is a hard failure the user must resolve by hand. Combined with D5 this is the
|
||||
routine path for key rotation, and it will be the most common way a legitimate user meets a
|
||||
rejection. Decide whether Phase 2 honours it or D5's recovery surface absorbs it.
|
||||
- **The web user never sees the reason.** `runtime/rpc/methods/ssh.ts` rethrows
|
||||
`getPublicSshError(status)` on all three paths, and push events are redacted through
|
||||
`getPublicSshState`, so a paired-web client always sees exactly `SSH connection unavailable`.
|
||||
Pre-existing, but it makes the Phase 2 dialog message unreachable there without a change. Note the
|
||||
redaction is not web-only: any target owned by a paired runtime environment is redacted, so a
|
||||
*desktop* user viewing a remote-Orca-server-owned host gets the same generic string.
|
||||
- **RPC fail-fast** becomes load-bearing the moment the dialog exists (see Phasing).
|
||||
|
||||
**Known gaps that Phase 1 accepts, listed so they are choices and not surprises:**
|
||||
|
||||
- **WSL** — a distro's `known_hosts` is unreachable, so WSL users get first-contact treatment for
|
||||
hosts they already verified through `ssh` inside the distro.
|
||||
- **`CheckHostIP`** — candidates are formed from the hostname only.
|
||||
- **Certificate validation** is still absent — a CA-covered host is now accepted on first contact
|
||||
rather than refused (D4), so those users connect, but the CA itself verifies nothing for us.
|
||||
- **`DEFAULT_SERVER_HOST_KEY_ALGORITHMS`** is a hand-copy of an ssh2 internal. A test pins it, so an
|
||||
ssh2 upgrade that changes it fails CI rather than shipping — but the pin has to be honoured, not
|
||||
deleted, because ssh2 throws `Unsupported algorithm` and every target stops connecting.
|
||||
|
||||
**Rollout:** the first release carrying this is the first time Orca can refuse an SSH connection at
|
||||
all. Worth a staged rollout or a kill switch: the failure modes we could not find are, by the shape
|
||||
of the five above, far more likely to be "a legitimate host is refused" than "a bad key is accepted".
|
||||
@@ -0,0 +1,147 @@
|
||||
# SSH reconnect: why the pane retry gets a byte tail, and what would actually change it
|
||||
|
||||
Status: investigation result. The obvious follow-up to PR #14844 was traced and **rejected**, and
|
||||
tracing it turned up the actual root cause: checkpointed source recovery has never run on an SSH
|
||||
reconnect. Both are recorded here — the rejected shape so nobody re-proposes it, and the verified
|
||||
cause with the fix it implies.
|
||||
|
||||
## The shape of the problem
|
||||
|
||||
A reconnect remounts the pane (`tab.generation` is its React key), so the xterm is disposed with its
|
||||
buffer and something must repaint it. Today that is a **byte tail**: `reattachSshPtySession` sends
|
||||
`requireReplay: true` and the relay returns `RecentPtyOutputBuffer.read()` — the last 100KB, read
|
||||
non-destructively, with no notion of what this client already consumed.
|
||||
|
||||
Two costs follow. Main's `@xterm/headless` model never sees those bytes (the tail bypasses
|
||||
`onPtyData`), so it is stale by exactly the outage — which is what forces
|
||||
`sshReconnectPaintsFromModel` to restrict the grid repaint to the alternate screen. And a shell loses
|
||||
outage output past 100KB permanently.
|
||||
|
||||
## The proposal that does not work
|
||||
|
||||
"Make the pane-retry path request source recovery like `reattachKnownPtys` does." Mechanically this
|
||||
is trivial — `sourceRecovery` is already an optional `pty.attach` param the relay parses, Path C
|
||||
already calls the same `requestSshPtyAttach` helper and already parses the response field. The
|
||||
required checkpoint state also survives a transport drop, in the module-level `recoveryByTarget` map
|
||||
(`ssh-pty-consumer-recovery.ts:17`), reachable from `connectionId` because `connectionId === targetId`.
|
||||
|
||||
It still fails, three ways:
|
||||
|
||||
1. **The relay answers `'existing'` before it looks at the recovery argument.**
|
||||
`relay-pty-source-publication.ts:99-109` short-circuits on a same-`clientId` attach, and a
|
||||
reconnected client presents the same id — see the root-cause section below, where this turns out
|
||||
to be the whole story rather than an obstacle specific to this proposal.
|
||||
2. **A failed `reattachKnownPtys` deletes the checkpoint on purpose** (`ssh-relay-session.ts:3006-3007`)
|
||||
and detaches the lease (`:3008`). The pane retry runs _after_ that, so it would present
|
||||
`checkpointUnavailable`, which the relay converts to `restoreRequired`
|
||||
(`relay-pty-source-publication.ts:124-130`) and the provider converts to
|
||||
`SSH_SESSION_EXPIRED_ERROR` (`ssh-pty-provider.ts:103-107`). We would trade a blank-pane-with-tail
|
||||
for a **killed session**.
|
||||
3. **Wrong payload shape.** Recovery replays only the post-checkpoint delta
|
||||
`(acceptedSourceEndSu → receivedEndSu]`. The byte tail is a screen snapshot for a _fresh, empty_
|
||||
xterm. Even a successful recovery returns roughly nothing in the common case, and the pane stays
|
||||
blank.
|
||||
|
||||
These two mechanisms answer different questions. Recovery keeps main's model whole; the tail repaints
|
||||
a new terminal. Substituting one for the other is a category error.
|
||||
|
||||
## A correction worth recording
|
||||
|
||||
The motivating argument was "`requireReplay` is optional, so older relays ignore it and still show
|
||||
blank panes." **That is wrong for the SSH relay.** The client deploys and launches its own relay
|
||||
build into a version-scoped directory (`ssh-relay-deploy.ts:231`, `:594`), and `validateGrant`
|
||||
rejects any grant whose `serverBuildId` differs from the expected one
|
||||
(`ssh-pty-consumer-session.ts:58-65`, rationale in-code: _"client and relay ship in one build"_).
|
||||
Client and SSH relay are version-locked; mixed versions do not occur on this channel. The
|
||||
independent-update rule in `remote-wire-compatibility.md` still governs remote _runtime_ hosts — just
|
||||
not this one.
|
||||
|
||||
So there is no old-host population to rescue, and the urgency that argument created was false.
|
||||
|
||||
## ANSWERED: checkpointed recovery never runs on an SSH reconnect
|
||||
|
||||
The question above was "does the reconnecting client present a new `clientId`?" It does not, and the
|
||||
consequence is that the whole checkpoint mechanism is dead on this path. Every link verified:
|
||||
|
||||
1. **The client keeps its id.** A reconnect calls `Dispatcher.setWrite`
|
||||
(`src/relay/dispatcher.ts:149-157`), which reuses `this.primaryClient` — including its `id` — and
|
||||
replaces only the writer. The dispatcher refuses to detach the primary. This is already stated
|
||||
in-repo at `src/relay/pty-handler.ts:1736-1742`.
|
||||
2. **So `activate()` short-circuits.** `relay-pty-source-publication.ts:99` tests
|
||||
`current?.clientId === context.clientId` and returns `'existing'` at `:108`. The `rotateDelivery`
|
||||
recovery path at `:118-142` is reachable **only** when the ids differ — i.e. never, here.
|
||||
3. **So the relay returns no `sourceRecovery`.**
|
||||
4. **So the client abandons.** `finishSourceRecovery` (`ssh-relay-session.ts:2766-2785`) fails its
|
||||
`!pendingRecovery` guard, calls `abandonPtySourceRecovery`, and returns false — which cancels the
|
||||
delivery and deletes the checkpoint (`:3006-3008`).
|
||||
5. **So the pane retry opens fresh and gets the byte tail**, via the `requireReplay` fix.
|
||||
|
||||
The byte tail is therefore not a fallback. It is the only path that has ever run for an SSH
|
||||
reconnect, and the flow-control/checkpoint machinery is inert on this path.
|
||||
|
||||
That also explains the original blank-pane bug exactly: the relay concluded "this client already
|
||||
holds the stream" because, by its own identity rule, it does.
|
||||
|
||||
### The fix this implies
|
||||
|
||||
Give a reconnected primary a distinguishable identity — a transport generation on the client record,
|
||||
bumped in `setWrite` — and have `activate()` compare it alongside `clientId`, so a reconnect takes
|
||||
`rotateDelivery` instead of `'existing'`.
|
||||
|
||||
Why this is the tractable shape:
|
||||
|
||||
- **No wire change.** `RequestContext`, `setWrite` and the publication are all relay-internal.
|
||||
- **No compatibility exposure.** Client and relay ship in one build and are version-locked.
|
||||
- **It does not disturb the invariant that broke three earlier attempts.** Deliveries still outlive
|
||||
their clients; nothing retires on `onClientDetached`. The delivery is _rotated on re-attach_,
|
||||
which is what the recovery design already intends and what its tests already cover.
|
||||
|
||||
**UNVERIFIED and to be checked before implementing:** that `rotateDelivery`'s preconditions hold at
|
||||
that moment (the checkpoint's `deliveryToken`, `clientGeneration`, `ownerGeneration` and
|
||||
`ptyIncarnation` must match the live identity, `:124-128`); that `outputFlowControl` is granted on
|
||||
the reconnected session; and what a rotation implies for the _renderer_, which still remounts with an
|
||||
empty xterm and needs a screen, not a post-checkpoint delta. Recovery keeps main's model whole — it
|
||||
does not by itself repaint a fresh terminal, so the tail may still be wanted for the pane even once
|
||||
the model stops going stale.
|
||||
|
||||
## Do not start at `onClientDetached`
|
||||
|
||||
Three attempts failed there, each plausible until run:
|
||||
|
||||
- Retiring the delivery on `dispatcher.onClientDetached` **breaks checkpoint recovery** (10 tests).
|
||||
A delivery outliving its client is deliberate — it is what lets a client resume from a checkpoint.
|
||||
- Retiring without `session.cancelDelivery()` orphans the credit ledger's one-upstream-owner slot;
|
||||
the next open throws `PTY source delivery already has an upstream owner`. Seen live as a toast and
|
||||
a blank pane.
|
||||
- Comparing `record.identity.clientGeneration` to the request is impossible: that value is
|
||||
client-supplied via `pty.openClient`, and `RequestContext` carries no generation of its own.
|
||||
|
||||
## The lead that survives
|
||||
|
||||
`reattachRejectedPty` (`ssh-relay-session.ts:1957-2004`) is an existing **single-PTY** entry point
|
||||
into the `reattachKnownPtys` machinery, taking `(relayPtyId, mux, providerGeneration, mode)` and
|
||||
driving recovery with `targetedDeliveryRecovery`. If per-pane recovery is wanted, that is the hook —
|
||||
and it does not involve the pane-retry path at all. Unverified whether it is reachable at the moment
|
||||
the renderer retries.
|
||||
|
||||
## Preconditions, unchanged
|
||||
|
||||
The SSH e2e lane must be green and triggering on **source** changes before any of this is attempted.
|
||||
It was skipping for 15 specs; four regressions reached a user during that window.
|
||||
|
||||
## Open: the pane behind a preserved tab does not always rebind
|
||||
|
||||
The merge now keeps a local tab the host has never been told about, so the tab and its title survive
|
||||
a reconnect. The reattach behind it does not, reliably — measured at three runs in four against the
|
||||
Docker-SSH lane. When it misses, the store holds the tab, the tab bar renders it, and the pane never
|
||||
rebinds: the "frozen tab" shape the original report described, one layer down from the deletion that
|
||||
used to cause it.
|
||||
|
||||
Deliberately NOT asserted in `ssh-reconnect-tab-destruction.spec.ts`. A one-in-four flake in the lane
|
||||
that exists to catch this class costs more than it proves — the lane stops being trusted, which is
|
||||
exactly how the earlier silent-skip failure happened. Tab survival is asserted there and is
|
||||
deterministic; the liveness gap is recorded here instead.
|
||||
|
||||
Worth checking first, since it is the same shape as everything else in this file: the tab is absent
|
||||
from the host snapshot, so whatever drives the per-tab reattach after an apply may simply not know to
|
||||
reattach a tab the snapshot never mentioned.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Running commands inside WSL
|
||||
|
||||
Two properties of `wsl.exe` decide how every guest invocation has to be written. Both are silent
|
||||
when you get them wrong: the command still runs and still exits 0, it just returns the wrong bytes.
|
||||
|
||||
## 1. Always `--exec`, never `--`
|
||||
|
||||
`wsl.exe -d <distro> -- <argv>` expands `$name` in **every argument** against the guest environment
|
||||
before the guest runs. This is `wsl.exe` itself, not the guest shell — it happens with no shell in
|
||||
the command at all:
|
||||
|
||||
```
|
||||
$ wsl.exe -d Ubuntu-24.04 -- /usr/bin/printf %s '$HOME'
|
||||
/home/you
|
||||
$ wsl.exe -d Ubuntu-24.04 --exec /usr/bin/printf %s '$HOME'
|
||||
$HOME
|
||||
```
|
||||
|
||||
So under `--`, a script means something other than what it says. `awk '{print $2}'` reaches the
|
||||
guest as `awk '{print }'` and prints the whole line; a positional `"$1"`, a shell local, and a
|
||||
`"\$literal"` are blanked or rewritten the same way. (Expansions with no `$` are unaffected — a
|
||||
`sed` backreference like `s/(a)(b)/\2\1/` survives either way.) Escaping `$` on the Windows side
|
||||
cannot fix this reliably — an earlier attempt skipped every `$` preceded by a backslash, which is
|
||||
exactly the case a POSIX script uses to mean a literal dollar.
|
||||
|
||||
Build argv with `buildWslExecArgs()` in `src/shared/wsl-login-shell-command.ts`. A test walks the
|
||||
tree and fails if the `--` form reappears.
|
||||
|
||||
The `--` inside `sh -s -- <path>` is a *shell* argument separator and is unrelated; leave it alone.
|
||||
|
||||
## 2. Machine-read output must be fenced
|
||||
|
||||
Orca runs guest commands through the distro user's **interactive** login shell (`-ilc` for
|
||||
bash/zsh) because that is the only shell that reads `~/.bashrc`, where `nvm`, `mise` and `asdf`
|
||||
install their PATH entries. Dropping `-i` would break tool detection for those users.
|
||||
|
||||
The cost is that an interactive shell also runs the distro's rc/motd, and that output goes to
|
||||
**stdout** — the same stream the answer arrives on. Stock Ubuntu 24.04 needs no customization to
|
||||
reproduce it:
|
||||
|
||||
```
|
||||
$ wsl.exe -d Ubuntu-24.04 --exec bash -ilc 'git --version'
|
||||
To run a command as administrator (user "root"), use "sudo <command>".
|
||||
See "man sudo_root" for details.
|
||||
|
||||
git version 2.43.0
|
||||
```
|
||||
|
||||
Any caller that parses stdout must use `buildWslCapturedLoginShellCommand()`, which fences the
|
||||
payload and returns a matching `readStdout`. `.trim()` does not help: the banner is a prefix, not
|
||||
surrounding whitespace, so a stat probe compared against `"directory"` simply never matches.
|
||||
|
||||
The fence carries a per-call nonce so that `cat`-ing a file whose contents happen to quote a marker
|
||||
is not truncated, and it preserves the payload's exit status so `exit 2` → `ENOENT` mappings keep
|
||||
working.
|
||||
|
||||
**Do not fence a command that `exec`s into a long-running program** (`codex app-server`, an
|
||||
interactive terminal). It never reaches the closing fence, and there the shell's own output either
|
||||
belongs to the program or is what the user wants to see.
|
||||
|
||||
## Prefer no shell at all
|
||||
|
||||
When a caller only needs a known binary with a known environment, skip the login shell entirely and
|
||||
run the binary directly:
|
||||
|
||||
```
|
||||
wsl.exe -d <distro> --exec /usr/bin/env PATH=… HOME=… /usr/bin/git -C <dir> status
|
||||
```
|
||||
|
||||
This is what the direct-git read path does. It is immune to both problems above by construction and
|
||||
avoids paying login-shell startup on every call, which also sidesteps profiles that block or print.
|
||||
Resolve the PATH/HOME once through a fenced probe, cache it per distro, then use this form.
|
||||
@@ -0,0 +1,244 @@
|
||||
# xterm Patch Regeneration
|
||||
|
||||
## Scope
|
||||
|
||||
Orca ships `@xterm/xterm` with four source changes it needs and upstream has
|
||||
not taken: the IME composition hooks, the `xterm-composition-*` custom events
|
||||
they raise, the `ICompositionHelper` surface those hooks widen, and a `SortedList`
|
||||
fix. pnpm applies them through `config/patches/@xterm__xterm@<version>.patch`.
|
||||
|
||||
That patch touches eight files. Four are hand-authored source
|
||||
(`src/browser/CoreBrowserTerminal.ts`, `src/browser/Types.ts`,
|
||||
`src/browser/input/CompositionHelper.ts`, `src/common/SortedList.ts`) and four
|
||||
are the build output those sources produce (`lib/xterm.js`, `lib/xterm.mjs`,
|
||||
and both sourcemaps). The bundle half is 7.3 MB of minified code. It is
|
||||
generated, and this document exists so nobody edits it by hand.
|
||||
|
||||
The two halves are the same edits diffed two ways, so the generator requires
|
||||
them to match byte for byte on every source file. A hunk the shipped patch
|
||||
cannot name — upstream's `.npmignore` strips `src/**/*.test.ts` — would be
|
||||
dropped by the next `--write`, so it fails the run instead.
|
||||
|
||||
`config/patches/xterm-src/@xterm__xterm@<version>.src.patch` is the source of
|
||||
truth. Everything else is derived from it by
|
||||
`config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact
|
||||
upstream commit the published tarball was built from.
|
||||
|
||||
This policy covers `@xterm/xterm` only. The addon patches
|
||||
(`@xterm/addon-webgl`, `@xterm/addon-serialize`) are still hand-edited bundles
|
||||
and are tracked separately; see [Known Gaps](#known-gaps).
|
||||
|
||||
## Rules
|
||||
|
||||
1. Never edit `config/patches/@xterm__xterm@<version>.patch`. Edit the source
|
||||
patch and regenerate.
|
||||
2. Never edit `lib/` inside a patched `node_modules` tree and re-run
|
||||
`pnpm patch-commit`. That is how bundle hunks stop matching their sources.
|
||||
3. Every source change must land together with the regenerated bundle hunks and
|
||||
the `pnpm-lock.yaml` hash bump, in one commit.
|
||||
4. The upstream commit lives in `config/patches/xterm-upstream.json`, not in a
|
||||
comment. A version bump that leaves it stale fails the generator, it does not
|
||||
silently patch the wrong tree.
|
||||
5. Sourcemaps move with the bundle, and are never silently omitted. The patch
|
||||
moves the code, so dropping only the map hunks would ship offsets pointing at
|
||||
the wrong lines. `sourcemaps.policy` accepts `include` and nothing else: it
|
||||
costs about 5.8 MB of the emitted patch and is required because
|
||||
`src/renderer/src/components/terminal-pane/terminal-ime-xterm-transaction-events.test.ts`
|
||||
reads `lib/*.map` and asserts the mapped `Version.ts` matches the runtime
|
||||
version. Deleting the maps was once an option; the code that did it was
|
||||
removed as unreachable, so re-adding the policy means re-adding that code.
|
||||
6. `--check` is the authority on the lockfile, not `pnpm install`. pnpm writes the
|
||||
patch hash in two places — `patchedDependencies` and every resolution key that
|
||||
depends on the patched package — and on a warm store it will leave the
|
||||
resolution keys at their previous value while reporting success. That installs
|
||||
locally and drifts on CI's cold store. Always finish on step 4, and if it
|
||||
reports a stale hash after an install, rerun `--write`.
|
||||
|
||||
## Workflow
|
||||
|
||||
```sh
|
||||
# 1. Edit the source hunks.
|
||||
$EDITOR config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
|
||||
|
||||
# 2. Rebuild the bundle hunks, the full patch, and the lockfile hash.
|
||||
node config/scripts/regenerate-xterm-patches.mjs --write
|
||||
|
||||
# 3. Reinstall so node_modules picks up the new patch hash.
|
||||
pnpm install
|
||||
|
||||
# 4. Confirm the tree is self-consistent.
|
||||
node config/scripts/regenerate-xterm-patches.mjs --check
|
||||
```
|
||||
|
||||
Editing a patch file by hand is awkward for anything larger than a one-liner.
|
||||
For a substantial change, work in the generator's own checkout instead — after
|
||||
any run it is left at the pinned commit with the source patch applied:
|
||||
|
||||
```sh
|
||||
node config/scripts/regenerate-xterm-patches.mjs --check --work-dir=/tmp/xterm
|
||||
$EDITOR /tmp/xterm/upstream/src/browser/input/CompositionHelper.ts
|
||||
git -C /tmp/xterm/upstream diff -- src/ > config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
|
||||
node config/scripts/regenerate-xterm-patches.mjs --write --work-dir=/tmp/xterm
|
||||
```
|
||||
|
||||
`--write` rewrites the source patch into the canonical form it would emit on a
|
||||
re-diff, so a hand-produced `git diff` gets normalized on the first run rather
|
||||
than fighting `--check` forever.
|
||||
|
||||
Run the checkout outside this repository. A build tree underneath it makes
|
||||
`tsgo` walk up into Orca's own `node_modules` and fail with `TS2300: Duplicate
|
||||
identifier`, which is a symptom of where the tree sits and not of the patch.
|
||||
|
||||
## How the Commit Is Known
|
||||
|
||||
Upstream `bin/publish.js` sets `packageJson.commit` before `npm publish`, so
|
||||
each published tarball names the commit that built it. The generator asserts
|
||||
that stamp against `xterm-upstream.json` and then compares the tarball's `src/`
|
||||
against the checkout file by file. Only `src/common/Version.ts` may differ,
|
||||
because `publish.js` rewrites the version immediately before packaging; the
|
||||
generator applies the same stamp.
|
||||
|
||||
That pair of checks is what makes the rebuild trustworthy. Without them a wrong
|
||||
commit would still produce a plausible-looking 7 MB patch.
|
||||
|
||||
## Build Order
|
||||
|
||||
Upstream's publish path is `npm ci` → stamp `Version.ts` → `npm run package`.
|
||||
`npm run package` runs webpack for `lib/xterm.js` and then, via `postpackage`,
|
||||
`bin/esbuild_all.mjs --prod` for `lib/xterm.mjs`.
|
||||
|
||||
**Do not run `npm run setup` after the packaging build.** `setup` is the
|
||||
development esbuild pass with `minify: false`. Running it afterwards overwrites
|
||||
`lib/xterm.mjs` with an unminified bundle and a map that no longer matches, and
|
||||
the resulting patch is silently wrong — the failure mode is a `.mjs` that is
|
||||
50% larger than the published one, which is easy to miss inside a 7 MB diff.
|
||||
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
|
||||
to run a build step that names one of those scripts.
|
||||
|
||||
The generator also builds the *unmodified* commit first and asserts that it
|
||||
reproduces the published `lib/` byte for byte before it emits anything. A
|
||||
toolchain or build-order problem therefore surfaces as an explicit "did not
|
||||
reproduce the published bundles" error rather than as 7 MB of mystery diff.
|
||||
|
||||
## Recovering From Hand-Edited Bundles
|
||||
|
||||
Between 2026-08-09 and 2026-08-17 this harness did not exist, and four fixes
|
||||
landed by editing the minified bundles directly. The tell is code no minifier
|
||||
emits: `const` in an otherwise `let`-only bundle, and identifiers like `$rl`,
|
||||
`$hp`, `$tid`.
|
||||
|
||||
Recovery is not a rewrite. The hand-edits were applied to `src/` as well, so the
|
||||
source hunks in the shipped patch were already correct and `--write` re-derives
|
||||
the bundles from them. What changes is cosmetic and expected:
|
||||
|
||||
- Hand-written locals collapse back into minifier names, which shifts esbuild's
|
||||
frequency-ordered allocation and can swap two short names bundle-wide (`i`↔`t`
|
||||
in the `.mjs`, `w`↔`y` in the `.js`). Most differing lines are the same length.
|
||||
- Hand-written equivalents normalize to what the toolchain actually emits
|
||||
(`!!x` back to `Boolean(x)`, an escaped `\u200E` back to the literal
|
||||
character).
|
||||
|
||||
To confirm a regeneration is semantically a no-op rather than a revert, compare
|
||||
identifier multisets between the old and new bundle instead of reading the diff:
|
||||
every name that is not a single-letter minifier local should appear the same
|
||||
number of times in both. Anything else is a real change and needs explaining.
|
||||
|
||||
## The Lockfile Moves With the Patch
|
||||
|
||||
pnpm derives the `patchedDependencies` hash in `pnpm-lock.yaml` — and the
|
||||
`.pnpm/@xterm+xterm@<version>_patch_hash=<hash>/` store directory name — from
|
||||
the sha256 of the patch file itself. A regenerated patch without the lockfile
|
||||
bump fails `pnpm install --frozen-lockfile` on every machine except the
|
||||
author's. `--write` makes that edit; `--check` fails if it is missing.
|
||||
|
||||
`config/scripts/regenerate-xterm-patches.test.mjs` asserts the same thing
|
||||
without a network or a build, so the ordinary test job catches lockfile drift
|
||||
in milliseconds even though the full rebuild runs in its own CI lane.
|
||||
|
||||
## Toolchain Pin
|
||||
|
||||
`toolchain` in the manifest records what upstream's `package-lock.json` resolves
|
||||
at the pinned commit, and the generator fails if `npm ci` produces something
|
||||
else. The entry that matters is `@typescript/native-preview`
|
||||
(`tsgo`), which upstream pins to a **dated development build** —
|
||||
`7.0.0-dev.20260521.1` at the time of writing. It is a real published version
|
||||
and npm does not prune old releases, but it is the one dependency of this scheme
|
||||
that is not a stable release.
|
||||
|
||||
If that version ever becomes unresolvable the generator fails with a toolchain
|
||||
error naming it. Recovery is to move the pin to the next upstream commit whose
|
||||
`package-lock.json` resolves, re-verify that the rebuild still reproduces the
|
||||
published bundles, and regenerate. The committed patch keeps working the whole
|
||||
time — only regeneration is blocked, so this is never an outage.
|
||||
|
||||
## Version Bumps
|
||||
|
||||
Bumping `@xterm/xterm` is:
|
||||
|
||||
1. Update the version in `package.json` and run `pnpm install`.
|
||||
2. Rename both patch files to the new version and update `patch`,
|
||||
`sourcePatch`, and `version` in `xterm-upstream.json`.
|
||||
3. Update `upstream.commit` to the `commit` field of the new tarball's
|
||||
`package.json`, and `toolchain` to whatever the new `package-lock.json`
|
||||
resolves.
|
||||
4. `node config/scripts/regenerate-xterm-patches.mjs --write`.
|
||||
|
||||
Step 4 is where a real upstream conflict shows up: `git apply` of the source
|
||||
patch fails against the new tree. Resolve it in the checkout, re-diff, and
|
||||
rerun. The bundle hunks need no attention at any point.
|
||||
|
||||
## Why Not Vendor a Fork
|
||||
|
||||
A vendored `@xterm/xterm` fork removes the patch entirely, but it moves Orca off
|
||||
the published package, so every upstream beta becomes a merge rather than a
|
||||
version bump, and Orca inherits responsibility for building and publishing a
|
||||
package it does not own. The patch is four small source hunks against a commit
|
||||
that reproduces byte for byte; a fork is a much larger standing cost for the
|
||||
same result.
|
||||
|
||||
## Why Not Handle Composition at Runtime
|
||||
|
||||
`CompositionHelper` hooks four private call sites upstream of `onData`, and
|
||||
`SortedList` has no public surface at all. There is no supported extension point
|
||||
that reaches either, so a runtime shim would mean reaching into `_core`
|
||||
internals that upstream renames freely between betas. The patch is the smaller
|
||||
risk.
|
||||
|
||||
## CI Contract
|
||||
|
||||
`xterm_patch_sync` in `.github/workflows/pr.yml` runs
|
||||
`regenerate-xterm-patches.mjs --check` on every PR and is part of the `verify`
|
||||
aggregate. It clones the pinned commit, installs upstream's toolchain, builds
|
||||
twice, and byte-compares the result against the committed patch. Both builds and
|
||||
the diff together are about eight seconds; `npm ci` for upstream's toolchain is
|
||||
what the job actually spends its minutes on, and the cache key is the manifest.
|
||||
|
||||
`config/scripts/regenerate-xterm-patches.test.mjs` covers the pure pieces —
|
||||
pnpm's diff flags and normalization, hunk splitting, round-trip stability, the
|
||||
commit and build-order assertions, and lockfile coupling — with no network and
|
||||
no build, so they run in the ordinary test shards.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
`@xterm/addon-webgl` and `@xterm/addon-serialize` are still hand-edited minified
|
||||
bundles. Their patches carry a literal `/* PATCH(orca): ... */` comment inside
|
||||
minified code and parser round-trip artifacts, and neither patch touches its
|
||||
`.map` file, so both addons currently ship sourcemaps whose offsets do not match
|
||||
the shipped bundle — the defect `sourcemaps.policy` rules out for `@xterm/xterm`
|
||||
and which folding them into this manifest would also fix.
|
||||
|
||||
Both addons do build from the pinned commit: the registry stamps
|
||||
`53a98a720ae4a973e384fa2440880d09537132f3` on `addon-webgl@0.20.0-beta.286` and
|
||||
`addon-serialize@0.15.0-beta.287` alike, despite the mismatched version numbers.
|
||||
On 2026-08-17 their published `lib/*.mjs` and `lib/*.mjs.map` reproduced byte for
|
||||
byte from that commit. That was a one-off measurement, not an invariant: no check
|
||||
in this repo re-runs it, so treat it as a starting point to re-measure rather than
|
||||
as something the harness holds true.
|
||||
|
||||
What blocks folding them in is the other half of their published output. Both
|
||||
also ship a CJS `lib/addon-*.js`, and the root `package` script does not build
|
||||
it — upstream's webpack entry is core's `Terminal.js` only, and `postpackage`
|
||||
emits ESM. So a manifest entry for either addon needs a build step this harness
|
||||
does not have, and the CJS halves are **unverified**: nothing here has yet
|
||||
reproduced them from source. Until that exists, folding them in would emit a
|
||||
patch whose CJS stanza came from the current hand-edited bundle.
|
||||
@@ -223,6 +223,9 @@ export const electronViteConfig: UserConfig = {
|
||||
'session-scanner-service-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-service-entry.ts'
|
||||
),
|
||||
'wsl-transcript-fs-process-entry': resolve(
|
||||
'src/main/native-chat/wsl-transcript-fs-process-entry.ts'
|
||||
),
|
||||
// Why: libuv spawns processes inline on the calling loop, so the port
|
||||
// scan's probe commands run on a worker thread instead of the UI one.
|
||||
'port-scan-command-worker-entry': resolve(
|
||||
|
||||
+10
-4
@@ -1,6 +1,12 @@
|
||||
# Pins fastlane for reproducible iOS releases in CI (see fastlane/Fastfile and
|
||||
# .github/workflows/mobile-build.yml). macOS runners ship a fastlane, but
|
||||
# pinning here keeps the release toolchain stable across runner image bumps.
|
||||
# Pins fastlane for reproducible iOS releases in CI (see fastlane/Fastfile,
|
||||
# .github/workflows/mobile-ios-release.yml, and the Fastfile smoke check in
|
||||
# .github/workflows/mobile.yml). macOS runners ship a fastlane, but pinning here
|
||||
# keeps the release toolchain stable across runner image bumps.
|
||||
#
|
||||
# Why an exact version plus a committed Gemfile.lock: ios-build (macos) and
|
||||
# ios-distribute (ubuntu) resolve gems ~25 minutes apart, so an unpinned
|
||||
# fastlane could split a single release across two versions. Bump deliberately
|
||||
# in a PR so the Fastfile smoke check runs against the new version first.
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem "fastlane", "2.238.0"
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1281.0)
|
||||
aws-sdk-core (3.254.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.130.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.229.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.1)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.6)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
erb (6.0.7)
|
||||
excon (1.7.0)
|
||||
logger
|
||||
faraday (2.14.3)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.238.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 2.0.0)
|
||||
faraday (~> 2.7)
|
||||
faraday-cookie_jar (~> 0.0.8)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-retry (~> 2.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.3.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
irb (>= 1.8)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 4)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.106.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (1.2.5)
|
||||
addressable (~> 2.9)
|
||||
faraday (~> 2.13)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
googleauth (~> 1.14)
|
||||
mini_mime (~> 1.1)
|
||||
multi_json (~> 1.11)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 3.1, < 5.0)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.66.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.2.2)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.62.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.0)
|
||||
googleauth (1.17.3)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
io-console (0.9.2)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.21.2)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
rbs (4.1.3)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.0.0)
|
||||
erb
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (4.2.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.22.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
trailblazer-option (0.1.2)
|
||||
tsort (0.2.0)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.28.1)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
base64
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
nkf
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin
|
||||
ruby
|
||||
x86_64-linux
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (= 2.238.0)
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
|
||||
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
|
||||
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
|
||||
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
|
||||
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
|
||||
aws-partitions (1.1281.0) sha256=97fcc7d6a068ab790a1efcd8c6587c08fb108b4bed7ff1409cb47e0d642782ec
|
||||
aws-sdk-core (3.254.1) sha256=518089e32134c3478cd4ec63d07fb966546a45e9e4cbf5f80d2cf16d5699d29b
|
||||
aws-sdk-kms (1.130.0) sha256=a2e83662ca31b77a2a19c9aa2f40a98165a67270c18c718fe1c70d0cbd7cd749
|
||||
aws-sdk-s3 (1.229.0) sha256=7dd11a111875b3505d2ec0a0a383a6aab6c1badb3d4e94e7fbb958a24451f596
|
||||
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
|
||||
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
|
||||
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
|
||||
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
|
||||
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
|
||||
excon (1.7.0) sha256=b8deec2bf978123b7e4b13a103ffec61557c6feaba34706dbc1ce4a94dec33e0
|
||||
faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278
|
||||
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
|
||||
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
|
||||
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
|
||||
faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
|
||||
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.238.0) sha256=78e9252df2224c7e427012638e41051edfa29b133a40fda883fd2f1c13a77b98
|
||||
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.106.0) sha256=b90b5312b70f8f731def88f24a2b8fb791fe1b979a7c2c14a905cb6cae19cf3f
|
||||
google-apis-core (1.2.5) sha256=e21562b5627a0fc6a0c3165e429c3afed0f6a628eaa514e83f7204dda1adff69
|
||||
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
|
||||
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
|
||||
google-apis-storage_v1 (0.66.0) sha256=fdf6d65d3fc77e7046b3494333a818ece9e2afd763bb161e1a7a9e70cf198351
|
||||
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
|
||||
google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7
|
||||
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
|
||||
google-cloud-storage (1.62.0) sha256=e2c3c08bf8fd40d50be92304084942203314d4fc0ee52028e99f9359c3ad1330
|
||||
google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b
|
||||
googleauth (1.17.3) sha256=9a655455885c0c17a8b70537acd2a992a7c7842c43c06c6612dfb0d93e755d58
|
||||
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
|
||||
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
|
||||
io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
|
||||
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
||||
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
|
||||
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
|
||||
jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
|
||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
|
||||
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
|
||||
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
|
||||
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
|
||||
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
|
||||
net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
|
||||
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
|
||||
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
||||
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
|
||||
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
|
||||
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
|
||||
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
||||
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
|
||||
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
|
||||
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
||||
rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4
|
||||
rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
|
||||
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
|
||||
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
|
||||
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
|
||||
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
|
||||
signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
|
||||
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
|
||||
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
|
||||
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
||||
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
|
||||
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
|
||||
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
|
||||
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
|
||||
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
||||
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
||||
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
|
||||
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
|
||||
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
|
||||
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.11
|
||||
@@ -30,9 +30,16 @@ import {
|
||||
} from '../../../src/transport/client-context'
|
||||
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
|
||||
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
|
||||
import {
|
||||
applyWorktreeRowDisplayState,
|
||||
clearConfirmedActiveWorktreeIdentity,
|
||||
getWorktreeRowIdentity,
|
||||
removeWorktreeRow,
|
||||
retainLiveSleptWorktreeIdentities
|
||||
} from '../../../src/worktree/worktree-host-row-identity'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
usePendingConnectionPath,
|
||||
useRelayRecoveryStatus,
|
||||
useReconnectAttempt
|
||||
} from '../../../src/transport/client-context-connection-metrics'
|
||||
import {
|
||||
@@ -139,7 +146,7 @@ export function HostScreen({
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const pendingConnectionPath = usePendingConnectionPath(hostId)
|
||||
const relayRecovery = useRelayRecoveryStatus(hostId)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const fetchWorktreesInFlightRef = useRef(false)
|
||||
// Why: useRef, not useMemo — React may discard memoized values, which would silently
|
||||
@@ -158,7 +165,9 @@ export function HostScreen({
|
||||
// path renders as a failure instead of an empty host. Cleared on the next success.
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null)
|
||||
// Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll.
|
||||
const [optimisticActiveWorktreeId, setOptimisticActiveWorktreeId] = useState<string | null>(null)
|
||||
const [optimisticActiveWorktreeIdentity, setOptimisticActiveWorktreeIdentity] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
// One tick drives every visible agent row's relative timestamp.
|
||||
const now = useNow(30_000)
|
||||
const [repoColorsByName, setRepoColorsByName] = useState<Map<string, string>>(new Map())
|
||||
@@ -472,26 +481,12 @@ export function HostScreen({
|
||||
setCachedWorktrees(hostId, confirmed, { proven: true })
|
||||
}
|
||||
// Drop the optimistic active override once the host reports it active, so later desktop changes win.
|
||||
setOptimisticActiveWorktreeId((pending) =>
|
||||
pending && confirmed.some((w) => w.worktreeId === pending && w.isActive)
|
||||
? null
|
||||
: pending
|
||||
setOptimisticActiveWorktreeIdentity((pending) =>
|
||||
clearConfirmedActiveWorktreeIdentity(pending, confirmed)
|
||||
)
|
||||
|
||||
// Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0).
|
||||
setSleptIds((prev) => {
|
||||
if (prev.size === 0) {
|
||||
return prev
|
||||
}
|
||||
const still = new Set<string>()
|
||||
for (const id of prev) {
|
||||
const wt = confirmed.find((w) => w.worktreeId === id)
|
||||
if (wt && wt.liveTerminalCount > 0) {
|
||||
still.add(id)
|
||||
}
|
||||
}
|
||||
return still.size === prev.size ? prev : still
|
||||
})
|
||||
setSleptIds((prev) => retainLiveSleptWorktreeIdentities(prev, confirmed))
|
||||
|
||||
// Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage.
|
||||
const serverPinned = new Set(confirmed.filter((w) => w.isPinned).map((w) => w.worktreeId))
|
||||
@@ -610,8 +605,7 @@ export function HostScreen({
|
||||
return
|
||||
}
|
||||
|
||||
const removeFromList = (list: Worktree[]) =>
|
||||
list.filter((w) => w.worktreeId !== item.worktreeId)
|
||||
const removeFromList = (list: Worktree[]) => removeWorktreeRow(list, item)
|
||||
setWorktrees(removeFromList)
|
||||
setLastKnownWorktrees(removeFromList)
|
||||
|
||||
@@ -667,7 +661,7 @@ export function HostScreen({
|
||||
|
||||
const openWorktreeSession = useCallback(
|
||||
(item: Worktree) => {
|
||||
setOptimisticActiveWorktreeId(item.worktreeId)
|
||||
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
|
||||
if (client && connState === 'connected') {
|
||||
void client
|
||||
.sendRequest('worktree.activate', {
|
||||
@@ -746,21 +740,8 @@ export function HostScreen({
|
||||
// Why: live `worktrees` is authoritative only while connected; under the amber
|
||||
// mount default, connecting/handshaking must keep the pre-reconnect list too.
|
||||
const base = connState === 'connected' ? worktrees : lastKnownWorktrees
|
||||
if (sleptIds.size === 0 && optimisticActiveWorktreeId === null) {
|
||||
return base
|
||||
}
|
||||
return base.map((w) => {
|
||||
const slept = sleptIds.has(w.worktreeId)
|
||||
? { liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const }
|
||||
: null
|
||||
// Force the just-opened worktree active until the next poll confirms it, so the highlight doesn't lag.
|
||||
const active =
|
||||
optimisticActiveWorktreeId !== null
|
||||
? { isActive: w.worktreeId === optimisticActiveWorktreeId }
|
||||
: null
|
||||
return slept || active ? { ...w, ...slept, ...active } : w
|
||||
})
|
||||
}, [connState, worktrees, lastKnownWorktrees, sleptIds, optimisticActiveWorktreeId])
|
||||
return applyWorktreeRowDisplayState(base, sleptIds, optimisticActiveWorktreeIdentity)
|
||||
}, [connState, worktrees, lastKnownWorktrees, sleptIds, optimisticActiveWorktreeIdentity])
|
||||
|
||||
const toggleCollapsed = useCallback(
|
||||
(key: string) => {
|
||||
@@ -773,7 +754,7 @@ export function HostScreen({
|
||||
[persistViewSettings]
|
||||
)
|
||||
const toggleWorktreeLineage = useCallback(
|
||||
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item.worktreeId)),
|
||||
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item)),
|
||||
[toggleCollapsed]
|
||||
)
|
||||
const { sections, rawSections, uniqueRepos, uniqueRepoColors } = useWorkspaceSections({
|
||||
@@ -820,7 +801,7 @@ export function HostScreen({
|
||||
state: connState,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt,
|
||||
pendingPath: pendingConnectionPath
|
||||
...relayRecovery
|
||||
})
|
||||
return (
|
||||
<>
|
||||
@@ -1099,8 +1080,8 @@ export function HostScreen({
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Auth failed banner */}
|
||||
{connState === 'auth-failed' && (
|
||||
{/* Auth failed: a latched relay rejection must reach the same re-pair affordance. */}
|
||||
{(connState === 'auth-failed' || relayRecovery.pairingRejected) && (
|
||||
<AuthFailedBanner
|
||||
canRetry={!!hostId}
|
||||
onRetry={() => hostId && void forceReconnectHost(hostId)}
|
||||
@@ -1146,7 +1127,7 @@ export function HostScreen({
|
||||
<SectionList
|
||||
ref={sectionListRef}
|
||||
sections={sections}
|
||||
keyExtractor={(w) => w.sectionListKey ?? w.worktreeId}
|
||||
keyExtractor={(w) => w.sectionListKey ?? getWorktreeRowIdentity(w)}
|
||||
stickySectionHeadersEnabled={false}
|
||||
// Why: keep the search IME up while tapping clear / scrolling results.
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -1355,7 +1336,9 @@ export function HostScreen({
|
||||
icon: Moon,
|
||||
onPress: () => {
|
||||
if (client) {
|
||||
setSleptIds((prev) => new Set(prev).add(actionTarget.worktreeId))
|
||||
setSleptIds((prev) =>
|
||||
new Set(prev).add(getWorktreeRowIdentity(actionTarget))
|
||||
)
|
||||
void client
|
||||
.sendRequest('worktree.sleep', {
|
||||
worktree: `id:${actionTarget.worktreeId}`
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
usePendingConnectionPath,
|
||||
useRelayRecoveryStatus,
|
||||
useReconnectAttempt
|
||||
} from '../../../../src/transport/client-context-connection-metrics'
|
||||
import {
|
||||
@@ -211,6 +211,7 @@ import {
|
||||
confirmsMirroredTabSelection,
|
||||
type AppliedSnapshotMarker
|
||||
} from '../../../../src/session/session-tab-snapshot-gate'
|
||||
import { resolveActiveSessionTab } from '../../../../src/session/active-session-tab'
|
||||
import {
|
||||
createInitialSessionAutoCreateState,
|
||||
useInitialSessionTerminalAutoCreate,
|
||||
@@ -286,6 +287,7 @@ import {
|
||||
import { colors } from '../../../../src/theme/mobile-theme'
|
||||
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
|
||||
import { styles } from '../../../../src/session/mobile-session-styles'
|
||||
import { DiffLineRow } from '../../../../src/session/DiffLineRow'
|
||||
import type { DiffComment } from '../../../../../src/shared/diff-comment-types'
|
||||
import type { TerminalQuickCommand } from '../../../../../src/shared/terminal-quick-command-types'
|
||||
import type {
|
||||
@@ -311,146 +313,6 @@ import type {
|
||||
|
||||
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
|
||||
|
||||
function DiffLineRow({
|
||||
line,
|
||||
title,
|
||||
index,
|
||||
comments,
|
||||
activeCommentLine,
|
||||
commentDraft,
|
||||
commentsBusy,
|
||||
onStartComment,
|
||||
onCancelComment,
|
||||
onDraftChange,
|
||||
onSubmitComment,
|
||||
onDeleteComment
|
||||
}: {
|
||||
line: RenderableDiffLine
|
||||
title: string
|
||||
index: number
|
||||
comments: DiffComment[]
|
||||
activeCommentLine: number | null
|
||||
commentDraft: string
|
||||
commentsBusy: boolean
|
||||
onStartComment: (lineNumber: number) => void
|
||||
onCancelComment: () => void
|
||||
onDraftChange: (value: string) => void
|
||||
onSubmitComment: (lineNumber: number) => void
|
||||
onDeleteComment: (commentId: string) => void
|
||||
}) {
|
||||
const commentLine = line.newLineNumber
|
||||
const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
|
||||
const canComment = commentLine !== undefined
|
||||
// Why: review notes anchor to the modified side, so show that line number in the single mobile gutter.
|
||||
const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? ''
|
||||
return (
|
||||
<View style={styles.diffLineBlock}>
|
||||
<View
|
||||
style={[
|
||||
styles.diffLine,
|
||||
line.kind === 'add' && styles.diffLineAdded,
|
||||
line.kind === 'delete' && styles.diffLineDeleted
|
||||
]}
|
||||
>
|
||||
<Text style={styles.diffGutter}>{gutterLineNumber}</Text>
|
||||
<Text
|
||||
selectable
|
||||
style={styles.diffText}
|
||||
accessibilityLabel={`${title} diff line ${index + 1}`}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.diffPrefix,
|
||||
line.kind === 'add' && styles.diffPrefixAdded,
|
||||
line.kind === 'delete' && styles.diffPrefixDeleted
|
||||
]}
|
||||
>
|
||||
{line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
|
||||
</Text>
|
||||
<MobileSyntaxSegments segments={line.segments} />
|
||||
</Text>
|
||||
{canComment ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.diffCommentAddButton,
|
||||
pressed && styles.diffCommentAddButtonPressed,
|
||||
commentsBusy && styles.diffCommentButtonDisabled
|
||||
]}
|
||||
disabled={commentsBusy}
|
||||
onPress={() => {
|
||||
if (commentLine !== undefined) {
|
||||
onStartComment(commentLine)
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={`Add note on line ${commentLine}`}
|
||||
>
|
||||
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{comments.length > 0 ? (
|
||||
<View style={styles.diffCommentList}>
|
||||
{comments.map((comment) => (
|
||||
<View key={comment.id} style={styles.diffCommentCard}>
|
||||
<View style={styles.diffCommentHeader}>
|
||||
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
|
||||
<Text style={styles.diffCommentMeta}>Line {comment.lineNumber}</Text>
|
||||
<Pressable
|
||||
style={styles.diffCommentDeleteButton}
|
||||
disabled={commentsBusy}
|
||||
onPress={() => onDeleteComment(comment.id)}
|
||||
accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
|
||||
>
|
||||
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.diffCommentBody}>{comment.body}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{isCommenting ? (
|
||||
<View style={styles.diffCommentComposer}>
|
||||
<TextInput
|
||||
style={[styles.textInput, styles.diffCommentInput]}
|
||||
value={commentDraft}
|
||||
onChangeText={onDraftChange}
|
||||
placeholder="Add review note"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
editable={!commentsBusy}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
autoFocus
|
||||
/>
|
||||
<View style={styles.diffCommentComposerActions}>
|
||||
<Pressable
|
||||
style={styles.diffCommentSecondaryAction}
|
||||
disabled={commentsBusy}
|
||||
onPress={onCancelComment}
|
||||
>
|
||||
<Text style={styles.diffCommentSecondaryText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.diffCommentPrimaryAction,
|
||||
(!commentDraft.trim() || commentsBusy) && styles.diffCommentButtonDisabled
|
||||
]}
|
||||
disabled={!commentDraft.trim() || commentsBusy}
|
||||
onPress={() => {
|
||||
if (commentLine !== undefined) {
|
||||
onSubmitComment(commentLine)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.diffCommentPrimaryText}>Save note</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function FileReader({
|
||||
doc,
|
||||
title,
|
||||
@@ -744,7 +606,7 @@ export default function SessionScreen() {
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const pendingConnectionPath = usePendingConnectionPath(hostId)
|
||||
const relayRecovery = useRelayRecoveryStatus(hostId)
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({
|
||||
client,
|
||||
@@ -940,6 +802,8 @@ export default function SessionScreen() {
|
||||
const activeHandleRef = useRef<string | null>(null)
|
||||
const activeSessionTabTypeRef = useRef<MobileSessionTabType | null>(null)
|
||||
const pendingActiveSessionTabIdRef = useRef<string | null>(null)
|
||||
// Why: survive transient snapshot gaps so the device's own tab pick can re-bind.
|
||||
const selectedSessionTabIdRef = useRef<string | null>(null)
|
||||
const pendingActiveTerminalHandleRef = useRef<string | null>(null)
|
||||
// Why: remember the page id to activate its session tab once it syncs (bridge auto-activate flags only webContents, not the app-level active tab).
|
||||
const pendingBrowserFocusPageIdRef = useRef<string | null>(null)
|
||||
@@ -1748,26 +1612,28 @@ export default function SessionScreen() {
|
||||
|
||||
const snapshotActive = nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null
|
||||
const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current
|
||||
const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current
|
||||
let active = snapshotActive
|
||||
let selectionSource = 'snapshot'
|
||||
if (pendingActiveSessionTabId) {
|
||||
if (snapshotActive?.id === pendingActiveSessionTabId) {
|
||||
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
|
||||
pendingActiveSessionTabIdRef.current = null
|
||||
} else {
|
||||
selectionSource = 'pending-tab-local-ack'
|
||||
}
|
||||
} else {
|
||||
const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId)
|
||||
if (pendingTab) {
|
||||
// Why: desktop tab snapshots can lag a mobile tap mid-activate-RPC; keep the local selection to avoid snapping back.
|
||||
active = pendingTab
|
||||
selectionSource = 'pending-tab'
|
||||
} else {
|
||||
pendingActiveSessionTabIdRef.current = null
|
||||
}
|
||||
}
|
||||
const followsHost = result.navigationIntent === 'follow'
|
||||
const pendingActiveTerminalHandle = followsHost
|
||||
? null
|
||||
: pendingActiveTerminalHandleRef.current
|
||||
if (followsHost) {
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
pendingBrowserFocusPageIdRef.current = null
|
||||
}
|
||||
const resolved = resolveActiveSessionTab(nextTabs, {
|
||||
pendingActiveSessionTabId,
|
||||
selectedSessionTabId: selectedSessionTabIdRef.current,
|
||||
navigationIntent: result.navigationIntent
|
||||
})
|
||||
let active = resolved.activeTab
|
||||
let selectionSource: string = resolved.selectionSource
|
||||
if (resolved.clearPendingActiveSessionTabId) {
|
||||
const localAck =
|
||||
!followsHost &&
|
||||
snapshotActive?.id === pendingActiveSessionTabId &&
|
||||
!confirmsMirroredTabSelection(result.publicationEpoch)
|
||||
selectionSource = localAck ? 'pending-tab-local-ack' : selectionSource
|
||||
pendingActiveSessionTabIdRef.current = localAck ? pendingActiveSessionTabId : null
|
||||
}
|
||||
if (pendingActiveTerminalHandle) {
|
||||
const pendingTerminalTab = nextTabs.find(
|
||||
@@ -1777,14 +1643,15 @@ export default function SessionScreen() {
|
||||
const pendingTerminalExists = mergedTerminalsForActive.some(
|
||||
(terminal) => terminal.handle === pendingActiveTerminalHandle
|
||||
)
|
||||
if (
|
||||
snapshotActive?.type === 'terminal' &&
|
||||
snapshotActive.terminal === pendingActiveTerminalHandle
|
||||
) {
|
||||
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
} else {
|
||||
if (active?.type === 'terminal' && active.terminal === pendingActiveTerminalHandle) {
|
||||
if (
|
||||
snapshotActive?.type === 'terminal' &&
|
||||
snapshotActive.terminal === pendingActiveTerminalHandle &&
|
||||
!confirmsMirroredTabSelection(result.publicationEpoch)
|
||||
) {
|
||||
selectionSource = 'pending-handle-local-ack'
|
||||
} else {
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
}
|
||||
} else if (pendingTerminalTab) {
|
||||
// Why: desktop active flags lag a mobile tap; key by handle too, as fallback PTY tabs lack a stable tab id at startup.
|
||||
@@ -1807,6 +1674,9 @@ export default function SessionScreen() {
|
||||
}
|
||||
}
|
||||
diagnostics.tabsApplied(result, nextTabs, active, selectionSource)
|
||||
if (!resolved.retainSelectedSessionTabId || active !== resolved.activeTab) {
|
||||
selectedSessionTabIdRef.current = active?.id ?? null
|
||||
}
|
||||
activeSessionTabTypeRef.current = active?.type ?? null
|
||||
activeSessionTabIdRef.current = active?.id ?? null
|
||||
setActiveSessionTabId(active?.id ?? null)
|
||||
@@ -2641,6 +2511,7 @@ export default function SessionScreen() {
|
||||
activeHandleRef.current = null
|
||||
activeSessionTabTypeRef.current = null
|
||||
pendingActiveSessionTabIdRef.current = null
|
||||
selectedSessionTabIdRef.current = null
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
pendingBrowserFocusPageIdRef.current = null
|
||||
pendingTerminalActivationAttemptRef.current = null
|
||||
@@ -4107,6 +3978,8 @@ export default function SessionScreen() {
|
||||
// so comparing against the ref keeps the anchor from being nulled out.
|
||||
if (activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0) {
|
||||
activeSessionTabTypeRef.current = null
|
||||
// Why: an explicit close is not a transient gap; drop the sticky pick so the snapshot picks the next tab.
|
||||
selectedSessionTabIdRef.current = null
|
||||
activeSessionTabIdRef.current = null
|
||||
setActiveSessionTabId(null)
|
||||
activeHandleRef.current = null
|
||||
@@ -4220,7 +4093,7 @@ export default function SessionScreen() {
|
||||
reconnectAttempts,
|
||||
lastConnectedAt,
|
||||
endpoint: hostEndpoint,
|
||||
pendingPath: pendingConnectionPath
|
||||
...relayRecovery
|
||||
})
|
||||
const showConnectionRetry =
|
||||
connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable'
|
||||
@@ -5017,7 +4890,9 @@ export default function SessionScreen() {
|
||||
ref={liveInputRef}
|
||||
style={styles.liveInputCapture}
|
||||
value={liveInputCapture}
|
||||
onChangeText={handleLiveInputChange}
|
||||
// Why onChange, not onChangeText: only the raw native event carries the
|
||||
// marked-text report that says whether this text is still preedit.
|
||||
onChange={handleLiveInputChange}
|
||||
onKeyPress={handleLiveInputKeyPress}
|
||||
onSubmitEditing={handleLiveInputSubmit}
|
||||
placeholder=""
|
||||
|
||||
@@ -37,7 +37,7 @@ import type { RpcSuccess } from '../../../src/transport/types'
|
||||
import { useHostClient } from '../../../src/transport/client-context'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
usePendingConnectionPath,
|
||||
useRelayRecoveryStatus,
|
||||
useReconnectAttempt
|
||||
} from '../../../src/transport/client-context-connection-metrics'
|
||||
import { classifyConnection } from '../../../src/transport/connection-health'
|
||||
@@ -2074,7 +2074,7 @@ export default function MobileTasksScreen() {
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const pendingConnectionPath = usePendingConnectionPath(hostId)
|
||||
const relayRecovery = useRelayRecoveryStatus(hostId)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const loadGenerationRef = useRef(0)
|
||||
const taskResumeRef = useRef<TaskResumeState>({})
|
||||
@@ -8676,7 +8676,7 @@ export default function MobileTasksScreen() {
|
||||
state: connState,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt,
|
||||
pendingPath: pendingConnectionPath
|
||||
...relayRecovery
|
||||
})
|
||||
const emptyLabel =
|
||||
connState !== 'connected'
|
||||
|
||||
@@ -263,6 +263,13 @@ export default function HomeScreen() {
|
||||
() => Object.fromEntries(allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])),
|
||||
[allClients]
|
||||
)
|
||||
const hostPairingRejected = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
allClients.map(({ hostId, pairingRejected }) => [hostId, pairingRejected])
|
||||
),
|
||||
[allClients]
|
||||
)
|
||||
const disconnectHostClient = useDisconnectHostClient()
|
||||
const forgetHostClient = useForgetHostClient()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
@@ -770,7 +777,8 @@ export default function HomeScreen() {
|
||||
reconnectAttempts: attempts,
|
||||
lastConnectedAt,
|
||||
endpoint: item.endpoint,
|
||||
pendingPath: hostPendingPaths[item.id] ?? null
|
||||
pendingPath: hostPendingPaths[item.id] ?? null,
|
||||
pairingRejected: hostPairingRejected[item.id] ?? false
|
||||
})
|
||||
return (
|
||||
<MobileHostCard
|
||||
|
||||
@@ -261,6 +261,12 @@ platform :ios do
|
||||
distribute_only: true,
|
||||
skip_waiting_for_build_processing: false,
|
||||
wait_processing_timeout_duration: TESTFLIGHT_PROCESSING_TIMEOUT_SECONDS,
|
||||
# submit_beta_review is on by default, so a superseded build of this same
|
||||
# version train still sitting in beta review blocks the new submission
|
||||
# ("Another build is in review" killed the 2026-08-05/08-06 runs).
|
||||
# Fastlane filters by this build's preReleaseVersion, so it can only
|
||||
# expire a build of the train this run is already replacing.
|
||||
reject_build_waiting_for_review: true,
|
||||
distribute_external: true,
|
||||
groups: TESTFLIGHT_GROUPS,
|
||||
notify_external_testers: true,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
require "minitest/autorun"
|
||||
require "ripper"
|
||||
|
||||
# Pins the upload_to_testflight arguments that two release outages proved a
|
||||
# release cannot run without. `bundle exec fastlane lanes` loads and parses the
|
||||
# Fastfile but exits 0 with either argument missing — both failures only surface
|
||||
# once a real release is already mid-flight — so the arguments are asserted here
|
||||
# statically instead.
|
||||
module FastfileTestflightArguments
|
||||
module_function
|
||||
|
||||
# [{ "app_platform" => "ios", ... }] for every upload_to_testflight call in
|
||||
# `source`. Values are flattened to the first meaningful token so a keyword's
|
||||
# presence and its literal can both be asserted.
|
||||
def calls(source, action: "upload_to_testflight")
|
||||
found = []
|
||||
each_node(Ripper.sexp(source) || []) do |node|
|
||||
next unless node[0] == :method_add_arg
|
||||
next unless identifier(node[1]) == action
|
||||
|
||||
found << keyword_arguments(node[2])
|
||||
end
|
||||
found
|
||||
end
|
||||
|
||||
def each_node(node, &block)
|
||||
return unless node.is_a?(Array)
|
||||
|
||||
block.call(node) if node[0].is_a?(Symbol)
|
||||
node.each { |child| each_node(child, &block) }
|
||||
end
|
||||
|
||||
def identifier(node)
|
||||
return nil unless node.is_a?(Array) && node[0] == :fcall
|
||||
|
||||
token(node[1], %i[@ident @const])
|
||||
end
|
||||
|
||||
def keyword_arguments(node)
|
||||
arguments = {}
|
||||
each_node(node) do |child|
|
||||
next unless child[0] == :assoc_new
|
||||
|
||||
label = token(child[1], [:@label])
|
||||
arguments[label.delete_suffix(":")] = describe_value(child[2]) if label
|
||||
end
|
||||
arguments
|
||||
end
|
||||
|
||||
# Strings collapse to their content; everything else (true, a constant, a
|
||||
# local) collapses to its first token, which is enough to assert intent.
|
||||
def describe_value(node)
|
||||
contents = []
|
||||
each_node(node) { |child| contents << child[1] if child[0] == :@tstring_content }
|
||||
return contents.join unless contents.empty?
|
||||
|
||||
token(node, %i[@kw @const @ident @int])
|
||||
end
|
||||
|
||||
def token(node, types)
|
||||
found = nil
|
||||
each_node(node) do |child|
|
||||
found ||= child[1] if types.include?(child[0]) && child[1].is_a?(String)
|
||||
end
|
||||
found
|
||||
end
|
||||
end
|
||||
|
||||
class FastfileTestflightArgumentsTest < Minitest::Test
|
||||
FASTFILE = File.expand_path("Fastfile", __dir__).freeze
|
||||
|
||||
def setup
|
||||
@calls = FastfileTestflightArguments.calls(File.read(FASTFILE))
|
||||
end
|
||||
|
||||
# Anchor: a parse that silently finds nothing would make every other
|
||||
# assertion here pass vacuously.
|
||||
def test_finds_the_upload_and_distribute_calls
|
||||
assert_operator(@calls.size, :>=, 2, "expected upload_to_testflight in the build and distribute lanes")
|
||||
end
|
||||
|
||||
def test_distribute_only_uploads_pass_the_app_platform
|
||||
distribute_only_calls.each do |arguments|
|
||||
# Without a local .ipa to sniff, pilot falls through to an interactive
|
||||
# platform prompt and crashes the ubuntu distribute job (#14087).
|
||||
assert_equal("ios", arguments["app_platform"])
|
||||
end
|
||||
end
|
||||
|
||||
def test_distribute_only_uploads_clear_a_build_stuck_in_beta_review
|
||||
distribute_only_calls.each do |arguments|
|
||||
# submit_beta_review defaults on, so a superseded same-train build left in
|
||||
# beta review fails the submission with "Another build is in review".
|
||||
assert_equal("true", arguments["reject_build_waiting_for_review"])
|
||||
end
|
||||
end
|
||||
|
||||
# The detector itself must be able to go red, or it is not a gate.
|
||||
def test_reports_a_distribute_only_call_that_omits_the_app_platform
|
||||
source = <<~RUBY
|
||||
lane :distribute_testflight do
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
distribute_only: true,
|
||||
groups: TESTFLIGHT_GROUPS,
|
||||
)
|
||||
end
|
||||
RUBY
|
||||
|
||||
arguments = FastfileTestflightArguments.calls(source).first
|
||||
|
||||
assert_equal("true", arguments["distribute_only"])
|
||||
assert_nil(arguments["app_platform"])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Asserting on an empty selection would pass vacuously, so the lane's presence
|
||||
# is part of the contract.
|
||||
def distribute_only_calls
|
||||
calls = @calls.select { |arguments| arguments["distribute_only"] == "true" }
|
||||
refute_empty(calls, "expected a distribute_only upload_to_testflight call")
|
||||
|
||||
calls
|
||||
end
|
||||
end
|
||||
@@ -85,6 +85,9 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"xcode>uuid": "11.1.1"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"react-native@0.83.9": "patches/react-native@0.83.9.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
index d02e003a439e410ddab597b2c036256c80db0395..3caff21b0d00bfbc6dea3f8470e812b8343ac3a2 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
@@ -703,6 +703,7 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
|
||||
{
|
||||
return {
|
||||
.text = RCTStringFromNSString(_backedTextInputView.attributedText.string),
|
||||
+ .isComposing = _backedTextInputView.markedTextRange != nil,
|
||||
.selectionRange = [self _selectionRange],
|
||||
.eventCount = static_cast<int>(_mostRecentEventCount),
|
||||
.contentOffset = RCTPointFromCGPoint(_backedTextInputView.contentOffset),
|
||||
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
index a9bc219f8bf729111e907423ca61b991fd712b46..60e9c6f54edb295295d6e48d1d554499106b7e57 100644
|
||||
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
@@ -24,6 +24,10 @@ static jsi::Value textInputMetricsPayload(
|
||||
|
||||
payload.setProperty(runtime, "eventCount", textInputMetrics.eventCount);
|
||||
|
||||
+ if (textInputMetrics.isComposing.has_value()) {
|
||||
+ payload.setProperty(runtime, "isComposing", *textInputMetrics.isComposing);
|
||||
+ }
|
||||
+
|
||||
if (includeSelectionState) {
|
||||
auto selection = jsi::Object(runtime);
|
||||
selection.setProperty(
|
||||
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
index a3f2e01c130c1a53aee0b39e72927d21f2a6973d..c9d34f8b42644800b5c85e5be45b6ebfbe94a54d 100644
|
||||
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
+#include <optional>
|
||||
+
|
||||
#include <react/renderer/attributedstring/AttributedString.h>
|
||||
#include <react/renderer/components/view/ViewEventEmitter.h>
|
||||
|
||||
@@ -18,6 +20,7 @@ class TextInputEventEmitter : public ViewEventEmitter {
|
||||
|
||||
struct Metrics {
|
||||
std::string text;
|
||||
+ std::optional<bool> isComposing;
|
||||
AttributedString::Range selectionRange;
|
||||
// ScrollView-like metrics
|
||||
Size contentSize;
|
||||
Generated
+5
@@ -7,6 +7,11 @@ settings:
|
||||
overrides:
|
||||
xcode>uuid: 11.1.1
|
||||
|
||||
patchedDependencies:
|
||||
react-native@0.83.9:
|
||||
hash: a6502e7d63769bf1fd639cbbaab1cc0b8aebef31d22048e8f2186fb399d0c16e
|
||||
path: patches/react-native@0.83.9.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
|
||||
export type MobileSpeechSetup = RuntimeSpeechSetupState
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { MessageSquare, Plus, X } from 'lucide-react-native'
|
||||
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-session-styles'
|
||||
import type { RenderableDiffLine } from './mobile-session-route-types'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
|
||||
// One row of a rendered diff, with its inline note thread. Lifted out of the session screen
|
||||
// because the row owns a whole comment-draft interaction of its own.
|
||||
|
||||
export function DiffLineRow({
|
||||
line,
|
||||
title,
|
||||
index,
|
||||
comments,
|
||||
activeCommentLine,
|
||||
commentDraft,
|
||||
commentsBusy,
|
||||
onStartComment,
|
||||
onCancelComment,
|
||||
onDraftChange,
|
||||
onSubmitComment,
|
||||
onDeleteComment
|
||||
}: {
|
||||
line: RenderableDiffLine
|
||||
title: string
|
||||
index: number
|
||||
comments: DiffComment[]
|
||||
activeCommentLine: number | null
|
||||
commentDraft: string
|
||||
commentsBusy: boolean
|
||||
onStartComment: (lineNumber: number) => void
|
||||
onCancelComment: () => void
|
||||
onDraftChange: (value: string) => void
|
||||
onSubmitComment: (lineNumber: number) => void
|
||||
onDeleteComment: (commentId: string) => void
|
||||
}) {
|
||||
const commentLine = line.newLineNumber
|
||||
const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
|
||||
const canComment = commentLine !== undefined
|
||||
// Why: review notes anchor to the modified side, so show that line number in the single mobile gutter.
|
||||
const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? ''
|
||||
return (
|
||||
<View style={styles.diffLineBlock}>
|
||||
<View
|
||||
style={[
|
||||
styles.diffLine,
|
||||
line.kind === 'add' && styles.diffLineAdded,
|
||||
line.kind === 'delete' && styles.diffLineDeleted
|
||||
]}
|
||||
>
|
||||
<Text style={styles.diffGutter}>{gutterLineNumber}</Text>
|
||||
<Text
|
||||
selectable
|
||||
style={styles.diffText}
|
||||
accessibilityLabel={`${title} diff line ${index + 1}`}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.diffPrefix,
|
||||
line.kind === 'add' && styles.diffPrefixAdded,
|
||||
line.kind === 'delete' && styles.diffPrefixDeleted
|
||||
]}
|
||||
>
|
||||
{line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
|
||||
</Text>
|
||||
<MobileSyntaxSegments segments={line.segments} />
|
||||
</Text>
|
||||
{canComment ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.diffCommentAddButton,
|
||||
pressed && styles.diffCommentAddButtonPressed,
|
||||
commentsBusy && styles.diffCommentButtonDisabled
|
||||
]}
|
||||
disabled={commentsBusy}
|
||||
onPress={() => {
|
||||
if (commentLine !== undefined) {
|
||||
onStartComment(commentLine)
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={`Add note on line ${commentLine}`}
|
||||
>
|
||||
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{comments.length > 0 ? (
|
||||
<View style={styles.diffCommentList}>
|
||||
{comments.map((comment) => (
|
||||
<View key={comment.id} style={styles.diffCommentCard}>
|
||||
<View style={styles.diffCommentHeader}>
|
||||
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
|
||||
<Text style={styles.diffCommentMeta}>Line {comment.lineNumber}</Text>
|
||||
<Pressable
|
||||
style={styles.diffCommentDeleteButton}
|
||||
disabled={commentsBusy}
|
||||
onPress={() => onDeleteComment(comment.id)}
|
||||
accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
|
||||
>
|
||||
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.diffCommentBody}>{comment.body}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{isCommenting ? (
|
||||
<View style={styles.diffCommentComposer}>
|
||||
<TextInput
|
||||
style={[styles.textInput, styles.diffCommentInput]}
|
||||
value={commentDraft}
|
||||
onChangeText={onDraftChange}
|
||||
placeholder="Add review note"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
editable={!commentsBusy}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
autoFocus
|
||||
/>
|
||||
<View style={styles.diffCommentComposerActions}>
|
||||
<Pressable
|
||||
style={styles.diffCommentSecondaryAction}
|
||||
disabled={commentsBusy}
|
||||
onPress={onCancelComment}
|
||||
>
|
||||
<Text style={styles.diffCommentSecondaryText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.diffCommentPrimaryAction,
|
||||
(!commentDraft.trim() || commentsBusy) && styles.diffCommentButtonDisabled
|
||||
]}
|
||||
disabled={!commentDraft.trim() || commentsBusy}
|
||||
onPress={() => {
|
||||
if (commentLine !== undefined) {
|
||||
onSubmitComment(commentLine)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.diffCommentPrimaryText}>Save note</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -84,7 +84,9 @@ describe('MobileNativeChatComposer', () => {
|
||||
|
||||
await act(async () => sendButton().props.onPress())
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith(' hello')
|
||||
// Verbatim: the send seam trims for the wire, so a rejected send can hand
|
||||
// back the draft byte-for-byte (#14819).
|
||||
expect(onSend).toHaveBeenCalledWith(' hello ')
|
||||
expect(onChangeText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -120,7 +122,7 @@ describe('MobileNativeChatComposer', () => {
|
||||
)
|
||||
})
|
||||
await act(async () => sendButton().props.onPress())
|
||||
expect(onSend).toHaveBeenCalledWith(' /clear is prose')
|
||||
expect(onSend).toHaveBeenCalledWith(' /clear is prose ')
|
||||
})
|
||||
|
||||
it('locks the option pickers while a composer send is in flight', async () => {
|
||||
@@ -219,7 +221,7 @@ describe('MobileNativeChatComposer', () => {
|
||||
|
||||
await act(async () => sendButton().props.onPress())
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith(' hello')
|
||||
expect(onSend).toHaveBeenCalledWith(' hello ')
|
||||
expect(onChangeText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -154,7 +154,9 @@ export function MobileNativeChatComposer({
|
||||
sendingRef.current = true
|
||||
setSending(true)
|
||||
try {
|
||||
const accepted = await onSend(value.trimEnd())
|
||||
// Raw, not trimmed: the send seam owns the wire trim, and a rejection has
|
||||
// to hand the user back exactly what they typed (#14819).
|
||||
const accepted = await onSend(value)
|
||||
if (accepted) {
|
||||
setCursor(0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveActiveSessionTab } from './active-session-tab'
|
||||
|
||||
type Tab = { id: string; type: 'terminal' | 'browser'; isActive: boolean }
|
||||
|
||||
function terminalTab(id: string, isActive: boolean): Tab {
|
||||
return { id, type: 'terminal', isActive }
|
||||
}
|
||||
|
||||
function browserTab(id: string, isActive: boolean): Tab {
|
||||
return { id, type: 'browser', isActive }
|
||||
}
|
||||
|
||||
describe('resolveActiveSessionTab', () => {
|
||||
it('keeps the device on its browser tab when a republication flags the agent terminal active', () => {
|
||||
const result = resolveActiveSessionTab(
|
||||
[terminalTab('agent', true), browserTab('browser', false)],
|
||||
{
|
||||
pendingActiveSessionTabId: null,
|
||||
selectedSessionTabId: 'browser'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.activeTab?.id).toBe('browser')
|
||||
expect(result.selectionSource).toBe('selected-tab')
|
||||
expect(result.retainSelectedSessionTabId).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the device on a terminal tab when an agent turn flags another terminal active', () => {
|
||||
const result = resolveActiveSessionTab(
|
||||
[terminalTab('agent', true), terminalTab('shell', false)],
|
||||
{
|
||||
pendingActiveSessionTabId: null,
|
||||
selectedSessionTabId: 'shell'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.activeTab?.id).toBe('shell')
|
||||
})
|
||||
|
||||
it('follows the host when it explicitly navigates this device', () => {
|
||||
const result = resolveActiveSessionTab(
|
||||
[terminalTab('agent', true), browserTab('browser', false)],
|
||||
{
|
||||
pendingActiveSessionTabId: null,
|
||||
selectedSessionTabId: 'browser',
|
||||
navigationIntent: 'follow'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.activeTab?.id).toBe('agent')
|
||||
expect(result.selectionSource).toBe('navigation-intent')
|
||||
expect(result.retainSelectedSessionTabId).toBe(false)
|
||||
})
|
||||
|
||||
it('lets explicit follow supersede an in-flight local activation', () => {
|
||||
const result = resolveActiveSessionTab(
|
||||
[terminalTab('agent', true), browserTab('browser', false)],
|
||||
{
|
||||
pendingActiveSessionTabId: 'browser',
|
||||
selectedSessionTabId: 'browser',
|
||||
navigationIntent: 'follow'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.activeTab?.id).toBe('agent')
|
||||
expect(result.selectionSource).toBe('navigation-intent')
|
||||
expect(result.clearPendingActiveSessionTabId).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the snapshot but retains the pick while the selected tab is missing', () => {
|
||||
const gap = resolveActiveSessionTab([terminalTab('agent', true)], {
|
||||
pendingActiveSessionTabId: null,
|
||||
selectedSessionTabId: 'browser'
|
||||
})
|
||||
|
||||
expect(gap.activeTab?.id).toBe('agent')
|
||||
expect(gap.retainSelectedSessionTabId).toBe(true)
|
||||
|
||||
// The guest re-registers and the tab comes back: focus must return to it.
|
||||
const restored = resolveActiveSessionTab(
|
||||
[terminalTab('agent', true), browserTab('browser', false)],
|
||||
{ pendingActiveSessionTabId: null, selectedSessionTabId: 'browser' }
|
||||
)
|
||||
expect(restored.activeTab?.id).toBe('browser')
|
||||
})
|
||||
|
||||
it('lets a pending activation win until the snapshot catches up', () => {
|
||||
const result = resolveActiveSessionTab(
|
||||
[terminalTab('agent', true), browserTab('browser', false)],
|
||||
{
|
||||
pendingActiveSessionTabId: 'browser',
|
||||
selectedSessionTabId: 'agent'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.activeTab?.id).toBe('browser')
|
||||
expect(result.selectionSource).toBe('pending-tab')
|
||||
expect(result.clearPendingActiveSessionTabId).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the pending marker once the snapshot acknowledges it', () => {
|
||||
const result = resolveActiveSessionTab(
|
||||
[terminalTab('agent', false), browserTab('browser', true)],
|
||||
{
|
||||
pendingActiveSessionTabId: 'browser',
|
||||
selectedSessionTabId: 'agent'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.activeTab?.id).toBe('browser')
|
||||
expect(result.clearPendingActiveSessionTabId).toBe(true)
|
||||
})
|
||||
|
||||
it('clears a stale pending marker whose tab is gone', () => {
|
||||
const result = resolveActiveSessionTab([terminalTab('agent', true)], {
|
||||
pendingActiveSessionTabId: 'removed',
|
||||
selectedSessionTabId: null
|
||||
})
|
||||
|
||||
expect(result.activeTab?.id).toBe('agent')
|
||||
expect(result.clearPendingActiveSessionTabId).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the snapshot when the device has no pick yet', () => {
|
||||
const result = resolveActiveSessionTab(
|
||||
[terminalTab('agent', false), browserTab('browser', true)],
|
||||
{
|
||||
pendingActiveSessionTabId: null,
|
||||
selectedSessionTabId: null
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.activeTab?.id).toBe('browser')
|
||||
expect(result.selectionSource).toBe('snapshot')
|
||||
})
|
||||
|
||||
it('returns null for an empty snapshot', () => {
|
||||
expect(
|
||||
resolveActiveSessionTab([], { pendingActiveSessionTabId: null, selectedSessionTabId: 'x' })
|
||||
.activeTab
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
type SessionTabLike = {
|
||||
id: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type ActiveSessionTabSelectionSource =
|
||||
| 'snapshot'
|
||||
| 'pending-tab'
|
||||
| 'selected-tab'
|
||||
| 'navigation-intent'
|
||||
|
||||
export type ResolveActiveSessionTabResult<T extends SessionTabLike> = {
|
||||
activeTab: T | null
|
||||
selectionSource: ActiveSessionTabSelectionSource
|
||||
clearPendingActiveSessionTabId: boolean
|
||||
retainSelectedSessionTabId: boolean
|
||||
}
|
||||
|
||||
export function resolveActiveSessionTab<T extends SessionTabLike>(
|
||||
tabs: readonly T[],
|
||||
opts: {
|
||||
pendingActiveSessionTabId: string | null
|
||||
selectedSessionTabId: string | null
|
||||
navigationIntent?: 'follow'
|
||||
}
|
||||
): ResolveActiveSessionTabResult<T> {
|
||||
const snapshotActive = tabs.find((tab) => tab.isActive) ?? tabs[0] ?? null
|
||||
const pendingActiveSessionTabId = opts.pendingActiveSessionTabId
|
||||
// Why: targeted follow is the only host action allowed to supersede phone-local intent.
|
||||
if (opts.navigationIntent === 'follow') {
|
||||
return {
|
||||
activeTab: snapshotActive,
|
||||
selectionSource: 'navigation-intent',
|
||||
clearPendingActiveSessionTabId: pendingActiveSessionTabId !== null,
|
||||
retainSelectedSessionTabId: false
|
||||
}
|
||||
}
|
||||
if (pendingActiveSessionTabId) {
|
||||
if (snapshotActive?.id === pendingActiveSessionTabId) {
|
||||
return {
|
||||
activeTab: snapshotActive,
|
||||
selectionSource: 'snapshot',
|
||||
clearPendingActiveSessionTabId: true,
|
||||
retainSelectedSessionTabId: false
|
||||
}
|
||||
}
|
||||
const pendingTab = tabs.find((tab) => tab.id === pendingActiveSessionTabId) ?? null
|
||||
if (pendingTab) {
|
||||
return {
|
||||
activeTab: pendingTab,
|
||||
selectionSource: 'pending-tab',
|
||||
clearPendingActiveSessionTabId: false,
|
||||
retainSelectedSessionTabId: false
|
||||
}
|
||||
}
|
||||
}
|
||||
const clearPendingActiveSessionTabId = pendingActiveSessionTabId !== null
|
||||
const selectedTab = opts.selectedSessionTabId
|
||||
? (tabs.find((tab) => tab.id === opts.selectedSessionTabId) ?? null)
|
||||
: null
|
||||
return {
|
||||
activeTab: selectedTab ?? snapshotActive,
|
||||
selectionSource: selectedTab ? 'selected-tab' : 'snapshot',
|
||||
clearPendingActiveSessionTabId,
|
||||
// Why: transient browser guest swaps must not erase the device's explicit pick.
|
||||
retainSelectedSessionTabId: Boolean(opts.selectedSessionTabId) && !selectedTab
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
saveMobileClipboardImageAsTempFile
|
||||
} from './mobile-clipboard-image'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
|
||||
function ok(id: string, result: unknown): RpcSuccess {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
|
||||
export const MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS = 24 * 1024 * 1024
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline'
|
||||
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'
|
||||
|
||||
function userTurn(id: string, text: string, timestamp: number): NativeChatMessage {
|
||||
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
|
||||
}
|
||||
|
||||
function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
function unresolved(
|
||||
id: string,
|
||||
text: string,
|
||||
expectedOccurrence = 1
|
||||
): MobileNativeChatPendingMessage {
|
||||
return { id, text, expectedOccurrence, baselineTailMessageId: null, baselineResolved: false }
|
||||
}
|
||||
|
||||
describe('rebaseMobileNativeChatPendingBaselines', () => {
|
||||
const history = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'passed', 1100)]
|
||||
|
||||
it('returns the same array when every baseline is already resolved', () => {
|
||||
const pending: MobileNativeChatPendingMessage[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
text: 'hi',
|
||||
expectedOccurrence: 1,
|
||||
baselineTailMessageId: 'm2',
|
||||
baselineResolved: true
|
||||
}
|
||||
]
|
||||
expect(rebaseMobileNativeChatPendingBaselines(history, pending)).toBe(pending)
|
||||
})
|
||||
|
||||
it('gives an unresolved send the loaded tail as its glue boundary', () => {
|
||||
expect(
|
||||
rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', 'run the tests')])
|
||||
).toEqual([
|
||||
{
|
||||
id: 'p1',
|
||||
text: 'run the tests',
|
||||
// NOT recounted against this read: the read may already carry this send's
|
||||
// own echo, and counting it as history would strand the bubble forever.
|
||||
expectedOccurrence: 1,
|
||||
baselineTailMessageId: 'm2',
|
||||
baselineResolved: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('pins to null when the authoritative transcript is empty', () => {
|
||||
expect(rebaseMobileNativeChatPendingBaselines([], [unresolved('p1', 'hi')])).toEqual([
|
||||
{
|
||||
id: 'p1',
|
||||
text: 'hi',
|
||||
expectedOccurrence: 1,
|
||||
baselineTailMessageId: null,
|
||||
baselineResolved: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the ordinals the queue already assigned', () => {
|
||||
expect(
|
||||
rebaseMobileNativeChatPendingBaselines(history, [
|
||||
unresolved('p1', 'run the tests', 1),
|
||||
unresolved('p2', 'run the tests', 2)
|
||||
]).map((item) => item.expectedOccurrence)
|
||||
).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('leaves already-resolved neighbours untouched', () => {
|
||||
const resolved: MobileNativeChatPendingMessage = {
|
||||
id: 'p1',
|
||||
text: 'run the tests',
|
||||
expectedOccurrence: 9,
|
||||
baselineTailMessageId: 'm1',
|
||||
baselineResolved: true
|
||||
}
|
||||
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
|
||||
resolved,
|
||||
unresolved('p2', 'run the tests', 2)
|
||||
])
|
||||
expect(rebased[0]).toBe(resolved)
|
||||
expect(rebased[1]).toEqual({
|
||||
id: 'p2',
|
||||
text: 'run the tests',
|
||||
expectedOccurrence: 2,
|
||||
baselineTailMessageId: 'm2',
|
||||
baselineResolved: true
|
||||
})
|
||||
})
|
||||
|
||||
// An unsettled read still shows this session's own retained history, so a send
|
||||
// made across a reconnect already owns a real boundary. Moving it onto the read
|
||||
// that follows pushes it past the send's own echo and strands the bubble.
|
||||
it('never moves a tail the send actually captured', () => {
|
||||
const captured: MobileNativeChatPendingMessage = {
|
||||
id: 'p1',
|
||||
text: 'run the tests',
|
||||
expectedOccurrence: 1,
|
||||
baselineTailMessageId: 'm1',
|
||||
baselineResolved: false
|
||||
}
|
||||
const rebased = rebaseMobileNativeChatPendingBaselines(history, [captured])
|
||||
expect(rebased[0]?.baselineTailMessageId).toBe('m1')
|
||||
expect(rebased[0]?.baselineResolved).toBe(true)
|
||||
})
|
||||
|
||||
// An image echo counts image turns AFTER its tail, so moving a real tail onto
|
||||
// this read would discard an echo the read already carries.
|
||||
it('keeps a caption-less image echo on the tail it actually captured', () => {
|
||||
const images = {
|
||||
...unresolved('p1', ''),
|
||||
baselineTailMessageId: 'm1',
|
||||
images: ['file:///a.png']
|
||||
}
|
||||
const rebased = rebaseMobileNativeChatPendingBaselines(history, [images])
|
||||
expect(rebased[0]?.baselineTailMessageId).toBe('m1')
|
||||
expect(rebased[0]?.expectedOccurrence).toBe(1)
|
||||
expect(rebased[0]?.baselineResolved).toBe(true)
|
||||
})
|
||||
|
||||
// A captioned image echo binds by an ordinal counted over the whole transcript.
|
||||
// Pinning a tail without recounting leaves it matching nothing, forever.
|
||||
it('leaves a captioned image echo entirely alone', () => {
|
||||
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
|
||||
{ ...unresolved('p1', 'here'), images: ['file:///a.png'] }
|
||||
])
|
||||
expect(rebased[0]?.baselineTailMessageId).toBe(null)
|
||||
expect(rebased[0]?.expectedOccurrence).toBe(1)
|
||||
expect(rebased[0]?.baselineResolved).toBe(true)
|
||||
})
|
||||
|
||||
// A supplied tail would sit at or after this send's own echo, and an image
|
||||
// echo counts turns AFTER its tail — so it would exclude the very row it is
|
||||
// waiting for, and image entries have no other retirement path.
|
||||
it('never supplies a tail to a caption-less image echo either', () => {
|
||||
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
|
||||
{ ...unresolved('p1', ''), images: ['file:///a.png'] },
|
||||
{ ...unresolved('p2', '', 2), images: ['file:///b.png'] }
|
||||
])
|
||||
expect(rebased.map((item) => item.baselineTailMessageId)).toEqual([null, null])
|
||||
expect(rebased.map((item) => item.expectedOccurrence)).toEqual([1, 2])
|
||||
expect(rebased.every((item) => item.baselineResolved)).toBe(true)
|
||||
})
|
||||
|
||||
// Same rule for a text-empty entry with no images: it reconciles by counting
|
||||
// image-source turns after its tail, so a supplied tail strands it too.
|
||||
it('never supplies a tail to a text-empty entry', () => {
|
||||
const rebased = rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', ' ')])
|
||||
expect(rebased[0]?.baselineTailMessageId).toBe(null)
|
||||
expect(rebased[0]?.baselineResolved).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile'
|
||||
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'
|
||||
|
||||
/**
|
||||
* Give the sends that never saw a transcript the boundary they lack, on the
|
||||
* first settled read.
|
||||
*
|
||||
* Such a send has no row to judge candidates against — which held it out of
|
||||
* matching entirely, stranding it as a queued bubble and, worse, as a permanent
|
||||
* glue barrier for its neighbours.
|
||||
*
|
||||
* Only a send that captured NO tail is pinned. An unsettled read still shows
|
||||
* this session's own retained history (see `createNativeChatTranscriptRetention`
|
||||
* — a reconnect or a failed read keeps the conversation on screen rather than
|
||||
* blanking it), so a send made then already owns a correct boundary. Moving it
|
||||
* onto this read would push it past the send's own echo and strand the bubble
|
||||
* for good.
|
||||
*
|
||||
* The ordinal is deliberately NOT recounted. The read can already carry the
|
||||
* send's own echo — a re-subscribe returns whatever exists now — and nothing
|
||||
* local separates that echo from an identical older prompt: the transport writes
|
||||
* keystrokes into a TUI and carries no message id, and row timestamps come from
|
||||
* the host while the send time comes from the phone. Counting it as history puts
|
||||
* the ordinal one past anything the transcript can supply.
|
||||
*
|
||||
* And only a TEXT-bearing send is given one. The glue matcher is the sole
|
||||
* consumer that a supplied tail helps; every other one is harmed by it. An image
|
||||
* echo reconciles by counting turns AFTER its tail, so a tail taken from a read
|
||||
* that already carries its echo excludes that echo and the bubble can never
|
||||
* retire — image entries have no other retirement path. A captioned one is worse
|
||||
* still: it binds by an ordinal counted over the whole transcript, so a tail
|
||||
* without a matching recount leaves it claiming nothing at all.
|
||||
*/
|
||||
export function rebaseMobileNativeChatPendingBaselines(
|
||||
messages: readonly NativeChatMessage[],
|
||||
current: MobileNativeChatPendingMessage[]
|
||||
): MobileNativeChatPendingMessage[] {
|
||||
if (current.every((item) => item.baselineResolved)) {
|
||||
return current
|
||||
}
|
||||
const baselineTailMessageId = messages.at(-1)?.id ?? null
|
||||
return current.map((item) => {
|
||||
if (item.baselineResolved) {
|
||||
return item
|
||||
}
|
||||
const reconcilesAgainstItsOwnTail =
|
||||
Boolean(item.images?.length) || normalizeReconcileText(item.text) === ''
|
||||
return item.baselineTailMessageId !== null || reconcilesAgainstItsOwnTail
|
||||
? { ...item, baselineResolved: true }
|
||||
: { ...item, baselineResolved: true, baselineTailMessageId }
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,11 @@ export type MobileNativeChatPendingMessage = {
|
||||
/** Local preview URIs carried by the send for its optimistic echo. */
|
||||
images?: string[]
|
||||
baselineTailMessageId: string | null
|
||||
/** Whether the transcript this baseline was captured from was already this
|
||||
* session's own history. A send issued mid-hydration is captured unresolved
|
||||
* and rebased onto the first authoritative read instead of reconciling
|
||||
* against rows that may belong to another tab. */
|
||||
baselineResolved: boolean
|
||||
}
|
||||
|
||||
export type MobileNativeChatSendOrigin = {
|
||||
@@ -13,6 +18,7 @@ export type MobileNativeChatSendOrigin = {
|
||||
normalizedText: string
|
||||
baselineOccurrences: number
|
||||
baselineTailMessageId: string | null
|
||||
baselineResolved: boolean
|
||||
}
|
||||
|
||||
type PendingByKey = Record<string, MobileNativeChatPendingMessage[]>
|
||||
@@ -56,6 +62,7 @@ export function appendMobileNativeChatPending(
|
||||
? expectedImageEchoOrdinal
|
||||
: origin.baselineOccurrences + earlierOutstanding + 1,
|
||||
baselineTailMessageId: origin.baselineTailMessageId,
|
||||
baselineResolved: origin.baselineResolved,
|
||||
...(images?.length ? { images } : {})
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'
|
||||
import {
|
||||
GLUE_SLIDE_BUDGET,
|
||||
retireLandedMobileNativeChatPending,
|
||||
selectGluedPendingIds
|
||||
} from './mobile-native-chat-pending-retirement'
|
||||
|
||||
const NO_IMAGE_ECHOES: ReadonlySet<string> = new Set()
|
||||
|
||||
function userTurn(id: string, text: string, timestamp: number): NativeChatMessage {
|
||||
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
|
||||
}
|
||||
|
||||
function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
/** A send captured with `tail` as the last transcript message it could see. */
|
||||
function pendingSend(
|
||||
id: string,
|
||||
text: string,
|
||||
tail: string | null,
|
||||
expectedOccurrence = 1,
|
||||
baselineResolved = true
|
||||
): MobileNativeChatPendingMessage {
|
||||
return { id, text, expectedOccurrence, baselineTailMessageId: tail, baselineResolved }
|
||||
}
|
||||
|
||||
function retiredIds(
|
||||
messages: readonly NativeChatMessage[],
|
||||
pending: readonly MobileNativeChatPendingMessage[]
|
||||
): string[] {
|
||||
return [...selectGluedPendingIds(messages, pending)].sort()
|
||||
}
|
||||
|
||||
describe('selectGluedPendingIds', () => {
|
||||
it('retires both bubbles when two rapid sends collapse into one user row', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
userTurn('m2', 'run the tests again', 5000)
|
||||
]
|
||||
const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
it('retires a glued row that concatenated with no separator at all', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
userTurn('m2', 'run the testsagain', 5000)
|
||||
]
|
||||
const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
it('retires three collapsed prompts in one row', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two three', 5000)]
|
||||
const pending = [
|
||||
pendingSend('p1', 'one', 'm1'),
|
||||
pendingSend('p2', 'two', 'm1'),
|
||||
pendingSend('p3', 'three', 'm1')
|
||||
]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2', 'p3'])
|
||||
})
|
||||
|
||||
it('matches across tab, newline and repeated-space separators', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
userTurn('m2', 'run the tests\t\n again', 5000)
|
||||
]
|
||||
const pending = [
|
||||
pendingSend('p1', ' run the tests\n', 'm1'),
|
||||
pendingSend('p2', '\tagain ', 'm1')
|
||||
]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
it('matches prompts that themselves contain the separator', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
userTurn('m2', 'fix the bug and run the tests', 5000)
|
||||
]
|
||||
const pending = [
|
||||
pendingSend('p1', 'fix the bug', 'm1'),
|
||||
pendingSend('p2', 'and run the tests', 'm1')
|
||||
]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
it('matches when one pending is a strict prefix of the next', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'run run the tests', 5000)]
|
||||
const pending = [pendingSend('p1', 'run', 'm1'), pendingSend('p2', 'run the tests', 'm1')]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
it('matches identical prompts sent twice and three times', () => {
|
||||
const twice = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'hi hi', 5000)]
|
||||
expect(
|
||||
retiredIds(twice, [pendingSend('p1', 'hi', 'm1', 1), pendingSend('p2', 'hi', 'm1', 2)])
|
||||
).toEqual(['p1', 'p2'])
|
||||
|
||||
const thrice = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'hi hi hi', 5000)]
|
||||
expect(
|
||||
retiredIds(thrice, [
|
||||
pendingSend('p1', 'hi', 'm1', 1),
|
||||
pendingSend('p2', 'hi', 'm1', 2),
|
||||
pendingSend('p3', 'hi', 'm1', 3)
|
||||
])
|
||||
).toEqual(['p1', 'p2', 'p3'])
|
||||
})
|
||||
|
||||
it('retires two separate glued rows against their own runs', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
userTurn('m2', 'one two', 5000),
|
||||
userTurn('m3', 'three four', 6000)
|
||||
]
|
||||
const pending = [
|
||||
pendingSend('p1', 'one', 'm1'),
|
||||
pendingSend('p2', 'two', 'm1'),
|
||||
pendingSend('p3', 'three', 'm2'),
|
||||
pendingSend('p4', 'four', 'm2')
|
||||
]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2', 'p3', 'p4'])
|
||||
})
|
||||
|
||||
// FP1: the concatenation already exists in history, older than the sends.
|
||||
it('never lets a transcript row that predates the sends retire them', () => {
|
||||
const messages = [
|
||||
userTurn('m1', 'run the tests again', 1),
|
||||
assistantTurn('m2', 'done', 2),
|
||||
assistantTurn('m3', 'anything else?', 3)
|
||||
]
|
||||
const pending = [pendingSend('p1', 'run the tests', 'm3'), pendingSend('p2', 'again', 'm3')]
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
})
|
||||
|
||||
// FP2: an old turn reads like the concatenation of two much later sends.
|
||||
it('never lets an older turn retire sends captured after it', () => {
|
||||
const messages = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)]
|
||||
const pending = [pendingSend('p1', 'fix the', 'm2'), pendingSend('p2', 'bug', 'm2')]
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a row the pending run only prefixes', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
userTurn('m2', 'run the tests again with coverage', 5000)
|
||||
]
|
||||
const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')]
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a run that only partly spells the row', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'run the tests', 5000)]
|
||||
const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')]
|
||||
// A lone exact match is an ordinary landing, not glue.
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects glue when a send in the run cannot resolve its own tail', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)]
|
||||
const pending = [pendingSend('p1', 'one', 'm1'), pendingSend('p2', 'two', 'paginated-away')]
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
})
|
||||
|
||||
it('treats a null tail as "transcript was empty", so any row can glue', () => {
|
||||
const messages = [userTurn('m1', 'one two', 5000)]
|
||||
const pending = [pendingSend('p1', 'one', null), pendingSend('p2', 'two', null)]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
// Unresolved baselines are held out of matching until the drafts hook rebases
|
||||
// them onto the first authoritative read — see mobile-native-chat-pending-baseline.
|
||||
it('holds out sends whose baseline has not been rebased onto a real transcript', () => {
|
||||
const messages = [userTurn('m1', 'one two', 1000)]
|
||||
const pending = [
|
||||
pendingSend('p1', 'one', null, 1, false),
|
||||
pendingSend('p2', 'two', null, 1, false)
|
||||
]
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
})
|
||||
|
||||
// A head that can never match must not freeze the run behind it: one stuck
|
||||
// bubble would otherwise disable glue retirement for the rest of the session.
|
||||
it('glues a later pair even when the head of the run can never match', () => {
|
||||
const messages = [
|
||||
userTurn('m0', 'unrelated', 1000),
|
||||
userTurn('m1', 'fix the bug', 5000),
|
||||
userTurn('m2', 'ship the fix', 6000)
|
||||
]
|
||||
const stuckHead = pendingSend('p0', 'bug', 'm0')
|
||||
const pair = [pendingSend('p1', 'ship the', 'm0'), pendingSend('p2', 'fix', 'm0')]
|
||||
expect(retiredIds(messages, [stuckHead, ...pair])).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
// Nothing bounds how many sends pile onto the agent's input line, so the
|
||||
// slide's budget must never truncate a genuine glue.
|
||||
it('retires a glued run far longer than the slide budget', () => {
|
||||
const words = Array.from({ length: 12 }, (_, index) => `w${index}`)
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', words.join(' '), 5000)]
|
||||
const pending = words.map((word, index) => pendingSend(`p${index}`, word, 'm1'))
|
||||
expect(retiredIds(messages, pending)).toHaveLength(words.length)
|
||||
})
|
||||
|
||||
it('skips a caption-less image echo instead of gluing it', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)]
|
||||
const pending = [
|
||||
pendingSend('p1', '', 'm1'),
|
||||
pendingSend('p2', 'one', 'm1'),
|
||||
pendingSend('p3', 'two', 'm1')
|
||||
]
|
||||
expect(retiredIds(messages, pending)).toEqual(['p2', 'p3'])
|
||||
})
|
||||
|
||||
it('keeps a captioned image echo and its neighbors until the preview is rebound', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)]
|
||||
const pending = [
|
||||
{ ...pendingSend('p1', 'one', 'm1'), images: ['file:///a.png'] },
|
||||
pendingSend('p2', 'two', 'm1')
|
||||
]
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
expect(
|
||||
retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id)
|
||||
).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
it('does nothing for a single pending send', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)]
|
||||
expect(retiredIds(messages, [pendingSend('p1', 'one', 'm1')])).toEqual([])
|
||||
})
|
||||
|
||||
// The cursor slides past a head that cannot match, so a turn may try several
|
||||
// start positions — but one inspection budget covers the whole slide, keeping
|
||||
// the work linear in the run length rather than quadratic. The attempt already
|
||||
// in flight is allowed to overshoot the remaining budget, deliberately: that is
|
||||
// what guarantees a genuine long glue is never truncated.
|
||||
it('keeps segment inspection linear in the run length per transcript turn', () => {
|
||||
const pendingCount = 96
|
||||
const turnCount = 12
|
||||
const text = `${'a '.repeat(pendingCount)}x`
|
||||
const messages = [
|
||||
assistantTurn('tail', 'ready', 1000),
|
||||
...Array.from({ length: turnCount }, (_, index) => userTurn(`m${index}`, text, 2000 + index))
|
||||
]
|
||||
const pending = Array.from({ length: pendingCount }, (_, index) =>
|
||||
pendingSend(`p${index}`, 'a', 'tail', index + 1)
|
||||
)
|
||||
const startsWith = vi.spyOn(String.prototype, 'startsWith')
|
||||
let segmentChecks = 0
|
||||
try {
|
||||
expect(retiredIds(messages, pending)).toEqual([])
|
||||
segmentChecks = startsWith.mock.calls.length
|
||||
} finally {
|
||||
startsWith.mockRestore()
|
||||
}
|
||||
expect(segmentChecks).toBeLessThanOrEqual(turnCount * (2 * pendingCount + GLUE_SLIDE_BUDGET))
|
||||
})
|
||||
})
|
||||
|
||||
describe('retireLandedMobileNativeChatPending', () => {
|
||||
it('retires exact landings by ordinal and glued rows in the same pass', () => {
|
||||
const messages = [
|
||||
assistantTurn('m1', 'ready', 1000),
|
||||
userTurn('m2', 'standalone', 4000),
|
||||
userTurn('m3', 'run the tests again', 5000)
|
||||
]
|
||||
const pending = [
|
||||
pendingSend('p0', 'standalone', 'm1'),
|
||||
pendingSend('p1', 'run the tests', 'm2'),
|
||||
pendingSend('p2', 'again', 'm2')
|
||||
]
|
||||
expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toEqual([])
|
||||
})
|
||||
|
||||
// The drafts effect early-outs on `next === current`; a fresh array on the
|
||||
// no-op path would re-enter it on every transcript frame.
|
||||
it('returns the input array itself when nothing retires', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000)]
|
||||
const pending = [pendingSend('p1', 'one', 'm1'), pendingSend('p2', 'two', 'm1')]
|
||||
expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toBe(pending)
|
||||
})
|
||||
|
||||
it('keeps sends whose glue candidate predates them', () => {
|
||||
const messages = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)]
|
||||
const pending = [pendingSend('p1', 'fix the', 'm2'), pendingSend('p2', 'bug', 'm2')]
|
||||
expect(
|
||||
retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id)
|
||||
).toEqual(['p1', 'p2'])
|
||||
})
|
||||
|
||||
it('does not glue former neighbors across an exact-landed send', () => {
|
||||
const messages = [
|
||||
assistantTurn('m0', 'ready', 1000),
|
||||
userTurn('m1', 'middle', 2000),
|
||||
userTurn('m2', 'one two', 3000)
|
||||
]
|
||||
const pending = [
|
||||
pendingSend('p1', 'one', 'm0'),
|
||||
pendingSend('p2', 'middle', 'm0'),
|
||||
pendingSend('p3', 'two', 'm0')
|
||||
]
|
||||
expect(
|
||||
retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id)
|
||||
).toEqual(['p1', 'p3'])
|
||||
})
|
||||
|
||||
it('keeps image echoes until their preview is rebound', () => {
|
||||
const messages = [assistantTurn('m1', 'ready', 1000)]
|
||||
const pending = [{ ...pendingSend('p1', 'photo', 'm1'), images: ['file:///a.png'] }]
|
||||
expect(
|
||||
retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id)
|
||||
).toEqual(['p1'])
|
||||
expect(retireLandedMobileNativeChatPending(messages, pending, new Set(['p1']))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
countImageSourceTurnsAfter,
|
||||
normalizeReconcileText,
|
||||
normalizedUserText
|
||||
} from './mobile-native-chat-draft-reconcile'
|
||||
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'
|
||||
|
||||
const SPACE = ' '
|
||||
const NO_PENDING_IDS: ReadonlySet<string> = new Set()
|
||||
// Slack the cursor slide may spend re-trying later start positions, on top of
|
||||
// one free pass over the run. Nothing bounds how many sends accumulate on the
|
||||
// agent's input line — that ends when the agent accepts input again — so the
|
||||
// budget must never truncate a genuine glue: the first attempt covers the whole
|
||||
// run and always fits. It only stops a run of identical prefix-matching sends
|
||||
// from making the scan quadratic.
|
||||
export const GLUE_SLIDE_BUDGET = 8
|
||||
|
||||
type UserTurn = { index: number; text: string }
|
||||
type GlueSegment = { text: string; tail: number } | null
|
||||
|
||||
/** Pending ids represented by post-send transcript rows that glued adjacent sends. */
|
||||
export function selectGluedPendingIds(
|
||||
messages: readonly NativeChatMessage[],
|
||||
pending: readonly MobileNativeChatPendingMessage[],
|
||||
excludedPendingIds: ReadonlySet<string> = NO_PENDING_IDS
|
||||
): ReadonlySet<string> {
|
||||
const retired = new Set<string>()
|
||||
if (pending.length < 2) {
|
||||
return retired
|
||||
}
|
||||
const messageIndexById = new Map<string, number>()
|
||||
const turns: UserTurn[] = []
|
||||
for (const [index, message] of messages.entries()) {
|
||||
messageIndexById.set(message.id, index)
|
||||
const text = normalizedUserText(message)
|
||||
if (text) {
|
||||
turns.push({ index, text })
|
||||
}
|
||||
}
|
||||
const segments: GlueSegment[] = pending.map((item) => {
|
||||
const text = normalizeReconcileText(item.text)
|
||||
const tail =
|
||||
item.baselineTailMessageId === null
|
||||
? -1
|
||||
: (messageIndexById.get(item.baselineTailMessageId) ?? null)
|
||||
return excludedPendingIds.has(item.id) ||
|
||||
!item.baselineResolved ||
|
||||
item.images?.length ||
|
||||
text === '' ||
|
||||
tail === null
|
||||
? null
|
||||
: { text, tail }
|
||||
})
|
||||
|
||||
// Barriers preserve original adjacency after exact landings retire.
|
||||
let runStart = 0
|
||||
while (runStart < pending.length) {
|
||||
while (runStart < pending.length && segments[runStart] === null) {
|
||||
runStart += 1
|
||||
}
|
||||
let runEnd = runStart
|
||||
while (runEnd < pending.length && segments[runEnd] !== null) {
|
||||
runEnd += 1
|
||||
}
|
||||
let cursor = runStart
|
||||
for (const turn of turns) {
|
||||
if (cursor >= runEnd - 1) {
|
||||
break
|
||||
}
|
||||
// A send that can never match must not freeze the run behind it. One
|
||||
// permanently unretirable head — a pair whose own glued row arrived with
|
||||
// the read, or a send the count pass claimed against an older row — would
|
||||
// otherwise disable glue retirement for every later pair, for the rest of
|
||||
// the session. Slide past it; the cursor stays monotonic, so a later turn
|
||||
// can never claim a send an earlier one already took.
|
||||
let budget = runEnd - runStart + GLUE_SLIDE_BUDGET
|
||||
let start = cursor
|
||||
let matched = 0
|
||||
for (; start <= runEnd - 2 && budget > 0; start++) {
|
||||
const attempt = matchGluedRun(turn, segments, start, runEnd)
|
||||
budget -= attempt.inspected
|
||||
matched = attempt.matched
|
||||
if (matched > 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (matched === 0) {
|
||||
continue
|
||||
}
|
||||
for (let index = start; index < start + matched; index++) {
|
||||
retired.add(pending[index]!.id)
|
||||
}
|
||||
cursor = start + matched
|
||||
}
|
||||
runStart = runEnd + 1
|
||||
}
|
||||
return retired
|
||||
}
|
||||
|
||||
/** Length of the exact glued run at `start`, plus the segments it had to read. */
|
||||
function matchGluedRun(
|
||||
turn: UserTurn,
|
||||
segments: readonly GlueSegment[],
|
||||
start: number,
|
||||
end: number
|
||||
): { matched: number; inspected: number } {
|
||||
let at = 0
|
||||
let matched = 0
|
||||
let inspected = 0
|
||||
for (let index = start; index < end; index++) {
|
||||
const segment = segments[index]!
|
||||
inspected += 1
|
||||
// Every send carries its OWN boundary: a row that already existed when this
|
||||
// send was issued can never be part of its echo, however well it reads.
|
||||
if (turn.index <= segment.tail) {
|
||||
return { matched: 0, inspected }
|
||||
}
|
||||
if (at > 0 && turn.text[at] === SPACE) {
|
||||
at += 1
|
||||
}
|
||||
if (!turn.text.startsWith(segment.text, at)) {
|
||||
return { matched: 0, inspected }
|
||||
}
|
||||
at += segment.text.length
|
||||
matched += 1
|
||||
if (at === turn.text.length) {
|
||||
// A lone exact match is an ordinary landing, which the count pass owns.
|
||||
return { matched: matched > 1 ? matched : 0, inspected }
|
||||
}
|
||||
}
|
||||
return { matched: 0, inspected }
|
||||
}
|
||||
|
||||
/** Retires exact and glued transcript landings while preserving pending order. */
|
||||
export function retireLandedMobileNativeChatPending(
|
||||
messages: readonly NativeChatMessage[],
|
||||
current: MobileNativeChatPendingMessage[],
|
||||
landedImagePendingIds: ReadonlySet<string>
|
||||
): MobileNativeChatPendingMessage[] {
|
||||
const landedCounts = new Map<string, number>()
|
||||
for (const message of messages) {
|
||||
const text = normalizedUserText(message)
|
||||
if (text) {
|
||||
landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
const landedPendingIds = new Set<string>()
|
||||
for (const item of current) {
|
||||
if (landedImagePendingIds.has(item.id)) {
|
||||
landedPendingIds.add(item.id)
|
||||
continue
|
||||
}
|
||||
// Keep image echoes until their local preview reaches the authoritative message.
|
||||
// An unresolved baseline has nothing to count against yet — `messages` is not
|
||||
// known to be the transcript this send was issued into.
|
||||
if (item.images?.length || !item.baselineResolved) {
|
||||
continue
|
||||
}
|
||||
const landed =
|
||||
item.text.trim() === ''
|
||||
? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) >=
|
||||
item.expectedOccurrence
|
||||
: (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= item.expectedOccurrence
|
||||
if (landed) {
|
||||
landedPendingIds.add(item.id)
|
||||
}
|
||||
}
|
||||
const glued = selectGluedPendingIds(messages, current, landedPendingIds)
|
||||
return landedPendingIds.size === 0 && glued.size === 0
|
||||
? current
|
||||
: current.filter((item) => !landedPendingIds.has(item.id) && !glued.has(item.id))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import {
|
||||
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS,
|
||||
openMobileNativeChatSendBudget,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
|
||||
import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing'
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ export type SessionTabsResult = {
|
||||
tabs: MobileSessionTab[]
|
||||
activeTabId: string | null
|
||||
activeTabType: MobileSessionTabType | null
|
||||
/** Host explicitly navigated this device (desktop/CLI `navigation: clients|all`), not a plain republication. */
|
||||
navigationIntent?: 'follow'
|
||||
}
|
||||
|
||||
export type RuntimeStatusResult = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { activateMobileSessionTab, focusMobileTerminal } from './mobile-session-tab-activation'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TabActivationIntent } from '../../../src/shared/tab-activation-intent'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import {
|
||||
getMobileTerminalDiagnosticErrorName,
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('mobile structured session reducer', () => {
|
||||
{ owner: 'native', direction: null, phase: 'idle', stage: null, operationId: null }
|
||||
]
|
||||
let state = EMPTY_MOBILE_STRUCTURED_AGENT_SESSION
|
||||
const projected: Array<Pick<AgentSessionHandoffStatus, 'owner' | 'phase' | 'stage'>> = []
|
||||
const projected: Pick<AgentSessionHandoffStatus, 'owner' | 'phase' | 'stage'>[] = []
|
||||
|
||||
for (const [index, handoff] of handoffs.entries()) {
|
||||
state = reduceMobileStructuredAgentSession(state, {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '../../../src/shared/structured-agent-session-outbox'
|
||||
import { createStructuredAgentSessionOperationId } from '../../../src/shared/structured-agent-session-mutation'
|
||||
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
|
||||
export {
|
||||
createStructuredAgentSessionOutboxEntry as createMobileStructuredOutboxEntry,
|
||||
|
||||
@@ -96,7 +96,8 @@ const ORIGIN = {
|
||||
pendingKey: 'h\0w\0tab-1\0session-1',
|
||||
normalizedText: 'look',
|
||||
baselineOccurrences: 0,
|
||||
baselineTailMessageId: null
|
||||
baselineTailMessageId: null,
|
||||
baselineResolved: true
|
||||
}
|
||||
|
||||
describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
|
||||
@@ -111,7 +111,8 @@ export function useMobileNativeChatController(args: {
|
||||
// a null is indistinguishable from a host retraction, and peeking at the
|
||||
// terminal view would permanently decline the prefill.
|
||||
chatActive: showNativeChat,
|
||||
transcriptLoading: nativeChatSession.transcriptLoading
|
||||
transcriptLoading: nativeChatSession.transcriptLoading,
|
||||
transcriptSettled: nativeChatSession.status === 'ready'
|
||||
})
|
||||
|
||||
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { buildMobileNativeChatTransientData } from './mobile-native-chat-render-data'
|
||||
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
||||
|
||||
type DraftState = ReturnType<typeof useMobileNativeChatDrafts>
|
||||
type TestRenderer = {
|
||||
unmount(): void
|
||||
update(element: ReturnType<typeof createElement>): void
|
||||
}
|
||||
|
||||
function userTurn(id: string, text: string, timestamp: number): NativeChatMessage {
|
||||
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
|
||||
}
|
||||
|
||||
function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
function imageTurn(id: string, path: string, timestamp: number): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: `[Image: source: ${path}]` }],
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
/** Texts of the optimistic bubbles the chat list actually renders. */
|
||||
function renderedPendingTexts(
|
||||
messages: NativeChatMessage[],
|
||||
state: DraftState | null
|
||||
): (string | undefined)[] {
|
||||
const { data } = buildMobileNativeChatTransientData({
|
||||
folded: messages,
|
||||
streaming: null,
|
||||
pending: state?.pending ?? []
|
||||
})
|
||||
return data
|
||||
.filter((message) => !messages.some((existing) => existing.id === message.id))
|
||||
.map((message) => message.blocks.find((block) => block.type === 'text')?.text)
|
||||
}
|
||||
|
||||
describe('useMobileNativeChatDrafts glued pending sends', () => {
|
||||
let renderer: TestRenderer | null = null
|
||||
let state: DraftState | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
state = null
|
||||
})
|
||||
|
||||
function Harness({
|
||||
messages,
|
||||
transcriptLoading = false,
|
||||
transcriptSettled = !transcriptLoading
|
||||
}: {
|
||||
messages: NativeChatMessage[]
|
||||
transcriptLoading?: boolean
|
||||
transcriptSettled?: boolean
|
||||
}): null {
|
||||
state = useMobileNativeChatDrafts({
|
||||
hostId: 'host',
|
||||
worktreeId: 'worktree',
|
||||
tabId: 'tab',
|
||||
sessionId: 'session',
|
||||
messages,
|
||||
transcriptLoading,
|
||||
transcriptSettled
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
async function mount(
|
||||
messages: NativeChatMessage[],
|
||||
transcriptLoading = false,
|
||||
transcriptSettled = !transcriptLoading
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { messages, transcriptLoading, transcriptSettled }))
|
||||
})
|
||||
}
|
||||
|
||||
async function update(
|
||||
messages: NativeChatMessage[],
|
||||
transcriptLoading = false,
|
||||
transcriptSettled = !transcriptLoading
|
||||
): Promise<void> {
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { messages, transcriptLoading, transcriptSettled }))
|
||||
)
|
||||
}
|
||||
|
||||
function send(text: string): void {
|
||||
const origin = state?.captureSendOrigin(text)
|
||||
if (origin) {
|
||||
state?.acceptSend(origin, text)
|
||||
}
|
||||
}
|
||||
|
||||
/** Two composer sends fired before either transcript echo lands. */
|
||||
function rapidSend(first: string, second: string): void {
|
||||
act(() => {
|
||||
send(first)
|
||||
send(second)
|
||||
})
|
||||
}
|
||||
|
||||
const pendingTexts = (): (string | undefined)[] | undefined =>
|
||||
state?.pending.map((item) => item.text)
|
||||
|
||||
it('retires both bubbles when the rapid sends land as one glued user row', async () => {
|
||||
const history = [assistantTurn('m1', 'ready', 1000)]
|
||||
await mount(history)
|
||||
rapidSend('run the tests', 'again')
|
||||
expect(pendingTexts()).toEqual(['run the tests', 'again'])
|
||||
|
||||
const glued = [...history, userTurn('m2', 'run the tests again', 5000)]
|
||||
await update(glued)
|
||||
expect(state?.pending).toEqual([])
|
||||
expect(renderedPendingTexts(glued, state)).toEqual([])
|
||||
})
|
||||
|
||||
// FP1: the concatenation already sits in history, older than the sends.
|
||||
it('keeps and renders both bubbles when the matching row predates the sends', async () => {
|
||||
const history = [
|
||||
userTurn('m1', 'run the tests again', 1000),
|
||||
assistantTurn('m2', 'done', 2000),
|
||||
assistantTurn('m3', 'anything else?', 3000)
|
||||
]
|
||||
await mount(history)
|
||||
rapidSend('run the tests', 'again')
|
||||
|
||||
await update([...history])
|
||||
expect(pendingTexts()).toEqual(['run the tests', 'again'])
|
||||
expect(renderedPendingTexts(history, state)).toEqual(['run the tests', 'again'])
|
||||
})
|
||||
|
||||
// FP2: an older turn reads like the concatenation of two much later sends.
|
||||
it('keeps and renders both bubbles when an older turn spells them out', async () => {
|
||||
const history = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)]
|
||||
await mount(history)
|
||||
rapidSend('fix the', 'bug')
|
||||
|
||||
await update([...history])
|
||||
expect(pendingTexts()).toEqual(['fix the', 'bug'])
|
||||
expect(renderedPendingTexts(history, state)).toEqual(['fix the', 'bug'])
|
||||
})
|
||||
|
||||
it('still retires each bubble on its own when the sends do not glue', async () => {
|
||||
const history = [assistantTurn('m1', 'ready', 1000)]
|
||||
await mount(history)
|
||||
rapidSend('run the tests', 'again')
|
||||
|
||||
const separate = [
|
||||
...history,
|
||||
userTurn('m2', 'run the tests', 5000),
|
||||
userTurn('m3', 'again', 5100)
|
||||
]
|
||||
await update(separate)
|
||||
expect(state?.pending).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the second bubble when only the first send has echoed', async () => {
|
||||
const history = [assistantTurn('m1', 'ready', 1000)]
|
||||
await mount(history)
|
||||
rapidSend('run the tests', 'again')
|
||||
|
||||
const partial = [...history, userTurn('m2', 'run the tests', 5000)]
|
||||
await update(partial)
|
||||
expect(pendingTexts()).toEqual(['again'])
|
||||
expect(renderedPendingTexts(partial, state)).toEqual(['again'])
|
||||
})
|
||||
|
||||
// STA-4492 / #14819: sends issued mid-hydration used to be marked permanently
|
||||
// untrusted, so their glued row could never retire them and they also barred
|
||||
// their neighbours from matching. The first authoritative read rebases them
|
||||
// instead: history that arrives with it is still not theirs, but everything
|
||||
// after it is.
|
||||
describe('sends issued while the transcript is still hydrating', () => {
|
||||
const loadedHistory = [userTurn('m1', 'run the tests again', 1000)]
|
||||
|
||||
it('keeps both bubbles when the loaded history only looks like their glue', async () => {
|
||||
await mount([], true)
|
||||
rapidSend('run the tests', 'again')
|
||||
|
||||
await update(loadedHistory)
|
||||
expect(pendingTexts()).toEqual(['run the tests', 'again'])
|
||||
expect(renderedPendingTexts(loadedHistory, state)).toEqual(['run the tests', 'again'])
|
||||
})
|
||||
|
||||
it('retires both bubbles once their glued row lands after hydration', async () => {
|
||||
await mount([], true)
|
||||
rapidSend('run the tests', 'again')
|
||||
await update(loadedHistory)
|
||||
|
||||
const glued = [...loadedHistory, userTurn('m2', 'run the tests again', 5000)]
|
||||
await update(glued)
|
||||
expect(state?.pending).toEqual([])
|
||||
expect(renderedPendingTexts(glued, state)).toEqual([])
|
||||
})
|
||||
|
||||
it('retires a hydration-time send on its own exact echo', async () => {
|
||||
await mount([], true)
|
||||
act(() => send('run the tests'))
|
||||
await update(loadedHistory)
|
||||
expect(pendingTexts()).toEqual(['run the tests'])
|
||||
|
||||
const echoed = [...loadedHistory, userTurn('m2', 'run the tests', 5000)]
|
||||
await update(echoed)
|
||||
expect(state?.pending).toEqual([])
|
||||
})
|
||||
|
||||
it('stops barring a later pair from gluing once its own echo lands', async () => {
|
||||
await mount([], true)
|
||||
act(() => send('hold this'))
|
||||
await update(loadedHistory)
|
||||
rapidSend('fix the', 'bug')
|
||||
|
||||
const landed = [
|
||||
...loadedHistory,
|
||||
userTurn('m2', 'hold this', 5000),
|
||||
userTurn('m3', 'fix the bug', 5100)
|
||||
]
|
||||
await update(landed)
|
||||
expect(state?.pending).toEqual([])
|
||||
})
|
||||
|
||||
// The read that resolves a hydration-time send can already contain that
|
||||
// send's own echo — a re-subscribe returns whatever exists now — and nothing
|
||||
// local tells that echo from an identical older prompt. Claiming the row is
|
||||
// the lesser evil, not a free one: normally the real echo lands a round trip
|
||||
// later, but a send that never landed and was claimed by an older identical
|
||||
// row stops being tracked at all — worst for short repeats ("yes", "1").
|
||||
// Holding instead costs a queued bubble that never clears AND a run that can
|
||||
// never glue again, for the rest of the session.
|
||||
it('retires a hydration-time send whose echo arrives with the read', async () => {
|
||||
await mount([], true)
|
||||
act(() => send('run the tests'))
|
||||
|
||||
const echoed = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'passed', 1100)]
|
||||
await update(echoed)
|
||||
expect(state?.pending).toEqual([])
|
||||
})
|
||||
|
||||
// The same read seen one reconnect later: the send left, the agent echoed it,
|
||||
// and only then did the first authoritative read land.
|
||||
it('retires a hydration-time send after a reconnect delivered the read late', async () => {
|
||||
await mount([], true)
|
||||
act(() => send('run the tests'))
|
||||
// Still loading: the first read never arrived before the drop.
|
||||
await update([], true)
|
||||
|
||||
const afterReconnect = [
|
||||
userTurn('m1', 'run the tests', 5000),
|
||||
assistantTurn('m2', 'ok', 5100)
|
||||
]
|
||||
await update(afterReconnect)
|
||||
expect(state?.pending).toEqual([])
|
||||
|
||||
// Nothing later resurrects it, and the run stays glue-capable.
|
||||
await update([...afterReconnect, assistantTurn('m3', 'all green', 6000)])
|
||||
expect(state?.pending).toEqual([])
|
||||
})
|
||||
|
||||
// A reconnect leaves this session's own retained history on screen, so sends
|
||||
// made across it own a real boundary already. Moving it onto the read that
|
||||
// follows would push it past their own glued row and strand both bubbles.
|
||||
it('retires a pair sent across a reconnect whose glued row arrives with the read', async () => {
|
||||
const retained = [userTurn('m1', 'earlier prompt', 1000), assistantTurn('m2', 'sure', 1100)]
|
||||
await mount(retained)
|
||||
// The read drops; the retained history stays visible and is still ours.
|
||||
await update(retained, true)
|
||||
rapidSend('run the tests', 'again')
|
||||
|
||||
const settled = [...retained, userTurn('m3', 'run the tests again', 5000)]
|
||||
await update(settled)
|
||||
expect(state?.pending).toEqual([])
|
||||
expect(renderedPendingTexts(settled, state)).toEqual([])
|
||||
})
|
||||
|
||||
// A send held here would sit as a live segment at the head of its run, so
|
||||
// the cursor could never reach a later pair — the stuck bubble would take
|
||||
// the whole feature down with it for the rest of the session.
|
||||
// An image entry retires ONLY by binding its preview, so a supplied tail
|
||||
// that excluded its own echo would leave the bubble — and the photo — stuck
|
||||
// for the life of the session.
|
||||
it('retires a photo whose image echo arrives with the read', async () => {
|
||||
await mount([], true)
|
||||
act(() => {
|
||||
const origin = state?.captureSendOrigin('')
|
||||
if (origin) {
|
||||
state?.acceptSend(origin, '', ['file:///new.png'])
|
||||
}
|
||||
})
|
||||
|
||||
const withEcho = [assistantTurn('m1', 'ready', 1000), imageTurn('m2', '/tmp/new.png', 5000)]
|
||||
await update(withEcho)
|
||||
expect(state?.pending).toEqual([])
|
||||
expect(state?.imagePreviewsByMessageId).toEqual({ m2: ['file:///new.png'] })
|
||||
})
|
||||
|
||||
it('leaves a later pair able to glue after the late read resolved the first send', async () => {
|
||||
await mount([], true)
|
||||
act(() => send('run the tests'))
|
||||
const afterReconnect = [userTurn('m1', 'run the tests', 5000)]
|
||||
await update(afterReconnect)
|
||||
expect(state?.pending).toEqual([])
|
||||
|
||||
rapidSend('fix the', 'bug')
|
||||
const glued = [...afterReconnect, userTurn('m2', 'fix the bug', 6000)]
|
||||
await update(glued)
|
||||
expect(state?.pending).toEqual([])
|
||||
expect(renderedPendingTexts(glued, state)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// A read that failed is not a boundary: `messages` is empty because the history
|
||||
// is unknown, not because the conversation is. Without that distinction the
|
||||
// empty list reads as "the transcript was empty", and any row the successful
|
||||
// read finally brings can claim these sends.
|
||||
describe('sends issued before any read settled', () => {
|
||||
it('keeps both bubbles when the late read carries only an older glue-alike', async () => {
|
||||
await mount([], false, false)
|
||||
rapidSend('fix the', 'bug')
|
||||
|
||||
const olderIdentical = [
|
||||
userTurn('m1', 'fix the bug', 1000),
|
||||
assistantTurn('m2', 'fixed', 1100)
|
||||
]
|
||||
await update(olderIdentical)
|
||||
expect(pendingTexts()).toEqual(['fix the', 'bug'])
|
||||
expect(renderedPendingTexts(olderIdentical, state)).toEqual(['fix the', 'bug'])
|
||||
})
|
||||
|
||||
it('still retires them once their own glued row lands', async () => {
|
||||
await mount([], false, false)
|
||||
rapidSend('fix the', 'bug')
|
||||
const olderIdentical = [
|
||||
userTurn('m1', 'fix the bug', 1000),
|
||||
assistantTurn('m2', 'fixed', 1100)
|
||||
]
|
||||
await update(olderIdentical)
|
||||
|
||||
await update([...olderIdentical, userTurn('m3', 'fix the bug', 5000)])
|
||||
expect(state?.pending).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -36,7 +36,8 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
launchDraft = null,
|
||||
launchDraftCreatedAt = null,
|
||||
chatActive = true,
|
||||
transcriptLoading = false
|
||||
transcriptLoading = false,
|
||||
transcriptSettled = !transcriptLoading
|
||||
}: {
|
||||
tabId: string
|
||||
sessionId?: string | null
|
||||
@@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
launchDraftCreatedAt?: number | null
|
||||
chatActive?: boolean
|
||||
transcriptLoading?: boolean
|
||||
transcriptSettled?: boolean
|
||||
}): null {
|
||||
state = useMobileNativeChatDrafts({
|
||||
hostId: 'host',
|
||||
@@ -55,7 +57,8 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive,
|
||||
transcriptLoading
|
||||
transcriptLoading,
|
||||
transcriptSettled
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
messages = [],
|
||||
launchDraft = null,
|
||||
chatActive = true,
|
||||
transcriptLoading = false
|
||||
transcriptLoading = false,
|
||||
transcriptSettled = !transcriptLoading
|
||||
}: {
|
||||
tabId: string
|
||||
sessionId?: string | null
|
||||
@@ -50,6 +51,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
launchDraft?: string | null
|
||||
chatActive?: boolean
|
||||
transcriptLoading?: boolean
|
||||
transcriptSettled?: boolean
|
||||
}): null {
|
||||
state = useMobileNativeChatDrafts({
|
||||
hostId: 'host',
|
||||
@@ -59,7 +61,8 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
messages,
|
||||
launchDraft,
|
||||
chatActive,
|
||||
transcriptLoading
|
||||
transcriptLoading,
|
||||
transcriptSettled
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
countImageSourceTurnsAfter,
|
||||
countUserTextOccurrences,
|
||||
findLandedImagePreviewEchoes,
|
||||
findLandedUnconfirmedSends,
|
||||
mergeLandedImagePreviewEchoes,
|
||||
migrateImagePreviewMessageIds,
|
||||
normalizeReconcileText,
|
||||
normalizedUserText,
|
||||
type UnconfirmedSend
|
||||
} from './mobile-native-chat-draft-reconcile'
|
||||
import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline'
|
||||
import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement'
|
||||
import {
|
||||
appendMobileNativeChatPending,
|
||||
combineMobileNativeChatPending,
|
||||
@@ -47,6 +47,10 @@ export function useMobileNativeChatDrafts(args: {
|
||||
* transcript still belongs to the previously active tab), so it cannot be
|
||||
* trusted to decline or retire the seed. */
|
||||
transcriptLoading?: boolean
|
||||
/** `messages` is this session's own settled history — so an empty one really
|
||||
* is an empty conversation, not a read that failed or never ran. Only then
|
||||
* does a send's captured tail describe a real boundary. */
|
||||
transcriptSettled: boolean
|
||||
}): {
|
||||
composerText: string
|
||||
setComposerText: Dispatch<SetStateAction<string>>
|
||||
@@ -80,7 +84,8 @@ export function useMobileNativeChatDrafts(args: {
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive = true,
|
||||
transcriptLoading
|
||||
transcriptLoading,
|
||||
transcriptSettled
|
||||
} = args
|
||||
const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId)
|
||||
const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null
|
||||
@@ -138,10 +143,14 @@ export function useMobileNativeChatDrafts(args: {
|
||||
pendingKey,
|
||||
normalizedText,
|
||||
baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText),
|
||||
baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null
|
||||
baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null,
|
||||
// Only a settled read makes this a boundary. Anything else — hydrating,
|
||||
// or a read that failed — hands back an empty list that reads as "the
|
||||
// conversation was empty", which lets any row claim this send later.
|
||||
baselineResolved: transcriptSettled
|
||||
}
|
||||
},
|
||||
[draftKey, pendingKey]
|
||||
[draftKey, pendingKey, transcriptSettled]
|
||||
)
|
||||
|
||||
// Why: over relay the send RPC can take seconds (or lose only its ack), and a
|
||||
@@ -282,7 +291,16 @@ export function useMobileNativeChatDrafts(args: {
|
||||
if (pending.length === 0) {
|
||||
return
|
||||
}
|
||||
const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending)
|
||||
// Only judge a send against a read known to be this session's. Note this
|
||||
// does NOT give an image echo a boundary — the rebase deliberately leaves
|
||||
// those on whatever they captured — so a caption-less photo sent before any
|
||||
// read settled can still claim an older photo turn, exactly as it does on
|
||||
// main. Fixing that needs a tail that excludes older image turns without
|
||||
// excluding the send's own echo, which is a separate change.
|
||||
const landedImagePreviews = findLandedImagePreviewEchoes(
|
||||
messages,
|
||||
pending.filter((item) => item.baselineResolved)
|
||||
)
|
||||
const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId))
|
||||
if (landedImagePreviews.length > 0) {
|
||||
setImagePreviewsBySession((previous) =>
|
||||
@@ -291,28 +309,13 @@ export function useMobileNativeChatDrafts(args: {
|
||||
}
|
||||
setPendingBySession((previous) => {
|
||||
const current = previous[pendingKey] ?? []
|
||||
const landedCounts = new Map<string, number>()
|
||||
for (const message of messages) {
|
||||
const text = normalizedUserText(message)
|
||||
if (text) {
|
||||
landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
// Image-only source-turn counts stay stable across reruns and ignore paginated history.
|
||||
const next = current.filter((item) => {
|
||||
if (landedImagePendingIds.has(item.id)) {
|
||||
return false
|
||||
}
|
||||
// Keep image echoes until their local preview reaches the authoritative message.
|
||||
if (item.images?.length) {
|
||||
return true
|
||||
}
|
||||
return item.text.trim() === ''
|
||||
? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) <
|
||||
item.expectedOccurrence
|
||||
: (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) < item.expectedOccurrence
|
||||
})
|
||||
if (next.length === current.length) {
|
||||
// Rebase before retiring: a send captured before the history was known has
|
||||
// to own a real boundary before any row can be judged against it.
|
||||
const rebased = transcriptSettled
|
||||
? rebaseMobileNativeChatPendingBaselines(messages, current)
|
||||
: current
|
||||
const next = retireLandedMobileNativeChatPending(messages, rebased, landedImagePendingIds)
|
||||
if (next === current) {
|
||||
return previous
|
||||
}
|
||||
if (next.length > 0) {
|
||||
@@ -322,7 +325,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
delete remaining[pendingKey]
|
||||
return remaining
|
||||
})
|
||||
}, [messages, pending, pendingKey])
|
||||
}, [messages, pending, pendingKey, transcriptSettled])
|
||||
|
||||
return {
|
||||
composerText: draftKey ? (drafts[draftKey] ?? '') : '',
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// Type-only, so it is erased before the `./mobile-native-chat-send` mock below applies.
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
|
||||
const sendWithOutcome = vi.fn()
|
||||
const clearInputWrite = vi.fn()
|
||||
@@ -36,6 +38,9 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let api: Send | null = null
|
||||
const acceptSend = vi.fn()
|
||||
const captureSendOrigin = vi.fn(() => ({ draftKey: 'k', pendingKey: 'p' }) as never)
|
||||
const clearDraftForSend = vi.fn()
|
||||
const restoreRejectedDraft = vi.fn()
|
||||
const holdUnconfirmedSend = vi.fn()
|
||||
const onCommandSend = vi.fn()
|
||||
const commandSendRef = { current: onCommandSend }
|
||||
@@ -55,10 +60,10 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
deviceTokenRef: { current: 'device' },
|
||||
agentRef,
|
||||
commandSendRef,
|
||||
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend: () => {},
|
||||
restoreRejectedDraft: () => {},
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
holdUnconfirmedSend,
|
||||
onSendError
|
||||
@@ -71,10 +76,12 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
}
|
||||
|
||||
const sentArgs = (): {
|
||||
text?: string
|
||||
clearInputFirst?: boolean
|
||||
resolvedLaunchDraft?: { text: string; createdAt: number }
|
||||
} =>
|
||||
sendWithOutcome.mock.calls[0]![0] as {
|
||||
text?: string
|
||||
clearInputFirst?: boolean
|
||||
resolvedLaunchDraft?: { text: string; createdAt: number }
|
||||
}
|
||||
@@ -89,6 +96,9 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
typeCommandWithOutcome.mockReset()
|
||||
typeCommandWithOutcome.mockResolvedValue('accepted')
|
||||
acceptSend.mockReset()
|
||||
captureSendOrigin.mockClear()
|
||||
clearDraftForSend.mockReset()
|
||||
restoreRejectedDraft.mockReset()
|
||||
holdUnconfirmedSend.mockReset()
|
||||
onCommandSend.mockReset()
|
||||
commandSendRef.current = onCommandSend
|
||||
@@ -381,4 +391,53 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true)
|
||||
releaseMobileNativeChatTerminalWrite('term')
|
||||
})
|
||||
|
||||
// The host writes these bytes verbatim, so whitespace the match key drops must
|
||||
// not reach the agent's input line and glue the next rapid send onto it (#14262).
|
||||
it('trims trailing whitespace without changing leading prompt content', async () => {
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.send(' run the tests \n')
|
||||
})
|
||||
expect(sentArgs().text).toBe(' run the tests')
|
||||
expect(captureSendOrigin).toHaveBeenCalledWith(' run the tests')
|
||||
expect(acceptSend.mock.calls[0]![1]).toBe(' run the tests')
|
||||
})
|
||||
|
||||
it('trims trailing whitespace on a question answer too', async () => {
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.answerQuestion('\n 1 ')
|
||||
})
|
||||
expect(sentArgs().text).toBe('\n 1')
|
||||
})
|
||||
|
||||
// #14819: the trim is a wire concern only. A rejected send hands the composer
|
||||
// back to the user, and it has to be the draft they typed, blank lines included.
|
||||
it('restores the untrimmed draft when the send is rejected', async () => {
|
||||
const draft = 'first line\n\nsecond line\n\n'
|
||||
sendWithOutcome.mockResolvedValue('rejected')
|
||||
mount(() => null)
|
||||
|
||||
await act(async () => {
|
||||
await api!.send(draft)
|
||||
})
|
||||
|
||||
expect(sentArgs().text).toBe('first line\n\nsecond line')
|
||||
expect(restoreRejectedDraft.mock.calls[0]![1]).toBe(draft)
|
||||
expect(clearDraftForSend.mock.calls[0]![1]).toBe(draft)
|
||||
})
|
||||
|
||||
it('restores the untrimmed draft when the launch-draft pre-clear is rejected', async () => {
|
||||
const draft = 'ship it '
|
||||
clearInputWrite.mockResolvedValue(false)
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
|
||||
await act(async () => {
|
||||
await api!.send(draft)
|
||||
})
|
||||
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
expect(restoreRejectedDraft.mock.calls[0]![1]).toBe(draft)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,12 +85,17 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
draftText: string,
|
||||
images: string[] | undefined,
|
||||
syncComposer: boolean,
|
||||
recordControlSend: boolean,
|
||||
sharedDeadline?: number
|
||||
): Promise<MobileNativeChatSendOutcome> => {
|
||||
// The host writes trailing whitespace verbatim onto the agent's input line,
|
||||
// where it can glue the next rapid send onto this one (#14262). Only the
|
||||
// bytes that go out are trimmed: `draftText` is what the user typed, and a
|
||||
// rejected send has to put back exactly that (#14819).
|
||||
const text = draftText.trimEnd()
|
||||
const handle = handleRef.current
|
||||
const origin = captureSendOrigin(text)
|
||||
const agent = agentRef.current
|
||||
@@ -124,7 +129,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
// round trip is visible, and a lost ack must not strand the sent prompt
|
||||
// in the box. Only a definite rejection puts the text back.
|
||||
if (syncComposer) {
|
||||
clearDraftForSend(origin, text)
|
||||
clearDraftForSend(origin, draftText)
|
||||
}
|
||||
// Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears
|
||||
// only one logical line. Size the clear to the text Orca injected, with
|
||||
@@ -150,7 +155,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
})
|
||||
if (!cleared) {
|
||||
if (syncComposer) {
|
||||
restoreRejectedDraft(origin, text)
|
||||
restoreRejectedDraft(origin, draftText)
|
||||
}
|
||||
onSendError('Message not sent')
|
||||
return 'rejected'
|
||||
@@ -204,7 +209,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
}
|
||||
if (outcome === 'rejected') {
|
||||
if (syncComposer) {
|
||||
restoreRejectedDraft(origin, text)
|
||||
restoreRejectedDraft(origin, draftText)
|
||||
}
|
||||
onSendError('Message not sent')
|
||||
return 'rejected'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
|
||||
import { openMobileNativeChatSendBudget } from './mobile-native-chat-send'
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalCursor,
|
||||
AgentJournalRenderItem
|
||||
} from '../../../src/shared/agent-session-journal-types'
|
||||
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
|
||||
import { STRUCTURED_AGENT_SESSION_CLIENT_COALESCE_MS } from '../../../src/shared/structured-agent-session-coalescer'
|
||||
import type { SubscribeOptions } from '../transport/rpc-client-contract'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { addEventListener: () => ({ remove: () => {} }) }
|
||||
}))
|
||||
|
||||
const SESSION_ID = 'session-a'
|
||||
const EPOCH = 'epoch-a'
|
||||
|
||||
function cursor(sequence: number): AgentJournalCursor {
|
||||
return { epoch: EPOCH, sequence }
|
||||
}
|
||||
|
||||
// Assistant-message batches are the ones the coalescer holds; anything else
|
||||
// bypasses it and would dispatch synchronously, hiding the window under test.
|
||||
function assistantItem(sequence: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: `item-${sequence}`,
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt: sequence,
|
||||
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `m${sequence}` }] }
|
||||
}
|
||||
}
|
||||
|
||||
function batchEvent(sequence: number): AgentSessionSubscribeEvent {
|
||||
return {
|
||||
type: 'batch',
|
||||
sessionId: SESSION_ID,
|
||||
batch: {
|
||||
cursor: cursor(sequence),
|
||||
items: [assistantItem(sequence)],
|
||||
removedItemIds: [],
|
||||
submissions: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tailResponse(liveCursor: AgentJournalCursor): RpcResponse {
|
||||
return {
|
||||
id: 'req-1',
|
||||
ok: true,
|
||||
result: {
|
||||
ok: true,
|
||||
page: {
|
||||
sessionId: SESSION_ID,
|
||||
epoch: EPOCH,
|
||||
fence: 1,
|
||||
direction: 'tail',
|
||||
items: [assistantItem(liveCursor.sequence)],
|
||||
removedItemIds: [],
|
||||
submissions: [],
|
||||
window: { oldest: liveCursor, newest: liveCursor, nextCursor: liveCursor },
|
||||
liveCursor,
|
||||
hasOlder: false,
|
||||
hasNewer: false
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile structured agent session resume cursor', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let onEvent: ((raw: unknown) => void) | null = null
|
||||
let paramsForReconnect: (() => unknown) | null = null
|
||||
let client: RpcClient
|
||||
|
||||
function Harness(props: { tick: number }): null {
|
||||
void props.tick
|
||||
useMobileStructuredAgentSession({ client, sessionId: SESSION_ID })
|
||||
return null
|
||||
}
|
||||
|
||||
function resumeCursor(): AgentJournalCursor | undefined {
|
||||
return (paramsForReconnect?.() as { cursor?: AgentJournalCursor } | undefined)?.cursor
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers()
|
||||
onEvent = null
|
||||
paramsForReconnect = null
|
||||
client = {
|
||||
sendRequest: vi.fn(async () => tailResponse(cursor(10))),
|
||||
subscribe: vi.fn(
|
||||
(
|
||||
_method: string,
|
||||
_params: unknown,
|
||||
onData: (raw: unknown) => void,
|
||||
options?: SubscribeOptions
|
||||
) => {
|
||||
onEvent = onData
|
||||
paramsForReconnect = options?.paramsForReconnect ?? null
|
||||
return () => {}
|
||||
}
|
||||
)
|
||||
} as unknown as RpcClient
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { tick: 0 }))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resumes from the committed reducer cursor, not the newest one the stream handler saw', async () => {
|
||||
expect(resumeCursor()).toEqual(cursor(10))
|
||||
|
||||
// Received but still sitting in the coalescer: the reducer has not applied it.
|
||||
onEvent?.(batchEvent(11))
|
||||
expect(resumeCursor()).toEqual(cursor(10))
|
||||
|
||||
// A re-render between the handler and the flush must not change the answer.
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { tick: 1 }))
|
||||
})
|
||||
expect(resumeCursor()).toEqual(cursor(10))
|
||||
|
||||
// Once the batch is committed, the resume cursor advances with it.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(STRUCTURED_AGENT_SESSION_CLIENT_COALESCE_MS)
|
||||
})
|
||||
expect(resumeCursor()).toEqual(cursor(11))
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
oldestStructuredAgentSessionCursor,
|
||||
reduceStructuredAgentSession,
|
||||
shouldAdvanceStructuredResumeCursor,
|
||||
type StructuredAgentSessionState
|
||||
} from '../../../src/shared/structured-agent-session-reducer'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
@@ -64,13 +63,16 @@ export function useMobileStructuredAgentSession(args: {
|
||||
useMobileStructuredSessionHold({ client, sessionId })
|
||||
const [state, dispatch] = useReducer(reduceStructuredAgentSession, EMPTY_STRUCTURED_AGENT_SESSION)
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
const resumeCursorRef = useRef<AgentJournalCursor | null>(state.cursor)
|
||||
resumeCursorRef.current = state.cursor
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
const reconnectRef = useRef(createMobileStructuredReconnectState())
|
||||
const cancelLongevityRef = useRef<() => void>(() => {})
|
||||
|
||||
// The reducer owns the resume cursor, not the stream handlers: mirroring it
|
||||
// post-commit means a reconnect can only replay, never skip undelivered items.
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'loading' })
|
||||
setLoadingOlder(false)
|
||||
@@ -110,7 +112,7 @@ export function useMobileStructuredAgentSession(args: {
|
||||
client,
|
||||
sessionId,
|
||||
cursor,
|
||||
resumeCursor: () => resumeCursorRef.current,
|
||||
resumeCursor: () => stateRef.current.cursor,
|
||||
onEvent: (raw) => {
|
||||
if (closed) {
|
||||
return
|
||||
@@ -126,12 +128,6 @@ export function useMobileStructuredAgentSession(args: {
|
||||
}
|
||||
if (event.type === 'snapshot' || event.type === 'reset') {
|
||||
setLoadingOlder(false)
|
||||
resumeCursorRef.current = event.snapshot.cursor
|
||||
} else if (event.type === 'batch') {
|
||||
const current = resumeCursorRef.current
|
||||
if (shouldAdvanceStructuredResumeCursor(current, event.batch.cursor)) {
|
||||
resumeCursorRef.current = event.batch.cursor
|
||||
}
|
||||
}
|
||||
coalescer.push(event)
|
||||
}
|
||||
@@ -149,7 +145,6 @@ export function useMobileStructuredAgentSession(args: {
|
||||
const result = response.result as AgentSessionHistoryResult
|
||||
if (result.ok) {
|
||||
dispatch({ type: 'tail-page', page: result.page })
|
||||
resumeCursorRef.current = result.page.liveCursor ?? null
|
||||
openSubscription(result.page.liveCursor ?? null)
|
||||
return
|
||||
}
|
||||
@@ -163,7 +158,6 @@ export function useMobileStructuredAgentSession(args: {
|
||||
fence: result.fence ?? 0
|
||||
}
|
||||
})
|
||||
resumeCursorRef.current = result.snapshot.cursor
|
||||
openSubscription(result.snapshot.cursor)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '../../../src/shared/structured-agent-session-mutation'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
|
||||
export type MobileStructuredPromptItem = AgentJournalRenderItem & {
|
||||
body: AgentJournalApprovalItem | AgentJournalQuestionItem
|
||||
|
||||
@@ -3,7 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { useQuickCommands } from './use-quick-commands'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
|
||||
import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
|
||||
|
||||
function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClient {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user