Merge origin/main into brennanb2025/ready-stream-transport-release

Conflicts:
- mobile-relay-rpc-streams.ts: keep main's sendOrder sibling skip on every
  unsubscribe; route the cancel-before-ready arm through isReadyIdStream and
  keep the name-guessing fallback deleted (only notifications and accounts
  reached it, both now in the ready-id table).
- rpc-client-stream-registry.ts: keep main's receivedSnapshot reset beside the
  shared subscriptionId reset in markForReplay.
- Goldens, bridged-parity counts: take main's; the next commit re-records.
- pilot-scenarios.json: main's manifest with this branch's three
  notifications.unsubscribe#1 replies removed.

Also: agentSession.subscribe gained agentSession.unsubscribe on main (#22835),
so its inventory release is now 'params', and the recorder comment no longer
names it as builder-less.
This commit is contained in:
Brennan Benson
2026-09-29 16:38:59 -07:00
5683 changed files with 286237 additions and 72951 deletions
@@ -2,6 +2,10 @@ name: Install Node dependencies
description: Installs the Node toolchain and repository dependencies for CI jobs, with optional Electron archive caching.
inputs:
cache-pnpm-verification:
description: Restore pnpm's policy-checked lockfile verification record on Linux.
required: false
default: 'true'
native-runtime:
description: Native runtime to prepare after the script-free install (none, node, or electron).
required: false
@@ -24,6 +28,15 @@ inputs:
default: 'false'
outputs:
verification-cache-hit:
description: Whether pnpm's verification record was restored.
value: ${{ steps.verification-cache-restore.outputs.cache-hit }}
verification-cache-path:
description: The small pnpm-owned verification record, without registry metadata.
value: ${{ steps.verification-cache.outputs.path }}
verification-cache-key:
description: Exact verification record key, also used by the isolated PR benchmark.
value: ${{ steps.verification-cache.outputs.key }}
node-version:
description: Resolved Node.js version used for the install.
value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
@@ -50,8 +63,9 @@ runs:
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }}
cache-dependency-path: ${{ inputs.cache-dependency-path }}
package-manager-cache: false
- name: Setup requested Node.js
id: requested-node
@@ -59,8 +73,50 @@ runs:
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
cache: pnpm
cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }}
cache-dependency-path: ${{ inputs.cache-dependency-path }}
package-manager-cache: false
# PR-local stores compete with reusable build caches for the repository quota.
- name: Resolve pnpm download store
id: pnpm-store
if: github.event_name == 'pull_request'
shell: bash
env:
LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }}
run: |
test -n "$LOCKFILE_HASH"
cache_path="$(pnpm store path --silent)"
test -n "$cache_path"
printf 'path=%s\n' "$cache_path" >> "$GITHUB_OUTPUT"
printf 'arch=%s\n' "$(node -p 'require("node:os").arch()')" >> "$GITHUB_OUTPUT"
# Match setup-node's key and path so existing default-branch stores remain reusable.
- name: Restore pnpm download store without saving
if: github.event_name == 'pull_request'
uses: actions/cache/restore@v5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }}
- name: Resolve pnpm verification cache
id: verification-cache
if: runner.os == 'Linux' && inputs.cache-pnpm-verification == 'true'
shell: bash
env:
POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }}
run: |
printf 'path=%s/lockfile-verified.jsonl\n' "$(pnpm cache path)" >> "$GITHUB_OUTPUT"
printf 'key=pnpm-verification-v1-%s-%s-%s-%s\n' "$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH" >> "$GITHUB_OUTPUT"
- name: Restore pnpm verification record
id: verification-cache-restore
if: steps.verification-cache.outputs.key != ''
continue-on-error: true
uses: actions/cache/restore@v5
with:
path: ${{ steps.verification-cache.outputs.path }}
key: ${{ steps.verification-cache.outputs.key }}
- name: Validate native runtime
shell: bash
@@ -96,6 +152,15 @@ runs:
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml
fi
# pnpm checks the cached record's policy and validity; never bypass verification.
- name: Save pnpm verification record on main
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache-restore.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@v5
with:
path: ${{ steps.verification-cache.outputs.path }}
key: ${{ steps.verification-cache.outputs.key }}
- name: Resolve Electron package cache
id: electron-package-cache
if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'
@@ -116,12 +181,19 @@ runs:
printf 'version=%s\n' "$(node -p "require('./node_modules/electron/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Electron package archive
if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'
if: (github.event_name != 'pull_request' || runner.os != 'Linux') && steps.electron-package-cache.outputs.version != ''
uses: actions/cache@v5
with:
path: ${{ steps.electron-package-cache.outputs.cache-root }}
key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }}
- name: Restore Electron package archive without saving
if: github.event_name == 'pull_request' && runner.os == 'Linux' && steps.electron-package-cache.outputs.version != ''
uses: actions/cache/restore@v5
with:
path: ${{ steps.electron-package-cache.outputs.cache-root }}
key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }}
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
@@ -0,0 +1,32 @@
name: Prepare baseline Git compatibility binary
description: Restore or build the pinned Linux Git binary for the compatibility contract.
runs:
using: composite
steps:
# The default-branch warmer shares this key across PRs; a PR save is private to its merge ref.
# Cache eviction remains possible, so keep the checksum-verified cold build below.
- name: Cache baseline Git build
uses: actions/cache@v5
with:
path: ~/.cache/orca-git-compat/git-2.25.5
key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5
# Finish the CPU-heavy build before any timed compatibility lanes start.
- name: Build the baseline Git binary
shell: bash
run: |
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$HOME/.cache/orca-git-compat/git-2.25.5"
if [ -x "$source/git" ]; then
exit 0
fi
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
# Object files are no longer needed after linking the cached binary.
find "$source" -name '*.o' -delete
@@ -0,0 +1,63 @@
name: Prepare cached Linux package fixture
description: Restore Docker build layers; only the main warmer builds and saves missing images.
inputs:
fixture:
description: Directory name below config/docker.
required: true
save-cache:
description: Build and save a missing fixture on the default branch.
default: 'false'
outputs:
image:
description: Loaded image to use as a Docker build cache, or empty on a cache miss.
value: ${{ steps.image.outputs.image }}
runs:
using: composite
steps:
- name: Restore fixture image
id: cache
uses: actions/cache/restore@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/orca-package-fixtures/${{ inputs.fixture }}.tar
key: linux-package-fixture-v1-${{ runner.os }}-${{ runner.arch }}-linux-amd64-${{ inputs.fixture }}-${{ hashFiles(format('config/docker/{0}/**', inputs.fixture), '.github/actions/prepare-linux-package-fixture/action.yml') }}
- name: Load or seed fixture build cache
id: image
shell: bash
env:
FIXTURE: ${{ inputs.fixture }}
SAVE_CACHE: ${{ inputs.save-cache }}
CACHE_HIT: ${{ steps.cache.outputs.cache-hit }}
run: |
set -euo pipefail
case "$FIXTURE" in
headless-serve-shutdown|cli-launch-contract) ;;
*) echo "Unsupported package fixture: $FIXTURE" >&2; exit 1 ;;
esac
archive="$RUNNER_TEMP/orca-package-fixtures/$FIXTURE.tar"
image="orca-package-fixture-$FIXTURE:cache"
if [[ "$CACHE_HIT" == true ]] && docker load --input "$archive"; then
if docker image inspect "$image" > /dev/null; then
echo "image=$image" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
if [[ "$SAVE_CACHE" != true ]]; then
echo 'No usable fixture cache; the package runner will build normally.'
exit 0
fi
docker build --platform linux/amd64 --build-arg BUILDKIT_INLINE_CACHE=1 \
--tag "$image" --file "config/docker/$FIXTURE/Dockerfile" "config/docker/$FIXTURE"
mkdir -p "$(dirname "$archive")"
docker save --output "$archive" "$image"
echo "image=$image" >> "$GITHUB_OUTPUT"
echo 'built=true' >> "$GITHUB_OUTPUT"
- name: Save fixture image
if: inputs.save-cache == 'true' && steps.cache.outputs.cache-hit != 'true' && steps.image.outputs.built == 'true'
uses: actions/cache/save@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/orca-package-fixtures/${{ inputs.fixture }}.tar
key: ${{ steps.cache.outputs.cache-primary-key }}
@@ -0,0 +1,33 @@
name: Restore xterm build dependencies
description: Reuse installed dependencies for the pinned upstream checkout.
outputs:
cache-key:
description: Exact dependency cache key.
value: ${{ steps.restore.outputs.cache-primary-key }}
cache-hit:
description: Whether the exact installed dependency cache was restored.
value: ${{ steps.restore.outputs.cache-hit }}
runs:
using: composite
steps:
- id: runtime
shell: bash
run: |
. /etc/os-release
echo "image=$ID-$VERSION_ID" >> "$GITHUB_OUTPUT"
echo "node=$(node --version)" >> "$GITHUB_OUTPUT"
- id: restore
uses: actions/cache/restore@v5
with:
path: |
${{ runner.temp }}/xterm-patch-build/upstream/.git
${{ runner.temp }}/xterm-patch-build/upstream/node_modules
key: xterm-dependencies-v1-${{ runner.os }}-${{ steps.runtime.outputs.image }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ hashFiles('config/patches/xterm-upstream.json', 'config/scripts/regenerate-xterm-patches.mjs', 'config/scripts/xterm-patch-text.mjs', '.github/actions/prepare-xterm-dependencies/action.yml') }}
- name: Restore download cache on an installed-dependency miss
if: steps.restore.outputs.cache-hit != 'true'
uses: actions/cache/restore@v5
with:
path: |
~/.npm
${{ runner.temp }}/xterm-patch-build/upstream/.git
key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
+2 -1
View File
@@ -12,7 +12,8 @@
## Linked Issue
<!-- Link the issue this PR addresses, there should ALWAYS be one -->
<!-- Link the issue this PR addresses, there should ALWAYS be one (for outside contributors) -->
<!-- SPECIAL CASE: If you are a maintainer (member of stablyai org) AVOID opening needless issues. Only attach pre-existing ones -->
Fixes #
+171
View File
@@ -0,0 +1,171 @@
name: Bun profile persistence
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'src/**'
- 'config/**'
- 'native/**'
- 'tests/**'
- 'resources/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.json'
- '.npmrc'
- '.pnpmfile.cjs'
- '.github/actions/install-node-dependencies/**'
- '.github/workflows/bun-profile-tests.yml'
# The pull request qualifies one platform for an unflavoured change; this is where all six
# are re-qualified, so a platform break surfaces minutes after merge instead of next cron.
push:
branches: [main]
paths:
- 'src/**'
- 'config/**'
- 'native/**'
- 'tests/**'
- 'resources/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.json'
- '.npmrc'
- '.pnpmfile.cjs'
- '.github/actions/install-node-dependencies/**'
- '.github/workflows/bun-profile-tests.yml'
workflow_dispatch:
schedule:
- cron: '30 11 * * *'
permissions:
contents: read
concurrency:
group: bun-profile-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
should_run: ${{ steps.scope.outputs.should_run }}
qualification: ${{ steps.scope.outputs.qualification }}
runners: ${{ steps.scope.outputs.runners }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Detect Bun build and test inputs
id: scope
shell: bash
run: |
# Compare the tested merge with its base, retaining both sides of renames.
if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/bun-changes"; then
node config/scripts/bun-profile-change-scope.mjs "$RUNNER_TEMP/bun-changes"
else
echo 'should_run=true' >> "$GITHUB_OUTPUT"
fi
persistence:
needs: changes
# Missing/failed detection runs the full matrix; manual runs remain unconditional.
# A draft carries no platform verdict; readiness re-triggers this workflow. Spelled against
# the event name so the push and schedule paths do not rest on a null property comparison.
if: >-
${{ !cancelled() && needs.changes.outputs.should_run != 'false' &&
(github.event_name != 'pull_request' || github.event.pull_request.draft != true) }}
strategy:
fail-fast: false
matrix:
os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-14","macos-15-intel","windows-2022","windows-11-arm"]') }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }}
# build:orcad reuses the host addon validated by native-runtime preparation.
- run: pnpm build:orcad
- run: pnpm test:bun:profile --artifact
- uses: actions/setup-node@v6
if: runner.arch == 'X64'
with:
node-version: '18'
- name: Verify Node 18 loads and hands off to bundled Bun
if: runner.arch == 'X64'
run: |
node out/orcad/orcad.js --orcad-smoke-load-check
node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018
linux_glibc_floor:
needs: [changes, persistence]
# A failed smoke already blocks qualification; missing scope still selects every platform.
if: >-
${{ !cancelled() && needs.persistence.result == 'success' &&
needs.changes.outputs.should_run != 'false' &&
needs.changes.outputs.qualification != 'false' }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
container: ubuntu:20.04
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- name: Install Ubuntu 20.04 prerequisites
run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Trust the checked-out workspace
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- uses: ./.github/actions/install-node-dependencies
- run: pnpm build:orcad
- run: pnpm test:bun:profile --artifact
linux_musl:
needs: [changes, persistence]
# A failed smoke already blocks qualification; missing scope still selects every platform.
if: >-
${{ !cancelled() && needs.persistence.result == 'success' &&
needs.changes.outputs.should_run != 'false' &&
needs.changes.outputs.qualification != 'false' }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Verify native Alpine artifact and persistence
run: |
docker run --rm --init -i \
-e ORCA_BACKGROUND_LAUNCH=1 \
-v "$GITHUB_WORKSPACE:/work" -w /work \
node:24-alpine3.23 sh -s <<'BUN_QUALIFICATION'
set -eu
apk add --no-cache bash git libstdc++ python3 make g++
git config --global --add safe.directory /work
npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")"
pnpm install --frozen-lockfile --ignore-scripts
pnpm build:orcad
pnpm test:bun:profile --artifact
BUN_QUALIFICATION
+131
View File
@@ -0,0 +1,131 @@
name: Warm shared CI caches
on:
schedule:
- cron: '41 * * * *'
workflow_dispatch:
push:
branches: [main]
paths:
- '.github/workflows/ci-cache-warmup.yml'
- '.github/actions/install-node-dependencies/**'
- '.github/actions/prepare-git-compatibility/**'
- '.github/actions/prepare-linux-package-fixture/**'
- 'config/docker/headless-serve-shutdown/**'
- 'config/docker/cli-launch-contract/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'config/tsconfig*.json'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'config/patches/node-pty@1.1.0.patch'
- 'config/patches/@vscode__windows-process-tree@0.8.0.patch'
- 'native/windows-registry/**'
pull_request:
paths:
- '.github/workflows/ci-cache-warmup.yml'
- '.github/actions/prepare-git-compatibility/**'
- '.github/actions/prepare-linux-package-fixture/**'
- 'config/docker/headless-serve-shutdown/**'
- 'config/docker/cli-launch-contract/**'
- 'config/scripts/ci-cache-warmup-workflow.test.mjs'
permissions:
contents: read
concurrency:
group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
warm:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
# Default-branch caches can be restored by every PR; PR caches cannot.
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: '24'
cache-electron-package: 'true'
- name: Populate shared Electron archive
run: node config/scripts/install-electron-package-binary.mjs
- uses: ./.github/actions/prepare-git-compatibility
- name: Cache TypeScript incremental state
id: typecheck-cache
uses: actions/cache@v5
with:
path: config/*.tsbuildinfo
key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }}
restore-keys: |
tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-
- name: Refresh TypeScript state for this commit
if: steps.typecheck-cache.outputs.cache-hit != 'true'
run: pnpm run typecheck
warm-linux-arm:
runs-on: ubuntu-24.04-arm
timeout-minutes: 10
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: '24'
cache-electron-package: 'true'
- name: Populate shared Electron archive
run: node config/scripts/install-electron-package-binary.mjs
- name: Verify native cache is usable
run: node config/scripts/ensure-native-runtime.mjs --check-only
warm-windows:
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-11-arm]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Verify native cache is usable
run: node config/scripts/ensure-native-runtime.mjs --check-only
warm-linux-package-fixtures:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/prepare-linux-package-fixture
with:
fixture: headless-serve-shutdown
save-cache: ${{ github.event_name != 'pull_request' }}
- uses: ./.github/actions/prepare-linux-package-fixture
with:
fixture: cli-launch-contract
save-cache: ${{ github.event_name != 'pull_request' }}
+33
View File
@@ -0,0 +1,33 @@
name: Clean closed PR caches
on:
pull_request_target:
types: [closed]
permissions:
actions: write
jobs:
clean:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# No checkout: this runs trusted default-branch code, including for fork PRs.
- uses: actions/github-script@v8
with:
script: |
const ref = `refs/pull/${context.payload.pull_request.number}/merge`
const caches = await github.paginate(github.rest.actions.getActionsCacheList, {
...context.repo, ref, per_page: 100
})
for (const cache of caches) {
if (cache.ref !== ref) throw new Error(`Unexpected cache ref: ${cache.ref}`)
try {
await github.rest.actions.deleteActionsCacheById({
...context.repo, cache_id: cache.id
})
} catch (error) {
if (error.status !== 404) throw error
}
}
core.info(`Removed ${caches.length} caches scoped to ${ref}`)
@@ -0,0 +1,65 @@
name: pnpm verification cache pilot
on:
pull_request:
paths: ['.github/workflows/ci-pnpm-verification-pilot.yml']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pnpm-verification-pilot-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
ORCA_BACKGROUND_LAUNCH: '1'
jobs:
seed:
strategy:
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
id: deps
# PR caches are scoped to the PR merge ref; main never restores them.
- uses: actions/cache/save@v5
if: steps.deps.outputs.verification-cache-hit != 'true'
with:
path: ${{ steps.deps.outputs.verification-cache-path }}
key: ${{ steps.deps.outputs.verification-cache-key }}
measure:
needs: seed
name: measure ${{ matrix.runner }} sample ${{ matrix.sample }} cache ${{ matrix.cache }}
strategy:
fail-fast: false
max-parallel: 4
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
sample: [1, 2, 3]
cache: ['false', 'true']
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
id: deps
with:
cache-pnpm-verification: ${{ matrix.cache }}
- name: Validate treatment and frozen install
env:
CACHE_ENABLED: ${{ matrix.cache }}
CACHE_HIT: ${{ steps.deps.outputs.verification-cache-hit }}
run: |
if [ "$CACHE_ENABLED" = true ]; then test "$CACHE_HIT" = true; fi
git diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml
node -e "require.resolve('vitest'); require.resolve('electron')"
+33
View File
@@ -0,0 +1,33 @@
name: CI runner demand
on:
schedule:
- cron: '23 4 * * *'
workflow_dispatch:
permissions:
contents: read
actions: read
concurrency:
group: ci-runner-demand
cancel-in-progress: false
jobs:
report:
runs-on: ubuntu-slim
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
sparse-checkout: config/scripts
persist-credentials: false
- name: Measure the previous complete 24 hours
env:
GH_TOKEN: ${{ github.token }}
run: node config/scripts/ci-runner-demand.mjs
- uses: actions/upload-artifact@v7
with:
name: ci-runner-demand-${{ github.run_id }}
path: ci-demand/
retention-days: 30
+55
View File
@@ -0,0 +1,55 @@
name: Shared xterm dependency cache
on:
push:
branches: [main]
paths:
- '.github/workflows/ci-xterm-cache.yml'
- '.github/actions/prepare-xterm-dependencies/**'
- 'config/patches/xterm-upstream.json'
- 'config/scripts/regenerate-xterm-patches.mjs'
- 'config/scripts/xterm-patch-text.mjs'
- 'package.json'
# Refresh after Node patch releases or cache eviction without rebuilding on hits.
schedule:
- cron: '17 5 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: xterm-cache-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
ORCA_BACKGROUND_LAUNCH: '1'
jobs:
seed:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04-arm
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
package-manager-cache: false
- uses: ./.github/actions/prepare-xterm-dependencies
id: cache
- name: Verify and populate dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$RUNNER_TEMP/xterm-patch-build"
- uses: actions/cache/save@v5
if: steps.cache.outputs.cache-hit != 'true'
with:
path: |
${{ runner.temp }}/xterm-patch-build/upstream/.git
${{ runner.temp }}/xterm-patch-build/upstream/node_modules
key: ${{ steps.cache.outputs.cache-key }}
- name: Preserve fallback downloads
if: steps.cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: |
~/.npm
${{ runner.temp }}/xterm-patch-build/upstream/.git
key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
+24 -1
View File
@@ -32,9 +32,30 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 1
- name: Pull Gitleaks image
id: gitleaks-image
background: true
run: docker pull zricethezav/gitleaks@sha256:cdbb7c955abce02001a9f6c9f602fb195b7fadc1e812065883f695d1eeaba854
- name: Pull TruffleHog image
id: trufflehog-image
background: true
run: docker pull trufflesecurity/trufflehog@sha256:5dc064868ba7933601b5cbaea6954954d524ddd5dc6222a9667acea70068bf7d
# Scan every ancestor of HEAD without downloading unrelated branch/tag histories.
- name: Fetch complete scan history
working-directory: .
run: |
git fetch --no-tags --unshallow origin "$GITHUB_SHA"
test "$(git rev-parse --is-shallow-repository)" = false
- wait: [gitleaks-image, trufflehog-image]
- name: Scan Cloud history reachable from HEAD with Gitleaks
id: history-scan
background: true
run: >-
docker run --rm
--volume "${GITHUB_WORKSPACE}:/repo:ro"
@@ -51,6 +72,8 @@ jobs:
--include-paths=/repo/cloud/.trufflehog-include-paths.txt
--exclude-paths=/repo/cloud/.trufflehog-exclude-paths.txt
- wait: history-scan
# Compiles the workspace. No Postgres service: nothing here reaches a
# database, and the service container costs ~13s of startup.
build:
+94 -31
View File
@@ -32,9 +32,8 @@ on:
required: false
type: string
schedule:
# Why: GitHub cron uses UTC; these slots map to 10am and 3pm
# America/Phoenix for the default-branch E2E run.
- cron: '0 17,22 * * *'
# One complete daily reference run; targeted PR coverage remains unchanged.
- cron: '0 17 * * *'
jobs:
build:
@@ -62,11 +61,26 @@ jobs:
status=0
pnpm run build:relay &
relay_pid=$!
npx electron-vite build --mode e2e || status=1
pnpm run build:web-from-renderer || status=1
pnpm run build:electron-vite:parallel --mode e2e || status=1
wait "$relay_pid" || status=1
exit "$status"
# The CLI emits into out/main too; start only after Electron finishes clearing that tree.
- name: Build shared E2E CLI
id: e2e-cli
background: true
run: |
if node -e 'process.exit(require("./package.json").scripts["prepare:cli-output"] ? 0 : 1)'; then
pnpm run build:cli
else
echo "Older ref: let each consumer build and install its CLI."
fi
- name: Project shared E2E web client
run: pnpm run build:web-from-renderer
- wait: e2e-cli
- name: Upload E2E build output
uses: actions/upload-artifact@v7
with:
@@ -186,7 +200,14 @@ jobs:
node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards/assignment.json ci-shards/selected-discovery.json
- name: Run E2E tests (${{ matrix.shard_name }})
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ci-shards/results.json
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt --reporter=list,json
- name: Summarize E2E failures
if: always()
continue-on-error: true
run: node config/scripts/ci-e2e-failure-summary.mjs ci-shards/results.json
- name: Upload E2E shard assignment
if: always()
@@ -221,8 +242,6 @@ jobs:
needs: [build, prepare-native-cache]
if: inputs.test_files != ''
runs-on: ubuntu-latest
# Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can
# pay a container image build plus ~22 serial SSH tests on top of the changed specs.
timeout-minutes: 45
steps:
@@ -256,14 +275,38 @@ jobs:
# ORCA_E2E_NATIVE_IBUS_HANGUL, which this lane cannot set because it has no ibus
# session. Running it here reported a green skip as coverage.
mapfile -t TEST_FILES < <(jq -r '.[] | select(
. != "tests/e2e/local-ssh-browser-routing.spec.ts" and
. != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and
. != "tests/e2e/pty-input-write-queue-ssh.spec.ts" and
. != "tests/e2e/ssh-ai-vault-session-history.spec.ts" and
. != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and
. != "tests/e2e/ssh-cold-activation-restore.spec.ts" and
. != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and
. != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and
. != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and
. != "tests/e2e/ssh-docker-half-open-link.spec.ts" and
. != "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts" and
. != "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts" and
. != "tests/e2e/ssh-docker-relay-stall-credential.spec.ts" and
. != "tests/e2e/ssh-docker-resource-accumulation.spec.ts" and
. != "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts" and
. != "tests/e2e/ssh-external-image-preview.spec.ts" and
. != "tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts" and
. != "tests/e2e/ssh-pi-compatible-agent-title.spec.ts" and
. != "tests/e2e/ssh-port-forward-lifecycle.spec.ts" and
. != "tests/e2e/ssh-reconnect-tab-destruction.spec.ts" and
. != "tests/e2e/ssh-restart-tab-accumulation.spec.ts" and
. != "tests/e2e/ssh-skill-installation.spec.ts" and
. != "tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts" and
. != "tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts" and
. != "tests/e2e/terminal-inline-images-ssh.spec.ts" and
. != "tests/e2e/ssh-docker-watcher-isolation.spec.ts" and
. != "tests/e2e/ssh-terminal-parking.spec.ts" and
. != "tests/e2e/terminal-retention-budget.spec.ts" and
. != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and
. != "tests/e2e/paired-startup-exec-readiness.spec.ts" and
. != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and
. != "tests/e2e/local-ssh-browser-routing.spec.ts" and
. != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and
. != "tests/e2e/ssh-localhost.spec.ts" and
. != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and
. != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and
. != "tests/e2e/terminal-ibus-hangul-native.spec.ts"
)' <<<"$TEST_FILES_JSON")
if [ "${#TEST_FILES[@]}" -eq 0 ]; then
@@ -295,25 +338,47 @@ jobs:
if-no-files-found: ignore
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
name: ssh docker watcher isolation (${{ matrix.shard }}/4)
needs: [build, prepare-native-cache]
# effect of one route listing a startup-readiness spec — pruning that spec would have
# silently retired the whole lane. The signal is now derived from the SSH routes directly.
# The explicit spec clauses stay for their honest purpose: changed-e2e hands these specs to this
# lane, so editing one must still run it here.
# Each excluded changed spec must trigger its dedicated owner.
if: >-
inputs.test_files == '' ||
inputs.ssh_source_changed == 'true' ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/local-ssh-browser-routing.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/pty-input-write-queue-ssh.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-ai-vault-session-history.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-cold-activation-restore.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-half-open-link.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-resource-accumulation.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-external-image-preview.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-port-forward-lifecycle.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-restart-tab-accumulation.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-skill-installation.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/terminal-inline-images-ssh.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-watcher-isolation.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-terminal-parking.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/terminal-retention-budget.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
runs-on: ubuntu-latest
# Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average
# ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35
# — and the sharded lanes already show that a lane which times out is a lane nobody trusts.
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
timeout-minutes: 60
steps:
@@ -338,13 +403,14 @@ jobs:
# Why: this is the release-path proof that the deployed Linux relay keeps
# its PTY and explorer live across a real watcher SIGSEGV.
- name: Run Docker SSH watcher isolation E2E
if: matrix.shard == 1
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
# Why: Playwright empties test-results/ when it starts, so each step here used to
# destroy the previous step's traces. Only the last lane's failure was ever
# diagnosable from the artifact; set each lane aside before the next one runs.
- name: Keep watcher-isolation traces
if: always()
if: always() && matrix.shard == 1
run: |
if [ -d test-results ]; then
mkdir -p e2e-traces
@@ -354,24 +420,21 @@ jobs:
# Why always(): this lane gates SSH parking/retention plus startup-exec
# readiness across live SSH, headed paired, and headless serve topologies.
- name: Run Docker SSH terminal parking + startup readiness E2E
if: always()
if: always() && matrix.shard == 1
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
- name: Keep terminal-parking traces
if: always()
if: always() && matrix.shard == 1
run: |
if [ -d test-results ]; then
mkdir -p e2e-traces
mv test-results "e2e-traces/terminal-parking"
fi
# Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every
# spec below skipped itself while the shard still reported green. Running them on this one
# VM pays the fixture image build once instead of ten times, and keeps an SSH regression
# legible as an SSH-named failure.
# Separate VMs isolate destructive SSH fixtures while keeping one worker per shard.
- name: Run remaining Docker SSH E2E
if: always()
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker --shard=${{ matrix.shard }}/4
- name: Keep remaining-ssh-docker traces
if: always()
@@ -385,7 +448,7 @@ jobs:
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-traces-ssh-docker-watcher-isolation
name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }}
path: e2e-traces/
retention-days: 7
if-no-files-found: ignore
@@ -7,6 +7,9 @@ on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: git-command-termination-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
windows-exit:
runs-on: windows-latest
+5 -5
View File
@@ -66,10 +66,10 @@ jobs:
- name: Run golden E2E tests on Linux
if: runner.os == 'Linux'
run: |
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
fi
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
@@ -80,10 +80,10 @@ jobs:
- name: Run golden E2E tests on macOS
if: runner.os == 'macOS'
run: |
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-core-flows.spec.ts
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
fi
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
@@ -104,7 +104,7 @@ jobs:
pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) {
pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
pnpm run test:e2e tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
pnpm run --if-present test:e2e:source-control-golden
+4 -5
View File
@@ -132,11 +132,10 @@ jobs:
print(src)
PY
# Why: reuse the existing buf0-bot GitHub App (also used by
# track-community-prs.yaml) instead of minting a new PAT. The app is
# installed org-wide, so it has access to stablyai/homebrew-orca
# automatically. GITHUB_TOKEN cannot push cross-repo; a short-lived
# installation token can.
# Why: reuse the existing buf0-bot GitHub App instead of minting a new
# PAT. The app is installed org-wide, so it has access to
# stablyai/homebrew-orca automatically. GITHUB_TOKEN cannot push
# cross-repo; a short-lived installation token can.
- name: Generate buf0-bot token
id: app-token
uses: actions/create-github-app-token@v3
+1 -1
View File
@@ -12,7 +12,7 @@ permissions:
jobs:
apply-os-label:
runs-on: ubuntu-latest
runs-on: ubuntu-slim
timeout-minutes: 5
steps:
- name: Apply OS label from issue form
+63 -11
View File
@@ -6,7 +6,6 @@ on:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- 'mobile/**'
# Mobile launch contracts exercise the real host dispatcher and durable receipt store.
@@ -38,9 +37,12 @@ on:
- '.github/workflows/mobile.yml'
- '.github/actions/install-node-dependencies/**'
- '.github/workflows/mobile-ios-release.yml'
# Why main too: a behaviour-change branch legitimately pins its own last fenced commit, and that
# commit only stops being reachable when the branch squash-merges. The pull_request run cannot
# see that; this one is where the pin guard finds it.
- 'config/scripts/mobile-release-check-scope*'
- 'config/scripts/mobile-test-change-scope*'
- 'config/scripts/pr-code-change-scope.mjs'
- 'config/scripts/mobile-recording-pin-checkout.test.mjs'
# Why main too: a squash is where a spliced corpus lands, and where a behaviour-change branch's
# own pinned commit leaves main's history for its pull request's head ref, which the guard follows.
push:
branches:
- main
@@ -58,7 +60,10 @@ concurrency:
jobs:
verify:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
# Why ARM: 209s of this job is Vitest and nothing here needs x86: no Android SDK, emulator, gradle,
# Hermes or Watchman, no docker, and no artifacts. The Gemfile.lock lists the generic `ruby`
# platform, so frozen bundler installs without an aarch64-linux entry.
runs-on: ubuntu-24.04-arm
env:
# Why: an unfrozen bundler silently re-resolves when Gemfile.lock drifts
@@ -73,6 +78,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 2
- uses: ./.github/actions/install-node-dependencies
with:
@@ -80,10 +87,23 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
- name: Detect Ruby release inputs
id: ruby-scope
shell: bash
working-directory: .
run: |
# Keep deletions when release files move into an application directory.
if ! git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/mobile-release-changes"; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
elif ! node config/scripts/mobile-release-check-scope.mjs "$RUNNER_TEMP/mobile-release-changes"; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
fi
# bundler-cache installs mobile/Gemfile.lock, so this job is also what
# proves the pinned fastlane the release workflow depends on still
# resolves — before a release run finds out.
- name: Setup Ruby and fastlane
if: steps.ruby-scope.outputs.should_run != 'false'
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
@@ -93,7 +113,10 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Both compilers are read-only; finish them before starting the test workers.
- name: Typecheck
id: production-types
background: true
run: pnpm typecheck
# Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until
@@ -103,19 +126,37 @@ jobs:
- name: Typecheck tests (ratchet)
run: pnpm run check:tests-typecheck
- wait: production-types
# This includes the bridged replay of the whole recording corpus, which used to be a second
# step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point:
# it fails when a divergence class grows, when a divergence lands in no class at all, or when
# one of the 103 goldens inside the C1 page closure changes the verdict it is pinned to. It is
# ~3 min of test time on its own, and Vitest runs it on a worker beside the rest of the suite,
# so folding it in costs a fraction of that in wall time and one step less to skip.
- name: Detect mobile test inputs
id: test-scope
shell: bash
working-directory: .
run: |
if ! git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/mobile-test-changes"; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
elif ! node config/scripts/mobile-test-change-scope.mjs "$RUNNER_TEMP/mobile-test-changes"; then
echo 'should_run=true' >> "$GITHUB_OUTPUT"
fi
- name: Test
if: steps.test-scope.outputs.should_run != 'false'
env:
ORCA_BACKGROUND_LAUNCH: '1'
run: pnpm test
- name: Test iOS release version resolution
if: steps.ruby-scope.outputs.should_run != 'false'
run: ruby fastlane/ios_release_version_test.rb
- name: Test TestFlight lane arguments
if: steps.ruby-scope.outputs.should_run != 'false'
run: ruby fastlane/fastfile_testflight_arguments_test.rb
# Why: nothing else in CI loads the Fastfile, so a syntax error, a broken
@@ -124,6 +165,7 @@ jobs:
# loads and lists, so it needs no App Store Connect credentials and makes
# no network calls to Apple.
- name: Smoke-check the Fastfile
if: steps.ruby-scope.outputs.should_run != 'false'
env:
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_OPT_OUT_USAGE: '1'
@@ -137,7 +179,12 @@ jobs:
recording-pin:
name: RPC recording pin
runs-on: ubuntu-latest
# Why ARM: pure Node plus git; the golden comparison masks `platform`.
runs-on: ubuntu-24.04-arm
# Why pull-requests: the guard asks GitHub which pull requests hold a pin main's history lacks.
permissions:
contents: read
pull-requests: read
defaults:
run:
@@ -147,11 +194,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
# The ancestry verdict is read straight off history. On a shallow checkout
# The reachability verdict is read straight off history. On a shallow checkout
# `git merge-base --is-ancestor` answers from grafted parents, so the guard refuses to
# answer at all rather than reporting a pass it has no evidence for -- and the pinned tree
# below has to be checkable out.
fetch-depth: 0
# Ancestry needs commits; the pinned worktree fetches its historical blobs on demand.
filter: blob:none
- uses: ./.github/actions/install-node-dependencies
with:
@@ -165,12 +214,14 @@ jobs:
# Seconds. No `--ref`, so the pin is judged against the same tree it was read out of. On a
# pull request that is the merge preview, which already carries main's repins; judging the
# branch head instead fails every branch cut before the day's repin, and its instruction would
# tell the author to pin their own head -- creating the break this guard exists to catch. A
# branch that pins its own commit passes here and fails on the push after the squash, which is
# where the pin actually leaves the history.
# tell the author to pin their own head. A branch that pins its own commit still passes on the
# push after the squash: the guard asks GitHub which pull requests hold the pin and fetches
# their `refs/pull/<n>/head`, which GitHub keeps for good. The token lifts the API rate limit.
- name: Check the recording pin is reachable
shell: bash
run: pnpm exec tsx scripts/rpc-recording-pin-guard.mts ancestry
env:
GITHUB_TOKEN: ${{ github.token }}
run: pnpm exec tsx scripts/rpc-recording-pin-guard.mts reachable
# ~2 min locally for the record itself, so it is gated rather than run twice over. A pull
# request that moves none of the corpus, the manifest or the recorder cannot move this
@@ -180,6 +231,7 @@ jobs:
- name: Reproduce the corpus from the pinned tree
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
PIN_GUARD_BASE: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$PIN_GUARD_BASE" ]; then
+15 -7
View File
@@ -1,8 +1,8 @@
name: Node next compatibility
name: Scheduled x86 unit compatibility
on:
schedule:
# Full future-runtime coverage is useful, but not worth doubling every PR matrix.
# Keep current and future Node coverage on x86 while PR unit shards use ARM.
- cron: '0 10 * * *'
workflow_dispatch:
@@ -14,9 +14,13 @@ permissions:
contents: read
jobs:
# A cold cache would otherwise make all eight Node 26 shards compile the same native addons.
# Prime each Node ABI before its shards restore native modules.
test_native_cache:
name: prepare test native cache node 26
name: prepare test native cache node ${{ matrix.node }}
strategy:
fail-fast: false
matrix:
node: ['24', '26']
runs-on: ubuntu-latest
steps:
@@ -28,10 +32,14 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: '26'
node-version: ${{ matrix.node }}
unit_plan:
uses: ./.github/workflows/unit-plan.yml
test:
needs: [test_native_cache]
needs: [test_native_cache, unit_plan]
uses: ./.github/workflows/unit-tests.yml
with:
node_versions: '["26"]'
node_versions: '["24", "26"]'
shards: ${{ needs.unit_plan.outputs.shards }}
+5 -1
View File
@@ -39,7 +39,11 @@ jobs:
root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca'
package=root/'orca-ide_1.4.188_amd64.deb'
expected='uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g=='
assert base64.b64encode(hashlib.sha512(package.read_bytes()).digest()).decode()==expected
digest=hashlib.sha512()
with package.open('rb') as archive:
for block in iter(lambda: archive.read(1024*1024), b''):
digest.update(block)
assert base64.b64encode(digest.digest()).decode()==expected
extracted=root/'extracted'
subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True)
executable=extracted/'opt'/'Orca'/'orca-ide'
+3
View File
@@ -9,6 +9,9 @@ on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pi-owner-runtime-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
runtime:
strategy:
@@ -7,6 +7,9 @@ on:
- '.github/workflows/pi-provider-runtime.yml'
permissions:
contents: read
concurrency:
group: pi-provider-runtime-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
runtime:
strategy:
+2 -2
View File
@@ -6,7 +6,6 @@ on:
- opened
- synchronize
- reopened
- ready_for_review
concurrency:
group: pr-test-loc-${{ github.event.pull_request.number }}
@@ -19,7 +18,8 @@ permissions:
jobs:
loc:
name: test vs non-test LoC
runs-on: ubuntu-latest
# API calls and a small Node script fit the free single-CPU container runner.
runs-on: ubuntu-slim
timeout-minutes: 2
steps:
# Why no checkout: the Files API already has per-file additions/deletions.
+296 -170
View File
@@ -1,4 +1,5 @@
name: PR Checks
run-name: "PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}"
on:
pull_request:
@@ -23,25 +24,32 @@ jobs:
# Per-job outputs also skip git-compat/xterm/packaging/shell when those
# inputs are unchanged; empty diffs fail closed and run everything.
code_paths:
name: detect code-relevant changes
runs-on: ubuntu-latest
name: detect changes and check repository guards
# Reuse one lightweight checkout for detection and the always-required guards.
runs-on: ubuntu-slim
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
reused_run_id: ${{ steps.readiness.outputs.run_id }}
# A proven success masks required work only; advisory routing still uses the full diff.
native_cache_changed: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.native_cache_changed }}
mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }}
mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }}
static_analysis: ${{ steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.filter.outputs.typecheck }}
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
codex_index_heal_contract: ${{ steps.filter.outputs.codex_index_heal_contract }}
xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
test: ${{ steps.filter.outputs.test }}
orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
package: ${{ steps.filter.outputs.package }}
package_windows: ${{ steps.filter.outputs.package_windows }}
mobile_web_app: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_web_app }}
static_analysis: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.typecheck }}
git_compatibility: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.git_compatibility }}
codex_index_heal_contract: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.codex_index_heal_contract }}
xterm_patch_sync: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.xterm_patch_sync }}
shell_contracts: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.shell_contracts }}
test: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.test }}
orcad_browser: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.orcad_browser }}
cross-version-wire: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.cross-version-wire }}
managed_hook_node18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }}
package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }}
package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }}
e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }}
test_files: ${{ steps.e2e_filter.outputs.test_files }}
ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }}
@@ -51,23 +59,59 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
# Why blob:none: full history is needed for the merge-base diff, but historical
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
# few this job actually reads on demand.
fetch-depth: 0
# Why depth 50 and not 0: every diff below resolves to HEAD^1, so only the merge commit
# and a little slack are needed. Fetching all 8127 refs' commit graph cost ~20s here and
# is charged to the start of all 22 jobs, since they all need this one.
# Why blob:none stays: the sparse tree below is ~7 files, so there are no blobs to
# materialize and no promisor refetch. Measured 9.5s -> 1.6s against 20.7s today.
fetch-depth: 50
filter: blob:none
sparse-checkout: |
/config/scripts/git-pull-request-diff-base.mjs
/package.json
/README.md
/docs/readme/
/.github/scripts/check-root-directory-entries.mjs
/config/scripts/check-readme-local-links.mjs
/config/scripts/pr-code-change-scope.mjs
/config/scripts/pr-e2e-source-routing.mjs
/config/scripts/pr-ready-check-reuse.mjs
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Reject new root-level files and folders
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
node .github/scripts/check-root-directory-entries.mjs "$DIFF_BASE" HEAD
# The full Git index retains link targets outside the sparse working tree.
- name: Check README local links
run: node config/scripts/check-readme-local-links.mjs
# Readiness changes eligibility for advisory tests, not the already-tested source.
- name: Find identical successful required checks
id: readiness
if: github.event.action == 'ready_for_review'
env:
GH_TOKEN: ${{ github.token }}
PR_CHECK_WORKFLOW_SHA: ${{ github.workflow_sha }}
run: node config/scripts/pr-ready-check-reuse.mjs
- name: Classify changed paths
id: filter
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# Why HEAD^1 and not --merge-base: HEAD is the pull request merge commit, so its first
# parent is the base side already. Computing a merge base instead would require the
# payload base SHA to be in the graph, which is what forced a full-history checkout.
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
# Why --no-renames: name-only rename detection can report only the destination.
# A code file moved under docs/ must still expose its code-side deletion.
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)"
echo "Changed paths:"
printf '%s\n' "$CHANGED"
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
@@ -79,8 +123,8 @@ jobs:
run: |
set -euo pipefail
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE")"
CHANGED="$(git diff --name-only --diff-filter=AMCR "$DIFF_BASE" HEAD)"
# Source routes are executable contracts so a test can prove exact
# authorities, exclusions, and sentinels without evaluating workflow shell.
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
@@ -94,7 +138,7 @@ jobs:
# trigger on IME source rather than on a spec name in some route's list.
NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)"
echo "native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE" "$HEAD")"
WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)"
WSL_SOURCE_CHANGED="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source)"
echo "wsl_source_changed=$WSL_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED"
@@ -111,38 +155,63 @@ jobs:
name: static analysis
needs: [code_paths]
if: needs.code_paths.outputs.static_analysis == 'true'
runs-on: ubuntu-latest
# Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster --
# type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke
# 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so
# the whole toolchain resolves. Free for public repositories, same as the typecheck job.
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why blob:none: full history is needed for the merge-base diff, but historical
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
# few this job actually reads on demand.
fetch-depth: 0
filter: blob:none
# Why depth 50: the gates below diff against HEAD^1, so no merge base is computed and
# the payload base SHA need not be in the graph.
# Why no blob:none here, unlike code_paths: this job checks out all 30,226 files, and
# the filter then forces a second promisor fetch of nearly every blob. Measured 23s
# blobless against ~11s without it.
fetch-depth: 50
persist-credentials: false
# Why two guarded installs: the mixed root+mobile store entry is 537 MB against
# 321 MB for root alone, and restoring it costs 8.6s against 4.6s. Most PRs skip the
# mobile install below, so they were paying 216 MB for packages they never link. The
# root-only key is also the one the hourly warmer reseeds. Only one of these runs.
- uses: ./.github/actions/install-node-dependencies
if: needs.code_paths.outputs.mobile_dependencies != 'true'
with:
native-runtime: node
- uses: ./.github/actions/install-node-dependencies
if: needs.code_paths.outputs.mobile_dependencies == 'true'
with:
native-runtime: node
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Keep each check in its own log while sharing this runner.
- name: Lint
id: root-lint
background: true
run: pnpm exec oxlint --format github
- name: Reject low-evidence patterns
run: pnpm run audit:anti-slop
- wait: root-lint
- name: Enforce focused code-quality plugins
id: native-code-quality
background: true
run: pnpm run audit:code-quality:native
- name: Enforce type-aware code-quality baseline
run: pnpm run audit:code-quality:type-aware
# Mobile installation changes import resolution for the native cycle check.
- wait: native-code-quality
# Why here: the changed-code gate lints mobile files too, and its type-aware pass
# resolves types from mobile/node_modules. Without the install every mobile type
# degrades to an `error` type — reported as phantom findings against the changed lines.
@@ -150,11 +219,15 @@ jobs:
if: needs.code_paths.outputs.mobile_dependencies == 'true'
- name: Enforce changed-code quality
id: changed-code-quality
background: true
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
- name: Enforce React Doctor on changed lines
run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}"
- wait: changed-code-quality
- name: Check Zustand selector fan-out budget
run: pnpm run check:zustand-selector-fanout
@@ -167,9 +240,9 @@ jobs:
- name: Check VM runtime rollback compatibility
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
if git diff --quiet "$DIFF_BASE" HEAD -- \
src/shared/ephemeral-vm-runtime-store.ts \
src/shared/ephemeral-vm-runtime-feature-store.ts \
src/shared/ephemeral-vm-runtime-rollback-projection.ts \
@@ -194,11 +267,39 @@ jobs:
- name: Enforce runtime Electron-import ratchet
run: pnpm run check:runtime-electron-ratchet
# Why: extraction writes sorted evidence to an isolated temporary path,
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
- name: Verify localization extraction
id: localization-extraction
background: true
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
# Detection failures run the full check; renames retain the removed input path.
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" &&
scope="$(node config/scripts/localization-extraction-change-scope.mjs "$RUNNER_TEMP/localization-changes")" && [ "$scope" = false ]; then
echo "Localization extraction inputs are unchanged."
else
pnpm run verify:localization-extraction
fi
# Why both: the ratchet proves nothing reachable from the runtime imports electron,
# which is a property of the import graph. This proves the Node artifact it enables
# actually boots, pairs, creates a worktree and round-trips a real PTY.
- name: Boot orcad and round-trip a terminal
run: pnpm run smoke:orcad-terminal
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
ORCA_BACKGROUND_LAUNCH: '1'
run: |
# Detection failures run the smoke; renames retain the removed input path.
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/orcad-smoke-changes" &&
scope="$(node config/scripts/orcad-terminal-smoke-change-scope.mjs "$RUNNER_TEMP/orcad-smoke-changes")" && [ "$scope" = false ]; then
echo "Orcad terminal smoke inputs are unchanged."
else
pnpm run smoke:orcad-terminal
fi
- name: Verify the generated RPC params catalog
run: pnpm run verify:rpc-params-catalog
@@ -209,23 +310,16 @@ jobs:
- name: Verify skill freshness manifest
run: pnpm run verify:skill-bundle-manifest
- name: Verify localization catalog
run: pnpm run verify:localization-catalog
# Why: the renderer ships only the English entries i18next cannot rebuild
# from each call site's inline default, so the generated subset has to
# track en.json and those defaults.
- name: Verify runtime-required localization catalog
run: pnpm run verify:localization-runtime-catalog
# Why: extraction writes sorted evidence to an isolated temporary path,
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
- name: Verify localization extraction
run: pnpm run verify:localization-extraction
- name: Verify localization catalogs
id: localization-catalogs
background: true
run: pnpm run verify:localization-catalogs
- name: Verify localization coverage
run: pnpm run verify:localization-coverage
- wait: [localization-catalogs, localization-extraction]
# 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
@@ -249,37 +343,11 @@ jobs:
- name: Verify macOS entitlements
run: pnpm verify:macos-entitlements
root_directory_guard:
name: root directory guard
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why blob:none: full history is needed for the merge-base diff, but historical
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
# few this job actually reads on demand.
fetch-depth: 0
filter: blob:none
persist-credentials: false
- name: Reject new root-level files and folders
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA"
# Why here: the READMEs embed media owned by docs/site and resources/onboarding,
# and the classifier skips static_analysis for docs-only diffs. This job runs
# on every PR and needs no install.
- name: Check README local links
run: node config/scripts/check-readme-local-links.mjs
typecheck:
needs: [code_paths]
if: needs.code_paths.outputs.typecheck == 'true'
runs-on: ubuntu-latest
# Typechecking uses no native runtime, so it can use the free public ARM runner.
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
@@ -317,39 +385,7 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
# Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the
# same binary on every PR for minutes of runner time. The key carries the version
# because that is the only input; the sha256 assertion below still guards the
# tarball on the miss path that actually builds. Only this PR's own later pushes
# can restore it — GitHub scopes a cache written from a pull_request run to that
# ref — so a first push always takes the build path below.
- name: Cache baseline Git build
uses: actions/cache@v5
with:
path: ~/.cache/orca-git-compat/git-2.25.5
key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5
# Why its own step: this is `make -j$(nproc)` on every core, and the lanes below
# spend their wall clock waiting on container starts, not on Git. Sharing a runner
# with the build stretched one ~1.5s boundary case past Vitest's 30s timeout, so
# the build has to finish before anything timed starts.
- name: Build the baseline Git binary
run: |
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$HOME/.cache/orca-git-compat/git-2.25.5"
if [ -x "$source/git" ]; then
exit 0
fi
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j"$(nproc)" \
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
# Why: the linked binaries are what the next run needs; the objects that
# produced them are most of the tree and would bloat the cache entry.
find "$source" -name '*.o' -delete
- uses: ./.github/actions/prepare-git-compatibility
- name: Verify Git binary compatibility matrix
run: |
@@ -399,9 +435,12 @@ jobs:
name: Codex index-heal contract
needs: [code_paths]
if: needs.code_paths.outputs.codex_index_heal_contract == 'true'
runs-on: ubuntu-latest
# Why ARM: @openai/codex ships @openai/codex-linux-arm64.
runs-on: ubuntu-24.04-arm
env:
CODEX_CLI_VERSION: '0.150.1'
# Why a second pin: --no-daemon only exists from 0.156, and Orca's codex wrapper relies on it.
CODEX_NO_DAEMON_CLI_VERSION: '0.158.0'
steps:
- name: Checkout
@@ -430,11 +469,30 @@ jobs:
pnpm exec vitest run --config config/vitest.config.ts \
src/main/codex/codex-index-heal-binary-contract.test.ts
- name: Install pinned no-daemon Codex CLI
run: |
set -euo pipefail
npm install --no-audit --no-fund --prefix "$RUNNER_TEMP/codex-cli-no-daemon" \
"@openai/codex@$CODEX_NO_DAEMON_CLI_VERSION"
- name: Verify Codex --no-daemon contract
env:
ORCA_CODEX_NO_DAEMON_CONTRACT_REQUIRED: '1'
ORCA_CODEX_NO_DAEMON_CONTRACT_VERSION: ${{ env.CODEX_NO_DAEMON_CLI_VERSION }}
run: |
set -euo pipefail
ORCA_CODEX_NO_DAEMON_CONTRACT_BINARY="$RUNNER_TEMP/codex-cli-no-daemon/node_modules/.bin/codex" \
pnpm exec vitest run --config config/vitest.config.ts \
src/main/pty/codex-no-daemon-binary-contract.test.ts
xterm_patch_sync:
name: xterm patch sync
needs: [code_paths]
if: needs.code_paths.outputs.xterm_patch_sync == 'true'
runs-on: ubuntu-latest
# Why ARM: the patch check rebuilds 4 packages x 2 builds and byte-compares against the
# checked-in bundles. Those were generated on Linux x64 and reproduce byte-for-byte on
# darwin-arm64, so the output is neither host-arch nor host-OS dependent.
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
@@ -442,21 +500,14 @@ jobs:
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
# Why: the check rebuilds every package in the manifest from a pinned upstream
# commit — @xterm/xterm and its three addons, each built twice (once unmodified to
# prove the toolchain still reproduces the published bundles, once patched). Caching
# the npm metadata and the shallow clone keeps the repeated cost to the builds
# themselves; the key is the manifest, so a commit, package or toolchain bump
# invalidates it.
- name: Restore upstream xterm build inputs
uses: actions/cache@v5
# The generator uses Node built-ins and installs its own upstream toolchain.
- uses: actions/setup-node@v6
with:
path: |
~/.npm
${{ runner.temp }}/xterm-patch-build/upstream/.git
key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
node-version-file: package.json
package-manager-cache: false
# Rebuild both pristine and patched bundles; reuse only the pinned toolchain.
- uses: ./.github/actions/prepare-xterm-dependencies
- name: Verify xterm patches match the pinned upstream build
env:
@@ -467,7 +518,8 @@ jobs:
name: shell contracts
needs: [code_paths]
if: needs.code_paths.outputs.shell_contracts == 'true'
runs-on: ubuntu-latest
# Why ARM: fish 4.x is published for noble/arm64 and zsh is in the arm64 archive.
runs-on: ubuntu-24.04-arm
# Why: this job's cost is almost entirely package download, and a stalled mirror has
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
# is generous; it exists so a wedge fails the job instead of holding the whole run
@@ -479,6 +531,8 @@ jobs:
# and its fish lane is the only end-to-end guard for #9993, so a skip would
# report green with nothing exercised. Turns those skips into failures.
ORCA_REQUIRE_FISH: '1'
# Why: the runner image ships pwsh, and the codex wrapper's PowerShell lane must not skip.
ORCA_REQUIRE_PWSH: '1'
steps:
- name: Checkout
@@ -493,6 +547,8 @@ jobs:
# fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships
# 3.7, so the PPA is what makes that lane real.
- name: Install zsh and fish
id: shells
background: true
run: |
# Why the update/PPA/fish steps are tolerant: a repo the runner image already
# ships can lack a Release file for this suite, and a failed add-apt-repository
@@ -541,6 +597,12 @@ jobs:
# first install command in this step to prove the lane really installs them.
timeout 300 sudo apt-get install -y zsh fish
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- wait: shells
# Separate from the install so the failure names the contract, not an apt error.
# ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds
# instead of after a full dependency install.
@@ -556,10 +618,6 @@ jobs:
exit 1
fi
- 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 --maxWorkers=1 \
@@ -569,6 +627,7 @@ jobs:
src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/codex-shell-launch-preflight.test.ts \
src/main/pty/codex-shell-no-daemon.test.ts \
src/main/pty/omp-shell-wrapper-alias-safety.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/main/shell-startup-feature-channel.test.ts \
@@ -576,6 +635,7 @@ jobs:
src/main/zsh-scoped-histfile.live-shell.test.ts \
src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
src/shared/pty-reply-echo-shapes.node-pty.test.ts \
@@ -588,7 +648,7 @@ jobs:
name: prepare test native cache node 24
needs: [code_paths]
if: needs.code_paths.outputs.native_cache_changed == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
@@ -601,15 +661,40 @@ jobs:
native-runtime: node
node-version: '24'
# Why hoisted out of unit-tests.yml: a caller's `needs` gate the whole called workflow, so
# while planning lived in there it queued behind static analysis and typecheck and the shards
# then queued behind it. Planning needs none of their output, so running it against
# code_paths alone overlaps it with the gate. Measured a median 93s off the matrix's start.
unit_plan:
needs: [code_paths]
if: needs.code_paths.outputs.test == 'true'
uses: ./.github/workflows/unit-plan.yml
with:
selection_mode: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }}
test:
needs: [code_paths, test_native_cache]
needs: [code_paths, unit_plan, test_native_cache, static_analysis, typecheck]
# Honor cancellation while allowing the optional native-cache primer to skip.
if: >-
always() &&
!cancelled() &&
needs.code_paths.outputs.test == 'true' &&
needs.unit_plan.result == 'success' &&
needs.static_analysis.result == 'success' &&
needs.typecheck.result == 'success' &&
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
uses: ./.github/workflows/unit-tests.yml
with:
node_versions: '["24"]'
runner: ubuntu-24.04-arm
shards: ${{ needs.unit_plan.outputs.shards }}
# Why a sibling and not part of the test workflow: it is advisory, so it must not delay the
# gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s
# after the last shard. Deliberately absent from verify's needs for the same reason.
unit_selection_evidence:
needs: [test]
if: ${{ !cancelled() && needs.test.result == 'success' }}
uses: ./.github/workflows/unit-selection-evidence.yml
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
# would pay for it on every shard to run one file in whichever shard it landed in.
@@ -673,9 +758,22 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is
# WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from
# the runner: Ubuntu ships no WebKit build to point at.
- name: Install WebKit for the drawer check
id: webkit
background: true
run: pnpm exec playwright install --with-deps webkit
# The entry lives in mobile/ so one React resolves; without this every RN import is nothing.
- uses: ./.github/actions/install-mobile-dependencies
- name: Prepare mobile route snapshot
id: mobile-routes
background: true
run: node config/scripts/run-mobile-web-app-checks.mjs --prepare-route-snapshot "$RUNNER_TEMP/mobile-routes.json"
# Why the runner's Google Chrome and not a downloaded chromium: same reason as the orcad
# browser job -- Ubuntu 24.04 only ships an AppArmor userns profile for the Chrome .deb.
# Why fail instead of skip: a silently skipped render check is the failure this job exists
@@ -691,15 +789,12 @@ jobs:
"$chrome" --version
echo "ORCA_MOBILE_WEB_RENDER_BROWSER=$chrome" >> "$GITHUB_ENV"
# The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is
# WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from
# the runner: Ubuntu ships no WebKit build to point at.
- name: Install WebKit for the drawer check
run: pnpm exec playwright install --with-deps webkit
- name: Build and verify the app bundle
run: pnpm run build:mobile-web
# Browser checks must observe both a finished install and the built bundle.
- wait: [webkit, mobile-routes]
# The bundling tests skip themselves where mobile dependencies are absent, which is how they
# stay green in the sharded `test` job. This is the job that installs them, so here a missing
# install has to fail rather than skip everything the job exists to run.
@@ -709,23 +804,20 @@ jobs:
# one `pr-code-change-scope.mjs` fires this job on, so naming a test into the family is all
# it takes to have it run. Quoted because these are vitest filename filters, matched as
# substrings against the discovered files, and the shell must not touch them.
#
# Cost: 18 files in 25-30s wall, of which the frame-budget sweep is 2.5s. That sweep encodes
# 111 noise JPEGs in Chromium, so it is the one step here whose cost grows with its viewport
# set; adding rows to that set is a decision about this job's runtime.
# Route censuses share fresh dependency lists for this invocation; scratch builds stay independent.
- name: Builder, override census and render checks
env:
ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1'
ORCA_MOBILE_WEB_PREPARED_ROUTE_SNAPSHOT: ${{ runner.temp }}/mobile-routes.json
run: |
pnpm exec vitest run --config config/vitest.config.ts \
'config/scripts/mobile-web-app-' \
'config/scripts/build-mobile-web-app-bundle.test.mjs'
node config/scripts/run-mobile-web-app-checks.mjs
cross-version-wire:
name: cross-version wire compatibility
needs: [code_paths]
if: needs.code_paths.outputs.cross-version-wire == 'true'
runs-on: ubuntu-latest
# Why ARM: source-only: tagged checkout plus in-process vitest, no docker or browser.
runs-on: ubuntu-24.04-arm
steps:
# Why fetch-depth 0: the harness extracts the newest release tag to skew
@@ -757,12 +849,16 @@ jobs:
tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts
tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts
tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts
tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts
managed_hook_node18:
name: managed hooks on Node 18
needs: [code_paths]
if: needs.code_paths.outputs.managed_hook_node18 == 'true'
runs-on: ubuntu-latest
# Why ARM: Node 18 publishes linux-arm64; the per-platform runtime files are read as data.
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
@@ -785,7 +881,7 @@ jobs:
package:
name: package
needs: [code_paths]
needs: [code_paths, static_analysis, typecheck]
if: needs.code_paths.outputs.package == 'true'
runs-on: ubuntu-latest
# Let the serial Docker gates reach their own deadlines and report cleanup failures.
@@ -798,7 +894,7 @@ jobs:
persist-credentials: false
- name: Cache electron-builder downloads
uses: actions/cache@v5
uses: actions/cache/restore@v5
with:
path: ~/.cache/electron-builder
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
@@ -835,6 +931,24 @@ jobs:
src/main/browser/browser-route-h3-egress.electron.test.ts
src/main/browser/browser-route-dns-prefetch.electron.test.ts
# Restore independent fixtures during builds; keep native lifecycle probes serial.
- uses: ./.github/actions/prepare-linux-package-fixture
id: shutdown-fixture-cache
background: true
with:
fixture: headless-serve-shutdown
- uses: ./.github/actions/prepare-linux-package-fixture
id: cli-fixture-cache
background: true
with:
fixture: cli-launch-contract
- name: Install Linux package tooling
id: linux-package-tools
background: true
run: sudo apt-get update && sudo apt-get install -y cpio rpm
- name: Build package inputs
run: |
status=0
@@ -853,6 +967,8 @@ jobs:
exit "$status"
- name: Project web client from renderer build
id: web-client
background: true
run: pnpm run build:web-from-renderer
# Why here and not inside "Build package inputs": this job assembles packaging inputs step by
@@ -864,17 +980,16 @@ jobs:
- name: Build native components
run: pnpm run build:native
- name: Install Linux package tooling
run: sudo apt-get update && sudo apt-get install -y cpio rpm
- wait: [linux-package-tools, web-client]
- name: Package unpacked app
env:
ORCA_BACKGROUND_LAUNCH: '1'
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
# PR artifacts are only inspected locally; gzip avoids release-size xz compression.
run: >-
pnpm exec electron-builder --config config/electron-builder.config.cjs
--linux AppImage deb rpm --x64 --publish never
--config.deb.compression=gz --config.rpm.compression=gzip
# Prepare once with every hook, then isolate the format-specific mutations.
run: |
pnpm exec electron-builder --config config/electron-builder.config.cjs --linux dir --x64 --publish never
node config/scripts/package-linux-formats.mjs
- name: Verify root-package marker payloads
run: |
@@ -889,13 +1004,21 @@ jobs:
[[ "$deb_marker" == deb ]] || { echo "Expected deb marker, got: $deb_marker"; exit 1; }
[[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; }
- wait: shutdown-fixture-cache
- name: Verify headless serve signal shutdown
env:
ORCA_SHUTDOWN_FIXTURE_CACHE_IMAGE: ${{ steps.shutdown-fixture-cache.outputs.image }}
run: >-
node config/scripts/run-headless-serve-shutdown-docker.mjs
--appimage dist/orca-linux.AppImage --all-entrypoints
# A default container reproduces the hostile AppImage launch environment.
- wait: cli-fixture-cache
- name: Verify Linux CLI launch contract
env:
ORCA_CLI_FIXTURE_CACHE_IMAGE: ${{ steps.cli-fixture-cache.outputs.image }}
run: node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage
- name: Smoke packaged CLI
@@ -906,7 +1029,7 @@ jobs:
package_windows:
name: package (windows)
needs: [code_paths]
needs: [code_paths, static_analysis, typecheck]
if: needs.code_paths.outputs.package_windows == 'true'
runs-on: windows-2022
timeout-minutes: 30
@@ -918,14 +1041,15 @@ jobs:
persist-credentials: false
- name: Cache electron-builder downloads
uses: actions/cache@v5
uses: actions/cache/restore@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-builder-windows-${{ hashFiles('pnpm-lock.yaml') }}
# Release builds seed this exact path set; PR-local copies cannot serve other PRs.
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-windows-
electron-builder-win-
# Why persist-native-cache false: this job later rebuilds the same path for
# Electron. A post-job save would store the Electron ABI under the Node key.
@@ -954,6 +1078,12 @@ jobs:
# vitest runs here directly rather than through `pnpm test`, so the addon
# assertions only hold once install-node-dependencies has rebuilt natives.
- name: Test Windows installer process probe
# Keep cold CIM startup out of the concurrent Electron/native process workload.
run: >-
pnpm exec vitest run --config config/vitest.config.ts
config/scripts/nsis-process-check.test.mjs
- name: Test Windows-specific boundaries
run: >-
pnpm exec vitest run --config config/vitest.config.ts
@@ -989,6 +1119,7 @@ jobs:
src/main/windows/windows-host-job.win32.test.ts
src/main/windows/windows-process-tree-command-line-patch.test.ts
src/main/windows/windows-process-table-native-addon.win32.test.ts
src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts
src/main/windows-live-tree-kill.win32.test.ts
src/main/wsl/wsl-runner.test.ts
src/main/wsl/wsl-guest-environment.test.ts
@@ -1017,11 +1148,9 @@ jobs:
uses: actions/cache@v5
with:
path: native/windows-cli-launcher/.build
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }}
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs', 'resources/build/icon.ico', 'package.json') }}
- name: Build package inputs
env:
ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1'
run: pnpm run build:release:parallel
- name: Restore compiled Electron native modules
@@ -1088,11 +1217,10 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
verify:
if: always()
if: ${{ !cancelled() }}
needs:
- code_paths
- static_analysis
- root_directory_guard
- typecheck
- git_compatibility
- codex_index_heal_contract
@@ -1105,7 +1233,9 @@ jobs:
- managed_hook_node18
- package
- package_windows
runs-on: ubuntu-latest
# Evaluating job results only needs the lightweight container runner.
runs-on: ubuntu-slim
timeout-minutes: 5
steps:
# Why: e2e is deliberately absent from needs. The suite is currently red on
@@ -1122,7 +1252,6 @@ jobs:
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
@@ -1151,9 +1280,6 @@ jobs:
if [ "$CODE_PATHS" != "success" ]; then
exit 1
fi
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
exit 1
fi
if [ "$SHOULD_RUN" != "true" ]; then
echo "Docs-only change; expensive PR checks skipped."
fi
+52 -2
View File
@@ -1,6 +1,6 @@
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
# Explicit review identities share workflow concurrency; legacy names use ordered cancellation.
name: Pullfrog
run-name: ${{ inputs.name || github.workflow }}
run-name: ${{ inputs.name || github.workflow }}${{ inputs.pull_request_number && format(' | PR {0}', inputs.pull_request_number) || '' }}
on:
workflow_dispatch:
inputs:
@@ -11,21 +11,71 @@ on:
type: string
description: Run name
pull_request_number:
type: string
description: Optional PR identity for cancelling superseded reviews
head_sha:
type: string
description: Optional expected PR head; stale reviews are skipped
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ inputs.pull_request_number && format('pullfrog-pr-{0}', inputs.pull_request_number) || format('pullfrog-run-{0}', github.run_id) }}
cancel-in-progress: true
jobs:
review_scope:
runs-on: ubuntu-slim
permissions:
contents: read
pull-requests: read
actions: write
outputs:
current: ${{ steps.scope.outputs.current }}
number: ${{ steps.scope.outputs.number }}
head: ${{ steps.scope.outputs.head }}
steps:
- uses: actions/checkout@v6
with:
sparse-checkout: config/scripts/pullfrog-review-scope.cjs
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/github-script@v8
id: scope
with:
script: |
const { reviewScope } = require('./config/scripts/pullfrog-review-scope.cjs')
await reviewScope({ github, context, core })
pullfrog:
needs: review_scope
if: needs.review_scope.outputs.current == 'true'
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: read
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Recheck review head before starting agent
id: freshness
if: needs.review_scope.outputs.number != ''
uses: actions/github-script@v8
env:
REVIEW_NUMBER: ${{ needs.review_scope.outputs.number }}
REVIEW_HEAD: ${{ needs.review_scope.outputs.head }}
with:
script: |
const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: Number(process.env.REVIEW_NUMBER) })
core.setOutput('current', pr.state === 'open' && pr.head.sha === process.env.REVIEW_HEAD)
- name: Run agent
if: needs.review_scope.outputs.number == '' || steps.freshness.outputs.current == 'true'
uses: pullfrog/pullfrog@v0
with:
prompt: ${{ inputs.prompt }}
+95 -101
View File
@@ -1314,6 +1314,16 @@ jobs:
restore-keys: |
electron-builder-${{ matrix.platform }}-
# PRs cache tools separately from Electron; identical paths preserve their cache version.
- name: Seed shared Linux packaging downloads
if: matrix.platform == 'linux-x64' && github.ref == 'refs/heads/main'
uses: actions/cache@v5
with:
path: ~/.cache/electron-builder
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
# The release cache above restores the same tools, so this only needs to save on a miss.
lookup-only: true
# Why: pnpm install triggers electron's postinstall, which downloads the
# Electron binary from GitHub release assets. GitHub's download CDN
# occasionally returns 504s that fail the whole release. Retry on
@@ -1530,6 +1540,7 @@ jobs:
}
- name: Install SignPath PowerShell module
id: install-signpath
if: matrix.platform == 'win' && github.run_attempt == 1
uses: ./.github/actions/install-signpath-module
@@ -1540,18 +1551,14 @@ jobs:
# existing installer signing request below. The NSIS uninstaller rides
# this same request (it is the MDE update cluster: old-uninstaller.exe /
# Uninstall Orca.exe), captured through electron-builder's sign hook and
# swapped back in during the rebuild — no third approval wait. Every step is
# fail-open (continue-on-error + outcome gating): any failure ships the
# original installer with unsigned inner binaries, exactly like releases
# did before this chain existed. Rehearsed end to end in run 28988432001
# (.github/workflows/windows-signing-rehearsal.yml).
# swapped back in during the rebuild — no third approval wait. Production
# releases require the entire signing chain to succeed (#23383).
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
- name: Stage unsigned inner PE files for signing
id: stage-inner
if: matrix.platform == 'win' && github.run_attempt == 1
continue-on-error: true
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
@@ -1593,34 +1600,18 @@ jobs:
# out of inner-signing-list.txt: that list drives the copy-back into
# dist/win-unpacked, and the uninstaller does not live there — it is
# re-injected through the sign hook during the rebuild instead.
# Why this name and not "Uninstall Orca.exe": the restore loop below
# matches staged files by suffix (`-like "*$relative"`) and takes the
# first hit, so any staged path ending in "Orca.exe" is separated from
# the real Orca.exe only by Get-ChildItem's enumeration order. That
# order happens to favour the root file today, but it is not a
# documented guarantee; a name that cannot suffix-match is.
# Why the whole block is caught rather than just Test-Path'd: this
# step's outcome gates the upload of every inner binary, so a locked
# file or a full disk here would cost all of them their signatures -
# worse than shipping no uninstaller signature at all.
try {
$exportedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\unsigned\orca-uninstaller.exe'
if (Test-Path -LiteralPath $exportedUninstaller) {
$uninstallerStagePath = Join-Path $stage.FullName 'uninstaller\orca-uninstaller.exe'
New-Item -ItemType Directory -Force -Path (Split-Path $uninstallerStagePath) -ErrorAction Stop | Out-Null
Copy-Item -LiteralPath $exportedUninstaller -Destination $uninstallerStagePath -Force -ErrorAction Stop
Write-Host 'Staged the NSIS uninstaller for signing: uninstaller\orca-uninstaller.exe'
} else {
Write-Host "::warning::No exported NSIS uninstaller at $exportedUninstaller; this release ships an unsigned uninstaller (fail-open)."
}
} catch {
Write-Host "::warning::Could not stage the NSIS uninstaller ($_); this release ships an unsigned uninstaller (fail-open)."
$exportedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\unsigned\orca-uninstaller.exe'
if (-not (Test-Path -LiteralPath $exportedUninstaller)) {
throw "No exported NSIS uninstaller at $exportedUninstaller."
}
$uninstallerStagePath = Join-Path $stage.FullName 'uninstaller\orca-uninstaller.exe'
New-Item -ItemType Directory -Force -Path (Split-Path $uninstallerStagePath) -ErrorAction Stop | Out-Null
Copy-Item -LiteralPath $exportedUninstaller -Destination $uninstallerStagePath -Force -ErrorAction Stop
Write-Host 'Staged the NSIS uninstaller for signing: uninstaller\orca-uninstaller.exe'
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.stage-inner.outcome == 'success'
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }}
@@ -1630,7 +1621,6 @@ jobs:
- name: Submit inner binaries signing request
id: submit-inner-signing
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.upload-unsigned-inner.outcome == 'success'
continue-on-error: true
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -1643,8 +1633,8 @@ jobs:
- name: Notify Slack that inner-binary signing is waiting for approval
id: notify-inner-signing
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success'
continue-on-error: true
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success'
shell: pwsh
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -1704,15 +1694,10 @@ jobs:
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
# Why gate on the notify outcome too: if nobody was told to approve,
# don't hold the release for the approval window — fall through and
# ship like today instead. The 1h wait (vs the installer's 4h) keeps
# both waits plus the build inside the 360-minute job cap; missing it
# falls through to today's unsigned-inner flow rather than blocking.
# Approval remains required even when the notification service is unavailable.
- name: Download signed inner binaries from SignPath
id: download-signed-inner
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
continue-on-error: true
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success'
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -1730,27 +1715,31 @@ jobs:
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
# Why: copy back strictly by the staged list so a layout mismatch in the
# returned artifact fails loudly (into fail-open) instead of silently
# returned artifact fails before publication instead of silently
# shipping a mix of signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
id: restore-signed-inner
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.download-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
SIGNING_POLICY: release-signing
run: |
$requireValid = $env:SIGNING_POLICY -ne 'test-signing'
$root = Resolve-Path 'dist/win-unpacked'
$failures = New-Object System.Collections.Generic.List[string]
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
Select-Object -First 1
if ($null -eq $signed) {
$failures.Add("missing from signed artifact: $relative")
$candidates = @(
(Join-Path 'signed-inner' $relative),
(Join-Path 'signed-inner/signing-stage' $relative)
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
if (@($candidates).Count -ne 1) {
$failures.Add("missing or ambiguous signed artifact path: $relative")
continue
}
$signed = Get-Item -LiteralPath @($candidates)[0]
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
$failures.Add("returned without a signature: $relative")
if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) {
$failures.Add("returned without a valid SignPath signature: $relative")
continue
}
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
@@ -1761,25 +1750,26 @@ jobs:
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
}
# Why gated separately from the inner restore above: if SignPath's
# windows-inner-binaries-zip artifact configuration does not (yet) cover the
# uninstaller/ directory, the uninstaller comes back missing. That must cost
# only the uninstaller signature — the rebuild below still runs and still
# ships the signed inner binaries, exactly as it does today.
# The uninstaller must return signed before rebuilding the installer.
- name: Restore signed uninstaller for the installer rebuild
id: restore-signed-uninstaller
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
SIGNING_POLICY: release-signing
run: |
$signed = Get-ChildItem -Path signed-inner -Recurse -File -Filter 'orca-uninstaller.exe' |
Select-Object -First 1
if ($null -eq $signed) {
throw 'SignPath did not return uninstaller/orca-uninstaller.exe; check the windows-inner-binaries-zip artifact configuration covers it.'
$requireValid = $env:SIGNING_POLICY -ne 'test-signing'
$candidates = @(
(Join-Path 'signed-inner' 'uninstaller\orca-uninstaller.exe'),
(Join-Path 'signed-inner/signing-stage' 'uninstaller\orca-uninstaller.exe')
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
if (@($candidates).Count -ne 1) {
throw 'Missing or ambiguous uninstaller/orca-uninstaller.exe in the signed artifact; check the windows-inner-binaries-zip configuration.'
}
$signed = Get-Item -LiteralPath @($candidates)[0]
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
throw 'The returned NSIS uninstaller carries no signature.'
if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) {
throw 'The returned NSIS uninstaller carries no valid SignPath signature.'
}
$signedDir = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed'
New-Item -ItemType Directory -Force -Path $signedDir | Out-Null
@@ -1804,13 +1794,11 @@ jobs:
- name: Replace cached elevate.exe with the signed copy
id: sign-elevate-cache
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
$signed = 'dist/win-unpacked/resources/elevate.exe'
if (-not (Test-Path $signed)) {
Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.'
exit 0
throw 'No elevate.exe in win-unpacked resources.'
}
# Why this guard stays: windows-signing-rehearsal.yml shares the
# electron-builder-win-<lockfile hash> cache key with this workflow, so a
@@ -1818,8 +1806,7 @@ jobs:
$signature = Get-AuthenticodeSignature -FilePath $signed
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap."
exit 0
throw "win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject)."
}
node config/scripts/replace-cached-nsis-elevate.mjs $signed
if ($LASTEXITCODE -ne 0) {
@@ -1837,35 +1824,16 @@ jobs:
- name: Rebuild NSIS installer from signed unpacked app
id: rebuild-nsis-signed
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
# Why unconditional: the sign hook keys off the file existing, which it
# only does when the restore step above succeeded. A missing file logs a
# warning and embeds the freshly built unsigned uninstaller instead.
ORCA_WIN_UNINSTALLER_SIGNED_PATH: ${{ runner.temp }}\uninstaller-signing\signed\orca-uninstaller.exe
run: |
# Why: keep the pre-rebuild artifacts so a failed rebuild can fall
# back to shipping them unchanged (fail-open).
New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null
Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force
Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
}
- name: Roll back to original installer after failed rebuild
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.rebuild-nsis-signed.outcome == 'failure'
shell: pwsh
run: |
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force
Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force
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' && github.run_attempt == 1
@@ -1919,7 +1887,7 @@ jobs:
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
}
# Why: releases where inner signing fell through have only this one request.
# Keep the two approval requests distinguishable in the notification.
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
# Why: approvers need tag + source ref/commit + who cut, not only the tag.
$sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' }
@@ -2026,24 +1994,18 @@ jobs:
# Why: evidence gate for inner-binary signing (issue #7785, supersedes
# PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node
# by extracting the shipped installer). Warn-only until the flow has been
# proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED
# to 'true' so unsigned inner binaries block the release.
# by extracting the shipped installer). Unsigned binaries block publication.
- name: Verify Windows inner binary signatures
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
env:
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'true'
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
UNINSTALLER_SIGNING_COMPLETED: ${{ steps.restore-signed-uninstaller.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.
# Keep diagnostics best-effort so they cannot mask a signature failure.
function Add-GateEvidence([string]$line) {
try {
Add-Content -Path 'inner-signing-evidence.txt' -Value "`n$line" -ErrorAction Stop
@@ -2071,19 +2033,14 @@ jobs:
}
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).'
$message = 'Windows inner-binary signing did not complete; publication is blocked.'
Write-GateVerdict "NOT VERIFIED — $message"
if ($required) { throw $message }
Write-Host "::warning::$message"
exit 0
}
# 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.
# Keep signature failures outside the catch to preserve the per-file report.
$policyFailure = $null
try {
@@ -2105,14 +2062,19 @@ jobs:
}
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Could not extract the shipped installer payload.' }
$root = Resolve-Path 'inner-evidence-extract'
# Verify the CLI even when a cached signed copy bypassed staging (#23383).
# Why elevate.exe is always appended: staging skips already-signed
# files, and the persisted electron-builder cache can carry a
# previously signed elevate.exe — so it may be absent from the list
# in some runs, yet it is the file most at risk of losing its
# signature in the NSIS rebuild. Verify it in every release.
$targets = @(Get-Content 'inner-signing-list.txt')
foreach ($requiredTarget in @('Orca.exe', 'resources\bin\orca.exe')) {
if ($targets -notcontains $requiredTarget) { $targets += $requiredTarget }
}
if ($targets -notcontains 'resources\elevate.exe') {
$targets += 'resources\elevate.exe'
}
@@ -2147,7 +2109,7 @@ jobs:
}
}
} else {
Write-Host '::warning::The NSIS uninstaller was not signed on this run; it is excluded from the evidence gate (fail-open).'
$failures.Add('The NSIS uninstaller signing did not complete.')
}
foreach ($relative in $targets) {
$path = Join-Path $root $relative
@@ -2197,6 +2159,38 @@ jobs:
# Outside the catch so the FAILED evidence report survives intact.
if ($policyFailure) { throw $policyFailure }
- name: Notify Slack when Windows signing fails
if: failure() && matrix.platform == 'win' && github.run_attempt == 1 && steps.install-signpath.outcome != '' && steps.install-signpath.outcome != 'skipped'
continue-on-error: true
shell: pwsh
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
TAG: ${{ needs.cut.outputs.tag }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
INNER_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
INSTALLER_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }}
INSTALLER_SUBMISSION_OUTCOME: ${{ steps.submit-signing-request.outcome }}
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
throw 'SLACK_WEBHOOK_URL is missing; cannot notify release approvers of the signing failure.'
}
$stage = 'inner-binary signing or installer rebuild'
if ($env:INSTALLER_SUBMISSION_OUTCOME -in @('success', 'failure')) {
$stage = 'installer signing or final signature verification'
}
$message = "Orca Windows release ``$($env:TAG)`` failed during $stage (including approval timeouts). Publication is blocked.`n<$($env:GITHUB_RUN_URL)|Open failed GitHub Actions run>"
foreach ($request in @(
@{ Id = $env:INNER_REQUEST_ID; Label = 'Inner-binary signing request' },
@{ Id = $env:INSTALLER_REQUEST_ID; Label = 'Installer signing request' }
)) {
if (-not [string]::IsNullOrWhiteSpace($request.Id)) {
$message += "`n<https://app.signpath.io/Web/c37aa192-a27a-4377-9c90-5d6c95912dc0/SigningRequests/$($request.Id)|$($request.Label)>"
}
}
$message += "`nCheck the failed step before retrying. Late SignPath approval does not resume this run; missing Windows assets require a fresh release dispatch, not Re-run failed jobs."
$payload = @{ text = $message } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
- name: Upload Windows inner signing evidence
if: always() && matrix.platform == 'win' && github.run_attempt == 1
uses: actions/upload-artifact@v7
+12 -74
View File
@@ -16,83 +16,21 @@ concurrency:
jobs:
enforce:
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
runs-on: ubuntu-slim
timeout-minutes: 5
steps:
# Why: release events run this file from the tagged commit, so load the module from the same commit.
- uses: actions/checkout@v6
with:
sparse-checkout: config/scripts/release-policy.mjs
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Enforce release policy
uses: actions/github-script@v8
with:
script: |
const release = context.payload.release;
const tag = release.tag_name;
const author = release.author?.login;
const number = "(?:0|[1-9][0-9]*)";
const version = `${number}\\.${number}\\.${number}`;
const stableTag = new RegExp(`^v${version}$`);
const prereleaseTag = new RegExp(
`^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`,
);
const expectedPrerelease = prereleaseTag.test(tag);
const allowed = author === "github-actions[bot]" &&
(stableTag.test(tag) || expectedPrerelease);
const { owner, repo } = context.repo;
async function restoreLatestStable() {
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const stable = releases
.filter((candidate) =>
!candidate.draft &&
!candidate.prerelease &&
candidate.author?.login === "github-actions[bot]" &&
stableTag.test(candidate.tag_name),
)
.sort((left, right) => {
const a = left.tag_name.slice(1).split(".").map(Number);
const b = right.tag_name.slice(1).split(".").map(Number);
return a.reduce((result, part, index) => result || part - b[index], 0);
})
.at(-1);
if (stable) {
await github.rest.repos.updateRelease({
owner,
repo,
release_id: stable.id,
make_latest: "true",
});
}
}
if (allowed) {
if (release.prerelease !== expectedPrerelease) {
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
prerelease: expectedPrerelease,
make_latest: expectedPrerelease ? "false" : "legacy",
});
}
await restoreLatestStable();
return;
}
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
draft: true,
prerelease: true,
make_latest: "false",
});
await restoreLatestStable();
await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id });
try {
await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` });
} catch (error) {
if (error.status !== 404) throw error;
}
core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`);
const { pathToFileURL } = await import("node:url")
const { enforceReleasePolicy } = await import(
pathToFileURL(`${process.env.GITHUB_WORKSPACE}/config/scripts/release-policy.mjs`).href
)
await enforceReleasePolicy({ github, context, core })
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
run: >-
xvfb-run --auto-servernum
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
pnpm run test:e2e --
pnpm run test:e2e
tests/e2e/terminal-ime-exact-byte.spec.ts
--workers=1
-119
View File
@@ -1,119 +0,0 @@
name: Track Community PRs
on:
pull_request_target:
types: [opened, reopened, ready_for_review]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to backfill or retry'
required: true
type: number
permissions:
contents: read
jobs:
track-community-pr:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Generate bufo-bot token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: 2590194
private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }}
owner: stablyai
- name: Add PR to project
uses: actions/github-script@v8
env:
PROJECT_OWNER: stablyai
PROJECT_NUMBER: '13'
INTERNAL_TEAM_SLUG: stably-eng
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const projectOwner = process.env.PROJECT_OWNER;
const projectNumber = Number(process.env.PROJECT_NUMBER);
const internalTeamSlug = process.env.INTERNAL_TEAM_SLUG;
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.eventName === 'workflow_dispatch'
? Number(context.payload.inputs.pr_number)
: context.payload.pull_request.number;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
const author = pr.user.login;
const skippedAuthors = new Set([
'github-actions[bot]',
'dependabot[bot]',
]);
if (skippedAuthors.has(author)) {
core.info(`Skipping bot PR author ${author}.`);
return;
}
let isInternalAuthor = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: projectOwner,
team_slug: internalTeamSlug,
username: author,
});
isInternalAuthor = membership.data.state === 'active';
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
if (isInternalAuthor) {
core.info(`Skipping internal PR author ${author}.`);
return;
}
const projectResult = await github.graphql(
`
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
}
}
}
`,
{ owner: projectOwner, number: projectNumber },
);
const projectId = projectResult.organization.projectV2.id;
await github.graphql(
`
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {
projectId: $projectId,
contentId: $contentId
}) {
item {
id
}
}
}
`,
{
projectId,
contentId: pr.node_id,
},
);
core.info(`Added PR #${pr.number} (${pr.html_url}) to project ${projectOwner}/${projectNumber}.`);
+48
View File
@@ -0,0 +1,48 @@
name: Unit plan
# Why its own workflow: the caller's `needs` gate the whole called workflow, so while this lived
# inside unit-tests.yml it waited on static analysis and typecheck before it could even start —
# and the shards then waited on it. Planning needs neither (its inputs are the checkout, a git
# diff against HEAD^1, the import graph, and the checked-in timing baseline), so hoisting it out
# lets it overlap the gate instead of queueing behind it. Measured: a median 93s off the shard
# matrix's start.
on:
workflow_call:
inputs:
selection_mode:
description: Shadow validates selection; selected applies it only to draft PRs.
required: false
default: shadow
type: string
outputs:
shards:
description: JSON array of shard assignments for the unit matrix.
value: ${{ jobs.plan.outputs.shards }}
permissions:
contents: read
jobs:
plan:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
shards: ${{ steps.plan.outputs.shards }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Plan unit selection
id: plan
env:
ORCA_UNIT_SELECTION_MODE: ${{ inputs.selection_mode }}
run: node config/scripts/ci-unit-plan.mjs
- uses: actions/upload-artifact@v7
continue-on-error: true
with:
name: unit-selection-attempt-${{ github.run_attempt }}
path: ci-shards/unit-selection.json
retention-days: 14
@@ -0,0 +1,41 @@
name: Unit selection evidence
# Why its own workflow: this job is advisory -- `continue-on-error` on both the job and its
# comparison step, so it can never fail a PR. But a caller's `needs: test` waits for every job in
# the called workflow, so while it lived in unit-tests.yml it held `verify` for ~36s after the
# last shard finished. Called as a sibling instead, it still runs on every PR and still uploads
# its review artifact; it just no longer sits on the critical path.
on:
workflow_call:
permissions:
contents: read
jobs:
selection_evidence:
if: ${{ !cancelled() }}
continue-on-error: true
runs-on: ubuntu-slim
steps:
- uses: actions/checkout@v6
with:
sparse-checkout: config/scripts/ci-unit-selection-review.mjs
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: '24'
- uses: actions/download-artifact@v8
with:
pattern: unit-shard-node-*-attempt-${{ github.run_attempt }}
path: unit-evidence/
- name: Compare selection with full results
continue-on-error: true
run: node config/scripts/ci-unit-selection-review.mjs unit-evidence
- uses: actions/upload-artifact@v7
if: always()
continue-on-error: true
with:
name: unit-selection-review-attempt-${{ github.run_attempt }}
path: unit-evidence/selection-review.json
retention-days: 30
+31 -32
View File
@@ -8,19 +8,29 @@ on:
required: true
type: string
runner:
description: Hosted runner for the unit shards; relay integration keeps its x86 host.
required: false
default: ubuntu-latest
type: string
shards:
description: JSON array of shard assignments, produced by unit-plan.yml.
required: true
type: string
permissions:
contents: read
jobs:
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
runs-on: ubuntu-latest
name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }}
runs-on: ${{ inputs.runner }}
strategy:
fail-fast: false
matrix:
node: ${{ fromJSON(inputs.node_versions) }}
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard_total: [8]
shard: ${{ fromJSON(inputs.shards) }}
steps:
- name: Checkout
@@ -33,41 +43,26 @@ jobs:
native-runtime: node
node-version: ${{ matrix.node }}
cache-electron-package: 'true'
cache-dependency-path: |
pnpm-lock.yaml
cloud/pnpm-lock.yaml
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- uses: actions/download-artifact@v8
continue-on-error: true
with:
name: unit-selection-attempt-${{ github.run_attempt }}
path: ci-shards/
- name: Test shard
env:
ORCA_BALANCE_UNIT_SHARDS: '1'
ORCA_BACKGROUND_LAUNCH: '1'
run: |
export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)"
ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)"
export ORCA_SHARD_SOURCE_SHA
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.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-zsh-launch-environment.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/main/shell-startup-feature-channel.test.ts \
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/relay-region-compatibility.unit.test.ts \
--exclude=tests/e2e/relay-region-correction.unit.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
--shard=${{ matrix.shard.index }}/${{ matrix.shard.count }} \
${{ inputs.runner == 'ubuntu-24.04-arm' && '--maxWorkers=4' || '' }}
- name: Upload unit shard assignment
if: always()
@@ -75,13 +70,17 @@ jobs:
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard.index }}-attempt-${{ github.run_attempt }}
path: ci-shards/
retention-days: 14
if-no-files-found: warn
relay_integration:
name: relay integration node ${{ fromJSON(inputs.node_versions)[0] }}
name: relay integration node ${{ matrix.node }}
strategy:
fail-fast: false
matrix:
node: ${{ fromJSON(inputs.node_versions) }}
runs-on: ubuntu-latest
steps:
@@ -93,7 +92,7 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ fromJSON(inputs.node_versions)[0] }}
node-version: ${{ matrix.node }}
cache-electron-package: 'true'
cache-dependency-path: |
pnpm-lock.yaml
@@ -35,6 +35,7 @@ jobs:
timeout-minutes: 50
env:
EXPECT: ${{ inputs.expect || 'survival' }}
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- name: Checkout
+57 -16
View File
@@ -29,6 +29,21 @@ on:
options:
- survival
- cold-restore
trace_installer:
description: Trace installer process checks in this disposable build
type: boolean
default: false
policy:
description: Execution policy inherited by harness child processes
type: choice
default: inherited
options:
- inherited
- Restricted
from_release:
description: Optional base release tag; blank uses this branch build
type: string
default: ''
permissions:
contents: read
@@ -39,7 +54,7 @@ concurrency:
jobs:
survival:
name: survival (branch build over itself)
name: update ${{ inputs.from_release || 'branch' }} to branch (${{ inputs.expect || 'survival' }}, policy=${{ inputs.policy || 'inherited' }})
runs-on: windows-2022
timeout-minutes: 50
env:
@@ -62,18 +77,23 @@ jobs:
with:
install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: cache the built installer keyed on the inputs that affect it, so a
# harness-only edit skips the ~20 min electron-builder build. The daemon
# relocation code lives under src/, so changing it correctly rebuilds.
# Hash before installation creates native build artifacts under native/.
- name: Cache branch installer
id: cache-installer
uses: actions/cache@v4
uses: actions/cache/restore@v4
with:
path: dist/orca-windows-setup.exe
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }}
key: branch-installer-${{ inputs.trace_installer && format('trace-{0}-', hashFiles('tests/tools/win-update-e2e/trace-installer-branches.mjs')) || '' }}${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/**', 'mobile/**', 'patches/**', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Instrument disposable installer and uninstaller
if: inputs.trace_installer && steps.cache-installer.outputs.cache-hit != 'true'
run: node tests/tools/win-update-e2e/trace-installer-branches.mjs
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules. Gated with the
@@ -88,26 +108,46 @@ jobs:
pnpm run build:desktop
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
# Why: install the branch build, open a terminal, update the SAME build
# over it, and assert the relocated daemon survives. Same build on both
# sides isolates the survival mechanism (NSIS kill sweep + userData daemon
# + adoption) from cross-version staleness policy. No --install-dir: a CI
# runner has no real Orca to protect.
- name: Cache successfully built installer before survival verification
if: steps.cache-installer.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: dist/orca-windows-setup.exe
key: ${{ steps.cache-installer.outputs.cache-primary-key }}
# A released base also exercises its old uninstaller; default runs isolate this branch.
- name: Run survival harness
id: harness
shell: pwsh
env:
ORCA_E2E_DIAG_DIR: artifacts/diag
ORCA_BACKGROUND_LAUNCH: '1'
DEBUG: 'pw:browser'
ORCA_E2E_NSIS_TRACE: ${{ github.workspace }}\artifacts\nsis-process-trace.log
ORCA_E2E_PROCESS_POLICY: ${{ inputs.policy || 'inherited' }}
FROM_RELEASE: ${{ inputs.from_release }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$exe = "dist/orca-windows-setup.exe"
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
$log = "artifacts/survival-output.log"
node tests/tools/win-update-e2e/run.mjs `
--from "$exe" `
--to "$exe" `
--expect "$env:EXPECT" `
--soak-seconds 60 2>&1 | Tee-Object -FilePath $log
$harnessArgs = @('--to', $exe, '--expect', $env:EXPECT, '--soak-seconds', '60')
if ($env:FROM_RELEASE) {
$harnessArgs += @('--from-release', $env:FROM_RELEASE)
} else {
$harnessArgs += @('--from', $exe)
}
# Only the harness and its children inherit the test policy.
$launch = @'
if (process.env.ORCA_E2E_PROCESS_POLICY === 'Restricted') {
process.env.PSExecutionPolicyPreference = 'Restricted'
}
console.log('Harness child process policy: ' + (process.env.PSExecutionPolicyPreference || 'inherited'))
process.argv.splice(1, 0, 'tests/tools/win-update-e2e/run.mjs')
await import('./tests/tools/win-update-e2e/run.mjs')
'@
node --input-type=module -e $launch -- @harnessArgs 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
- name: Upload survival output
@@ -117,6 +157,7 @@ jobs:
name: win-update-survival-output
path: |
artifacts/survival-output.log
artifacts/nsis-process-trace.log
artifacts/diag/**
retention-days: 7
if-no-files-found: warn
+29 -14
View File
@@ -210,20 +210,25 @@ jobs:
# signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
shell: pwsh
env:
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }}
run: |
$requireValid = $env:SIGNING_POLICY -ne 'test-signing'
$root = Resolve-Path 'dist/win-unpacked'
$failures = New-Object System.Collections.Generic.List[string]
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
Select-Object -First 1
if ($null -eq $signed) {
$failures.Add("missing from signed artifact: $relative")
$candidates = @(
(Join-Path 'signed-inner' $relative),
(Join-Path 'signed-inner/signing-stage' $relative)
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
if (@($candidates).Count -ne 1) {
$failures.Add("missing or ambiguous signed artifact path: $relative")
continue
}
$signed = Get-Item -LiteralPath @($candidates)[0]
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
$failures.Add("returned without a signature: $relative")
if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) {
$failures.Add("returned without a valid SignPath signature: $relative")
continue
}
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
@@ -236,15 +241,21 @@ jobs:
- name: Restore signed uninstaller for the installer rebuild
shell: pwsh
env:
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }}
run: |
$signed = Get-ChildItem -Path signed-inner -Recurse -File -Filter 'orca-uninstaller.exe' |
Select-Object -First 1
if ($null -eq $signed) {
throw 'SignPath did not return uninstaller/orca-uninstaller.exe; check the inner-binaries artifact configuration covers it.'
$requireValid = $env:SIGNING_POLICY -ne 'test-signing'
$candidates = @(
(Join-Path 'signed-inner' 'uninstaller\orca-uninstaller.exe'),
(Join-Path 'signed-inner/signing-stage' 'uninstaller\orca-uninstaller.exe')
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
if (@($candidates).Count -ne 1) {
throw 'Missing or ambiguous uninstaller/orca-uninstaller.exe in the signed artifact; check the windows-inner-binaries-zip configuration.'
}
$signed = Get-Item -LiteralPath @($candidates)[0]
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
throw 'The returned NSIS uninstaller carries no signature.'
if ($null -eq $signature.SignerCertificate -or ($requireValid -and ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notlike '*CN=SignPath Foundation*'))) {
throw 'The returned NSIS uninstaller carries no valid SignPath signature.'
}
$signedDir = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed'
New-Item -ItemType Directory -Force -Path $signedDir | Out-Null
@@ -487,7 +498,11 @@ jobs:
Test-Signature "shipped: Uninstall Orca.exe (via $installedVia)" $installedUninstaller.FullName
}
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$targets = @(Get-Content 'inner-signing-list.txt')
foreach ($requiredTarget in @('Orca.exe', 'resources\bin\orca.exe')) {
if ($targets -notcontains $requiredTarget) { $targets += $requiredTarget }
}
foreach ($relative in $targets) {
$path = Join-Path $root $relative
if (-not (Test-Path $path)) {
$failures.Add("missing from installer payload: $relative")
@@ -22,6 +22,7 @@ jobs:
timeout-minutes: 30
env:
NODE_OPTIONS: --max-old-space-size=4096
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- name: Checkout
+3 -1
View File
@@ -6,7 +6,9 @@
"trailingComma": "none",
"ignorePatterns": [
"cloud/**",
"resources/licenses/**",
".github/actions/cloud-sql-rollout-lease/**",
".anti-slop-plugin/**"
".anti-slop-plugin/**",
"src/main/runtime/__fixtures__/*.timing.json"
]
}
+20 -1
View File
@@ -143,7 +143,26 @@
{
"files": ["src/renderer/src/**/*.{ts,tsx}"],
"rules": {
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "error"
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "error",
"no-restricted-properties": [
"error",
{
"property": "randomUUID",
"message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'."
}
]
}
},
{
"files": ["src/shared/**/*.ts"],
"rules": {
"no-restricted-properties": [
"error",
{
"property": "randomUUID",
"message": "src/shared is compiled into the web bundle, where crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP). Use createNonSecureContextUuid() from './non-secure-context-uuid', or import randomUUID from 'node:crypto' in main-only code."
}
]
}
},
{
+1 -2
View File
@@ -5,8 +5,7 @@ cask "orca" do
sha256 arm: "fc707f290ff3b631b7b7947bf339885b61a43d2e89475997c125b61268ed4966",
intel: "5f677c13a08f7a5740442e29d388285a86488c8c1f7aa5f10a8721a2c6ede8e4"
url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg",
verified: "github.com/stablyai/orca/"
url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg"
name "Orca"
desc "IDE for orchestrating AI coding agents across terminals and worktrees"
homepage "https://onorca.dev/"
+1 -2
View File
@@ -5,8 +5,7 @@ cask "orca@rc" do
sha256 arm: "563b6b14323fc9d5489299c82442d514bc12cabffc9d06d3964ed572af4b3955",
intel: "457088c7021f07de1a419197f7b2bd00092741ad4727d4fef3d86af38a6831e7"
url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg",
verified: "github.com/stablyai/orca/"
url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg"
name "Orca RC"
desc "IDE for orchestrating AI coding agents across terminals and worktrees"
homepage "https://onorca.dev/"
+5 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -179,6 +179,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
@@ -194,7 +195,9 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuddy.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuddy.ai&sz=64" alt="CodeBuddy logo" width="16" valign="middle" /> CodeBuddy</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://freebuff.com"><kbd><img src="src/shared/agent-icons/freebuff.png" alt="Freebuff logo" width="16" valign="middle" /> Freebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
@@ -231,7 +234,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Download APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
---
@@ -157,7 +157,7 @@ it('keeps claim, exclusion and prune correct without the queue index', async ()
for (const claim of leased) await store.finish(claim)
expect((await store.claim())?.notification.notificationSeq).toBe(2)
advance(10 * 60_000)
expect(await store.prune()).toBe(1)
expect(await store.prune()).toEqual({ deleted: 1, saturated: false })
expect(await batchCount(db)).toBe(0)
})
@@ -211,11 +211,11 @@ it('prunes a large backlog in bounded calls without touching live or leased work
[JSON.stringify(notification(9)), now - 1, now - 1, now + 1000, now - 1]
)
await store.accept('host', 'phone-live', notification(1))
expect(await store.prune()).toBe(perCall)
expect(await store.prune()).toBe(500)
expect(await store.prune()).toBe(0)
expect(await store.prune()).toEqual({ deleted: perCall, saturated: true })
expect(await store.prune()).toEqual({ deleted: 500, saturated: false })
expect(await store.prune()).toEqual({ deleted: 0, saturated: false })
advance(1000)
expect(await store.prune()).toBe(1)
expect(await store.prune()).toEqual({ deleted: 1, saturated: false })
expect(await batchCount(db)).toBe(1)
expect(await store.pendingCount('phone-live')).toBe(1)
})
+12 -7
View File
@@ -12,6 +12,8 @@ const CLAIM_CANDIDATE_ATTEMPTS = 4
export const PRUNE_BATCH_ROWS = 2_000
export const PRUNE_MAX_BATCHES = 50
export const DELIVERY_LEASE_MS = 30_000
// `saturated` means the batch budget ran out with rows still matching, so a backlog remains.
export type PushPruneSweep = { deleted: number; saturated: boolean }
export type QueuedPushDelivery = {
id: string
registrationId: string
@@ -200,10 +202,10 @@ export class DurablePushStore {
}
// Bounded per call and per statement, so it drains any backlog on its own without holding locks.
async prune(): Promise<number> {
async prune(): Promise<PushPruneSweep> {
const now = this.now()
// Also clears terminal rows older revisions kept, since each carries a past expires_at.
let deleted = await this.deleteInBatches(
let { deleted, saturated } = await this.deleteInBatches(
'push_delivery_batches',
'batch_id',
'expires_at <= ? AND lease_until <= ?',
@@ -215,9 +217,11 @@ export class DurablePushStore {
['push_event_recipients', 'event_id, registration_id'],
['push_events', 'event_id']
] as const) {
deleted += await this.deleteInBatches(table, key, 'created_at < ?', [now - RETENTION_MS])
const sweep = await this.deleteInBatches(table, key, 'created_at < ?', [now - RETENTION_MS])
deleted += sweep.deleted
saturated ||= sweep.saturated
}
return deleted
return { deleted, saturated }
}
private async deleteInBatches(
@@ -225,7 +229,7 @@ export class DurablePushStore {
key: string,
where: string,
params: unknown[]
): Promise<number> {
): Promise<PushPruneSweep> {
const lockRows = this.background.dialect === 'postgres' ? ' FOR UPDATE SKIP LOCKED' : ''
let total = 0
for (let batch = 0; batch < PRUNE_MAX_BATCHES; batch++) {
@@ -235,8 +239,9 @@ export class DurablePushStore {
)
const changes = Number(result?.changes ?? 0)
total += changes
if (changes < PRUNE_BATCH_ROWS) break
// A short batch drained the predicate; only a full last batch leaves rows behind.
if (changes < PRUNE_BATCH_ROWS) return { deleted: total, saturated: false }
}
return total
return { deleted: total, saturated: true }
}
}
+174
View File
@@ -0,0 +1,174 @@
import { afterEach, expect, it, vi } from 'vitest'
import { startPushBackground } from './push-background.js'
import { createPushServerHarness } from './push-server-harness.test-fixture.js'
import { reserveRequestConnection } from './push-background-database.js'
import { DurablePushStore, PRUNE_BATCH_ROWS, type PushPruneSweep } from './durable-push-store.js'
import type { PushDatabase } from './push-database.js'
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) await cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
async function fixture(mode: 'active' | 'validation' = 'active') {
const harness = await createPushServerHarness()
const runtime = harness.server
const challenges = vi.spyOn(runtime.challenges, 'pruneExpired').mockResolvedValue(0)
const sessions = vi.spyOn(runtime.sessions, 'pruneExpired').mockResolvedValue(0)
const deliveries = vi
.spyOn(runtime.deliveryStore, 'prune')
.mockResolvedValue({ deleted: 0, saturated: false })
vi.spyOn(runtime.worker, 'start').mockImplementation(() => {})
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.useFakeTimers()
const stop = startPushBackground({ mode }, runtime)
cleanups.push(async () => {
await stop()
await harness.close()
})
return { stop, challenges, sessions, deliveries, warn }
}
it('keeps one slow sweep per store while other stores keep their cadence', async () => {
const h = await fixture()
let finish!: (sweep: PushPruneSweep) => void
h.deliveries.mockImplementationOnce(
() => new Promise<PushPruneSweep>((resolve) => (finish = resolve))
)
try {
await vi.advanceTimersByTimeAsync(10 * 60_000)
expect(h.deliveries).toHaveBeenCalledTimes(1)
expect(h.challenges).toHaveBeenCalledTimes(10)
expect(h.sessions).toHaveBeenCalledTimes(1)
} finally {
finish({ deleted: 100_000, saturated: false })
}
await vi.advanceTimersByTimeAsync(60_000)
expect(h.deliveries).toHaveBeenCalledTimes(2)
await h.stop()
await vi.advanceTimersByTimeAsync(10 * 60_000)
expect(h.deliveries).toHaveBeenCalledTimes(2)
expect(h.challenges).toHaveBeenCalledTimes(11)
expect(h.sessions).toHaveBeenCalledTimes(1)
})
it('reports a sweep that is still running a full interval after it started', async () => {
const h = await fixture()
let finish!: (sweep: PushPruneSweep) => void
h.deliveries.mockImplementationOnce(
() => new Promise<PushPruneSweep>((resolve) => (finish = resolve))
)
try {
await vi.advanceTimersByTimeAsync(119_999)
expect(h.warn).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(h.warn).toHaveBeenCalledWith(
JSON.stringify({ event: 'orca_push_prune_overdue', target: 'deliveries' })
)
} finally {
finish({ deleted: 0, saturated: false })
}
// One report per sweep: the settled sweep clears its own watchdog.
await vi.advanceTimersByTimeAsync(10 * 60_000)
expect(h.warn).toHaveBeenCalledTimes(1)
})
it('releases a failed sweep so the next scheduled sweep can recover', async () => {
const h = await fixture()
h.deliveries.mockRejectedValueOnce(new Error('database unavailable'))
await vi.advanceTimersByTimeAsync(60_000)
expect(h.deliveries).toHaveBeenCalledTimes(1)
expect(h.warn).toHaveBeenCalledWith(
JSON.stringify({ event: 'orca_push_prune_failed', target: 'deliveries', error: 'Error' })
)
await vi.advanceTimersByTimeAsync(60_000)
expect(h.deliveries).toHaveBeenCalledTimes(2)
expect(h.warn).toHaveBeenCalledTimes(1)
})
it('resumes a saturated sweep at once rather than waiting out the interval', async () => {
const h = await fixture()
let backlogSweeps = 3
h.deliveries.mockImplementation(async () => {
const saturated = backlogSweeps-- > 0
return { deleted: saturated ? PRUNE_BATCH_ROWS : 0, saturated }
})
await vi.advanceTimersByTimeAsync(60_000)
expect(h.deliveries).toHaveBeenCalledTimes(1)
// Three saturated sweeps resume within milliseconds instead of costing an interval each.
await vi.advanceTimersByTimeAsync(10)
expect(h.deliveries).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(59_000)
expect(h.deliveries).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(1_000)
expect(h.deliveries).toHaveBeenCalledTimes(5)
})
it('keeps a delivery claim behind one statement while a backlog drains back to back', async () => {
const h = await fixture()
let backlog = true
let finishedDeletes = 0
const database: PushDatabase = {
dialect: 'postgres',
query: async (sql) => {
if (!sql.startsWith('DELETE')) return []
await new Promise((resolve) => setTimeout(resolve, 4_000))
finishedDeletes++
// Only deliveries hold a backlog, so each sweep spends its whole budget there and returns.
if (!backlog || !sql.includes('push_delivery_batches')) return [{ changes: 0 }]
return [{ changes: PRUNE_BATCH_ROWS }]
},
transaction: (operation) => operation(database),
lockQuotaScope: async () => {},
tryLockScope: async () => true,
tryLockSharedScope: async () => true,
close: async () => {}
}
const store = new DurablePushStore(database, Date.now, reserveRequestConnection(database, 2))
const sweeps: Promise<PushPruneSweep>[] = []
let inFlight = 0
let concurrentSweeps = 0
h.deliveries.mockImplementation(() => {
concurrentSweeps = Math.max(concurrentSweeps, ++inFlight)
const sweep = store.prune().finally(() => void inFlight--)
sweeps.push(sweep)
return sweep
})
try {
await vi.advanceTimersByTimeAsync(10 * 60_000)
const queuedAt = finishedDeletes
const startedAt = Date.now()
let statementsAhead: number | undefined
let claimDelay: number | undefined
const claim = store.claim().then(() => {
statementsAhead = finishedDeletes - queuedAt
claimDelay = Date.now() - startedAt
})
await vi.advanceTimersByTimeAsync(44_000)
await claim
// Sweeping serially parks one statement ahead of the claim no matter how long the drain runs.
expect({ statementsAhead, concurrentSweeps }).toEqual({
statementsAhead: 1,
concurrentSweeps: 1
})
expect(claimDelay).toBeLessThanOrEqual(4_000)
// Each sweep exhausts its 50-batch budget, so the drain continues instead of idling out the tick.
expect(sweeps.length).toBeGreaterThan(1)
} finally {
await h.stop()
backlog = false
await vi.advanceTimersByTimeAsync(10 * 60_000)
await Promise.all(sweeps)
}
})
it('keeps validation mode free of sweeps and timers', async () => {
const h = await fixture('validation')
await vi.advanceTimersByTimeAsync(20 * 60_000)
expect(h.challenges).not.toHaveBeenCalled()
expect(h.sessions).not.toHaveBeenCalled()
expect(h.deliveries).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
+55 -18
View File
@@ -1,26 +1,55 @@
import type { PushConfig } from './config.js'
import type { PushPruneSweep } from './durable-push-store.js'
import type { createPushServer } from './push-server.js'
const CHALLENGE_PRUNE_INTERVAL_MS = 60_000
const SESSION_PRUNE_INTERVAL_MS = 10 * 60_000
const DELIVERY_PRUNE_INTERVAL_MS = 60_000
function prune(label: string, run: () => Promise<number>, intervalMs: number): NodeJS.Timeout {
const timer = setInterval(() => {
void run().catch((error: unknown) => {
console.warn(
JSON.stringify({
event: 'orca_push_prune_failed',
target: label,
error: error instanceof Error ? error.name : 'unknown'
})
)
})
}, intervalMs)
timer.unref()
return timer
// Chained rather than periodic, so a sweep spanning many bounded statements never overlaps itself and
// one that exhausted its batch budget resumes at once instead of idling out the rest of the interval.
function prune(label: string, run: () => Promise<PushPruneSweep>, intervalMs: number): () => void {
let timer: NodeJS.Timeout | undefined
let overdue: NodeJS.Timeout | undefined
let stopped = false
function schedule(delayMs: number): void {
if (stopped) return
timer = setTimeout(tick, delayMs)
timer.unref()
}
function tick(): void {
// Admission waits are untimed, so a lost slot release would otherwise stall retention silently.
overdue = setTimeout(() => {
console.warn(JSON.stringify({ event: 'orca_push_prune_overdue', target: label }))
}, intervalMs)
overdue.unref()
void run()
.then((sweep) => schedule(sweep.saturated ? 0 : intervalMs))
.catch((error: unknown) => {
console.warn(
JSON.stringify({
event: 'orca_push_prune_failed',
target: label,
error: error instanceof Error ? error.name : 'unknown'
})
)
schedule(intervalMs)
})
.finally(() => clearTimeout(overdue))
}
schedule(intervalMs)
return () => {
stopped = true
clearTimeout(timer)
clearTimeout(overdue)
}
}
// One DELETE under a statement timeout: there is no batch budget for it to exhaust.
const unbatchedSweep =
(run: () => Promise<number>) =>
async (): Promise<PushPruneSweep> => ({ deleted: await run(), saturated: false })
export function startPushBackground(
config: Pick<PushConfig, 'mode'>,
runtime: Pick<
@@ -30,14 +59,22 @@ export function startPushBackground(
): () => Promise<void> {
if (config.mode === 'validation') return async () => {}
const { challenges, sessions, deliveryStore, worker } = runtime
const timers = [
prune('challenges', () => challenges.pruneExpired(), CHALLENGE_PRUNE_INTERVAL_MS),
prune('sessions', () => sessions.pruneExpired(), SESSION_PRUNE_INTERVAL_MS),
const stops = [
prune(
'challenges',
unbatchedSweep(() => challenges.pruneExpired()),
CHALLENGE_PRUNE_INTERVAL_MS
),
prune(
'sessions',
unbatchedSweep(() => sessions.pruneExpired()),
SESSION_PRUNE_INTERVAL_MS
),
prune('deliveries', () => deliveryStore.prune(), DELIVERY_PRUNE_INTERVAL_MS)
]
worker.start()
return async () => {
for (const timer of timers) clearInterval(timer)
for (const stop of stops) stop()
await worker.stop()
}
}
+10 -8
View File
@@ -85,15 +85,17 @@ class SqliteDatabase extends SqliteTransaction {
let release!: () => void
this.tail = new Promise((resolve) => (release = resolve))
await previous
this.database.exec('BEGIN IMMEDIATE')
const transaction = new SqliteTransaction(this.database)
try {
const result = await operation(transaction)
this.database.exec('COMMIT')
return result
} catch (error) {
this.database.exec('ROLLBACK')
throw error
this.database.exec('BEGIN IMMEDIATE')
const transaction = new SqliteTransaction(this.database)
try {
const result = await operation(transaction)
this.database.exec('COMMIT')
return result
} catch (error) {
this.database.exec('ROLLBACK')
throw error
}
} finally {
release()
}
@@ -0,0 +1,101 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { setImmediate } from 'node:timers/promises'
import { expect, it, vi } from 'vitest'
import { openPushDatabase } from './push-database.js'
it('releases queued work and close after a SQLite transaction cannot acquire its lock', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'orca-push-sqlite-queue-'))
let connection: DatabaseSync | undefined
const prepare = DatabaseSync.prototype.prepare
// Keep the native handle reachable for cleanup even if a queue regression strands close().
const capture = vi
.spyOn(DatabaseSync.prototype, 'prepare')
.mockImplementation(function (this: DatabaseSync, sql) {
connection = this
return prepare.call(this, sql)
})
const database = await openPushDatabase({ dataDir })
capture.mockRestore()
const blocker = new DatabaseSync(join(dataDir, 'orca-push.sqlite'))
try {
await database.query('CREATE TABLE queue_progress (value INTEGER)')
await database.query('INSERT INTO queue_progress VALUES (0)')
blocker.exec('BEGIN IMMEDIATE')
const operation = vi.fn(async () => undefined)
await expect(database.transaction(operation)).rejects.toThrow('database is locked')
let lockFailures = 0
const blocked = Array.from({ length: 5 }, () =>
database.transaction(operation).catch(() => {
lockFailures += 1
})
)
await setImmediate()
expect(lockFailures).toBe(5)
await Promise.all(blocked)
expect(operation).not.toHaveBeenCalled()
blocker.exec('ROLLBACK')
let completed = 0
const pending = Array.from({ length: 100 }, () =>
database.transaction(async (transaction) => {
await transaction.query('UPDATE queue_progress SET value = value + 1')
completed += 1
})
)
for (const request of pending) void request.catch(() => undefined)
await setImmediate()
expect(completed).toBe(100)
await Promise.all(pending)
expect(await database.query('SELECT value FROM queue_progress')).toEqual([{ value: 100 }])
let closed = false
const closing = database.close().then(() => {
closed = true
})
await setImmediate()
expect(closed).toBe(true)
await closing
} finally {
capture.mockRestore()
blocker.close()
if (connection?.isOpen) connection.close()
rmSync(dataDir, { recursive: true, force: true })
}
})
it('does not roll back a transaction when BEGIN failed before taking ownership', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'orca-push-sqlite-owner-'))
let connection: DatabaseSync | undefined
const prepare = DatabaseSync.prototype.prepare
const capture = vi
.spyOn(DatabaseSync.prototype, 'prepare')
.mockImplementation(function (this: DatabaseSync, sql) {
connection = this
return prepare.call(this, sql)
})
const database = await openPushDatabase({ dataDir })
capture.mockRestore()
try {
await database.query('CREATE TABLE queue_owner (value INTEGER)')
await database.query('BEGIN IMMEDIATE')
await database.query('INSERT INTO queue_owner VALUES (7)')
await expect(database.transaction(async () => undefined)).rejects.toThrow(
'cannot start a transaction within a transaction'
)
expect(connection?.isTransaction).toBe(true)
let rows: unknown
void database.query('SELECT value FROM queue_owner').then((result) => {
rows = result
})
await setImmediate()
expect(rows).toEqual([{ value: 7 }])
await database.query('ROLLBACK')
expect(await database.query('SELECT value FROM queue_owner')).toEqual([])
await database.close()
} finally {
capture.mockRestore()
if (connection?.isOpen) connection.close()
rmSync(dataDir, { recursive: true, force: true })
}
})
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it } from 'vitest'
import { RelayAssignmentStore } from './assignment-store.js'
import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js'
const NOW = 100_000
const CELL_COUNT = 32
const identity = { userId: 'headroom-user', relayHostId: 'headroomhost0001' }
function observeHeadroom(database: RelayDatabase, rowCounts: number[]): RelayDatabase {
return {
dialect: database.dialect,
async query(sql, params) {
const rows = await database.query(sql, params)
if (sql.includes('FROM relay_cell_connection_limits limits')) rowCounts.push(rows.length)
return rows
},
queryLocked: (sql, params, options) => database.queryLocked(sql, params, options),
transaction: (operation, options) =>
database.transaction((transaction) => operation(observeHeadroom(transaction, rowCounts)), options),
close: () => database.close()
}
}
describe('assignment headroom query scope', () => {
let database: RelayDatabase | undefined
afterEach(async () => await database?.close())
async function setup() {
database = await openInMemoryRelayDatabase()
const rowCounts: number[] = []
const store = new RelayAssignmentStore(observeHeadroom(database, rowCounts), () => NOW, {
requireLiveCells: true,
heartbeatTtlMs: 45_000
})
const cells = Array.from({ length: CELL_COUNT }, (_, index) => ({
id: `cell-${String(index).padStart(2, '0')}`,
url: `https://cell-${index}.example.com`,
capacityRequests: 1_000,
connectionHardCap: 600 as const,
connectionUnobservedBound: 50
}))
await store.reconcileCells(cells)
for (const cell of cells) {
await store.recordCellHeartbeat({
cellId: cell.id,
cellUrl: cell.url,
cellIncarnation: '11111111-1111-4111-8111-111111111111',
startedAt: NOW - 10,
ready: true,
observedRequests: 0,
totalConnections: 0,
inFlightConnections: 0,
reservedConnectionUnits: 0,
enforcedConnectionUnits: 0,
connectionHardCap: 600,
connectionUnobservedBound: 50
})
}
await database.query(
`INSERT INTO relay_assignments
(user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at,
last_activity_at, reserved_controls, reserved_splices, reserved_invites,
pending_installs, pending_confirmations, migration_leases)
VALUES (?, ?, 'cell-00', 1, ?, ?, 0, 0, 0, 0, 0, 0)`,
[identity.userId, identity.relayHostId, NOW + 90_000, NOW]
)
await store.acquireActivity(identity, {
activityId: 'existing-splice',
kind: 'splice',
cellId: 'cell-00'
})
rowCounts.length = 0
return { store, rowCounts, database }
}
it('reads one cell for a sticky assignment even when the capped fleet grows', async () => {
const { store, rowCounts } = await setup()
await expect(store.assign(identity)).resolves.toMatchObject({
...identity,
cellId: 'cell-00',
assignmentEpoch: 1
})
console.info('sticky headroom rows', { fleetCells: CELL_COUNT, rowCounts })
expect(rowCounts).toEqual([1])
})
it('still considers the full fleet for a new placement', async () => {
const { store, rowCounts } = await setup()
await expect(
store.assign({ userId: 'new-user', relayHostId: 'newheadroomhost1' })
).resolves.toMatchObject({ cellId: 'cell-01', assignmentEpoch: 1 })
expect(rowCounts).toEqual([CELL_COUNT])
})
it.each([
['missing snapshot', `DELETE FROM relay_cell_connection_snapshots WHERE cell_id = 'cell-00'`],
['expired snapshot', `UPDATE relay_cell_connection_snapshots SET snapshot_at = ${NOW - 45_000} WHERE cell_id = 'cell-00'`],
['old incarnation', `UPDATE relay_cell_connection_snapshots SET cell_incarnation = 'old' WHERE cell_id = 'cell-00'`],
['capacity boundary', `UPDATE relay_cell_connection_snapshots SET enforced_connection_units = 450 WHERE cell_id = 'cell-00'`]
])('rejects the pinned active host with %s', async (_name, sql) => {
const { store, database } = await setup()
await database.query(sql)
await expect(store.assign(identity)).rejects.toThrow('relay_connection_headroom_exhausted')
})
it('preserves admission for cells without a connection limit', async () => {
const { store, rowCounts, database } = await setup()
await database.query(`DELETE FROM relay_cell_connection_limits WHERE cell_id = 'cell-00'`)
await expect(store.assign(identity)).resolves.toMatchObject({ cellId: 'cell-00' })
expect(rowCounts).toEqual([0])
})
it('counts outstanding reservations at the admission boundary', async () => {
const { store, database } = await setup()
await database.query(
`UPDATE relay_cell_connection_snapshots SET enforced_connection_units = 449
WHERE cell_id = 'cell-00'`
)
await database.query(
`INSERT INTO relay_control_connection_reservations
(reservation_id, idempotency_key, user_id, relay_host_id, assignment_epoch,
cell_id, state, created_at, timeout_at, updated_at)
VALUES ('pending', 'pending', 'other-user', 'other-host', 1,
'cell-00', 'reserved', ?, ?, ?)`,
[NOW, NOW + 90_000, NOW]
)
await expect(store.assign(identity)).rejects.toThrow('relay_connection_headroom_exhausted')
})
})
+10 -4
View File
@@ -7466,10 +7466,16 @@ export class RelayAssignmentStore {
}
private async connectionHeadroomByCell(
database: RelayDatabase
database: RelayDatabase,
cellId?: string
): Promise<Map<string, boolean>> {
const now = this.now()
const rows = await database.query(ASSIGNMENT_CONNECTION_HEADROOM_QUERY)
const rows = await database.query(
cellId === undefined
? ASSIGNMENT_CONNECTION_HEADROOM_QUERY
: `${ASSIGNMENT_CONNECTION_HEADROOM_QUERY} WHERE limits.cell_id = ?`,
cellId === undefined ? [] : [cellId]
)
return new Map(
rows.map((row) => {
const heartbeat = optionalInteger(row, 'last_heartbeat_at')
@@ -7503,7 +7509,7 @@ export class RelayAssignmentStore {
database: RelayDatabase,
cellId: string
): Promise<boolean> {
return (await this.connectionHeadroomByCell(database)).get(cellId) !== false
return (await this.connectionHeadroomByCell(database, cellId)).get(cellId) !== false
}
private async cellIsLive(
@@ -8476,7 +8482,7 @@ function isDatabaseLockUnavailable(error: unknown): boolean {
return error instanceof Error && error.message === 'database_lock_unavailable'
}
export function cellInventoryLockOptions(mode: CellInventoryLockMode): RelayLockOptions {
function cellInventoryLockOptions(mode: CellInventoryLockMode): RelayLockOptions {
if (mode === 'nowait') return { failIfUnavailable: true, measureHoldMs: true }
if (mode === 'pool-default') return { measureHoldMs: true }
return { lockTimeoutMs: CELL_INVENTORY_LOCK_TIMEOUT_MS, measureHoldMs: true }
@@ -1,335 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { cellInventoryLockOptions, type CellInventoryLockMode } from './assignment-store.js'
// Which entry points can reach a call site. A site a sweep can enter must never
// take the bounded wait: its 55P03 becomes a terminal transaction failure, and
// the incident monitor freezes on a single one.
type Reachability = 'request' | 'sweep' | 'both' | 'orphan'
// 'caller' is not a CellInventoryLockMode: those sites take the mode threaded
// from `assign`, which is 'request' for a client and 'pool-default' for the
// evacuateDeadCells sweep.
type CensusMode = CellInventoryLockMode | 'caller'
type CensusEntry = { method: string; mode: CensusMode; reach: Reachability }
// Every lockCellInventory / lockGeneralCellInventory call site in
// assignment-store.ts, in source order. A new site fails this test until it is
// classified here, which is the point.
const CENSUS: CensusEntry[] = [
// assignStickyOnce is gone from this list: its retry now locks only the row
// the host is pinned to (lockCellRows), which is what a sticky refresh
// touches. Placement below is the one genuinely fleet-wide decision left.
{ method: 'assignOnce', mode: 'caller', reach: 'both' },
{ method: 'assignOnce', mode: 'caller', reach: 'both' },
{ method: 'assignOnce', mode: 'nowait', reach: 'both' },
{ method: 'assignOnce', mode: 'nowait', reach: 'both' },
{ method: 'assignOnce', mode: 'nowait', reach: 'both' },
{ method: 'refreshDrainMigrationLeasesOnce', mode: 'request', reach: 'request' },
// changeActivity, acquireActivity, activateControl and
// removeSupersededSameCellControls no longer take the inventory: they lock
// only the one or two cell rows they touch, in cell_id order (lockCellRows),
// so they cannot cycle with placement's ordered inventory lock, and the
// 23-row lock there had serialised every reconnect in the fleet behind every
// other one. The control accept path went one step further and takes no cell
// read lock at all: its single conditional write is the last statement before
// COMMIT.
{ method: 'startEvacuation', mode: 'request', reach: 'request' },
{ method: 'completeEvacuationFromDeadSourceOnce', mode: 'request', reach: 'request' },
{ method: 'completeEvacuationFromDeadSourceOnce', mode: 'nowait', reach: 'request' },
{ method: 'supersedeRegisteredEvacuationOnce', mode: 'request', reach: 'request' },
{ method: 'supersedeRegisteredEvacuationOnce', mode: 'nowait', reach: 'request' },
{ method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' },
{ method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' },
{ method: 'completeEvacuation', mode: 'nowait', reach: 'both' },
{ method: 'completeEvacuation', mode: 'pool-default', reach: 'both' },
{ method: 'rebalanceDormant', mode: 'request', reach: 'request' },
// startRegionalRehomeCandidate is gone: the rehome commit reads the inventory
// unlocked and locks only its target row, NOWAIT, as the statement before
// COMMIT (reserveRegionalRehomeTargetRow below).
{ method: 'completeRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' },
// Both regional-rehome abort sweeps share this rollback; only the 24-hour
// one also disables the durable switch.
{ method: 'rollBackStalledRegionalRehomes', mode: 'nowait', reach: 'sweep' },
{ method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' },
{ method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' },
{ method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' },
{ method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' }
// reconcileReservationAccounting and leastLoadedCell are gone too: the first
// repairs exactly two cells' counters and now holds only those rows, and the
// second selects from the inventory its single caller has already locked.
]
// Every inline `FROM relay_cells ... FOR UPDATE` outside the named lock helpers,
// in source order: whole-table locks in reconciliation and sticky placement,
// and single-row locks for a cell the method is already scoped to (heartbeat,
// fence, drain generation, configuration, or a reservation adjust that runs
// under a lock its caller already holds). A new inline lock fails the census
// below until it is listed here; per-connection paths that touch more than one
// cell go through lockCellRows so the order is fixed.
const NAMED_LOCK_HELPERS = ['lockCellInventory', 'lockGeneralCellInventory', 'lockCellRows']
const INLINE_CELL_LOCK_SITES = [
'reconcileCellsWithOptions',
'assignStickyOnce',
'recordCellHeartbeat',
'attestCellFence',
'adoptLegacyCellFence',
'commitLegacyCellFenceAdoption',
'prepareCellFenceAttempt',
'attestCellFenceAttempt',
'attestCellFenceAttempt',
'configureCell',
'reserveRegionalRehomeTargetRow',
'assertDrainCellGeneration',
'adjustCellReservation'
]
// The background sweeps, and nothing else. A method reachable from one of these
// can be entered by a sweep tick, whatever else can also enter it. Both lists are
// read from source, so a new sweep step or a new route widens the derivation here
// instead of silently widening what a bounded wait can be entered from.
const SWEEP_ENTRY_FILES = ['./assignment-cleanup-steps.ts', './regional-rehome-worker.ts']
const REQUEST_ENTRY_FILES = [
'./app.ts',
'./relay-server.ts',
'./host-session-registry.ts',
'./cell-admission-startup.ts'
]
const DECLARATION = /^ {2}(?:private |public )?(?:static )?(?:async )?([A-Za-z_][\w]*)[(<]/
function storeSource(): string[] {
return readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8').split('\n')
}
function entryPoints(files: string[]): string[] {
return files.flatMap((file) =>
[
...readFileSync(new URL(file, import.meta.url), 'utf8').matchAll(
/assignments\.([A-Za-z_][\w]*)\(/g
)
].map((call) => call[1]!)
)
}
// Same-class call graph: store methods only ever reach each other through `this.`.
function storeCallGraph(lines: string[]): Map<string, Set<string>> {
const bounds: { name: string; start: number }[] = []
lines.forEach((line, index) => {
const declaration = DECLARATION.exec(line)
if (declaration) bounds.push({ name: declaration[1]!, start: index })
})
const callees = new Map<string, Set<string>>()
bounds.forEach((method, index) => {
const end = bounds[index + 1]?.start ?? lines.length
const names = callees.get(method.name) ?? new Set<string>()
for (const call of lines
.slice(method.start, end)
.join('\n')
.matchAll(/this\.([A-Za-z_][\w]*)\s*\(/g)) {
names.add(call[1]!)
}
callees.set(method.name, names)
})
return callees
}
function closure(callees: Map<string, Set<string>>, roots: string[]): Set<string> {
const reached = new Set<string>()
const pending = [...roots]
while (pending.length > 0) {
const name = pending.pop()!
if (reached.has(name)) continue
reached.add(name)
for (const callee of callees.get(name) ?? []) if (!reached.has(callee)) pending.push(callee)
}
return reached
}
// Why: a hand-written reachability column is a claim, not a check. Derive both
// directions, so a new sweep edge into a bounded site fails here instead of in
// production, and so 'sweep' and 'both' stop being asserted by hand.
function derivedReachability(lines: string[]): (method: string) => Reachability {
const callees = storeCallGraph(lines)
const sweep = closure(callees, entryPoints(SWEEP_ENTRY_FILES))
const request = closure(callees, entryPoints(REQUEST_ENTRY_FILES))
return (method) =>
sweep.has(method)
? request.has(method)
? 'both'
: 'sweep'
: request.has(method)
? 'request'
: 'orphan'
}
function readCallSites(): { method: string; mode: CensusMode }[] {
const sites: { method: string; mode: CensusMode }[] = []
let method = '<module>'
for (const line of storeSource()) {
const declaration = DECLARATION.exec(line)
if (declaration) method = declaration[1]!
if (/private async lock(General)?CellInventory\(/.test(line)) continue
const call = /lock(?:General)?CellInventory\(\s*\w+\s*,\s*(?:'([a-z-]+)'|(\w+))\s*\)/.exec(line)
if (!call) continue
sites.push({ method, mode: (call[1] ?? 'caller') as CensusMode })
}
return sites
}
// Tier 3 and tier 4 of the row lock order documented in assignment-store.ts. A
// transaction that takes relay_cells before this host's reservation rows can
// cycle with one that takes them the other way round, and PostgreSQL resolves
// that as a 40P01 during exactly the drain and rehome waves these paths exist
// to run. The cell row is the one every host on a cell shares, so it is the
// lock that must be taken last, which fixes the direction for everyone else.
const CELL_LOCK_CALL =
/this\.(?:lockCellInventory|lockGeneralCellInventory|lockCellRows|adjustCellReservationAtomically|adjustCellReservation)\(|UPDATE relay_cells/
const RESERVATION_LOCK_CALL =
/this\.(?:lockControlConnectionReservations|insertControlConnectionReservation|claimControlConnectionReservation|releaseSupersededControlConnectionReservations)\(|(?:UPDATE|INTO|DELETE FROM)\s+relay_control_connection_reservations/
// The lock helpers themselves, plus the one reporting query that reads both
// tables without locking either.
const ROW_LOCK_ORDER_EXEMPT = [
'lockCellInventory',
'lockGeneralCellInventory',
'lockCellRows',
'lockControlConnectionReservations',
'adjustCellReservation',
'adjustCellReservationAtomically',
'insertControlConnectionReservation',
'claimControlConnectionReservation',
'releaseSupersededControlConnectionReservations',
'cellDeploymentStatus'
]
function methodSpans(lines: string[]): { name: string; start: number; end: number }[] {
const starts: { name: string; start: number }[] = []
lines.forEach((line, index) => {
const declaration = DECLARATION.exec(line)
if (declaration) starts.push({ name: declaration[1]!, start: index })
})
return starts.map((entry, index) => ({
...entry,
end: starts[index + 1]?.start ?? lines.length
}))
}
function pathsTakingCellsBeforeReservations(lines: string[]): string[] {
const offending: string[] = []
for (const span of methodSpans(lines)) {
if (ROW_LOCK_ORDER_EXEMPT.includes(span.name)) continue
let cell = Number.POSITIVE_INFINITY
let reservation = Number.POSITIVE_INFINITY
for (let index = span.start; index < span.end; index++) {
const line = lines[index]!
if (CELL_LOCK_CALL.test(line)) cell = Math.min(cell, index)
if (RESERVATION_LOCK_CALL.test(line)) reservation = Math.min(reservation, index)
}
if (cell < reservation && reservation !== Number.POSITIVE_INFINITY) {
offending.push(span.name)
}
}
return offending
}
describe('cell inventory lock call-site census', () => {
it('classifies every call site exactly as recorded', () => {
expect(readCallSites()).toEqual(CENSUS.map(({ method, mode }) => ({ method, mode })))
})
// Why: the census only sees lockCellInventory calls, so a hand-written
// `relay_cells ... FOR UPDATE` would escape classification entirely.
it('routes every relay_cells row lock through a named lock helper', () => {
const lines = storeSource()
const rawSites: string[] = []
// Whole statements, not a fixed window: a wide column list or a raw
// FOR UPDATE inside query() must not slip past.
const source = lines.join('\n')
const bounds: { name: string; start: number }[] = []
lines.forEach((line, index) => {
const declaration = DECLARATION.exec(line)
if (declaration) bounds.push({ name: declaration[1]!, start: index })
})
const methodAt = (offset: number): string => {
const lineIndex = source.slice(0, offset).split('\n').length - 1
let name = '<module>'
for (const bound of bounds) if (bound.start <= lineIndex) name = bound.name
return name
}
const tick = String.fromCharCode(96)
const statementCall = new RegExp(
'\\.(queryLocked|query)\\(\\s*' + tick + '([^' + tick + ']*)' + tick,
'g'
)
for (const call of source.matchAll(statementCall)) {
const statement = call[2]!
if (!/\bFROM\s+relay_cells\b/.test(statement)) continue
const locks = call[1] === 'queryLocked' || /\bFOR\s+UPDATE\b/.test(statement)
if (!locks) continue
const method = methodAt(call.index)
if (NAMED_LOCK_HELPERS.includes(method)) continue
rawSites.push(method)
}
expect(rawSites).toEqual(INLINE_CELL_LOCK_SITES)
})
it('takes the host reservation rows before the shared cell row everywhere', () => {
expect(pathsTakingCellsBeforeReservations(storeSource())).toEqual([])
})
it('leaves no call site taking the inventory without naming a mode', () => {
const source = readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8')
const unclassified = source
.split('\n')
.filter((line) => /lock(?:General)?CellInventory\(\s*\w+\s*\)/.test(line))
.filter((line) => !line.includes('private async'))
expect(unclassified).toEqual([])
})
it('derives the same reachability the census claims', () => {
const reachOf = derivedReachability(storeSource())
expect(readCallSites().map(({ method }) => reachOf(method))).toEqual(
CENSUS.map((entry) => entry.reach)
)
})
// Why: this is the whole point of the classification. A shorter wait on a
// sweep-reachable site turns contention into a terminal transaction failure
// that counts against the incident gate's relayPostgresRetryExhausted bar.
// Why: the hold distribution is what the 500ms bound will be tuned against, so
// a mode that stops asking for it goes unmeasured in exactly the lane that
// matters. Nothing else in the suite reads the pool-default branch.
it('measures the hold in every lock mode', () => {
const modes: CellInventoryLockMode[] = ['request', 'nowait', 'pool-default']
expect(modes.map((mode) => cellInventoryLockOptions(mode).measureHoldMs)).toEqual([
true,
true,
true
])
})
it('never puts a sweep-reachable site on the bounded wait', () => {
const reachOf = derivedReachability(storeSource())
const bounded = readCallSites().filter(
(site) => site.mode === 'request' && ['sweep', 'both'].includes(reachOf(site.method))
)
expect(bounded).toEqual([])
})
it('routes every sweep-only site to NOWAIT so it can skip the tick', () => {
const reachOf = derivedReachability(storeSource())
const queueing = readCallSites().filter(
(site) => reachOf(site.method) === 'sweep' && site.mode !== 'nowait'
)
expect(queueing).toEqual([])
})
})
@@ -1,4 +1,3 @@
import { readFileSync } from 'node:fs'
import { afterEach, describe, expect, it, vi } from 'vitest'
const fakes = vi.hoisted(() => ({
@@ -78,7 +77,6 @@ describe('bounded cell-inventory lock wait', () => {
// Why: a bound at or above the pool default would fence nothing, and one far
// below the hold time would convert ordinary contention into terminal failures.
it('keeps the request bound strictly inside the pool default', () => {
expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBe(500)
expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBeLessThan(POSTGRES_LOCK_TIMEOUT_MS)
})
@@ -361,15 +359,6 @@ describe('bounded cell-inventory lock wait', () => {
await database.close()
})
// Why: index.ts boots a server on import, so its wiring can only be read. An
// unspread hold metric is invisible: the flush simply omits the fields.
it('spreads the hold counts into the runtime metrics flush', () => {
const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8')
const flush = /observability\.start\(\(\) => \(\{([^}]*)\}\)\)/.exec(source)
expect(flush?.[1]).toContain('...consumeRelayCellInventoryHold(database)')
})
// Why: 500ms is a first value, not a measurement. Tuning it needs the hold
// distribution, which no runtime metric carried.
it('reports how long the inventory lock was held to COMMIT', async () => {
@@ -492,25 +481,6 @@ describe('background sweeps skip a contended cell inventory', () => {
expect(warnings.entries).toEqual([])
await database.close()
})
it('still aborts the expired evacuation once the inventory is free', async () => {
const database = await openInMemoryRelayDatabase()
const probe = new InventoryLockProbe(database)
let now = 1_000
const store = new RelayAssignmentStore(probe, () => now)
await store.reconcileCells(CELLS)
const assignment = await store.assign(identity)
await store.activateControl(identity, {
cellId: assignment.cellId,
assignmentEpoch: assignment.assignmentEpoch,
generation: 1
})
await store.startEvacuation(identity, 'cell-b')
now += 24 * 60 * 60_000
expect(await store.abortExpiredEvacuations()).toBe(1)
await database.close()
})
})
// Returns each inventory lock the run took, as its bound or 'nowait'.
+11 -9
View File
@@ -852,16 +852,18 @@ class SqliteDatabase extends SqliteTransaction {
let release!: () => void
this.tail = new Promise((resolve) => (release = resolve))
await previous
this.database.exec('BEGIN IMMEDIATE')
const transaction = new SqliteTransaction(this.database)
try {
const result = await operation(transaction)
this.database.exec('COMMIT')
recordMeasuredHold(this.holds, transaction)
return result
} catch (error) {
this.database.exec('ROLLBACK')
throw error
this.database.exec('BEGIN IMMEDIATE')
const transaction = new SqliteTransaction(this.database)
try {
const result = await operation(transaction)
this.database.exec('COMMIT')
recordMeasuredHold(this.holds, transaction)
return result
} catch (error) {
this.database.exec('ROLLBACK')
throw error
}
} finally {
release()
}
@@ -535,6 +535,8 @@ describe('host session cleanup races', () => {
const socket = new FakeSocket()
const activation = activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
await vi.advanceTimersByTimeAsync(0)
expect(activateControl).toHaveBeenCalledOnce()
socket.close()
blocked.resolve('control:production-gce-c3:1')
await activation
@@ -560,6 +562,8 @@ describe('host session cleanup races', () => {
const rebindSocket = new FakeSocket()
const rebinding = activate(rebindSocket as unknown as WebSocket, identity, original, 1, true, 1)
await vi.advanceTimersByTimeAsync(0)
expect(activateControl).toHaveBeenCalledTimes(2)
rebindSocket.close()
blocked.resolve('control:production-gce-c3:1')
await rebinding
@@ -574,6 +578,49 @@ describe('host session cleanup races', () => {
)
})
it('skips a closed queued control so its live retry avoids abandoned database work', async () => {
const stalled = deferred<string>()
const activateControl = vi
.fn<RelayAssignmentStore['activateControl']>()
.mockReturnValueOnce(stalled.promise)
.mockImplementation(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 4_000))
return 'control:production-gce-c3:1'
})
const { registry, activate, releaseActivity } = createRegistry(activateControl)
const firstSocket = new FakeSocket()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface.
const first = activate(firstSocket as unknown as WebSocket, identity, null, 1, false, 1)
await vi.advanceTimersByTimeAsync(0)
expect(activateControl).toHaveBeenCalledOnce()
const abandonedSocket = new FakeSocket()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface.
const abandoned = activate(abandonedSocket as unknown as WebSocket, identity, null, 1, false, 1)
const liveSocket = new FakeSocket()
const startedAt = Date.now()
let liveCompletedAt: number | undefined
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface.
const live = activate(liveSocket as unknown as WebSocket, identity, null, 1, false, 1)
.then(() => { liveCompletedAt = Date.now() })
firstSocket.close()
abandonedSocket.close()
stalled.resolve('control:production-gce-c3:1')
await vi.advanceTimersByTimeAsync(8_000)
await Promise.all([first, abandoned, live])
console.log(JSON.stringify({
scenario: 'closed queued control before a live retry',
activationCalls: activateControl.mock.calls.length,
activityReleases: releaseActivity.mock.calls.length,
liveReadyMs: liveCompletedAt === undefined ? null : liveCompletedAt - startedAt
}))
expect(activateControl).toHaveBeenCalledTimes(2)
expect(releaseActivity).toHaveBeenCalledOnce()
expect(liveCompletedAt! - startedAt).toBe(4_000)
expect(registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })?.socket)
.toBe(liveSocket)
})
it('rejects client lookup when the indexed control socket is not open', async () => {
const reservation = {
userId: identity.sub,
@@ -1103,7 +1103,7 @@ export class HostSessionRegistry {
.catch(() => undefined)
.then(async () => {
clearTimeout(queueWaitTimer)
if (queueWaitExpired) return
if (queueWaitExpired || socket.readyState !== socket.OPEN) return
if ((this.sessions.get(key) ?? null) !== existing) {
socket.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded')
return
@@ -0,0 +1,272 @@
import { connect, type Socket } from 'node:net'
import { afterEach, expect, it, vi } from 'vitest'
import { RELAY_CLOSE_CODE, RELAY_PROTOCOL_LIMITS } from '@orca-cloud/relay-contract'
import { loadRelayConfig } from './config.js'
import type { RelayDatabase } from './database.js'
import { createRelayServer } from './relay-server.js'
const cleanups: (() => Promise<void> | void)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
vi.restoreAllMocks()
})
const IDLE_LEDGER = {
physicalConnections: 0,
inFlightConnections: 0,
reservedConnectionUnits: 0,
enforcedConnectionUnits: 0
}
async function fixture(
options: { role?: 'cell' | 'director'; hardCap?: number } = {}
) {
const role = options.role ?? 'cell'
const database: RelayDatabase = {
query: vi.fn(async () => []),
queryLocked: vi.fn(async () => []),
transaction: (operation) => operation(database),
close: async () => {}
}
const config = loadRelayConfig({
ORCA_RELAY_PUBLIC_URL: 'http://127.0.0.1',
ORCA_RELAY_CELL_URL: 'http://127.0.0.1',
ORCA_RELAY_AUTH_ISSUER: 'https://auth.example.test',
ORCA_RELAY_JWKS_URL: 'https://auth.example.test/jwks',
ORCA_RELAY_ASSIGNMENT_SIGNING_KEY: 'synthetic-assignment-key-for-test-only',
ORCA_RELAY_ROLE: role,
ORCA_RELAY_ADMIN_AUDIENCE: 'https://auth.example.test/admin',
ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'deploy@example.test',
ORCA_RELAY_CELL_CONNECTION_HARD_CAP: '600',
ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND: '60',
...(role === 'director'
? {
ORCA_RELAY_CELLS_JSON: JSON.stringify([
{ id: 'cell-1', url: 'https://cell-1.example.test', capacityRequests: 900 }
])
}
: {})
})
const relay = createRelayServer(config, database, {
connectionLedgerLimits: { hardCap: options.hardCap ?? 3, controlReserve: 1 }
})
await new Promise<void>((resolve) => relay.server.listen(0, '127.0.0.1', resolve))
cleanups.push(() => new Promise<void>((resolve) => relay.server.close(() => resolve())))
const address = relay.server.address()
if (!address || typeof address === 'string') throw new Error('missing test port')
return { relay, port: address.port, database }
}
type RawPeer = {
socket: Socket
received: () => Buffer
transport: () => { ended: boolean; error: string | null }
}
async function silentUpgrade(port: number, target: string): Promise<RawPeer> {
const socket = connect(port, '127.0.0.1')
cleanups.push(() => {
socket.destroy()
})
await new Promise<void>((resolve, reject) => {
let header = ''
socket.once('error', reject)
socket.once('connect', () => {
socket.write(
`GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\n` +
'Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\n' +
// RFC 6455 example nonce, matching the existing raw-upgrade fixture.
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n'
)
})
const readHeader = (chunk: Buffer): void => {
header += chunk.toString()
if (!header.includes('\r\n\r\n')) return
socket.off('data', readHeader)
if (header.startsWith('HTTP/1.1 101 ')) resolve()
else reject(new Error(header.split('\r\n')[0]))
}
socket.on('data', readHeader)
})
socket.removeAllListeners('error')
// This raw peer reads frames without answering the server's close handshake.
const chunks: Buffer[] = []
let ended = false
let error: string | null = null
socket.on('data', (chunk: Buffer) => chunks.push(chunk))
socket.on('end', () => {
ended = true
})
socket.on('error', (caught: Error) => {
error = caught.message
})
return {
socket,
received: () => Buffer.concat(chunks),
transport: () => ({ ended, error })
}
}
// A 43-character base64url credential is the shortest value RelayAuthSchema accepts.
const WELL_FORMED_CREDENTIAL = 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678'
function maskedTextFrame(payload: string): Buffer {
const body = Buffer.from(payload)
const mask = Buffer.from([1, 2, 3, 4])
const masked = Buffer.from(body.map((byte, index) => byte ^ mask[index % 4]!))
return Buffer.concat([Buffer.from([0x81, 0x80 | body.length]), mask, masked])
}
function relayAuthFrame(): Buffer {
return maskedTextFrame(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: WELL_FORMED_CREDENTIAL
})
)
}
// The close frame is the last unmasked frame a rejected peer receives: 0x88, length,
// then a big-endian status code followed by the UTF-8 reason.
function readCloseFrame(received: Buffer): { code: number; reason: string } | null {
const start = received.lastIndexOf(0x88)
if (start < 0 || received.length < start + 4) return null
const length = received[start + 1]!
return {
code: received.readUInt16BE(start + 2),
reason: received.subarray(start + 4, start + 2 + length).toString('utf8')
}
}
async function expectIdleLedger(
relay: Awaited<ReturnType<typeof fixture>>['relay'],
timeout: number
): Promise<void> {
await vi.waitFor(() => expect(relay.connectionSnapshot()).toMatchObject(IDLE_LEDGER), { timeout })
}
const PHONE_TARGET = '/v1/connect/abcdefghijklmnop'
it.each([
{ label: 'first-frame timeout', target: PHONE_TARGET, opcode: undefined },
{ label: 'binary first frame', target: PHONE_TARGET, opcode: 0x82 },
{ label: 'invalid phone auth', target: PHONE_TARGET, opcode: 0x81 },
{ label: 'invalid host-data auth', target: '/v1/host/data/connection-1', opcode: 0x81 }
])(
'releases admission after $label even when the peer ignores close',
async ({ target, opcode }) => {
const { relay, port, database } = await fixture()
const peer = await silentUpgrade(port, target)
const firstFrameDeadline = opcode === undefined ? RELAY_PROTOCOL_LIMITS.firstFrameDeadlineMs : 0
if (opcode !== undefined) peer.socket.write(Buffer.from([opcode, 0x80, 0, 0, 0, 0]))
await expectIdleLedger(relay, firstFrameDeadline + 2_000)
expect(relay.runtimeCounts().preAuthConnections).toBe(0)
expect(database.query).not.toHaveBeenCalled()
expect(database.queryLocked).not.toHaveBeenCalled()
await silentUpgrade(port, PHONE_TARGET)
expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2)
}
)
it('releases admission after a rejected director invite when the peer ignores close', async () => {
const { relay, port } = await fixture({ role: 'director' })
const peer = await silentUpgrade(port, PHONE_TARGET)
peer.socket.write(relayAuthFrame())
await expectIdleLedger(relay, 2_000)
expect(readCloseFrame(peer.received())).toEqual({
code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
reason: 'invalid invite'
})
expect(relay.runtimeCounts().preAuthConnections).toBe(0)
await silentUpgrade(port, PHONE_TARGET)
expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2)
})
it('releases admission after a director move redirect when the peer ignores close', async () => {
const { relay, port } = await fixture({ role: 'director' })
const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }
vi.spyOn(relay.store, 'resolveInviteForMove').mockResolvedValue({
userId: identity.userId,
relayDeviceId: 'device-1'
})
vi.spyOn(relay.assignments, 'resolve').mockResolvedValue({
...identity,
cellId: 'cell-1',
cellUrl: 'https://cell-1.example.test',
assignmentEpoch: 1,
leaseExpiresAt: Date.now() + 60_000
})
const peer = await silentUpgrade(port, PHONE_TARGET)
peer.socket.write(relayAuthFrame())
await expectIdleLedger(relay, 2_000)
expect(peer.received().toString('utf8')).toContain('"type":"relay-moved"')
expect(readCloseFrame(peer.received())).toEqual({
code: RELAY_CLOSE_CODE.DRAINING,
reason: 'connect to assigned cell'
})
await silentUpgrade(port, PHONE_TARGET)
expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2)
})
// A peer that keeps draining its socket does get the rejection: the close frame is written
// before the force-close timer can fire, and TCP delivers those bytes ahead of the FIN.
//
// Scope, deliberately narrow: this peer reads every byte as it arrives, so the assertion below
// speaks only for a responsive peer. It is not evidence that delivery survives backpressure —
// `terminate()` destroys the socket a second later, and a frame still queued in the kernel or in
// `ws`'s own buffer goes unsent. Treat the rejection as best effort; the bound on the close is
// what the trade-off actually buys.
it('delivers the rejection code and a graceful FIN to a peer that keeps reading', async () => {
const { relay, port } = await fixture()
const peer = await silentUpgrade(port, PHONE_TARGET)
peer.socket.write(maskedTextFrame(JSON.stringify({ type: 'relay-auth', v: 1, mode: 'wrong' })))
await vi.waitFor(() => expect(peer.transport().ended).toBe(true), { timeout: 3_000 })
expect(peer.received().toString('utf8')).toContain('"code":4401')
expect(readCloseFrame(peer.received())).toEqual({
code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
reason: 'invalid relay auth'
})
expect(peer.transport().error).toBeNull()
await expectIdleLedger(relay, 2_000)
})
const BINARY_FIRST_FRAME = Buffer.from([0x82, 0x80, 0, 0, 0, 0])
// maxPreAuthPerSource caps how many unauthenticated sockets one source may hold at once.
const CONCURRENT_PEERS_PER_SOURCE = 4
it('returns capacity to its exact baseline after repeated bursts of rejections', async () => {
const { relay, port } = await fixture({ hardCap: 2 * CONCURRENT_PEERS_PER_SOURCE + 1 })
for (let wave = 0; wave < 3; wave++) {
const peers = await Promise.all(
Array.from({ length: CONCURRENT_PEERS_PER_SOURCE }, () =>
silentUpgrade(port, PHONE_TARGET)
)
)
expect(relay.connectionSnapshot()).toMatchObject({
enforcedConnectionUnits: 2 * CONCURRENT_PEERS_PER_SOURCE
})
for (const peer of peers) peer.socket.write(BINARY_FIRST_FRAME)
await expectIdleLedger(relay, 3_000)
expect(relay.runtimeCounts().preAuthConnections).toBe(0)
}
})
// A rejection racing the peer's own disconnect must release once, not twice: the ledger
// does not clamp at zero, so a double release shows up as a negative count here.
it('releases exactly once when a rejected peer disconnects at the same moment', async () => {
const { relay, port } = await fixture({ hardCap: 2 * CONCURRENT_PEERS_PER_SOURCE + 1 })
const peers = await Promise.all(
Array.from({ length: CONCURRENT_PEERS_PER_SOURCE }, () => silentUpgrade(port, PHONE_TARGET))
)
for (const peer of peers) {
peer.socket.write(BINARY_FIRST_FRAME)
peer.socket.destroy()
}
await expectIdleLedger(relay, 3_000)
expect(relay.connectionSnapshot()).toMatchObject(IDLE_LEDGER)
expect(relay.runtimeCounts().preAuthConnections).toBe(0)
await silentUpgrade(port, PHONE_TARGET)
expect(relay.connectionSnapshot()?.enforcedConnectionUnits).toBe(2)
})
@@ -0,0 +1,106 @@
import { expect, it, vi } from 'vitest'
import type { RelayDatabase } from './database.js'
import { createRelayReadiness } from './relay-readiness.js'
function database(query: RelayDatabase['query']): RelayDatabase {
return {
query,
queryLocked: query,
transaction: (operation) => operation(database(query)),
close: async () => {}
}
}
function gate() {
let release!: () => void
const wait = new Promise<void>((resolve) => (release = resolve))
return { wait, release }
}
it('shares both dependency probes and caches from completion for concurrent callers', async () => {
const sql = gate()
let now = 1_000
const query = vi.fn(async () => {
await sql.wait
return [{ ready: 1 }]
})
const fetchImpl = vi.fn(async () => new Response('{}'))
const observe = vi.fn()
const readiness = createRelayReadiness(database(query), 'https://jwks.example.test', {
fetch: fetchImpl,
now: () => now,
observe
})
const checks = Array.from({ length: 100 }, () => readiness.check())
try {
expect({ sql: query.mock.calls.length, jwks: fetchImpl.mock.calls.length }).toEqual({
sql: 1,
jwks: 1
})
expect(observe).not.toHaveBeenCalled()
now = 5_000
} finally {
sql.release()
}
expect(await Promise.all(checks)).toEqual(Array(100).fill(true))
expect(observe).toHaveBeenCalledTimes(1)
now = 14_999
expect(await readiness.check()).toBe(true)
expect(query).toHaveBeenCalledTimes(1)
now = 15_000
expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual(
Array(100).fill(true)
)
expect(query).toHaveBeenCalledTimes(2)
expect(fetchImpl).toHaveBeenCalledTimes(2)
})
it('shares failures, retains the failure cache, and retries after expiry', async () => {
let healthy = false
let now = 1_000
const query = vi.fn(async () => {
if (!healthy) throw new Error('offline')
return [{ ready: 1 }]
})
const fetchImpl = vi.fn(async () => new Response('{}', { status: healthy ? 200 : 503 }))
const observe = vi.fn()
const readiness = createRelayReadiness(database(query), 'https://jwks.example.test', {
fetch: fetchImpl,
now: () => now,
observe
})
expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual(
Array(100).fill(false)
)
expect(query).toHaveBeenCalledTimes(1)
expect(fetchImpl).toHaveBeenCalledTimes(1)
expect(observe).toHaveBeenCalledTimes(1)
expect(readiness.degradedDependencies()).toEqual([])
healthy = true
now = 10_999
expect(await readiness.check()).toBe(false)
expect(query).toHaveBeenCalledTimes(1)
now = 11_000
expect(await Promise.all(Array.from({ length: 100 }, () => readiness.check()))).toEqual(
Array(100).fill(true)
)
expect(query).toHaveBeenCalledTimes(2)
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(observe).toHaveBeenCalledTimes(2)
})
it('keeps separate readiness owners independent', async () => {
const query = vi.fn(async () => [{ ready: 1 }])
const fetchImpl = vi.fn(async () => new Response('{}'))
const first = createRelayReadiness(database(query), 'https://one.example.test', {
fetch: fetchImpl
})
const second = createRelayReadiness(database(query), 'https://two.example.test', {
fetch: fetchImpl
})
expect(await Promise.all([first.check(), first.check(), second.check(), second.check()])).toEqual(
[true, true, true, true]
)
expect(query).toHaveBeenCalledTimes(2)
expect(fetchImpl).toHaveBeenCalledTimes(2)
})
+10 -2
View File
@@ -131,6 +131,7 @@ export function createRelayReadiness(
const settleSql = createDependencyGrace('sql', options.sqlGraceMs ?? RELAY_READINESS_SQL_GRACE_MS)
let cachedAt = Number.NEGATIVE_INFINITY
let cached = false
let pending: Promise<boolean> | null = null
let lastObservedReady: boolean | undefined
let degraded: RelayReadinessDependency[] = []
@@ -153,8 +154,7 @@ export function createRelayReadiness(
}
}
const check = async (): Promise<boolean> => {
if (now() - cachedAt < cacheMs) return cached
const probe = async (): Promise<boolean> => {
const startedAt = now()
const [jwks, sql] = await Promise.all([timed(now, probeJwks), timed(now, probeSql)])
const completedAt = now()
@@ -185,5 +185,13 @@ export function createRelayReadiness(
return cached
}
const check = async (): Promise<boolean> => {
if (now() - cachedAt < cacheMs) return cached
pending ??= probe().finally(() => {
pending = null
})
return pending
}
return { check, degradedDependencies: () => [...degraded] }
}
+22 -11
View File
@@ -276,7 +276,7 @@ export function createRelayServer(
finished = true
authenticated(source)
observability.recordAuth(false)
socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'first frame timeout')
closeRelayWebSocket(socket, RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'first frame timeout')
}, RELAY_PROTOCOL_LIMITS.firstFrameDeadlineMs)
socket.once('message', (raw, binary) => {
if (finished) return
@@ -285,7 +285,11 @@ export function createRelayServer(
authenticated(source)
if (binary) {
observability.recordAuth(false)
socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'first frame must be text')
closeRelayWebSocket(
socket,
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
'first frame must be text'
)
return
}
void callback(raw).catch((error: unknown) => {
@@ -356,7 +360,11 @@ export function createRelayServer(
webSocket.send(
JSON.stringify({ type: 'relay-hello', ok: false, code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL })
)
webSocket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'invalid relay auth')
closeRelayWebSocket(
webSocket,
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
'invalid relay auth'
)
return
}
if (config.role === 'director') {
@@ -376,7 +384,11 @@ export function createRelayServer(
code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL
})
)
webSocket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'invalid invite')
closeRelayWebSocket(
webSocket,
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
'invalid invite'
)
return
}
phoneAdmission?.hostData.release()
@@ -389,7 +401,7 @@ export function createRelayServer(
assignmentEpoch: assignment.assignmentEpoch
})
)
webSocket.close(RELAY_CLOSE_CODE.DRAINING, 'connect to assigned cell')
closeRelayWebSocket(webSocket, RELAY_CLOSE_CODE.DRAINING, 'connect to assigned cell')
return
}
await sessions.acceptClient(
@@ -442,7 +454,11 @@ export function createRelayServer(
const auth = HostDataAuthSchema.safeParse(firstPayload(raw, 'host-data-auth'))
if (!auth.success) {
observability.recordAuth(false)
webSocket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'invalid host data auth')
closeRelayWebSocket(
webSocket,
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
'invalid host data auth'
)
return
}
const accepted = await sessions.acceptHostData(
@@ -566,8 +582,3 @@ export function createRelayServer(
cellIncarnation
}
}
export function closeWithDrain(socket: WebSocket, graceMs: number): void {
socket.send(JSON.stringify({ type: 'drain', graceMs, recovery: 'resolve-director' }))
socket.close(RELAY_CLOSE_CODE.DRAINING, 'resolve configured director')
}
@@ -45,21 +45,6 @@ describe('sweep schedule jitter', () => {
expect(timers).toEqual([6_600])
})
// Why: index.ts boots a server on import, so its wiring can only be read.
it('jitters the director assignment cleanup tick', () => {
const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8')
const cleanup = /runAssignmentCleanup\(assignments\)\s*\},\s*([^\n]*?)\)\n/.exec(source)
expect(cleanup?.[1]).toBe('jitteredSweepIntervalMs(30_000)')
})
it('jitters the credential cleanup tick', () => {
const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8')
const cleanup = /'\[orca-relay\] credential cleanup failed'\s*\),\s*([^\n]*?)\n/.exec(source)
expect(cleanup?.[1]).toBe('jitteredSweepIntervalMs(30_000)')
})
// A census, not a list of the timers that happen to be gated today: an ungated sweep runs in
// every cell as well as the director, which multiplies one table scan by the fleet size.
it('gates every periodic sweep in index.ts on the maintenance role', () => {
@@ -3,6 +3,14 @@ import type WebSocket from 'ws'
const RELAY_WEBSOCKET_FORCE_CLOSE_MS = 1_000
const forceCloseTimers = new WeakMap<WebSocket, ReturnType<typeof setTimeout>>()
// Delivering the rejection is best effort, and deliberately so. The close frame carrying the
// code and reason is written before the timer can fire, so a peer reading normally gets its
// rejection ahead of the FIN — that much is asserted in relay-first-frame-close.blackbox.test.ts.
// It is not a delivery guarantee: `ws` writes the frame to the socket, and `terminate()` destroys
// the socket a second later, so under backpressure the frame (and any `relay-moved` message queued
// before it) can still be dropped unsent even though the peer never stopped reading. Bounding the
// close is what keeps a stalled peer from holding admission, and no finite grace makes delivery
// certain. Keep the close write ahead of any new wait added here.
export function closeRelayWebSocket(socket: WebSocket, code: number, reason: string): void {
if (socket.readyState === socket.CLOSED) return
if (!forceCloseTimers.has(socket)) {
@@ -0,0 +1,101 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { setImmediate } from 'node:timers/promises'
import { expect, it, vi } from 'vitest'
import { openRelayDatabase } from './database.js'
it('releases queued work and close after a SQLite transaction cannot acquire its lock', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'orca-relay-sqlite-queue-'))
let connection: DatabaseSync | undefined
const prepare = DatabaseSync.prototype.prepare
// Keep the native handle reachable for cleanup even if a queue regression strands close().
const capture = vi
.spyOn(DatabaseSync.prototype, 'prepare')
.mockImplementation(function (this: DatabaseSync, sql) {
connection = this
return prepare.call(this, sql)
})
const database = await openRelayDatabase({ dataDir })
capture.mockRestore()
const blocker = new DatabaseSync(join(dataDir, 'orca-relay.sqlite'))
try {
await database.query('CREATE TABLE queue_progress (value INTEGER)')
await database.query('INSERT INTO queue_progress VALUES (0)')
blocker.exec('BEGIN IMMEDIATE')
const operation = vi.fn(async () => undefined)
await expect(database.transaction(operation)).rejects.toThrow('database is locked')
let lockFailures = 0
const blocked = Array.from({ length: 5 }, () =>
database.transaction(operation).catch(() => {
lockFailures += 1
})
)
await setImmediate()
expect(lockFailures).toBe(5)
await Promise.all(blocked)
expect(operation).not.toHaveBeenCalled()
blocker.exec('ROLLBACK')
let completed = 0
const pending = Array.from({ length: 100 }, () =>
database.transaction(async (transaction) => {
await transaction.query('UPDATE queue_progress SET value = value + 1')
completed += 1
})
)
for (const request of pending) void request.catch(() => undefined)
await setImmediate()
expect(completed).toBe(100)
await Promise.all(pending)
expect(await database.query('SELECT value FROM queue_progress')).toEqual([{ value: 100 }])
let closed = false
const closing = database.close().then(() => {
closed = true
})
await setImmediate()
expect(closed).toBe(true)
await closing
} finally {
capture.mockRestore()
blocker.close()
if (connection?.isOpen) connection.close()
rmSync(dataDir, { recursive: true, force: true })
}
})
it('does not roll back a transaction when BEGIN failed before taking ownership', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'orca-relay-sqlite-owner-'))
let connection: DatabaseSync | undefined
const prepare = DatabaseSync.prototype.prepare
const capture = vi
.spyOn(DatabaseSync.prototype, 'prepare')
.mockImplementation(function (this: DatabaseSync, sql) {
connection = this
return prepare.call(this, sql)
})
const database = await openRelayDatabase({ dataDir })
capture.mockRestore()
try {
await database.query('CREATE TABLE queue_owner (value INTEGER)')
await database.query('BEGIN IMMEDIATE')
await database.query('INSERT INTO queue_owner VALUES (7)')
await expect(database.transaction(async () => undefined)).rejects.toThrow(
'cannot start a transaction within a transaction'
)
expect(connection?.isTransaction).toBe(true)
let rows: unknown
void database.query('SELECT value FROM queue_owner').then((result) => {
rows = result
})
await setImmediate()
expect(rows).toEqual([{ value: 7 }])
await database.query('ROLLBACK')
expect(await database.query('SELECT value FROM queue_owner')).toEqual([])
await database.close()
} finally {
capture.mockRestore()
if (connection?.isOpen) connection.close()
rmSync(dataDir, { recursive: true, force: true })
}
})
@@ -2,8 +2,7 @@ import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
parseProductionCapacityCellArguments,
prepareProductionCapacityCell,
PRODUCTION_CAPACITY_CELL_IDS
prepareProductionCapacityCell
} from './prepare-relay-production-capacity-canary.mjs'
const config = {
@@ -63,24 +62,6 @@ function canaryFetch() {
describe('production Relay capacity cell admission', () => {
it('allows only the serving rollout cells', () => {
assert.deepEqual(PRODUCTION_CAPACITY_CELL_IDS, [
'production-gce-c7',
'production-gce-c8',
'production-gce-c9',
'production-gce-c10',
'production-gce-c13',
'production-gce-c14',
'production-gce-c15',
'production-gce-c16',
'production-gce-c19',
'production-gce-c20',
'production-gce-c21',
'production-gce-c22',
'production-gce-c23',
'production-gce-c24',
'production-gce-c25',
'production-gce-c26'
])
assert.deepEqual(parseProductionCapacityCellArguments([
'--director-origin', 'https://relay.onorca.dev',
'--cell-origin', 'https://c7.relay.onorca.dev',
@@ -1,5 +1,4 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import {
LEASED_WORKFLOWS,
@@ -21,7 +20,6 @@ import {
revisionMintingScripts,
workflowFiles
} from './cloud-sql-rollout-lock-census.mjs'
import { relayWorkflowFile } from './relay-repository.mjs'
const expectedLease = { production: PRODUCTION_LEASE, staging: STAGING_LEASE, selectable: SELECTABLE_LEASE }
const leasedFiles = Object.keys(LEASED_WORKFLOWS)
@@ -182,29 +180,3 @@ test('census: no workflow rolls out against the shared instance outside the leas
assert.ok(!(file in NOT_A_CLOUD_SQL_CANDIDATE), `${file} cannot be both leased and a non-candidate`)
}
})
// The API and auth deploy scripts share this contract but stay in the private repository.
const serviceCapScripts = ['dev/scripts/deploy-relay-blue-green.mjs']
test('budgets tagged Cloud Run candidates outside the service-wide instance cap', () => {
for (const file of serviceCapScripts) {
const script = readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8')
assert.match(script, /'--no-traffic'/, file)
assert.match(script, /'--max'/, file)
}
const budget = readFileSync(
new URL('../../dev/scripts/relay-cloud-sql-connection-budget.mjs', import.meta.url),
'utf8'
)
assert.match(budget, /directly addressable tagged revisions outside service-level caps/)
assert.match(
budget,
/apiCandidate: retainedDirectorRollback \+ inputs\.apiInstances \* inputs\.apiPoolMax/
)
const director = readWorkflow(relayWorkflowFile('deploy-relay-production-director.yml'))
const capacity = readWorkflow(relayWorkflowFile('deploy-relay-production-capacity-job.yml'))
const asia = readWorkflow(relayWorkflowFile('operate-relay-asia-admission.yml'))
assert.match(director, /--max-instances "\$\{DIRECTOR_MAX_INSTANCES\}"/)
assert.match(capacity, /--max-instances 5/)
assert.match(asia, /--max-instances "\$\{DIRECTOR_MAX_INSTANCES\}"/)
})
@@ -309,11 +309,6 @@ test('push credentials cannot assume the shared Relay deploy identity', () => {
assert.doesNotMatch(terraform('push-gateway.tf'), /member\s*=\s*local\.relay_github_deploy_service_account_member/)
})
// A latest revision needs a successor even when validation is inert.
test('dedicated database admits three simultaneous revision pools', () => {
assert.match(terraform('push-gateway.tf'), /var\.push_max_instances \* var\.push_database_pool_max \* 3 <= 64/)
})
test('push has only a dedicated database attachment and a narrowly scoped deployment lease', () => {
const service = terraform('push-gateway.tf')
const database = terraform('push-dedicated-database.tf')
@@ -1,48 +0,0 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { readRelayWorkflow } from './relay-repository.mjs'
const workflow = readRelayWorkflow('push-deploy.yml')
const position = (name) => {
const index = workflow.indexOf(`- name: ${name}`)
assert.notEqual(index, -1)
return index
}
const capability = position('Require image support for inert validation')
const deploy = position('Deploy the candidate revision with no traffic')
const activation = position('Retire inert validation and activate the verified image')
const shift = position('Shift all traffic to the verified candidate')
test('the exact build digest must support validation before production boot', () => {
assert.match(workflow, /docker buildx build --push --platform linux\/amd64 --provenance=false --metadata-file/)
assert.match(workflow, /containerimage\.digest/)
assert.doesNotMatch(workflow, /gcloud artifacts docker images describe/)
assert.ok(capability < deploy)
const preflight = workflow.slice(capability, deploy)
assert.match(preflight, /docker run --rm --network none --entrypoint node "\$\{IMAGE\}"/)
assert.match(preflight, /loadPushConfig\(env\)\.mode !== "validation"/)
assert.match(preflight, /validation_mode_not_fail_closed/)
})
test('inert validation and credential checks precede deliberate activation of the same digest', () => {
assert.match(workflow.slice(deploy, activation), /--update-env-vars ORCA_PUSH_MODE=validation/)
assert.match(workflow.slice(deploy, activation), /\.mode == "validation"/)
assert.ok(position('Prove the runtime identity can reach FCM') < activation)
const active = workflow.slice(activation, shift)
assert.ok(active.indexOf('gcloud run deploy') < active.indexOf('gcloud run revisions delete'))
assert.match(active, /--image "\$\{IMAGE\}"/)
assert.match(active, /--remove-env-vars ORCA_PUSH_MODE/)
assert.match(active, /\.spec\.containers\[0\]\.image == \$image/)
assert.match(active, /\.spec\.serviceAccountName == \$account/)
assert.match(active, /\.mode == "active"/)
assert.ok(active.indexOf('ACTIVATION_ATTEMPTED=true') < active.indexOf('gcloud run deploy'))
assert.match(workflow, /deletion below must stop its workers/)
})
test('production startup connects read-only and gates all background work in validation', () => {
const entry = readFileSync(new URL('../../apps/push/src/index.ts', import.meta.url), 'utf8')
assert.match(entry, /readOnly: config\.mode === 'validation'/)
assert.match(entry, /startPushBackground\(config,/)
assert.doesNotMatch(entry, /worker\.start\(/)
})
@@ -1,375 +0,0 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { relayWorkflowUrl } from './relay-repository.mjs'
const workflow = readFileSync(
relayWorkflowUrl('operate-relay-asia-admission.yml'),
'utf8'
)
const iam = readFileSync(new URL('../../infra/terraform/relay-github-actions.tf', import.meta.url), 'utf8')
const stagingProof = readFileSync(
relayWorkflowUrl('prove-relay-asia-staging.yml'),
'utf8'
)
const directorWorkflow = readFileSync(
relayWorkflowUrl('deploy-relay-production-director.yml'),
'utf8'
)
const terraformReadme = readFileSync(
new URL('../../infra/terraform/README.md', import.meta.url),
'utf8'
)
const proofIam = readFileSync(
new URL('../../infra/terraform/relay-asia-proof-iam.tf', import.meta.url),
'utf8'
)
const relayTerraform = readFileSync(
new URL('../../infra/terraform/relay.tf', import.meta.url),
'utf8'
)
const rolloutEvidence = readFileSync(
new URL('./relay-asia-rollout-evidence.mjs', import.meta.url),
'utf8'
)
const admissionBudgets = readFileSync(
new URL('../../packages/relay-contract/src/admission-budgets.ts', import.meta.url),
'utf8'
)
test('offers the exact audited admission modes under the shared deployment lock', () => {
for (const mode of [
'inspect', 'initialize', 'verify', 'register', 'configure', 'promote', 'rollback'
]) {
assert.match(workflow, new RegExp(`\\b${mode}\\b`))
}
assert.match(workflow, /production-cloud-sql-rollout/)
assert.match(workflow, /relay-staging-mutation/)
assert.match(workflow, /selector-generation/)
assert.match(workflow, /selector-attempt-id/)
})
test('requires exact confirmations and uses the existing admin identity', () => {
assert.match(workflow, /INITIALIZE_ADMISSION_SELECTOR/)
assert.match(workflow, /REGISTER_ASIA_MIGRATION_ONLY/)
assert.match(workflow, /PROMOTE_ASIA_GENERAL/)
assert.match(workflow, /ROLLBACK_ASIA_MIGRATION_ONLY/)
assert.match(workflow, /CONFIGURE_ASIA_DIRECTOR/)
assert.match(workflow, /GCP_RELAY_DEPLOY_SERVICE_ACCOUNT/)
assert.match(workflow, /id_token_audience: \$\{\{ env\.DIRECTOR_ORIGIN \}\}\/v1\/admin\/drain/)
assert.match(iam, /"operate-relay-asia-admission\.yml"/)
})
test('discovers generation read-only and explicitly initializes only generation zero', () => {
assert.match(workflow, /leave empty only for inspect/)
assert.match(workflow, /test -z "\$\{EXPECTED_SELECTOR_GENERATION\}"/)
assert.match(workflow, /test "\$\{EXPECTED_SELECTOR_GENERATION\}" = 0/)
assert.match(workflow, /\^\(0\|\[1-9\]\[0-9\]\*\)\$/)
assert.match(workflow, /selector-membership-sha256/)
assert.match(workflow, /\^\[a-f0-9\]\{64\}\$/)
assert.match(workflow, /director-image-digest/)
assert.match(workflow, /\.spec\.containers\[0\]\.image == \$image/)
})
test('uploads one sanitized machine-readable admission result', () => {
assert.match(workflow, /sanitize-relay-asia-admission-result\.mjs/)
const upload = /- name: Upload sanitized admission result\n([\s\S]*?)(?=\n - name:)/
.exec(workflow)?.[1]
assert.ok(upload)
assert.match(
upload,
/if: \$\{\{ inputs\.mode != 'configure' && steps\.admission-operation\.outcome == 'success' \}\}/
)
assert.match(upload, /uses: actions\/upload-artifact@v4/)
assert.match(
upload,
/relay-asia-admission-result-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/
)
assert.match(upload, /path: \$\{\{ runner\.temp \}\}\/relay-asia-admission-result\/result\.json/)
assert.match(upload, /if-no-files-found: error/)
assert.match(upload, /retention-days: 7/)
assert.ok(
workflow.indexOf('Upload sanitized admission result') >
workflow.indexOf('Upload immutable canary evidence')
)
})
test('binds selector operations and director configuration to reviewed implementations', () => {
assert.match(workflow, /operate-relay-asia-admission\.mjs/)
assert.match(workflow, /prepare-relay-asia-director-cells\.mjs/)
assert.match(workflow, /deploy-relay-blue-green\.mjs/)
assert.match(workflow, /--prune-revisions false/)
assert.doesNotMatch(workflow, /gcloud secrets versions add/)
assert.match(workflow, /orca-cloud-relay-regional-placement-enabled/)
assert.match(workflow, /\.valueSource\.secretKeyRef/)
assert.match(workflow, /jq -er --arg secret "\$\{REGIONAL_PLACEMENT_SECRET\}"/)
assert.doesNotMatch(workflow, /jq -e --arg secret "\$\{REGIONAL_PLACEMENT_SECRET\}"/)
assert.doesNotMatch(workflow, /--regional-placement-enabled/)
assert.doesNotMatch(workflow, /"\$\{\{ inputs\./)
assert.doesNotMatch(workflow, /dns/i)
})
test('requires immutable staged evidence and a timed production canary before expansion', () => {
assert.match(workflow, /actions: read/)
assert.match(workflow, /actions\/download-artifact@v4/)
assert.match(workflow, /relay-asia-staging-\$\{EVIDENCE_RUN_ID\}-\$\{EVIDENCE_RUN_ATTEMPT\}/)
assert.match(workflow, /evidence_kind=staging/)
assert.match(workflow, /load-relay-controls\.mjs/)
assert.match(workflow, /--controls 1/)
assert.match(workflow, /--splices 1/)
assert.match(workflow, /--splice-hold-seconds 60/)
assert.match(workflow, /--relay-asia-load-principals 1/)
assert.match(workflow, /--duration-seconds 300/)
assert.match(workflow, /--required-lease-horizons 2/)
assert.match(workflow, /pnpm\/action-setup@v4/)
assert.match(workflow, /Install exact canary dependencies/)
assert.match(workflow, /pnpm install --frozen-lockfile/)
assert.match(workflow, /pnpm --filter @orca-cloud\/relay-contract build/)
assert.ok(
workflow.indexOf('Build the canary Relay contract') <
workflow.indexOf('Run a real five-minute canary control and splice')
)
assert.match(workflow, /--load-report "\$\{RUNNER_TEMP\}\/relay-asia-canary-load\.json"/)
assert.match(workflow, /"production-gce-c28":"migration-only","production-gce-c29":"migration-only"/)
// C28/C29 promotion downloads C27's canary under exactly this name.
assert.match(workflow, /relay-asia-\$\{\{ steps\.inputs\.outputs\.canary_hostname \}\}-canary-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/)
assert.match(workflow, /echo "canary_hostname=\$\{canary_cell##\*-\}"/)
assert.match(workflow, /id: canary-evidence-upload/)
assert.match(workflow, /Return an unproven canary cell to migration-only/)
assert.match(workflow, /steps\.canary-evidence-upload\.outcome != 'success'/)
assert.match(workflow, /--mode recover-promotion[\s\S]*?--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}"/)
assert.match(workflow, /--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}-rollback"/)
assert.match(workflow, /evidence_kind=c27/)
assert.match(workflow, /orca_relay_runtime_metrics/)
assert.match(workflow, /relay-asia-rollout-evidence\.mjs create-canary \\\n\s+--cell-id "\$\{CANARY_CELL\}"/)
assert.match(workflow, /retention-days: 7/)
assert.match(workflow, /Require the exact director image before promotion/)
assert.match(workflow, /DIRECTOR_ORIGIN.*\/v1\/admin\/runtime-status/)
assert.match(workflow, /\.imageDigest.*IMAGE_DIGEST/)
const provenance = /- name: Verify evidence provenance and rollout binding before authentication\n([\s\S]*?)(?=\n - id: auth)/
.exec(workflow)?.[1]
assert.ok(provenance)
assert.match(provenance, /\.head_sha \| select\(type == "string" and test\("\^\[a-f0-9\]\{40\}\$"\)\)/)
assert.match(provenance, /--commit-sha "\$\{evidence_commit_sha\}"/)
assert.doesNotMatch(provenance, /--commit-sha "\$\{GITHUB_SHA\}"/)
})
test('binds each production promotion wave to its exact evidence and canary', () => {
const cases = /case "\$\{TARGET_CELL_IDS\}" in\n([\s\S]*?)\n\s*esac/.exec(workflow)?.[1]
assert.ok(cases)
const waves = Object.fromEntries(
[...cases.matchAll(/^ {14}([a-z0-9,-]+)\)\n([\s\S]*?);;/gm)].map((match) => [match[1], {
evidence: /evidence_kind=([a-z0-9]+)/.exec(match[2])?.[1] ?? 'none',
canary: /canary_cell=([a-z0-9-]+)/.exec(match[2])?.[1] ?? 'none'
}])
)
assert.deepEqual(waves, {
'production-gce-c27': { evidence: 'staging', canary: 'production-gce-c27' },
'production-gce-c28,production-gce-c29': { evidence: 'c27', canary: 'none' },
'production-gce-c30': { evidence: 'none', canary: 'production-gce-c30' }
})
assert.match(cases, /\*\) echo "production promotion wave is not reviewed" >&2; exit 1 ;;/)
assert.match(workflow, /if test "\$\{evidence_kind\}" = none; then\n\s+test -z "\$\{EVIDENCE_RUN_ID\}"/)
assert.doesNotMatch(workflow, /inputs\.cell-ids == /)
})
test('runs the timed canary and its automatic rollback for C27 and C30 alike', () => {
const steps = workflow.split(/\n(?= - )/)
const named = (name) => steps.find((step) => step.includes(`name: ${name}`))
for (const name of [
'Install exact canary dependencies',
'Build the canary Relay contract',
'Verify the canary cell state and start the timed canary',
'Run a real five-minute canary control and splice',
'Collect regional, Relay SQL, and Cloud SQL canary evidence',
'Upload immutable canary evidence'
]) {
assert.match(named(name), /if: \$\{\{ steps\.inputs\.outputs\.canary == 'true' \}\}/, name)
}
assert.match(
workflow,
/if: \$\{\{ inputs\.mode == 'configure' \|\| steps\.inputs\.outputs\.canary == 'true' \}\}/
)
const rollback = named('Return an unproven canary cell to migration-only')
assert.match(
rollback,
/if: \$\{\{ always\(\) && steps\.inputs\.outputs\.canary == 'true' && steps\.admission-operation\.outcome != 'skipped' && steps\.canary-evidence-upload\.outcome != 'success' \}\}/
)
assert.match(rollback, /CANARY_CELL: \$\{\{ steps\.inputs\.outputs\.canary_cell \}\}/)
assert.match(rollback, /--mode recover-promotion \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/)
assert.match(rollback, /--mode rollback \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/)
assert.match(rollback, /'\.states\[\$cell\]' <<< "\$\{result\}"\)" = migration-only/)
const start = named('Verify the canary cell state and start the timed canary')
assert.match(start, /production-gce-c30\)\n\s+verify_cells=production-gce-c30\n\s+expected_states='\{"production-gce-c30":"general"\}'/)
assert.match(start, /test "\$\(jq -cS '\.states' <<< "\$\{result\}"\)" = "\$\(jq -cS '\.' <<< "\$\{expected_states\}"\)"/)
assert.match(named('Run a real five-minute canary control and splice'), /--duration-seconds 300/)
})
test('creates staging evidence only after the bounded launch-path load and rollback', () => {
assert.match(stagingProof, /runs-on: \[self-hosted, linux, x64, relay-asia-east2-load\]/)
assert.doesNotMatch(stagingProof, /group: relay-asia-east2-load/)
assert.match(stagingProof, /pnpm\/action-setup@v4/)
assert.match(stagingProof, /pnpm install --frozen-lockfile/)
assert.match(stagingProof, /pnpm --filter @orca-cloud\/relay-contract build/)
assert.match(stagingProof, /run_phase launch 5 5/)
assert.doesNotMatch(stagingProof, /run_phase control|run_phase mixed/)
assert.match(stagingProof, /--aggregate-controls "\$\(\(controls \* 4\)\)"/)
assert.match(stagingProof, /--aggregate-splices "\$\(\(splices \* 4\)\)"/)
assert.match(stagingProof, /--required-lease-horizons 2/)
assert.match(stagingProof, /--splice-ramp-seconds 120/)
assert.match(stagingProof, /--max-generator-rss-growth-mib 512/)
assert.match(stagingProof, /--relay-asia-load-principals 32/)
assert.match(stagingProof, /ulimit -n/)
assert.match(stagingProof, /--region-behavior-probes 1/)
assert.match(stagingProof, /--capacity-cell-origin https:\/\/c4\.relay-staging\.onorca\.dev/)
assert.match(stagingProof, /--rebind-probes 2/)
assert.match(stagingProof, /--skip-rebind-overflow-check/)
assert.doesNotMatch(stagingProof, /--request-unit-invites|--regional-fallback-probes/)
assert.match(stagingProof, /--aggregate-reader-splices.*echo 5/)
assert.match(stagingProof, /--aggregate-reader-bytes.*echo 12582912/)
assert.match(stagingProof, /--phase-barrier-dir "\$\{proof_dir\}\/\$\{phase\}-barrier"/)
assert.match(stagingProof, /--duration-seconds 210/)
assert.match(stagingProof, /trap stop_shards EXIT/)
assert.match(stagingProof, /if ! wait "\$\{pid\}"; then failed=1; break; fi/)
assert.match(stagingProof, /connectionFailuresByReason/)
assert.match(stagingProof, /--launch-report "\$\{proof_dir\}\/launch\.json"/)
assert.match(stagingProof, /id-token: write/)
assert.match(stagingProof, /STAGING_GCP_RELAY_ASIA_PROOF_WORKLOAD_IDENTITY_PROVIDER/)
assert.match(stagingProof, /STAGING_GCP_RELAY_ASIA_PROOF_SERVICE_ACCOUNT/)
assert.doesNotMatch(stagingProof, /STAGING_GCP_DEPLOY_SERVICE_ACCOUNT/)
assert.doesNotMatch(stagingProof, /STAGING_RELAY_LOAD_ACCESS_TOKEN/)
assert.doesNotMatch(stagingProof, /secrets versions access|signing-key-file/)
assert.match(stagingProof, /relay-asia-rollout-evidence\.mjs create-staging/)
assert.match(stagingProof, /Require the exact staging director image before promotion/)
assert.match(stagingProof, /DIRECTOR_ORIGIN.*\/v1\/admin\/runtime-status/)
assert.match(stagingProof, /\.imageDigest.*IMAGE_DIGEST/)
assert.match(stagingProof, /Return staging C4 to migration-only/)
assert.match(stagingProof, /steps\.promote\.outcome != 'skipped'/)
assert.match(stagingProof, /--mode recover-promotion[\s\S]*?--attempt-id "\$\{PROMOTE_ATTEMPT_ID\}"/)
assert.match(stagingProof, /--mode rollback[\s\S]*?--expected-generation "\$\{promoted_generation\}"/)
assert.match(stagingProof, /if: \$\{\{ success\(\) \}\}/)
assert.match(
stagingProof,
/recover:\n if: \$\{\{ always\(\) && github\.ref == 'refs\/heads\/main' \}\}/
)
assert.match(stagingProof, /needs: prove/)
assert.match(stagingProof, /Recover staging C4 with a fresh identity/)
assert.equal((stagingProof.match(/google-github-actions\/auth@v2/g) ?? []).length, 2)
assert.equal((stagingProof.match(/--mode recover-promotion/g) ?? []).length, 2)
assert.equal((stagingProof.match(/--mode rollback/g) ?? []).length, 2)
})
test('keeps the private runner below its 64-port Cloud NAT allocation', () => {
const profile = /run_phase launch (\d+) (\d+)/.exec(stagingProof)
const controlsPerShard = Number(profile?.[1])
const splicesPerShard = Number(profile?.[2])
const rebindProbes = Number(/--rebind-probes (\d+)/.exec(stagingProof)?.[1])
const runtimeStatusSockets = 1
assert.ok(
controlsPerShard * 4 + splicesPerShard * 4 * 2 + rebindProbes + runtimeStatusSockets < 64
)
})
test('paces one-source staging upgrades below the Relay anti-abuse ceiling', () => {
const splicesPerShard = Number(/run_phase launch \d+ (\d+)/.exec(stagingProof)?.[1])
const rebindProbes = Number(/--rebind-probes (\d+)/.exec(stagingProof)?.[1])
const spliceRampMs = Number(/--splice-ramp-seconds (\d+)/.exec(stagingProof)?.[1]) * 1000
const ceiling = Number(
/maxPreAuthAttemptsPerSourcePerMinute: (\d+)/.exec(admissionBudgets)?.[1]
)
const totalSplices = splicesPerShard * 4
const attempts = Array.from({ length: totalSplices }, (_, ordinal) =>
Math.floor(ordinal * spliceRampMs / (totalSplices - 1))
).flatMap((startedAt) => [startedAt, startedAt])
attempts.push(...Array.from({ length: 4 + rebindProbes }, () => 0))
const busiestMinute = Math.max(...attempts.map((startedAt) =>
attempts.filter((attempt) => attempt >= startedAt && attempt < startedAt + 60_000).length
))
assert.ok(busiestMinute < ceiling)
})
test('reserves rollback time beyond the complete bounded staging proof envelope', () => {
const timeoutMinutes = Number(/timeout-minutes: (\d+)/.exec(stagingProof)?.[1])
assert.equal(timeoutMinutes, 75)
const spliceRampSeconds = Number(/--splice-ramp-seconds (\d+)/.exec(stagingProof)?.[1])
const launchSeconds = 180 + spliceRampSeconds + 210 + 60
const setupEvidenceAndRollbackSeconds = 10 * 60
const envelopeMinutes = Math.ceil((launchSeconds + setupEvidenceAndRollbackSeconds) / 60)
assert.ok(timeoutMinutes - envelopeMinutes >= 30)
assert.match(stagingProof, /--ramp-seconds 180/)
assert.match(stagingProof, /--duration-seconds 210/)
})
test('binds the staging proof to one least-privilege Google identity', () => {
assert.match(
proofIam,
/github_relay_asia_proof_workflow_file = "prove-relay-asia-staging\.yml"/
)
assert.match(
proofIam,
/assertion\.workflow_ref == '\$\{prefix\}\$\{local\.github_relay_asia_proof_workflow_file\}@refs\/heads\/main'/
)
assert.match(proofIam, /assertion\.environment == 'staging'/)
assert.match(proofIam, /assertion\.event_name == 'workflow_dispatch'/)
assert.match(proofIam, /roles\/logging\.viewer/)
assert.match(proofIam, /roles\/monitoring\.viewer/)
assert.match(rolloutEvidence, /readCloudSqlBackends/)
assert.match(rolloutEvidence, /cloudSql: await readCloudSqlBackends/)
assert.doesNotMatch(proofIam, /compute\.|cloudsql\.|secretmanager\.|roles\/editor|roles\/run\./)
})
test('keeps the production US-first switch in durable Secret Manager state', () => {
assert.match(directorWorkflow, /options: \[preserve, enable, disable\]/)
assert.match(directorWorkflow, /default: preserve/)
assert.match(directorWorkflow, /gcloud secrets versions add/)
assert.match(directorWorkflow, /preserve\) desired="\$\{current\}"/)
assert.match(directorWorkflow, /--regional-placement-secret-version "\$\{target_version\}"/)
assert.match(directorWorkflow, /test "\$\{CEILING\}" = "\$\{DIRECTOR_MAX_INSTANCES\}"/)
assert.match(directorWorkflow, /orca-cloud-relay-regional-placement-enabled/)
assert.match(directorWorkflow, /\.valueSource\.secretKeyRef \/\/ \.valueFrom\.secretKeyRef/)
assert.match(directorWorkflow, /\.version \/\/ \.key/)
assert.match(directorWorkflow, /\.secret \/\/ \.name/)
assert.match(workflow, /\.valueSource\.secretKeyRef \/\/ \.valueFrom\.secretKeyRef/)
assert.doesNotMatch(directorWorkflow, /--regional-placement-enabled/)
assert.doesNotMatch(workflow, /inputs\.regional-placement-enabled/)
})
test('prunes incompatible production revisions only when explicitly confirmed', () => {
assert.match(
directorWorkflow,
/prune-incompatible-revisions:[\s\S]*?default: false[\s\S]*?type: boolean/
)
assert.match(directorWorkflow, /PRUNE_INCOMPATIBLE_RELAY_DIRECTOR_REVISIONS/)
assert.match(
directorWorkflow,
/test "\$\{REGIONAL_PLACEMENT_MODE\}" = preserve[\s\S]*?test "\$\{CONFIRMATION\}" = PRUNE_INCOMPATIBLE_RELAY_DIRECTOR_REVISIONS/
)
assert.match(
directorWorkflow,
/--prune-revisions "\$\{PRUNE_INCOMPATIBLE_REVISIONS\}"/
)
})
test('documents the exact regional-placement secret bootstrap before director rollout', () => {
for (const address of [
'google_secret_manager_secret.relay_regional_placement_enabled',
'google_secret_manager_secret_version.relay_regional_placement_enabled',
'google_secret_manager_secret_iam_member.relay_regional_placement_runtime_accessor',
'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_accessor[0]',
'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_adder[0]',
'google_secret_manager_secret_iam_member.relay_regional_placement_deploy_viewer[0]'
]) {
assert.match(terraformReadme, new RegExp(address.replaceAll(/[.[\]]/g, '\\$&')))
}
assert.match(terraformReadme, /Before the first director deployment/)
assert.match(terraformReadme, /Pass the exact environment tfvars/)
// The Cloudflare records left with the apps root; a -var for a variable this root no longer
// declares is a hard error, so no relay procedure may still tell an operator to pass it.
assert.doesNotMatch(terraformReadme, /manage_artifact_dns/)
assert.match(terraformReadme, /exactly these six additions/)
assert.match(terraformReadme, /version metadata/)
assert.match(
relayTerraform,
/resource "google_secret_manager_secret_iam_member" "relay_regional_placement_deploy_viewer"[\s\S]*?role\s+= "roles\/secretmanager\.viewer"/
)
})
@@ -860,11 +860,12 @@ export class RelayLoadControlPeer {
scheduleRefresh(delayMs) {
if (this.stopped) return
const socket = this.socket
const reschedule = () => {
if (this.socket === socket) this.scheduleRefresh(this.phase.refreshIntervalMs)
}
this.refreshTimer = setTimeout(() => {
void this.refresh().then(
() => this.scheduleRefresh(this.phase.refreshIntervalMs),
() => this.scheduleRefresh(this.phase.refreshIntervalMs)
)
void this.refresh().then(reschedule, reschedule)
}, delayMs)
}
@@ -901,3 +901,105 @@ test('shutdown closes both splice legs and waits for the in-flight splice', asyn
assert.equal(observations.at(-1).type, 'shutdown')
assert.equal(observations.at(-1).detail.activeSpliceSockets, 0)
})
function trackRefreshTimers(context) {
const schedule = global.setTimeout
const cancel = global.clearTimeout
const timers = new Map()
context.mock.method(global, 'setTimeout', (callback, milliseconds, ...args) => {
const timer = schedule(() => {
timers.delete(timer)
callback(...args)
}, milliseconds)
timers.set(timer, milliseconds)
return timer
})
context.mock.method(global, 'clearTimeout', (timer) => {
timers.delete(timer)
cancel(timer)
})
context.after(() => {
for (const timer of timers.keys()) cancel(timer)
})
return () => [...timers.values()].filter((milliseconds) => milliseconds >= 180_000).length
}
function reconnectingPeer(context, observations) {
const peer = new RelayLoadControlPeer(
0,
peerOptions({
directorOrigin: undefined,
targetOrigin: 'https://cell.test',
accessToken: 'test-access-token'
}),
(type) => observations.push(type)
)
peer.phase.refreshOffsetMs = 180_000
peer.phase.refreshIntervalMs = 200_000
peer.createSocket = () => {
const socket = fakeHandshakeSocket()
socket.send = (raw) => {
const message = JSON.parse(raw)
if (message.type === 'host-hello') {
queueMicrotask(() => socket.message(validChallenge(peer)))
} else if (message.type === 'host-challenge-ack') {
queueMicrotask(() =>
socket.message({
type: 'host-hello-ack',
generation: 1,
controlResumeSecret: 'test-secret'
})
)
}
}
return openOnNextTurn(socket)
}
context.after(() => peer.shutdown())
return peer
}
for (const outcome of ['success', 'failure']) {
for (const owner of ['current connection', 'replacement connection', 'shutdown']) {
test(`refresh ${outcome} respects its ${owner} ownership`, async (context) => {
const refreshTimers = trackRefreshTimers(context)
const observations = []
const peer = reconnectingPeer(context, observations)
const pendingResponse = deferred()
const requestStarted = deferred()
let delayResponse = false
context.mock.method(global, 'fetch', async () => {
if (delayResponse) {
requestStarted.resolve()
return await pendingResponse.promise
}
return response({ relayToken: 'test-relay-token' })
})
await peer.connect()
assert.equal(refreshTimers(), 1)
clearTimeout(peer.refreshTimer)
delayResponse = true
peer.scheduleRefresh(0)
await requestStarted.promise
delayResponse = false
if (owner !== 'current connection') {
peer.socket.close(1006)
await new Promise((resolve) => setImmediate(resolve))
await peer.connect()
assert.equal(refreshTimers(), 1)
}
const shutdown = owner === 'shutdown' ? peer.shutdown() : undefined
if (outcome === 'success') pendingResponse.resolve(response({ relayToken: 'fresh-token' }))
else pendingResponse.reject(new Error('token request failed'))
await new Promise((resolve) => setImmediate(resolve))
const timersAfterCompletion = refreshTimers()
await (shutdown ?? peer.shutdown())
assert.equal(timersAfterCompletion, owner === 'shutdown' ? 0 : 1)
assert.equal(refreshTimers(), 0)
assert.equal(
observations.filter((type) => type === 'refresh').length,
owner === 'current connection' && outcome === 'success' ? 1 : 0
)
})
}
}
@@ -1,269 +0,0 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { relayWorkflowUrl } from './relay-repository.mjs'
function workflow(name) {
return readFileSync(
fileURLToPath(relayWorkflowUrl(name)),
'utf8'
)
}
test('same-cap wrapper is reusable, canary-bound, and sequential', () => {
const wrapper = workflow('deploy-relay-production-same-cap.yml')
const job = workflow('deploy-relay-production-same-cap-job.yml')
assert.match(wrapper, /options: \[verify, canary-apply, batch-apply, rollback\]/)
assert.match(wrapper, /relay-same-cap-canary-\$\{\{ inputs\.canary-run-id \}\}/)
assert.match(wrapper, /needs: \[gate, cell_1\]/)
assert.match(wrapper, /needs: \[gate, cell_2\]/)
assert.match(wrapper, /needs: \[gate, cell_3\]/)
assert.match(job, /on:\n workflow_call:/)
assert.match(job, /c27\|c28\|c29\|c30\)/)
assert.match(job, /EXPECTED_HARD_CAP=3000/)
assert.match(job, /EXPECTED_REGION=asia-east2/)
assert.match(job, /--hard-cap "\$\{EXPECTED_HARD_CAP\}"/)
assert.match(job, /--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}"/)
assert.match(job, /--argjson protocol "\$\{PREDECESSOR_REHOME_PROTOCOL\}"/)
assert.match(job, /runtime predecessor mismatch fields=/)
// A rollback interrupted between apply and restore must be resumable.
assert.match(job, /ROLLBACK_RESUME=true/)
assert.match(job, /test "\$\{LIVE_IMAGE_DIGEST\}" = "\$\{DESIRED_IMAGE_DIGEST\}"/)
// Resume must skip BOTH the drain (no restart will clear the flag) and the
// apply (state already converged), and prove convergence instead.
assert.match(
job,
/Reversibly isolate and drain only the selected cell\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME != 'true' \}\}/
)
assert.match(
job,
/Apply only the selected same-cap template and MIG\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME != 'true' \}\}/
)
assert.match(
job,
/Require converged Terraform state and a stable MIG on resume\n if: \$\{\{ inputs\.mode != 'verify' && env\.ROLLBACK_RESUME == 'true' \}\}/
)
assert.match(job, /resume found unconverged resources/)
// A canary or batch cell that failed before its template apply also
// resumes here with template drift from repo changes since its last roll;
// only a plan the reviewed validator approves for the image the cell
// already serves may pass, and resume still applies nothing.
assert.match(job, /requiring reviewed rollback-image drift/)
assert.match(
job,
/--image "\$\{DESIRED_IMAGE\}" \\\n {16}--rollback-image "\$\{DESIRED_IMAGE\}"/
)
// The relaxation is only safe if the reviewed validator actually runs on
// the NON-converged branch, in same-cap-cell mode, with the trust config
// the validator requires, restricted to the template-and-MIG change pair.
assert.match(
job,
/if ! terraform -chdir=infra\/terraform show -json[\s\S]{0,220}\| length == 0' >\/dev\/null\n then\n/
)
assert.match(
job,
/requiring reviewed rollback-image drift'\n[\s\S]{0,400}?\n {16}--mode same-cap-cell --cell-id "\$\{TARGET_CELL_ID\}" \\\n/
)
assert.match(
job,
/Require converged Terraform state and a stable MIG on resume[\s\S]{0,300}CAPACITY_SERVICE_ACCOUNT: \$\{\{ vars\.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT \}\}\n {10}DIRECTOR_RUNTIME_SERVICE_ACCOUNT: \$\{\{ vars\.PRODUCTION_GCP_RELAY_DIRECTOR_RUNTIME_SERVICE_ACCOUNT \}\}/
)
assert.match(
job,
/--rollback-image "\$\{DESIRED_IMAGE\}" \\\n {16}--capacity-service-account "\$\{CAPACITY_SERVICE_ACCOUNT\}" \\\n {16}--rehome-director-service-account "\$\{DIRECTOR_RUNTIME_SERVICE_ACCOUNT\}"/
)
assert.match(
job,
/host-drain \\\n {16}--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}" \\\n {16}"\$\{POOL_ARGUMENTS\[@\]\}"\)"\n {12}echo "\$\{RESUME_REVIEW\}"\n {12}jq -e '\.changes == 2' <<< "\$\{RESUME_REVIEW\}" >\/dev\/null/
)
// A resume applies nothing at all, which is what a resume means: the only accepted
// unconverged plan is the template-and-MIG rollback-image drift, and it is left pending.
const resumeStep = job.slice(
job.indexOf('- name: Require converged Terraform state and a stable MIG on resume'),
job.indexOf('- name: Apply only the selected same-cap template and MIG')
)
assert.equal(resumeStep.split('terraform -chdir=infra/terraform apply').length, 1)
assert.match(job, /resume requires the isolated migration-only cell/)
assert.match(job, /test "\$\{TARGET_INCARNATION\}" = "\$\{SOURCE_INCARNATION\}"/)
assert.match(job, /\(.regionalRehomeProtocol \/\/ 0\) == \$protocol/)
assert.match(job, /\(\.draining == false or \$drainingOk\)/)
// Selector expectations must follow the mutations' returned generations,
// not fixed offsets: isolate is a no-op on a cell a failed canary already
// isolated, and the restore inspect must expect post-restore membership.
assert.match(job, /SELECTOR_GENERATION_AFTER_ISOLATE=\$\{EFFECTIVE_SELECTOR_GENERATION\}/)
assert.match(job, /SELECTOR_GENERATION_AFTER_ISOLATE=\$\{ISOLATE_GENERATION\}/)
assert.match(job, /--expected-selector-generation "\$\{SELECTOR_GENERATION_AFTER_ISOLATE\}"/)
assert.match(job, /--expected-selector-generation "\$\{SELECTOR_GENERATION_AFTER_RESTORE\}"/)
assert.match(job, /--expected-migration-only-cells "\$\{RESTORED_MIGRATION_CELLS\}"/)
assert.match(job, /--expected-general-cells "\$\{RESTORED_GENERAL_CELLS\}"/)
assert.match(job, /FAILSAFE_GENERATION/)
// Later batch waves start after ~16-min predecessor rolls, so BOTH evidence
// age checks must scale by wave or cell_2+ can never pass; the bound's
// per-wave step is the cell job timeout, so the two must move together.
assert.match(job, /--required-migration-policy strict \\\n --wave-index "\$\{WAVE_INDEX\}"/)
// Wave 0 must retry freshness-only failures too: one Cloud Monitoring publish
// lag at the sample instant is not health evidence, and single-shot wave 0
// failed a whole batch on a series that was fresh again a minute later.
assert.match(
job,
/dry-run\.state\.json" \\\n {14}--wave-index "\$\{WAVE_INDEX\}" \\\n {14}--selector-wave-delta "\$\{SELECTOR_WAVE_DELTA\}" --retry-freshness/
)
assert.doesNotMatch(job, /RETRY_ARGS/)
// Break-glass: the override skips the aggregate 15-minute monitor evidence and
// nothing else. The live per-wave recheck still runs on the override path, off
// the dispatch inputs the rehome inspect below verifies against the director.
assert.match(
job,
/if test -n "\$\{GATE_OVERRIDE_CONFIRMATION\}"; then[\s\S]{0,700}?--no-monitor-state \\\n {14}--expected-selector-generation "\$\{EXPECTED_SELECTOR_GENERATION\}" \\\n {14}--selector-membership-file[\s\S]{0,160}?--wave-index "\$\{WAVE_INDEX\}" \\\n {14}--selector-wave-delta "\$\{SELECTOR_WAVE_DELTA\}" --retry-freshness/
)
// The override is re-validated here, not trusted from the caller, and it is
// bound to the digest this wave installs.
assert.match(
job,
/test "\$\{GATE_OVERRIDE_CONFIRMATION\}" = \\\n {14}"SKIP_RELAY_MONITOR_GATE \$\{TARGET_IMAGE_DIGEST\}"/
)
assert.match(job, /\[\[ "\$\{GATE_OVERRIDE_REASON\}" =~ \^\[\[:print:\]\]\{12,500\}\$ \]\]/)
// Exactly the aggregate-evidence steps are skipped, and only them: every step
// that reads or spends the sealed monitor artifact carries the override guard.
const overrideSkipped = [
'Require fresh aggregate monitor evidence reference',
'Download private aggregate monitor evidence',
'Verify monitor evidence provenance',
"Download this wave's single-use safety authority",
'Require safety evidence consumed by this workflow'
]
for (const name of overrideSkipped) {
assert.match(
job,
new RegExp(`- name: ${name}\\n {8}if: \\$\\{\\{ inputs\\.mode != 'verify' && inputs\\.gate-override-confirmation == '' \\}\\}`)
)
}
assert.equal(
job.match(/inputs\.gate-override-confirmation == ''/g).length,
overrideSkipped.length
)
// The wrapper validates the override before anything runs, passes it to every
// cell, seals it into the canary artifact, and prints it in the run summary.
assert.match(wrapper, /--gate-override-reason "\$\{GATE_OVERRIDE_REASON\}" \\\n {12}--gate-override-confirmation "\$\{GATE_OVERRIDE_CONFIRMATION\}"\)/)
// One per cell job in the serial cell_1..cell_10 chain.
assert.equal(
wrapper.match(/gate-override-confirmation: \$\{\{ inputs\.gate-override-confirmation \}\}/g).length,
10
)
assert.match(wrapper, /Aggregate monitor gate overridden \(break-glass\)/)
assert.match(wrapper, /ACTOR: \$\{\{ github\.actor \}\}/)
for (const name of [
'Reject previously consumed aggregate safety evidence',
'Consume aggregate safety evidence for this exact wave'
]) {
assert.match(
wrapper,
new RegExp(`- name: ${name}\\n {8}if: \\$\\{\\{ inputs\\.mode != 'verify' && inputs\\.gate-override-confirmation == '' \\}\\}`)
)
}
assert.match(job, /timeout-minutes: 75/)
// Both age gates step by the cell job timeout above; the constant is
// duplicated across the two languages, so pin each copy to it.
for (const source of [
'../../dev/scripts/relay-monitor-evidence.mjs',
'../../apps/relay-ops/src/incident-live-preflight-cli.ts'
]) {
const body = readFileSync(fileURLToPath(new URL(source, import.meta.url)), 'utf8')
assert.match(body, /WAVE_PREDECESSOR_TIMEOUT_MS = 75 \* 60_000/)
assert.match(body, /\^\[0-9\]\$/)
}
// Aged-evidence replay via job re-runs is fenced: mutations are
// single-dispatch, so a failed cell needs a fresh gate and monitor run.
assert.match(job, /test "\$\{GITHUB_RUN_ATTEMPT\}" = 1/)
for (const index of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) {
assert.match(wrapper, new RegExp(`wave-index: '${index}'`))
}
assert.doesNotMatch(job, /EFFECTIVE_SELECTOR_GENERATION \+ 1\)/)
assert.doesNotMatch(job, /EFFECTIVE_SELECTOR_GENERATION \+ 2\)/)
assert.match(job, /\$region == "us-central1" and \$protocol == 0 and [.]region == null/)
assert.match(job, /[.]regionalRehomeProtocol \/\/ 0/)
assert.match(job, /runtime predecessor normalized legacy fields=/)
assert.match(job, /probe-relay-rehome-trust[.]mjs/)
assert.doesNotMatch(job, /service_account: \$\{\{ vars\.PRODUCTION_GCP_RELAY_(?:DIRECTOR_)?RUNTIME_SERVICE_ACCOUNT/)
assert.doesNotMatch(job, /roles\/iam\.serviceAccountTokenCreator/)
})
// Why: the same-cap caller defines release_lease itself, and a caller-defined job presents the
// caller as job_workflow_ref, so the pair must admit the caller alongside its reusable job.
test('shared deploy WIF admits the exact same-cap reusable workflow pair and the caller itself', () => {
const terraform = readFileSync(
fileURLToPath(new URL('../../infra/terraform/relay-github-actions.tf', import.meta.url)),
'utf8'
)
const providerStart = terraform.indexOf(
'resource "google_iam_workload_identity_pool_provider" "github"'
)
const providerEnd = terraform.indexOf('\nresource "', providerStart + 1)
const sharedProvider = terraform.slice(providerStart, providerEnd)
assert.ok(providerStart >= 0 && providerEnd > providerStart)
assert.match(sharedProvider, /local\.relay_github_workflow_conditions\["github"\]/)
// The pairing itself now lives in the clause the provider renders, once per accepted repository.
assert.match(
terraform,
/assertion\.workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_workflow_file\}@refs\/heads\/main' && \(assertion\.job_workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_job_workflow_file\}@refs\/heads\/main' \|\| assertion\.job_workflow_ref == '\$\{prefix\}\$\{local\.github_production_relay_same_cap_workflow_file\}@refs\/heads\/main'\)/
)
})
test('pause and disable precede optional installation and cloud diagnostics', () => {
const job = workflow('operate-relay-production-rehome-job.yml')
const emergency = job.indexOf('Apply emergency durable pause or disable before diagnostics')
const install = job.indexOf('pnpm install --frozen-lockfile')
const revision = job.indexOf('Verify exact serving and rollback director identities')
assert.ok(emergency > 0)
assert.ok(emergency < install)
assert.ok(emergency < revision)
assert.match(job, /inputs\.mode == 'pause' \|\| inputs\.mode == 'disable'/)
assert.match(job, /Seal 24-hour aggregate region observation evidence/)
assert.match(job, /--freshness=25h --limit=30000/)
assert.match(job, /relay-region-observation-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/)
assert.match(job, /test "\$\{RATE_PER_MINUTE\}" = 10/)
})
test('a failed enable independently restores and verifies durable disabled state', () => {
const job = workflow('operate-relay-production-rehome-job.yml')
const enable = job.indexOf('Apply exact durable regional rehome enable')
const evidence = job.indexOf('Read fresh aggregate completion and abort evidence')
const summary = job.indexOf('Publish aggregate control evidence')
const recovery = job.indexOf('Fail closed after an unsuccessful enable run')
assert.ok(enable > 0 && enable < evidence && evidence < summary && summary < recovery)
const recoveryStep = job.slice(recovery)
assert.match(
recoveryStep,
/failure\(\) && inputs\.mode == 'enable' && steps\.google-auth\.outcome == 'success'/
)
assert.match(recoveryStep, /--mode recover-enable/)
assert.match(recoveryStep, /--expected-control-generation "\$\{EXPECTED_CONTROL_GENERATION\}"/)
assert.match(recoveryStep, /RECOVER_FAILED_REGIONAL_REHOME_ENABLE/)
assert.match(recoveryStep, /\.control\.enabled == false/)
assert.doesNotMatch(recoveryStep, /gcloud|pnpm/)
})
test('director rollout has a strict one-time identity bootstrap', () => {
const workflowBody = workflow('deploy-relay-production-director.yml')
const script = readFileSync(
fileURLToPath(new URL('./deploy-relay-blue-green.mjs', import.meta.url)),
'utf8'
)
assert.match(workflowBody, /BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY/)
assert.match(workflowBody, /--predecessor-runtime-service-account/)
assert.match(workflowBody, /--expected-rehome-generation/)
assert.match(script, /args\.push\('--service-account', config\['runtime-service-account'\]\)/)
assert.match(script, /director predecessor runtime service account does not match/)
const candidateProof = script.indexOf('await verifyRehomeDisabled(candidate.origin)')
const trafficMove = script.indexOf('operations.updateTraffic(config, [`--to-tags=')
assert.ok(candidateProof > 0 && candidateProof < trafficMove)
assert.equal(script.indexOf('verifyRehomeDisabled', trafficMove), -1)
})
test('rehome job pipes every control result through tee under pipefail', () => {
const job = workflow('operate-relay-production-rehome-job.yml')
// Without `shell: bash` the step exit code is tee's, so a thrown inspect/apply passes green.
assert.match(job, /defaults:\n run:\n(?: #.*\n)* shell: bash\n/)
assert.ok((job.match(/\| tee "\$\{RUNNER_TEMP\}/g) ?? []).length >= 5)
})
@@ -13,7 +13,6 @@ import { readRelayWorkflow } from './relay-repository.mjs'
import { validateCapacityPlan } from './validate-relay-capacity-plan.mjs'
const workflow = readRelayWorkflow('deploy-relay-production-same-cap-job.yml')
const capacityWorkflow = readRelayWorkflow('deploy-relay-production-capacity-job.yml')
const production = readFileSync(
new URL('../../infra/terraform/environments/production.tfvars', import.meta.url),
'utf8'
@@ -295,74 +294,10 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
assert.equal(resolveCellShape('production-gce-c31').status, 1)
})
it('passes the same-cap allowlist on every canary invocation the job runs', () => {
const invocations = workflow.split('prepare-relay-production-capacity-canary.mjs').slice(1)
assert.equal(invocations.length, 4)
for (const invocation of invocations) {
const lines = invocation.split('\n')
const end = lines.findIndex((line) => !line.endsWith('\\'))
const call = lines.slice(0, end + 1).join(' ')
assert.match(call, /--approved-cells same-cap/)
// The restore call picks its mode from the cell's entry admission class.
assert.match(call, /--mode (isolate|drain|activate|"\$\{RESTORE_MODE\}")/)
}
})
it('paces the drain it sends to the selected cell', () => {
const drain = workflow.split('--mode drain')[1] ?? ''
assert.match(drain.split('\n').slice(0, 2).join(' '), /--pace-window-ms "\$\{DRAIN_PACE_WINDOW_MS\}"/)
// 5 min is the cell's DRAIN_PACE_WINDOW_MAX_MS; a 2,700-host cell at 2 min overruns the director's sticky lane.
assert.match(workflow, /DRAIN_PACE_WINDOW_MS: '300000'/)
// The transition wait has to outlast the pacing window on top of the leases it waits on.
assert.match(workflow, /--activity restart-safe[\s\S]*?--timeout-ms 1200000/)
})
it('passes this cell\'s rehome protocol and pool on every plan validation the job runs', () => {
const invocations = workflow.split('validate-relay-capacity-plan.mjs').slice(1)
assert.equal(invocations.length, 2)
for (const invocation of invocations) {
const lines = invocation.split('\n')
const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\'))
const call = lines.slice(0, end + 1).join(' ')
assert.match(call, /--mode same-cap-cell/)
assert.match(call, /--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}"/)
assert.match(call, /"\$\{POOL_ARGUMENTS\[@\]\}"/)
}
// Each of those steps must build the flag from the resolved pool, and only when there is one.
const builders = workflow.split(
'if test -n "${EXPECTED_DATABASE_POOL_MAX}"; then\n' +
' POOL_ARGUMENTS=(--database-pool-max "${EXPECTED_DATABASE_POOL_MAX}")'
)
assert.equal(builders.length, 3)
assert.equal(workflow.split('POOL_ARGUMENTS=()').length, 3)
})
// One cell's compute path and nothing else: the template and the MIG bound to it. The cell
// backend service stays out because the capacity role has no compute.backendServices.update,
// so naming it fails the apply after the MIG has already rolled.
it('targets exactly this cell template and MIG on every plan the job runs', () => {
const plans = workflow.split('terraform -chdir=infra/terraform plan').slice(1)
assert.equal(plans.length, 2)
for (const plan of plans) {
const lines = plan.split('\n')
const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\'))
const call = lines.slice(0, end + 1).join('\n')
assert.deepEqual(
[...call.matchAll(/-target=([\w.]+)\[\\"\$\{TARGET_CELL_ID\}\\"\]/g)]
.map(([, resource]) => resource),
[
'google_compute_instance_template.relay_gce_cell',
'google_compute_instance_group_manager.relay_gce_cell'
]
)
// Any target that is not one of those two, or not scoped to this cell, fails here.
assert.equal(call.split('-target=').length, 3)
}
})
it('never names a backend service on any plan or apply in the job', () => {
assert.equal(workflow.includes('google_compute_backend_service'), false)
})
it('validates a correct plan for every wave cell at that cell\'s rehome protocol', () => {
const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell))
@@ -569,25 +504,6 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
)
})
it('pins the capacity identity on every plan validation the job runs', () => {
const invocations = workflow.split('validate-relay-capacity-plan.mjs').slice(1)
assert.equal(invocations.length, 2)
for (const invocation of invocations) {
const lines = invocation.split('\n')
const end = lines.findIndex((line) => !line.trimEnd().endsWith('\\'))
assert.match(
lines.slice(0, end + 1).join(' '),
/--capacity-service-account "\$\{CAPACITY_SERVICE_ACCOUNT\}"/
)
}
// Both steps must read it from the same repository variable the job already requires.
assert.equal(
workflow.split(
'CAPACITY_SERVICE_ACCOUNT: ${{ vars.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }}'
).length,
4
)
})
it('decides the predecessor draining rule from the real block, for both classes', () => {
// A zero-host cell sheds nothing, and a failed canary's own drain leaves the flag set
@@ -777,29 +693,5 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
assert.equal(rolls('resume', { changes: 0 }), 'no-replace')
assert.equal(rolls('none', { changes: 0 }), 'no-replace')
})
it('waits on the image a stranded cell actually serves', () => {
const isolate = workflow
.split('name: Reversibly isolate and drain only the selected cell')[1]
.split('\n - id:')[0]
assert.match(isolate, /--expected-image-digests "\$\{PREDECESSOR_IMAGE_DIGEST\}"/)
// A stranded cell has to come back on a new process, which is what clears the drain.
const after = workflow
.split('name: Verify new incarnation, exact image, protocol, and durable safety')[1]
.split('\n - name:')[0]
assert.match(after, /test "\$\{TARGET_INCARNATION\}" != "\$\{SOURCE_INCARNATION\}"/)
assert.match(after, /if test "\$\{ROLLBACK_RESUME\}" = true; then/)
})
it('leaves the US-only capacity job on the default allowlist', () => {
assert.doesNotMatch(capacityWorkflow, /--approved-cells/)
})
})
// Both trusted versions must prove the same authenticated drain boundary.
it('proves rehome trust for protocol 3 on forward and rollback rolls', () => {
const step = workflow.split('name: Prove exact per-host trust and idempotent no-neighbor behavior')[1].split('\n - name:')[0]
assert.match(step, /inputs\.rollback-rehome-protocol != '0'/)
assert.match(step, /inputs\.target-rehome-protocol != '0'/)
assert.match(step, /probe-relay-rehome-trust\.mjs/)
})
+3 -3
View File
@@ -20,13 +20,13 @@
"load:relay:model": "node dev/scripts/run-relay-load-model.mjs",
"load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs",
"ops:relay": "pnpm --filter @orca-cloud/relay-ops dev",
"pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-admission-workflow.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs",
"test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/push-validation-workflow.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs",
"pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs",
"test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": {
"@types/node": "^24.10.0",
"tsx": "^4.21.0",
"tsx": "^4.23.15",
"typescript": "^5.9.3",
"vitest": "^4.1.11"
}
+344 -17
View File
@@ -12,14 +12,14 @@ importers:
specifier: ^24.10.0
version: 24.13.2
tsx:
specifier: ^4.21.0
version: 4.22.4
specifier: ^4.23.15
version: 4.23.15
typescript:
specifier: ^5.9.3
version: 5.9.3
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15))
apps/push:
dependencies:
@@ -65,7 +65,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4))
apps/relay:
dependencies:
@@ -114,7 +114,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4))
apps/relay-fence-broker:
dependencies:
@@ -139,7 +139,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4))
apps/relay-ops:
dependencies:
@@ -164,7 +164,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4))
packages/postgres-schema:
devDependencies:
@@ -176,7 +176,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15))
packages/push-contract:
dependencies:
@@ -192,7 +192,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15))
packages/relay-contract:
dependencies:
@@ -208,7 +208,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15))
packages:
@@ -227,156 +227,312 @@ packages:
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.1':
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.28.2':
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.1':
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.28.2':
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.1':
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.28.2':
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.1':
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.28.2':
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.1':
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.28.2':
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.1':
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.28.2':
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.1':
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.2':
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.1':
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.28.2':
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.1':
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.28.2':
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.1':
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.28.2':
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.1':
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.28.2':
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.1':
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.28.2':
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.1':
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.28.2':
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.1':
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.28.2':
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.1':
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.28.2':
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.1':
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.28.2':
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.1':
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-arm64@0.28.2':
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.1':
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.2':
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.1':
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-arm64@0.28.2':
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.1':
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.2':
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.1':
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/openharmony-arm64@0.28.2':
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.1':
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.28.2':
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.1':
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.28.2':
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.1':
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.28.2':
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.1':
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.28.2':
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@hono/node-server@1.19.17':
resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
engines: {node: '>=18.14.1'}
@@ -593,6 +749,11 @@ packages:
engines: {node: '>=18'}
hasBin: true
esbuild@0.28.2:
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
engines: {node: '>=18'}
hasBin: true
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -871,6 +1032,11 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tsx@4.23.15:
resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
engines: {node: '>=18.0.0'}
hasBin: true
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
@@ -1015,81 +1181,159 @@ snapshots:
'@esbuild/aix-ppc64@0.28.1':
optional: true
'@esbuild/aix-ppc64@0.28.2':
optional: true
'@esbuild/android-arm64@0.28.1':
optional: true
'@esbuild/android-arm64@0.28.2':
optional: true
'@esbuild/android-arm@0.28.1':
optional: true
'@esbuild/android-arm@0.28.2':
optional: true
'@esbuild/android-x64@0.28.1':
optional: true
'@esbuild/android-x64@0.28.2':
optional: true
'@esbuild/darwin-arm64@0.28.1':
optional: true
'@esbuild/darwin-arm64@0.28.2':
optional: true
'@esbuild/darwin-x64@0.28.1':
optional: true
'@esbuild/darwin-x64@0.28.2':
optional: true
'@esbuild/freebsd-arm64@0.28.1':
optional: true
'@esbuild/freebsd-arm64@0.28.2':
optional: true
'@esbuild/freebsd-x64@0.28.1':
optional: true
'@esbuild/freebsd-x64@0.28.2':
optional: true
'@esbuild/linux-arm64@0.28.1':
optional: true
'@esbuild/linux-arm64@0.28.2':
optional: true
'@esbuild/linux-arm@0.28.1':
optional: true
'@esbuild/linux-arm@0.28.2':
optional: true
'@esbuild/linux-ia32@0.28.1':
optional: true
'@esbuild/linux-ia32@0.28.2':
optional: true
'@esbuild/linux-loong64@0.28.1':
optional: true
'@esbuild/linux-loong64@0.28.2':
optional: true
'@esbuild/linux-mips64el@0.28.1':
optional: true
'@esbuild/linux-mips64el@0.28.2':
optional: true
'@esbuild/linux-ppc64@0.28.1':
optional: true
'@esbuild/linux-ppc64@0.28.2':
optional: true
'@esbuild/linux-riscv64@0.28.1':
optional: true
'@esbuild/linux-riscv64@0.28.2':
optional: true
'@esbuild/linux-s390x@0.28.1':
optional: true
'@esbuild/linux-s390x@0.28.2':
optional: true
'@esbuild/linux-x64@0.28.1':
optional: true
'@esbuild/linux-x64@0.28.2':
optional: true
'@esbuild/netbsd-arm64@0.28.1':
optional: true
'@esbuild/netbsd-arm64@0.28.2':
optional: true
'@esbuild/netbsd-x64@0.28.1':
optional: true
'@esbuild/netbsd-x64@0.28.2':
optional: true
'@esbuild/openbsd-arm64@0.28.1':
optional: true
'@esbuild/openbsd-arm64@0.28.2':
optional: true
'@esbuild/openbsd-x64@0.28.1':
optional: true
'@esbuild/openbsd-x64@0.28.2':
optional: true
'@esbuild/openharmony-arm64@0.28.1':
optional: true
'@esbuild/openharmony-arm64@0.28.2':
optional: true
'@esbuild/sunos-x64@0.28.1':
optional: true
'@esbuild/sunos-x64@0.28.2':
optional: true
'@esbuild/win32-arm64@0.28.1':
optional: true
'@esbuild/win32-arm64@0.28.2':
optional: true
'@esbuild/win32-ia32@0.28.1':
optional: true
'@esbuild/win32-ia32@0.28.2':
optional: true
'@esbuild/win32-x64@0.28.1':
optional: true
'@esbuild/win32-x64@0.28.2':
optional: true
'@hono/node-server@1.19.17(hono@4.13.7)':
dependencies:
hono: 4.13.7
@@ -1195,13 +1439,21 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))':
'@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4))':
dependencies:
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)
vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)
'@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15))':
dependencies:
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)
'@vitest/pretty-format@4.1.11':
dependencies:
@@ -1284,6 +1536,35 @@ snapshots:
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
esbuild@0.28.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.2
'@esbuild/android-arm': 0.28.2
'@esbuild/android-arm64': 0.28.2
'@esbuild/android-x64': 0.28.2
'@esbuild/darwin-arm64': 0.28.2
'@esbuild/darwin-x64': 0.28.2
'@esbuild/freebsd-arm64': 0.28.2
'@esbuild/freebsd-x64': 0.28.2
'@esbuild/linux-arm': 0.28.2
'@esbuild/linux-arm64': 0.28.2
'@esbuild/linux-ia32': 0.28.2
'@esbuild/linux-loong64': 0.28.2
'@esbuild/linux-mips64el': 0.28.2
'@esbuild/linux-ppc64': 0.28.2
'@esbuild/linux-riscv64': 0.28.2
'@esbuild/linux-s390x': 0.28.2
'@esbuild/linux-x64': 0.28.2
'@esbuild/netbsd-arm64': 0.28.2
'@esbuild/netbsd-x64': 0.28.2
'@esbuild/openbsd-arm64': 0.28.2
'@esbuild/openbsd-x64': 0.28.2
'@esbuild/openharmony-arm64': 0.28.2
'@esbuild/sunos-x64': 0.28.2
'@esbuild/win32-arm64': 0.28.2
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
@@ -1542,13 +1823,19 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tsx@4.23.15:
dependencies:
esbuild: 0.28.2
optionalDependencies:
fsevents: 2.3.3
tweetnacl@1.0.3: {}
typescript@5.9.3: {}
undici-types@7.18.2: {}
vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4):
vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -1557,14 +1844,27 @@ snapshots:
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 24.13.2
esbuild: 0.28.1
esbuild: 0.28.2
fsevents: 2.3.3
tsx: 4.22.4
vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)):
vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.28
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 24.13.2
esbuild: 0.28.2
fsevents: 2.3.3
tsx: 4.23.15
vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)):
dependencies:
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
'@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
@@ -1581,7 +1881,34 @@ snapshots:
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)
vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.22.4)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.2
transitivePeerDependencies:
- msw
vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)):
dependencies:
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
'@vitest/spy': 4.1.11
'@vitest/utils': 4.1.11
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.3
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.2)(tsx@4.23.15)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.2
+15 -1
View File
@@ -76,6 +76,9 @@ export function createPdfjsViewerAssetsPlugin(root = pdfjsRoot()): Plugin {
next()
return
}
if (response.destroyed) {
return
}
response.statusCode = 200
response.setHeader('Content-Length', size)
response.setHeader(
@@ -86,7 +89,18 @@ export function createPdfjsViewerAssetsPlugin(root = pdfjsRoot()): Plugin {
response.end()
return
}
createReadStream(filePath).pipe(response)
const stream = createReadStream(filePath)
const stopReading = (): void => {
stream.destroy()
}
response.once('close', stopReading)
response.once('error', stopReading)
stream.once('close', () => {
response.off('close', stopReading)
response.off('error', stopReading)
})
stream.once('error', () => response.destroy())
stream.pipe(response)
})
},
writeBundle(options) {
+26 -5
View File
@@ -16,15 +16,34 @@ type OutputChunk = Rollup.OutputChunk
// electron, and smoke-loads daemon-entry under plain Node to prove its module
// graph still resolves.
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
// The CLI loads these paths after electron-vite replaces out/main.
export const CLI_MAIN_ENTRY_NAMES = [
'agent-hooks/managed-agent-hook-controls',
'orca-profiles/profile-index-store',
'codex/managed-home-shell-preflight',
'claude-accounts/keychain',
...[
'access',
'active-location',
'storage-classification',
'offline-settings',
'backup-path',
'database-recovery',
'domain-reader',
'recovery-command'
].map((module) => `persistence/profile-state/profile-state-${module}`),
'persistence/profile-state/legacy-json/profile-state-export-path',
'persistence/profile-state/legacy-json/profile-state-recovery',
'startup/http1-compatibility-marker'
] as const
// Plain-Node processes and CLI modules cannot load Electron's API.
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'computer-sidecar',
'wsl-transcript-fs-process-entry',
'agent-hooks/managed-agent-hook-controls'
...CLI_MAIN_ENTRY_NAMES
] as const
// Entries executed as worker threads of the main process. Electron's module is
@@ -41,7 +60,9 @@ const WORKER_THREAD_ENTRY_NAMES = [
'session-scanner-worker-entry',
'main-thread-hang-watchdog-entry',
'port-scan-command-worker-entry',
'usage-scan-worker-entry'
'usage-scan-worker-entry',
'profile-state-backup-worker-entry',
'profile-state-writer-worker-entry'
] as const
export const GUARDED_ENTRY_NAMES = [
+1 -1
View File
@@ -1,4 +1,4 @@
ARG BASE_IMAGE=ubuntu:24.04
ARG BASE_IMAGE=ubuntu@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
FROM ${BASE_IMAGE}
ARG LIBASOUND_PACKAGE=libasound2t64
+1
View File
@@ -0,0 +1 @@
[]
@@ -0,0 +1,8 @@
const release = require('./electron-builder.config.cjs')
// CI discards these packages after smoke tests; keep release compression unchanged.
module.exports = {
...release,
deb: { ...release.deb, fpm: [...(release.deb.fpm ?? []), '--deb-compression-level=1'] },
rpm: { ...release.rpm, fpm: [...(release.rpm.fpm ?? []), '--rpm-compression-level=1'] }
}
+8
View File
@@ -188,6 +188,9 @@ module.exports = {
// Why: these repo-only inputs are either bundled into out/ or copied via
// extraResources. Shipping them in app.asar bloats the desktop bundle.
'!src{,/**/*}',
'!out/orcad{,/**/*}',
'!out/orcad-template{,/**/*}',
'!out/.orcad-*{,/**/*}',
'!config{,/**/*}',
'!docs{,/**/*}',
'!mobile{,/**/*}',
@@ -207,6 +210,8 @@ module.exports = {
// it is gitignored, but exclude it defensively so a stray local capture at
// package time never bloats app.asar.
'!pr-evidence{,/**/*}',
// Local build logs and rollback copies are never application resources.
'!notes{,/**/*}',
// Why: local agent/tooling directories may contain worktree symlink loops;
// they are never runtime inputs and must not be traversed by electron-builder.
'!{.claude,.grok,.agents,.codex}{,/**/*}',
@@ -281,6 +286,9 @@ module.exports = {
'out/main/gemini/**',
'out/main/grok/**',
'out/main/hermes/**',
'out/main/orca-profiles/profile-index-store.js',
'out/main/persistence/profile-state/**',
'out/main/startup/http1-compatibility-marker.js',
'out/main/daemon-entry.js',
'out/main/session-scanner-service-entry.js',
'out/main/wsl-transcript-fs-process-entry.js',
+2
View File
@@ -3,6 +3,8 @@
; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall /
; customUnInstall hook Orca needs lives here.
!include "${__FILEDIR__}\orca-process-check.nsh"
; ---------------------------------------------------------------------------
; Markdown "Open with Orca" (issue #10138)
;
+17
View File
@@ -0,0 +1,17 @@
; Defining the hook suppresses electron-builder's process-info declarations.
!include "getProcessInfo.nsh"
Var pid
Var /GLOBAL IsPowerShellAvailable
!macro customCheckAppRunning
; Restricted permits inline commands; test the process query rather than script-file policy.
; Match upstream FIND/KILL's profile behavior so the probe cannot skip a failing profile.
nsExec::Exec `"$PowerShellPath" -Command "try { Get-CimInstance -ClassName Win32_Process -ErrorAction Stop | Out-Null; exit 0 } catch { exit 1 }"`
Pop $0
; Launch errors, timeouts, and failed queries retain upstream's image-name fallback.
StrCpy $IsPowerShellAvailable 1
${if} $0 == 0
StrCpy $IsPowerShellAvailable 0
${endIf}
!insertmacro _CHECK_APP_RUNNING
!macroend
+1 -1
View File
@@ -113,7 +113,7 @@
// `shapedSidebar` is a persisted onboarding-checklist field and a telemetry enum member;
// renaming it would orphan saved state.
{
"files": ["**/src/shared/constants.ts", "**/src/shared/onboarding-state-types.ts"],
"files": ["**/src/shared/onboarding-defaults.ts", "**/src/shared/onboarding-state-types.ts"],
"rules": {
"anti-slop/no-shape-in-symbol-names": "off"
}
@@ -28,13 +28,14 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..0bb2af7923b6e6f1f0da40cae8067304
"/guard:cf",
"/sdl",
diff --git a/lib/index.js b/lib/index.js
index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba20906615 100644
index e586cd6ead522a4ff060f5338201f45e3467d639..0ee62c35558ff40e2298c464fdf6e9485f9d181d 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -7,11 +7,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
@@ -7,11 +7,14 @@
exports.getAllProcesses = exports.getProcessTree = exports.getProcessCpuUsage = exports.getProcessList = exports.filterProcessList = exports.buildProcessTree = exports.ProcessDataFlag = void 0;
const util_1 = require("util");
const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined;
+exports.getProcessCreationTime = native === undefined ? undefined : native.getProcessCreationTime;
+exports.supportedProcessDataFlags = native === undefined ? undefined : native.supportedProcessDataFlags;
var ProcessDataFlag;
(function (ProcessDataFlag) {
@@ -45,7 +46,7 @@ index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba
})(ProcessDataFlag = exports.ProcessDataFlag || (exports.ProcessDataFlag = {}));
// requestInProgress is used for any function that uses CreateToolhelp32Snapshot, as multiple calls
// to this cannot be done at the same time.
@@ -66,11 +68,12 @@ function buildProcessTree(rootPid, processList, maxDepth = MAX_FILTER_DEPTH) {
@@ -66,11 +69,12 @@
// • the properties are inlined/splatted
// • the 'ppid' field is omitted
// • the depth of the tree is limited by `maxDepth`
@@ -60,13 +61,14 @@ index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba
});
return buildNode(root, maxDepth);
diff --git a/lib/index.ts b/lib/index.ts
index f9aa005d9ced9e42885b8a976de5eb5bd61899ee..1b509af0b9065918bcb5cb75f2d7f23821d4a56a 100644
index 7b53aad05c3682a5c7d814d81a39c3f391ae97e3..284dae185b56241244b4d6bcab9e08f7530b8cf3 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -6,12 +6,15 @@
@@ -6,12 +6,16 @@
import { promisify } from 'util';
const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined;
+export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined = native?.getProcessCreationTime;
+/** The flag bits this compiled addon reports; undefined off win32. */
+export const supportedProcessDataFlags: number | undefined = native?.supportedProcessDataFlags;
import { IProcessInfo, IProcessTreeNode, IProcessCpuInfo } from '@vscode/windows-process-tree';
@@ -80,7 +82,7 @@ index f9aa005d9ced9e42885b8a976de5eb5bd61899ee..1b509af0b9065918bcb5cb75f2d7f238
}
type RequestCallback = (processList: IProcessInfo[]) => void;
@@ -81,11 +84,12 @@ export function buildProcessTree(rootPid: number, processList: Iterable<IProcess
@@ -81,11 +85,12 @@
// • the properties are inlined/splatted
// • the 'ppid' field is omitted
// • the depth of the tree is limited by `maxDepth`
@@ -95,11 +97,33 @@ index f9aa005d9ced9e42885b8a976de5eb5bd61899ee..1b509af0b9065918bcb5cb75f2d7f238
});
diff --git a/src/addon.cc b/src/addon.cc
index 9214aff281251e797a70ecb9f6e0b52932a0503f..722edd42ddb4740296bfc47582a181bd6d00c464 100644
index 5253960ad97496b53c4a02572b64b270302af22f..a801526e20af72b6442c769a8ba1640386c23f46 100644
--- a/src/addon.cc
+++ b/src/addon.cc
@@ -53,6 +53,10 @@ void GetProcessCpuUsage(const Napi::CallbackInfo& args) {
@@ -50,9 +50,32 @@
worker->Queue();
}
+Napi::Value ReadProcessCreationTime(const Napi::CallbackInfo& args) {
+ Napi::Env env(args.Env());
+ if (args.Length() != 1 || !args[0].IsNumber()) {
+ return env.Undefined();
+ }
+ const double pid = args[0].As<Napi::Number>().DoubleValue();
+ if (!(pid >= 1 && pid <= MAXDWORD) || pid != static_cast<DWORD>(pid)) {
+ return env.Undefined();
+ }
+ ProcessInfo pinfo{};
+ pinfo.pid = static_cast<DWORD>(pid);
+ GetProcessCreationTime(pinfo);
+ if (pinfo.creationTimeMs == 0) {
+ return env.Undefined();
+ }
+ return Napi::Number::New(env, static_cast<double>(pinfo.creationTimeMs));
+}
+
Napi::Object Init(Napi::Env env, Napi::Object exports) {
+ exports.Set("getProcessCreationTime", Napi::Function::New(env, ReadProcessCreationTime));
exports.Set("getProcessList", Napi::Function::New(env, GetProcessList));
exports.Set("getProcessCpuUsage", Napi::Function::New(env, GetProcessCpuUsage));
+ // Lets a caller prove THIS BINARY understands CREATIONTIME. The JS enum is
@@ -398,10 +422,10 @@ index c9e3457a759c1acaa2644231a4917d45aed951f8..3f26a354477f062b34bd31fbd17be529
}
diff --git a/typings/windows-process-tree.d.ts b/typings/windows-process-tree.d.ts
index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e3791ec1b629 100644
index 70e242b123e76d43c452007be1ce92a6d224c8bb..b1d0a53c0c18cc16531b394c19bb256bdbaf782f 100644
--- a/typings/windows-process-tree.d.ts
+++ b/typings/windows-process-tree.d.ts
@@ -7,9 +7,17 @@ declare module '@vscode/windows-process-tree' {
@@ -7,8 +7,17 @@
export enum ProcessDataFlag {
None = 0,
Memory = 1,
@@ -409,18 +433,18 @@ index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e379
+ CommandLine = 2,
+ CreationTime = 4
}
+
+ /**
+ * The flag bits the compiled addon actually understands, or undefined off
+ * win32. `ProcessDataFlag` above is source; this is what the binary reports,
+ * so it is the only way to tell a patched build from a stale prebuilt.
+ */
+ export const supportedProcessDataFlags: number | undefined;
+
+ export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined;
export interface IProcessInfo {
pid: number;
ppid: number;
@@ -24,6 +32,9 @@ declare module '@vscode/windows-process-tree' {
@@ -24,6 +33,9 @@
* The string returned is at most 512 chars, strings exceeding this length are truncated.
*/
commandLine?: string;
@@ -430,7 +454,7 @@ index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e379
}
export interface IProcessCpuInfo extends IProcessInfo {
@@ -35,6 +46,7 @@ declare module '@vscode/windows-process-tree' {
@@ -35,6 +47,7 @@
name: string;
memory?: number;
commandLine?: string;
File diff suppressed because one or more lines are too long
+44
View File
@@ -0,0 +1,44 @@
diff --git a/dist/cjs/extractor/core/extractor.js b/dist/cjs/extractor/core/extractor.js
index 6d61ade2471c20bf1a253e3784bfadcb01440545..c1b001e78a215f89a8d4da1756a87562a5a6e723 100644
--- a/dist/cjs/extractor/core/extractor.js
+++ b/dist/cjs/extractor/core/extractor.js
@@ -143,6 +143,8 @@ const extractionSiteRegexes = new WeakMap();
* @internal
*/
function mayContainExtractionSite(code, config) {
+ // Escaped identifiers need the parser; comments can separate a name from its call.
+ if (code.includes('\\u')) return true;
let re = extractionSiteRegexes.get(config);
if (!re) {
const calls = new Set(['t']);
@@ -160,7 +162,7 @@ function mayContainExtractionSite(code, config) {
for (const component of config.extract.transComponents || ['Trans'])
names.add(component.split('.').pop());
const alt = (set) => [...set].map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
- re = new RegExp(`(?<!\\w)(?:(?:${alt(calls)})\\s*(?:\\?\\.\\s*)?[(<]|(?:${alt(names)})(?![\\w$]))`);
+ re = new RegExp(`(?<![\\w$])(?:${alt(calls)}|${alt(names)})(?![\\w$])`);
extractionSiteRegexes.set(config, re);
}
return re.test(code);
diff --git a/dist/esm/extractor/core/extractor.js b/dist/esm/extractor/core/extractor.js
index 910bdb6e25e26c01ea6127191f1a00c7089693c1..00da4a6a7acb6c3da8d4b6fe7be106c409542f8d 100644
--- a/dist/esm/extractor/core/extractor.js
+++ b/dist/esm/extractor/core/extractor.js
@@ -141,6 +141,8 @@ const extractionSiteRegexes = new WeakMap();
* @internal
*/
function mayContainExtractionSite(code, config) {
+ // Escaped identifiers need the parser; comments can separate a name from its call.
+ if (code.includes('\\u')) return true;
let re = extractionSiteRegexes.get(config);
if (!re) {
const calls = new Set(['t']);
@@ -158,7 +160,7 @@ function mayContainExtractionSite(code, config) {
for (const component of config.extract.transComponents || ['Trans'])
names.add(component.split('.').pop());
const alt = (set) => [...set].map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
- re = new RegExp(`(?<!\\w)(?:(?:${alt(calls)})\\s*(?:\\?\\.\\s*)?[(<]|(?:${alt(names)})(?![\\w$]))`);
+ re = new RegExp(`(?<![\\w$])(?:${alt(calls)}|${alt(names)})(?![\\w$])`);
extractionSiteRegexes.set(config, re);
}
return re.test(code);
+117 -53
View File
@@ -707,7 +707,7 @@ index 98733dc0cd752b554bd94e45904ca341ad141bba..3dd5ad9b3124dbd5ba7f76679b26650d
// Stop processing immediately on unexpected error and log
this._writeQueue.length = 0;
diff --git a/src/win/conpty.cc b/src/win/conpty.cc
index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c6678cf86 100644
index 7b286d3d64..5239ba4e40 100644
--- a/src/win/conpty.cc
+++ b/src/win/conpty.cc
@@ -18,6 +18,7 @@
@@ -718,7 +718,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
#include <vector>
#include <Windows.h>
#include <strsafe.h>
@@ -44,12 +45,40 @@ struct pty_baton {
@@ -44,15 +45,37 @@ struct pty_baton {
HANDLE hOut;
HPCON hpc;
@@ -749,18 +749,25 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
};
static std::vector<std::unique_ptr<pty_baton>> ptyHandles;
+// Orca: guards the job accessors below, and PtyKill, against the exit watcher
+// thread. It does NOT make the whole table safe -- PtyResize and PtyClear still
+// read it unlocked, as they always have -- but it closes the window this patch
+// opened, where the watcher can close hShell/hJob and free the baton between a
+// lookup and its use.
+// Handle VALUES are recycled aggressively, so an unguarded read could pass the
+// shell-pid check against an unrelated process and terminate the wrong job.
+// Orca: every table access shares the exit watcher's lock; handles can be recycled.
+static std::mutex ptyJobMutex;
static volatile LONG ptyCounter;
static pty_baton* get_pty_baton(int id) {
@@ -102,8 +131,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
-static pty_baton* get_pty_baton(int id) {
+static pty_baton* get_pty_baton_locked(int id) {
auto it = std::find_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) {
return ptyHandle->id == id;
});
@@ -62,7 +85,7 @@ static pty_baton* get_pty_baton(int id) {
return nullptr;
}
-static bool remove_pty_baton(int id) {
+static bool remove_pty_baton_locked(int id) {
auto it = std::remove_if(ptyHandles.begin(), ptyHandles.end(), [id](const auto& ptyHandle) {
return ptyHandle->id == id;
});
@@ -102,8 +125,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
// Get process exit code.
GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code));
// Clean up handles
@@ -782,7 +789,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
+ // NDEBUG would compile the call away and leak every baton.
+ baton->shellExited = true;
+ if (baton->consoleClosed) {
+ const bool removed = remove_pty_baton(baton->id);
+ const bool removed = remove_pty_baton_locked(baton->id);
+ assert(removed);
+ (void)removed;
+ }
@@ -794,7 +801,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
switch (status) {
@@ -242,6 +294,20 @@
@@ -242,6 +288,20 @@ HRESULT CreateNamedPipesAndPseudoConsole(const Napi::CallbackInfo& info,
return HRESULT_FROM_WIN32(GetLastError());
}
@@ -815,15 +822,40 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
@@ -303,6 +369,7 @@
@@ -301,8 +361,10 @@ static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) {
// We were able to instantiate a conpty
const int ptyId = InterlockedIncrement(&ptyCounter);
marshal.Set("pty", Napi::Number::New(env, ptyId));
ptyHandles.emplace_back(
std::make_unique<pty_baton>(ptyId, hIn, hOut, hpc));
+ ptyHandles.back()->allowJobBreakaway = !usesCygwinRuntime(shellpath);
- ptyHandles.emplace_back(
- std::make_unique<pty_baton>(ptyId, hIn, hOut, hpc));
+ auto baton = std::make_unique<pty_baton>(ptyId, hIn, hOut, hpc);
+ baton->allowJobBreakaway = !usesCygwinRuntime(shellpath);
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ ptyHandles.emplace_back(std::move(baton));
} else {
throw Napi::Error::New(env, "Cannot launch conpty");
}
@@ -409,6 +476,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -350,11 +412,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
const bool useConptyDll = info[4].As<Napi::Boolean>().Value();
Napi::Function exitCallback = info[5].As<Napi::Function>();
- // Fetch pty handle from ID and start process
- pty_baton* handle = get_pty_baton(id);
- if (!handle) {
- throw Napi::Error::New(env, "Invalid pty handle");
+ pty_baton* handle;
+ {
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ handle = get_pty_baton_locked(id);
+ if (!handle || handle->consoleClosed) {
+ throw Napi::Error::New(env, "Invalid pty handle");
+ }
}
+ // No watcher exists for this baton until SetupExitCallback below; pipe waits stay unlocked.
// Prepare command line
std::unique_ptr<wchar_t[]> mutableCommandline = std::make_unique<wchar_t[]>(cmdline.length() + 1);
@@ -409,6 +475,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "UpdateProcThreadAttribute failed");
}
@@ -839,7 +871,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
PROCESS_INFORMATION piClient{};
fSuccess = !!CreateProcessW(
nullptr,
@@ -416,7 +492,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -416,7 +491,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
nullptr, // lpProcessAttributes
nullptr, // lpThreadAttributes
false, // bInheritHandles VERY IMPORTANT that this is false
@@ -851,7 +883,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
envArg, // lpEnvironment
mutableCwd.get(), // lpCurrentDirectory
&siEx.StartupInfo, // lpStartupInfo
@@ -426,8 +505,48 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -426,8 +504,48 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "Cannot create process");
}
@@ -902,25 +934,52 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
if (useConptyDll && fLoadedDll)
{
PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress(
@@ -440,6 +559,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -438,8 +556,12 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
}
}
// Update handle
handle->hShell = piClient.hProcess;
+ handle->shellPid = piClient.dwProcessId;
+ handle->hJob = hJob;
- // Update handle
- handle->hShell = piClient.hProcess;
+ {
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ handle->hShell = piClient.hProcess;
+ handle->shellPid = piClient.dwProcessId;
+ handle->hJob = hJob;
+ }
// Close the thread handle to avoid resource leak
CloseHandle(piClient.hThread);
@@ -544,27 +665,213 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
@@ -472,9 +594,10 @@ static Napi::Value PtyResize(const Napi::CallbackInfo& info) {
SHORT rows = static_cast<SHORT>(info[2].As<Napi::Number>().Uint32Value());
const bool useConptyDll = info[3].As<Napi::Boolean>().Value();
- const pty_baton* handle = get_pty_baton(id);
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton_locked(id);
- if (handle != nullptr) {
+ if (handle != nullptr && !handle->consoleClosed) {
HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
bool fLoadedDll = hLibrary != nullptr;
if (fLoadedDll)
@@ -513,9 +636,10 @@ static Napi::Value PtyClear(const Napi::CallbackInfo& info) {
return env.Undefined();
}
- const pty_baton* handle = get_pty_baton(id);
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton_locked(id);
- if (handle != nullptr) {
+ if (handle != nullptr && !handle->consoleClosed) {
HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
bool fLoadedDll = hLibrary != nullptr;
if (fLoadedDll)
@@ -544,29 +668,215 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
int id = info[0].As<Napi::Number>().Int32Value();
const bool useConptyDll = info[1].As<Napi::Boolean>().Value();
- const pty_baton* handle = get_pty_baton(id);
-
- if (handle != nullptr) {
- HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
- bool fLoadedDll = hLibrary != nullptr;
- if (fLoadedDll)
+ // Orca: resolve the DLL BEFORE touching any baton state, for the same reason
+ // PtyConnect does it before creating anything. LoadConptyDll throws when
+ // conpty.dll is missing, and a throw after consoleClosed was set would strand
@@ -934,7 +993,18 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
+ (HMODULE)hLibrary,
+ useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
+ }
+
- if (handle != nullptr) {
- HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
- bool fLoadedDll = hLibrary != nullptr;
- if (fLoadedDll)
- {
- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress(
- (HMODULE)hLibrary,
- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
- if (pfnClosePseudoConsole)
- {
- pfnClosePseudoConsole(handle->hpc);
+ // Orca: the baton now outlives the shell, so this runs on a self-exited pty
+ // too -- that is the whole point. Take what we need under the lock: the
+ // watcher thread nulls hShell the moment the shell dies, and TerminateProcess
@@ -945,7 +1015,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
+ bool owed = false;
+ {
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ pty_baton* handle = get_pty_baton(id);
+ pty_baton* handle = get_pty_baton_locked(id);
+ // Why the consoleClosed check: a second kill() would otherwise close the
+ // same pseudoconsole twice. Upstream relied on the baton being gone.
+ if (handle != nullptr && !handle->consoleClosed) {
@@ -965,9 +1035,9 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
+ hShellDup = nullptr;
+ TerminateProcess(handle->hShell, 1);
+ }
+ }
}
+ if (handle->shellExited) {
+ const bool removed = remove_pty_baton(id);
+ const bool removed = remove_pty_baton_locked(id);
+ assert(removed);
+ (void)removed;
+ }
@@ -979,19 +1049,11 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
+ // drained, and the watcher must be able to take the lock while it does.
+ if (owed) {
+ if (pfnClosePseudoConsole)
{
- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress(
- (HMODULE)hLibrary,
- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
- if (pfnClosePseudoConsole)
- {
- pfnClosePseudoConsole(handle->hpc);
- }
- }
+ {
+ pfnClosePseudoConsole(hpc);
}
- if (useConptyDll) {
- TerminateProcess(handle->hShell, 1);
+ pfnClosePseudoConsole(hpc);
+ }
+ if (hShellDup != nullptr) {
+ TerminateProcess(hShellDup, 1);
+ CloseHandle(hShellDup);
@@ -999,8 +1061,8 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
}
return env.Undefined();
+}
+
}
+/**
+ * Orca: confirm a baton really is the pty the caller means.
+ *
@@ -1032,7 +1094,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise
+ // close these handles and free the baton in between.
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton(info[0].As<Napi::Number>().Int32Value());
+ const pty_baton* handle = get_pty_baton_locked(info[0].As<Napi::Number>().Int32Value());
+ if (!ownsShell(handle, info[1].As<Napi::Number>().Uint32Value())) {
+ return Napi::Boolean::New(env, false);
+ }
@@ -1064,7 +1126,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
+ // Held across the lookup AND the Win32 call: the watcher thread can otherwise
+ // close these handles and free the baton in between.
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ const pty_baton* handle = get_pty_baton(info[0].As<Napi::Number>().Int32Value());
+ const pty_baton* handle = get_pty_baton_locked(info[0].As<Napi::Number>().Int32Value());
+ if (!ownsShell(handle, info[1].As<Napi::Number>().Uint32Value())) {
+ return env.Null();
+ }
@@ -1138,10 +1200,12 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c
+ }
+ hHostJob = job;
+ return Napi::Boolean::New(env, true);
}
+}
+
/**
@@ -577,6 +884,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
* Init
*/
@@ -577,6 +887,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("clear", Napi::Function::New(env, PtyClear));
exports.Set("kill", Napi::Function::New(env, PtyKill));
@@ -1,8 +1,21 @@
diff --git a/src/IIPHandler.ts b/src/IIPHandler.ts
index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df64c5bd088 100644
index 559b907416eb38318f439d060d7f89311ed34c7e..73cedbe99f831bffd202697cc8ba80a46a39cb99 100644
--- a/src/IIPHandler.ts
+++ b/src/IIPHandler.ts
@@ -34,6 +34,7 @@ const DEFAULT_HEADER: IHeaderFields = {
@@ -13,8 +13,10 @@ import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
// Local const enum mirror - esbuild can't inline const enums from external packages
const enum DecoderConst {
- // Limit held memory in base64 decoder (encoded bytes).
- KEEP_DATA = 4194304,
+ // Held memory in base64/QOI decoders between images. Zero because each kept
+ // decoder pins a wasm memory, and V8 caps those per process (~124 in a
+ // sandboxed renderer), so idle terminals must not hold one.
+ KEEP_DATA = 0,
// Initial buffer allocation for the decoder.
INITIAL_DATA = 1048576,
// Local mirror of const enum (esbuild can't inline const enums from external packages)
@@ -34,6 +36,7 @@ const DEFAULT_HEADER: IHeaderFields = {
export class IIPHandler implements IOscHandler, IResetHandler {
@@ -10,7 +23,7 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6
private _aborted = false;
private _hp = new HeaderParser();
private _header: IHeaderFields = DEFAULT_HEADER;
@@ -55,6 +56,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
@@ -55,6 +58,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
}
public reset(): void {
@@ -18,7 +31,47 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6
this._hp.reset();
this._dec.release();
this._qoiDec.release();
@@ -198,8 +200,13 @@ export class IIPHandler implements IOscHandler, IResetHandler {
@@ -92,7 +96,10 @@ export class IIPHandler implements IOscHandler, IResetHandler {
this._aborted = true;
return;
}
- this._dec.init();
+ if (!this._initDecoder()) {
+ this._aborted = true;
+ return;
+ }
} else if (this._abortMulti) {
this._aborted = true;
return;
@@ -135,7 +142,9 @@ export class IIPHandler implements IOscHandler, IResetHandler {
this._isMultipart = true;
this._abortMulti = false;
this._dec.release();
- this._dec.init();
+ if (!this._initDecoder()) {
+ this._abortMulti = true;
+ }
return true;
}
@@ -179,7 +188,15 @@ export class IIPHandler implements IOscHandler, IResetHandler {
let blob: Blob | ImageData;
if (metrics.mime === 'image/qoi') {
- const data = this._qoiDec.decode(this._dec.data8);
+ let data: Uint8Array<ArrayBuffer>;
+ try {
+ data = this._qoiDec.decode(this._dec.data8);
+ } catch (e) {
+ console.warn('IIP: could not decode QOI image', e);
+ this._dec.release();
+ this._qoiDec.release();
+ return true;
+ }
blob = new ImageData(
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
this._qoiDec.width,
@@ -198,8 +215,13 @@ export class IIPHandler implements IOscHandler, IResetHandler {
blob = new Blob([this._dec.data8], { type: metrics.mime });
}
this._dec.release();
@@ -32,6 +85,25 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df6
this._storage.addImage(bm);
return true;
})
@@ -209,6 +231,18 @@ export class IIPHandler implements IOscHandler, IResetHandler {
});
}
+ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.
+ private _initDecoder(): boolean {
+ try {
+ this._dec.init();
+ return true;
+ } catch (e) {
+ console.warn('IIP: could not allocate decoder', e);
+ this._dec.release();
+ return false;
+ }
+ }
+
private _resize(w: number, h: number): [number, number] {
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
diff --git a/src/ImageAddon.ts b/src/ImageAddon.ts
index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..0c44d2a81642113417bf8dc10a4faa76d7cc5864 100644
--- a/src/ImageAddon.ts
@@ -144,8 +216,156 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33
}
public get document(): Document | undefined {
diff --git a/src/SixelHandler.ts b/src/SixelHandler.ts
index 1af2d85bcdd541ed60b1e707f6186a91f1bbf1b2..0711a122ea43d5212a2851add9e744b65550ec85 100644
--- a/src/SixelHandler.ts
+++ b/src/SixelHandler.ts
@@ -10,6 +10,7 @@ import { RGBA8888 } from 'sixel/lib/Types';
import { ImageRenderer } from './ImageRenderer';
import { DecoderAsync, Decoder } from 'sixel/lib/Decoder';
+import { LIMITS } from 'sixel/lib/wasm';
// always free decoder ressources after decoding if it exceeds this limit
const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB
@@ -18,48 +19,88 @@ const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB
const DEFAULT_PALETTE = PALETTE_ANSI_256;
DEFAULT_PALETTE.set(PALETTE_VT340_COLOR);
+// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories
+// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only
+// while a SIXEL sequence is open, so idle terminals hold none.
+const MAX_IDLE_DECODERS = 2;
+const idleDecoders = new Map<number, Decoder[]>();
+let poolPrimed = false;
+
+function primeDecoderPool(memoryLimit: number): void {
+ if (poolPrimed) return;
+ poolPrimed = true;
+ // Async compile once, off the parser's hot path; later decoders reuse the cached module.
+ DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then(
+ d => releaseDecoder(d, memoryLimit),
+ () => { poolPrimed = false; }
+ );
+}
+
+function acquireDecoder(memoryLimit: number): Decoder {
+ return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE });
+}
+
+function releaseDecoder(dec: Decoder, memoryLimit: number): void {
+ if (dec.memoryUsage > MEM_PERMA_LIMIT) {
+ dec.release();
+ }
+ const idle = idleDecoders.get(memoryLimit) ?? [];
+ if (idle.length < MAX_IDLE_DECODERS) {
+ idle.push(dec);
+ idleDecoders.set(memoryLimit, idle);
+ }
+}
+
export class SixelHandler implements IDcsHandler, IResetHandler {
private _size = 0;
private _aborted = false;
private _dec: Decoder | undefined;
+ private _decMemoryLimit = 0;
+ // Color registers outlive a single image, so they live here rather than in a pooled decoder.
+ private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE);
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _storage: SixelImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
- DecoderAsync({
- memoryLimit: this._opts.pixelLimit * 4,
- palette: DEFAULT_PALETTE,
- paletteLimit: this._opts.sixelPaletteLimit
- }).then(d => this._dec = d);
+ this._palette.set(DEFAULT_PALETTE);
+ primeDecoderPool(this._opts.pixelLimit * 4);
}
public reset(): void {
- /**
- * reset sixel decoder to defaults:
- * - release all memory
- * - nullify palette (4096)
- * - apply default palette (256)
- */
- if (this._dec) {
- this._dec.release();
- // FIXME: missing interface on decoder to nullify full palette
- (this._dec as any)._palette.fill(0);
- this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);
- }
+ this._returnDecoder();
+ this._palette.fill(0);
+ this._palette.set(DEFAULT_PALETTE);
}
public hook(params: IParams): void {
this._size = 0;
this._aborted = false;
- if (this._dec) {
- const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(
- this._coreTerminal._core._inputHandler._curAttrData,
- this._coreTerminal._core._themeService?.colors);
- this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);
+ this._returnDecoder();
+ const memoryLimit = this._opts.pixelLimit * 4;
+ try {
+ this._dec = acquireDecoder(memoryLimit);
+ } catch (e) {
+ // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue.
+ console.warn(`SIXEL: could not allocate decoder - ${e}`);
+ this._aborted = true;
+ return;
}
+ this._decMemoryLimit = memoryLimit;
+ const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(
+ this._coreTerminal._core._inputHandler._curAttrData,
+ this._coreTerminal._core._themeService?.colors);
+ this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit);
+ }
+
+ private _returnDecoder(): void {
+ const dec = this._dec;
+ if (!dec) return;
+ this._dec = undefined;
+ this._palette.set(dec.palette);
+ releaseDecoder(dec, this._decMemoryLimit);
}
public put(data: Uint32Array, start: number, end: number): void {
@@ -83,6 +124,14 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
}
public unhook(success: boolean): boolean | Promise<boolean> {
+ try {
+ return this._unhook(success);
+ } finally {
+ this._returnDecoder();
+ }
+ }
+
+ private _unhook(success: boolean): boolean {
if (this._aborted || !success || !this._dec) {
return true;
}
@@ -100,9 +149,6 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
const canvas = ImageRenderer.createCanvas(undefined, width, height);
canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray<ArrayBuffer>, width, height), 0, 0);
- if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {
- this._dec.release();
- }
this._storage.addImage(canvas);
return true;
}
diff --git a/src/kitty/KittyGraphicsHandler.ts b/src/kitty/KittyGraphicsHandler.ts
index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1fa320c4f8 100644
index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d88201134f78 100644
--- a/src/kitty/KittyGraphicsHandler.ts
+++ b/src/kitty/KittyGraphicsHandler.ts
@@ -7,6 +7,7 @@ import { IDisposable } from '@xterm/xterm';
@@ -172,10 +392,12 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f
this._cleanupAllPending();
if (this._activeDecoder) {
this._activeDecoder.release();
@@ -200,6 +203,25 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
@@ -200,8 +203,38 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
this._activeDecoder = pending.decoder;
}
if (!this._activeDecoder) {
- this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
- this._activeDecoder.init();
+ // Budget WASM capacity, including one page of decoder state and rounding.
+ const decoderCapacity = this._maxEncodedBytes + 131072;
+ if (decoderCapacity > this._opts.storageLimit * 1000000) {
@@ -195,10 +417,23 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f
+ this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);
+ }
+ }
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
this._activeDecoder.init();
+ const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
+ try {
+ decoder.init();
+ } catch (e) {
+ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.
+ console.warn('KITTY: could not allocate decoder', e);
+ this._aborted = true;
+ if (this._parsedCommand?.id !== undefined) {
+ this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0);
+ }
+ return;
+ }
+ this._activeDecoder = decoder;
}
@@ -550,9 +572,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
@@ -550,9 +583,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
@@ -210,7 +445,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f
const cropX = Math.max(0, cmd.x ?? 0);
const cropY = Math.max(0, cmd.y ?? 0);
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
@@ -660,6 +684,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
@@ -660,6 +695,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
}
@@ -218,7 +453,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f
const zIndex = cmd.zIndex ?? 0;
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
bitmap = undefined; // ownership transferred to storage
@@ -693,6 +718,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
@@ -693,6 +729,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
if (image.format === KittyFormat.PNG) {
@@ -231,7 +466,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1f
const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
@@ -775,27 +806,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
@@ -775,27 +817,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
try {
return await this._decompress(compressed, 'deflate');
File diff suppressed because it is too large Load Diff
@@ -7,9 +7,9 @@ import process from 'node:process'
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-fixtures.ts` here, not under `__tests__/`.
// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-test-rig.ts` / `*-fixtures.ts` here, not under `__tests__/`.
const TEST_SUPPORT_FILE_PATTERN =
/[.-](?:test-harness|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/
/[.-](?:test-harness|test-rig|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate'])
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
@@ -50,6 +50,7 @@ describe('localization coverage file skipping', () => {
it('skips test-only modules that sit beside their spec', () => {
expect(skipped('src/renderer/src/components/browser-pane/stream-test-harness.ts')).toBe(true)
expect(skipped('src/renderer/src/runtime/browser-tab-creation-test-rig.ts')).toBe(true)
expect(skipped('src/renderer/src/hooks/ipc-events-test-fixtures.ts')).toBe(true)
expect(skipped('src/renderer/src/lib/session-test-state.ts')).toBe(true)
expect(skipped('src/renderer/src/store/slices/routing-fixture.ts')).toBe(true)
@@ -1,4 +1,5 @@
import { readFile } from 'node:fs/promises'
import { readRouteSnapshot } from './mobile-web-app-route-snapshot.mjs'
import { realpathSync } from 'node:fs'
import { basename, extname, join, resolve } from 'node:path'
import { createRequire } from 'node:module'
@@ -466,7 +467,10 @@ const isScriptOutput = (path) => path.endsWith('.js')
* wrap every route, and their imports are part of the page as surely as the route module's.
*/
export async function mobileWebAppRouteClosure(routeModule) {
return await mobileWebAppModuleClosure(['app/_layout', 'app/h/_layout', routeModule])
return (
readRouteSnapshot(routeModule) ??
(await mobileWebAppModuleClosure(['app/_layout', 'app/h/_layout', routeModule]))
)
}
/**
@@ -1,8 +1,13 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
withScratch,
readAppBundle,
readWrittenBundle,
copyWrittenBundle
} from './mobile-web-app-bundle-test-fixture.mjs'
import {
MOBILE_WEB_APP_NATIVE_PARITY_STYLE,
MOBILE_WEB_APP_ROOT_RESET,
@@ -65,15 +70,6 @@ function allScriptSource({ script, chunks }) {
return [script, ...chunks.map((chunk) => chunk.bytes)].map((bytes) => bytes.toString('utf8'))
}
async function withScratch(run) {
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-test-'))
try {
return await run(scratch)
} finally {
await rm(scratch, { recursive: true, force: true })
}
}
describe('the CRLF pin', () => {
it('exempts the same extensions in .gitattributes as the CRLF scan skips', async () => {
const attributes = await readFile(join(projectDir, '.gitattributes'), 'utf8')
@@ -91,8 +87,36 @@ describe('the CRLF pin', () => {
})
describeBundling('the app bundle', () => {
it('isolates read-only fixture consumers from mutations in another assertion', async () => {
const first = await readAppBundle()
const original = first.script[0]
first.script[0] ^= 255
first.chunks.length = 0
first.routeKeys.length = 0
const next = await readAppBundle()
expect(next.script[0]).toBe(original)
expect(next.chunks.length).toBeGreaterThan(0)
expect(next.routeKeys.length).toBeGreaterThan(0)
const written = await readWrittenBundle()
written.manifest.assets.length = 0
expect((await readWrittenBundle()).manifest.assets.length).toBeGreaterThan(0)
const originalByte = written.files[0].bytes[0]
written.files[0].bytes[0] ^= 255
expect((await readWrittenBundle()).files[0].bytes[0]).toBe(originalByte)
await withScratch(async (scratch) => {
const firstDir = join(scratch, 'first')
await copyWrittenBundle(firstDir)
await writeFile(join(firstDir, 'manifest.json'), 'corrupted')
const secondDir = join(scratch, 'second')
const second = await copyWrittenBundle(secondDir)
for (const { file, bytes } of second.files) {
expect((await readFile(join(secondDir, file))).equals(bytes), file).toBe(true)
}
})
}, 120_000)
it('resolves react-native to react-native-web and leaves no require.context', async () => {
const sources = allScriptSource(await bundleMobileWebApp())
const sources = allScriptSource(await readAppBundle())
for (const source of sources) {
expect(source).not.toContain('require.context')
}
@@ -101,7 +125,7 @@ describeBundling('the app bundle', () => {
}, 120_000)
it('cuts the routes into chunks the entry does not load', async () => {
const { script, chunks, entryStaticBytes } = await bundleMobileWebApp()
const { script, chunks, entryStaticBytes } = await readAppBundle()
expect(chunks.length).toBeGreaterThan(1)
// The entry's own bytes plus the chunks it imports statically, which is what the browser
// parses before any route paints. Every route chunk is outside it.
@@ -112,7 +136,7 @@ describeBundling('the app bundle', () => {
}, 120_000)
it('names the chunk each route lands in', async () => {
const { chunks, routeChunks, routeKeys } = await bundleMobileWebApp()
const { chunks, routeChunks, routeKeys } = await readAppBundle()
expect(Object.keys(routeChunks).sort()).toEqual([...routeKeys].sort())
const emitted = new Set(chunks.map((chunk) => chunk.name))
for (const [key, name] of Object.entries(routeChunks)) {
@@ -202,7 +226,7 @@ describeBundling('the app bundle', () => {
)
it('bundles every route module', async () => {
const { routeKeys } = await bundleMobileWebApp()
const { routeKeys } = await readAppBundle()
expect(routeKeys).toEqual(await collectMobileWebAppRouteKeys(appDir))
}, 120_000)
@@ -274,7 +298,7 @@ describeBundling('the app bundle', () => {
}, 240_000)
it("names an output the same way the manifest's own asset hash does", async () => {
const { script, chunks } = await bundleMobileWebApp()
const { script, chunks } = await readAppBundle()
// The name is embedded in the importer, so it cannot be recomputed later; this is what says
// the name inside the bytes and the manifest's sha256 of those bytes are the same string.
expect(hashedAsset(script, 'js').path).toBe(`assets/${sha256Hex(script)}.js`)
@@ -330,7 +354,7 @@ describeBundling('the app bundle', () => {
// once per call. The file explorer calls triggerSelection on every row tap, and C1.9 already
// traced a swallowed long press on the worktree list to that stray click. `haptics.web.ts` is
// what keeps the whole shim out of the bundle, so this reads the bytes rather than the import.
for (const source of allScriptSource(await bundleMobileWebApp())) {
for (const source of allScriptSource(await readAppBundle())) {
// The shim's own fingerprint, not `navigator.vibrate`: react-native-web's Vibration export
// calls that too, and it touches no DOM until something invokes it.
expect(source).not.toContain('ariaHidden')
@@ -340,7 +364,7 @@ describeBundling('the app bundle', () => {
}, 120_000)
it("ships react-native-web's hairline at one device pixel, whichever of its builds resolves", async () => {
const sources = allScriptSource(await bundleMobileWebApp())
const sources = allScriptSource(await readAppBundle())
// Minified, so the assignment reads `<name>.hairlineWidth=`; RNW's own value is the literal 1.
const assignments = sources.flatMap(
(source) => source.match(/\.hairlineWidth=[^;]{0,120}/g) ?? []
@@ -354,7 +378,7 @@ describeBundling('the app bundle', () => {
it('embeds no absolute path from this checkout', async () => {
// Every chunk, not only the entry: the route manifest names each route by absolute path, and
// the chunk that import resolves to is where such a path would survive.
for (const source of allScriptSource(await bundleMobileWebApp())) {
for (const source of allScriptSource(await readAppBundle())) {
expect(source).not.toContain(projectDir)
}
}, 120_000)
@@ -370,76 +394,56 @@ describeBundling('the app bundle', () => {
}, 120_000)
it('loads the entry as a module, so its route imports resolve', async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'module-tag')
const { manifest } = await buildMobileWebAppBundle({ outDir })
const html = await readFile(join(outDir, 'index.html'), 'utf8')
// import() in a classic script is a syntax error, so the tag and the format are one fact.
expect(html).toContain('<script type="module" src="/assets/')
const entry = html.match(/src="\/(assets\/[^"]+)"/)?.[1]
expect(manifest.assets.map((asset) => asset.path)).toContain(entry)
})
const { manifest, html } = await readWrittenBundle()
// import() in a classic script is a syntax error, so the tag and the format are one fact.
expect(html).toContain('<script type="module" src="/assets/')
const entry = html.match(/src="\/(assets\/[^"]+)"/)?.[1]
expect(manifest.assets.map((asset) => asset.path)).toContain(entry)
}, 120_000)
it('declares an icon, so no browser asks the shell for one', async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'icon')
const { manifest } = await buildMobileWebAppBundle({ outDir })
const html = await readFile(join(outDir, 'index.html'), 'utf8')
// Undeclared, a browser asks the origin for /favicon.ico on its own, and the shell's asset
// server answers 403 because the path is in no manifest — repeatedly, on the emulator run.
expect(html).toContain('<link rel="icon" href="data:," />')
// And the empty URI rather than an asset: the bundle carries no icon, so a declaration
// naming one would point at a route image whose name changes with its bytes.
expect(manifest.assets.map((asset) => asset.path)).not.toContain('favicon.ico')
})
const { manifest, html } = await readWrittenBundle()
// Undeclared, a browser asks the origin for /favicon.ico on its own, and the shell's asset
// server answers 403 because the path is in no manifest — repeatedly, on the emulator run.
expect(html).toContain('<link rel="icon" href="data:," />')
// And the empty URI rather than an asset: the bundle carries no icon, so a declaration
// naming one would point at a route image whose name changes with its bytes.
expect(manifest.assets.map((asset) => asset.path)).not.toContain('favicon.ico')
}, 120_000)
it('declares no viewport-fit, because the shell owns the safe area', async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'viewport')
await buildMobileWebAppBundle({ outDir })
const html = await readFile(join(outDir, 'index.html'), 'utf8')
// The shell pads the WebView out of the system bars, so the page has nothing to extend
// under; asking to would invite a second pad from every page-side SafeAreaView.
expect(html).toContain(
'<meta name="viewport" content="width=device-width, initial-scale=1" />'
)
expect(html).not.toContain('viewport-fit')
})
const { html } = await readWrittenBundle()
// The shell pads the WebView out of the system bars, so the page has nothing to extend
// under; asking to would invite a second pad from every page-side SafeAreaView.
expect(html).toContain('<meta name="viewport" content="width=device-width, initial-scale=1" />')
expect(html).not.toContain('viewport-fit')
}, 120_000)
it('carries the root reset, so the mounted tree has a height to be 1 of', async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'root-reset')
await buildMobileWebAppBundle({ outDir })
const html = await readFile(join(outDir, 'index.html'), 'utf8')
expect(html).toContain(MOBILE_WEB_APP_ROOT_RESET)
expect(html).toContain(MOBILE_WEB_APP_NATIVE_PARITY_STYLE)
// Literals rather than substrings taken off the constant, which would read it back against
// itself and follow any rule dropped from it. Every rule, because the chain is only as
// definite as its weakest link: a height on #root alone resolves against a body that has
// none, and percent of auto is auto. Named one by one so a failure says which rule went.
for (const rule of [
'html,body{height:100%}',
'body{overflow:hidden}',
'#root{display:flex;height:100%;flex:1}'
]) {
expect(MOBILE_WEB_APP_ROOT_RESET, rule).toContain(rule)
}
// The id travels with the rules: it is what marks this block as the template's reset rather
// than something the page grew its own copy of.
expect(MOBILE_WEB_APP_ROOT_RESET).toContain('<style id="expo-reset">')
// In the document itself, not a linked asset: the CSP that allows it is the one already
// relaxed for react-native-web's runtime sheet.
expect(html).not.toContain('<link rel="stylesheet"')
})
const { html } = await readWrittenBundle()
expect(html).toContain(MOBILE_WEB_APP_ROOT_RESET)
expect(html).toContain(MOBILE_WEB_APP_NATIVE_PARITY_STYLE)
// Literals rather than substrings taken off the constant, which would read it back against
// itself and follow any rule dropped from it. Every rule, because the chain is only as
// definite as its weakest link: a height on #root alone resolves against a body that has
// none, and percent of auto is auto. Named one by one so a failure says which rule went.
for (const rule of [
'html,body{height:100%}',
'body{overflow:hidden}',
'#root{display:flex;height:100%;flex:1}'
]) {
expect(MOBILE_WEB_APP_ROOT_RESET, rule).toContain(rule)
}
// The id travels with the rules: it is what marks this block as the template's reset rather
// than something the page grew its own copy of.
expect(MOBILE_WEB_APP_ROOT_RESET).toContain('<style id="expo-reset">')
// In the document itself, not a linked asset: the CSP that allows it is the one already
// relaxed for react-native-web's runtime sheet.
expect(html).not.toContain('<link rel="stylesheet"')
}, 120_000)
it('writes the manifest shape the packaging contract reads', async () => {
const { manifest } = await withScratch((scratch) =>
buildMobileWebAppBundle({ outDir: join(scratch, 'c') })
)
const { manifest } = await readWrittenBundle()
expect(manifest.schemaVersion).toBe(1)
expect(manifest.entrypoint).toBe('index.html')
expect(manifest.assets.map((asset) => asset.path)).toContain('index.html')
@@ -457,9 +461,8 @@ describe('the Phase C budget', () => {
itBundling(
'is not already exceeded by the current bundle',
async () => {
const { manifest, chunkCount, entryStaticBytes, imageCount, routeKeys } = await withScratch(
(scratch) => buildMobileWebAppBundle({ outDir: join(scratch, 'd') })
)
const { manifest, chunkCount, entryStaticBytes, imageCount, routeKeys } =
await readWrittenBundle()
expect(manifest.totalBytes).toBeLessThanOrEqual(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)
expect(manifest.assets.length).toBeLessThanOrEqual(
mobileWebAppBundleMaxAssets(routeKeys.length, imageCount)
@@ -596,9 +599,7 @@ describe('the Phase C budget', () => {
itBundling(
'keeps the derived ceiling under the map the phone actually holds',
async () => {
const { manifest, routeKeys, imageCount } = await withScratch((scratch) =>
buildMobileWebAppBundle({ outDir: join(scratch, 'e') })
)
const { manifest, routeKeys, imageCount } = await readWrittenBundle()
const ceiling = mobileWebAppBundleMaxAssets(routeKeys.length, imageCount)
expect(manifest.assets.length).toBeLessThanOrEqual(ceiling)
// The native side refuses a manifest past this, so the derived ceiling has to stay inside it.
@@ -630,7 +631,7 @@ describe('the verifier', () => {
async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'mobile-web')
await buildMobileWebAppBundle({ outDir })
await copyWrittenBundle(outDir)
await expect(verifyMobileWebAppBundle({ bundleDir: outDir })).resolves.toBeDefined()
})
},
@@ -642,7 +643,7 @@ describe('the verifier', () => {
async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'mobile-web')
await buildMobileWebAppBundle({ outDir })
await copyWrittenBundle(outDir)
const manifestPath = join(outDir, 'manifest.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
manifest.buildId = 'f'.repeat(64)
@@ -660,7 +661,7 @@ describe('the verifier', () => {
async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'mobile-web')
const { manifest } = await buildMobileWebAppBundle({ outDir })
const { manifest } = await copyWrittenBundle(outDir)
// What a stale out/ actually looks like: every digest agrees with its bytes and the
// buildId derives from the asset list, but the source has moved on. Only the two fresh
// builds the verifier runs can tell, which is the check this covers.
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join, resolve } from 'node:path'
import { orcadBunRuntimeFilename } from '../../src/shared/orcad-artifacts.ts'
import {
ORCAD_BUN_RELEASE_ASSETS,
ORCAD_BUN_VERSION,
orcadBunReleaseUrl
} from '../../src/shared/orcad-bun-runtime.ts'
import { runProcessSync } from './script-child-process.mjs'
import { getZipExtractorCommand } from './zip-extractor-command.mjs'
const root = resolve(import.meta.dirname, '../..')
const cacheRoot = join(root, 'out', '.orcad-bun-runtime', `v${ORCAD_BUN_VERSION}`)
export function currentTarget() {
if (process.platform === 'darwin') {
return `darwin-${process.arch}`
}
if (process.platform === 'win32') {
return `win32-${process.arch}`
}
if (process.platform !== 'linux') {
throw new Error(`Unsupported Bun platform: ${process.platform}`)
}
const glibc = process.report?.getReport()?.header?.glibcVersionRuntime
return `linux-${process.arch}-${glibc ? 'glibc' : 'musl'}`
}
function argument(name) {
const index = process.argv.indexOf(name)
return index === -1 ? null : process.argv[index + 1]
}
async function download(url, destination) {
const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(120_000) })
if (!response.ok) {
await response.body?.cancel()
throw new Error(`Bun download failed: ${response.status} ${response.statusText}`)
}
writeFileSync(destination, new Uint8Array(await response.arrayBuffer()))
}
function sha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
export function bunExecutableName(target) {
return target.startsWith('win32-') ? 'bun.exe' : 'bun'
}
export function findBunExecutable(rootDir, target) {
const expected = bunExecutableName(target)
const entries = readdirSync(rootDir, { recursive: true, withFileTypes: true })
const entry = entries.find((candidate) => candidate.isFile() && candidate.name === expected)
if (!entry) {
throw new Error(`Downloaded archive contained no ${expected}`)
}
return join(entry.parentPath, entry.name)
}
function verifyRuntime(path) {
const result = runProcessSync({ program: path, args: ['--version'] })
if (result.code !== 0 || result.stdout.trim() !== ORCAD_BUN_VERSION) {
throw new Error(
`Expected Bun ${ORCAD_BUN_VERSION} at ${path}, got ${result.stdout.trim() || result.stderr.trim()}`
)
}
}
async function materializeRuntime(target, outputPath) {
const asset = ORCAD_BUN_RELEASE_ASSETS[target]
if (!asset) {
throw new Error(`Unsupported Bun target: ${target}`)
}
const cached = join(cacheRoot, target, orcadBunRuntimeFilename(target))
if (existsSync(cached) && sha256(cached) !== asset.executableSha256) {
rmSync(cached, { force: true })
}
if (!existsSync(cached)) {
const temporary = mkdtempSync(join(tmpdir(), 'orca-bun-download-'))
try {
const zipPath = join(temporary, basename(asset.filename))
await download(orcadBunReleaseUrl(asset), zipPath)
const actual = sha256(zipPath)
if (actual !== asset.sha256) {
throw new Error(`Bun checksum mismatch for ${asset.filename}: ${actual}`)
}
const extracted = join(temporary, 'extracted')
mkdirSync(extracted)
// Node 24.16 can leave extract-zip's stream promise unsettled with no active handles.
const command = getZipExtractorCommand(zipPath, extracted)
const result = runProcessSync({
program: command.file,
args: command.args,
timeoutMs: 120_000
})
if (result.code !== 0) {
throw new Error(
`Bun archive extraction failed with exit ${result.code}: ${result.stderr || result.stdout}`
)
}
mkdirSync(join(cacheRoot, target), { recursive: true })
copyFileSync(findBunExecutable(extracted, target), cached)
if (!target.startsWith('win32-')) {
chmodSync(cached, 0o755)
}
} finally {
rmSync(temporary, { recursive: true, force: true })
}
}
const executableHash = sha256(cached)
if (executableHash !== asset.executableSha256) {
throw new Error(`Bun executable checksum mismatch for ${target}: ${executableHash}`)
}
if (target === currentTarget()) {
verifyRuntime(cached)
}
mkdirSync(resolve(outputPath, '..'), { recursive: true })
if (resolve(cached) !== resolve(outputPath)) {
copyFileSync(cached, outputPath)
}
if (!target.startsWith('win32-')) {
chmodSync(outputPath, 0o755)
}
}
async function main() {
const target = argument('--target') ?? currentTarget()
const outputDir = argument('--out-dir')
const cachedRuntimePath = join(cacheRoot, target, orcadBunRuntimeFilename(target))
const runtimePath =
process.argv.includes('--runtime-only') && outputDir
? join(resolve(outputDir), orcadBunRuntimeFilename(target))
: cachedRuntimePath
await materializeRuntime(target, runtimePath)
if (process.argv.includes('--runtime-only')) {
process.stdout.write(`${runtimePath}\n`)
return
}
const result = runProcessSync({
program: process.execPath,
args: [join(root, 'config/scripts/build-orcad.mjs')],
cwd: root,
env: {
...process.env,
ORCAD_BUILD_TARGET: target,
ORCAD_BUILD_TARGET_IS_CURRENT: target === currentTarget() ? '1' : '0',
ORCAD_BUN_RUNTIME_PATH: runtimePath,
...(outputDir ? { ORCAD_OUT_DIR: resolve(outputDir) } : {})
},
stdio: 'inherit',
timeoutMs: null
})
if (result.code !== 0) {
process.exit(result.code ?? 1)
}
}
if (process.argv[1]?.endsWith('build-orcad-bun.mjs')) {
await main()
}
+39
View File
@@ -0,0 +1,39 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import { bunExecutableName, findBunExecutable } from './build-orcad-bun.mjs'
const temporaryDirs = []
afterEach(() => {
for (const dir of temporaryDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
function archiveTree(filename) {
const root = mkdtempSync(join(tmpdir(), 'orcad-bun-archive-'))
temporaryDirs.push(root)
const nested = join(root, 'bun-release')
mkdirSync(nested)
writeFileSync(join(nested, filename), '')
return root
}
describe('orcad Bun archive extraction', () => {
it('selects bun.exe for a Windows target on a non-Windows builder', () => {
const root = archiveTree('bun.exe')
expect(findBunExecutable(root, 'win32-x64')).toBe(join(root, 'bun-release', 'bun.exe'))
})
it('selects bun for a POSIX target', () => {
const root = archiveTree('bun')
expect(findBunExecutable(root, 'linux-x64-glibc')).toBe(join(root, 'bun-release', 'bun'))
})
it('derives executable names from the target rather than the builder host', () => {
expect(bunExecutableName('win32-arm64')).toBe('bun.exe')
expect(bunExecutableName('darwin-arm64')).toBe('bun')
})
})
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import {
ORCAD_BUILD_TARGET_FILENAME,
ORCAD_TEMPLATE_MANIFEST_FILENAME,
ORCAD_TEMPLATE_TARGETS_DIR,
ORCAD_RIPGREP_ARTIFACTS,
orcadTemplateCommonFilenames
} from '../../src/shared/orcad-artifacts.ts'
import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts'
import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/orcad-bun-runtime.ts'
import { runProcessSync } from './script-child-process.mjs'
import { materializeWatcherPackage } from './orcad-watcher-package.mjs'
import { verifyPackagedOrcadTemplate } from './verify-packaged-orcad-template.cjs'
const root = resolve(import.meta.dirname, '../..')
const outputDir = join(root, 'out', 'orcad-template')
const buildDir = join(root, 'out', '.orcad-template-build')
const commonArtifacts = orcadTemplateCommonFilenames()
function copy(source, destination, executable = false) {
mkdirSync(dirname(destination), { recursive: true })
copyFileSync(source, destination)
if (executable && process.platform !== 'win32') {
chmodSync(destination, 0o755)
}
}
function sha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
function targetPlatform(target) {
return target.split('-')[0]
}
function targetArch(target) {
return target.split('-')[1]
}
function buildCommonArtifacts() {
rmSync(buildDir, { recursive: true, force: true })
const result = runProcessSync({
program: process.execPath,
args: [join(root, 'config/scripts/build-orcad-bun.mjs'), '--out-dir', buildDir],
cwd: root,
stdio: 'inherit',
timeoutMs: null
})
if (result.code !== 0) {
throw new Error(`Common orcad artifact build failed with exit ${result.code ?? 'unknown'}`)
}
}
async function stageTarget(target) {
const destination = join(outputDir, ORCAD_TEMPLATE_TARGETS_DIR, target)
const targetIdentity = join(destination, ORCAD_BUILD_TARGET_FILENAME)
mkdirSync(destination, { recursive: true })
writeFileSync(targetIdentity, `${target}\n`)
const watcherSource = await materializeWatcherPackage(target)
const watcherDestination = join(destination, 'watcher.node')
copy(watcherSource, watcherDestination)
const browserName = orcadAgentBrowserNativeName(
targetPlatform(target),
targetArch(target),
target.endsWith('-musl') ? 'musl' : 'glibc'
)
const browserSource = join(root, 'node_modules', 'agent-browser', 'bin', browserName)
const browserDestination = join(destination, browserName)
if (existsSync(browserSource)) {
copy(browserSource, browserDestination, true)
}
return {
targetSha256: sha256(targetIdentity),
watcherSha256: sha256(watcherDestination),
...(existsSync(browserDestination)
? { browserName, browserSha256: sha256(browserDestination) }
: {})
}
}
async function main() {
buildCommonArtifacts()
rmSync(outputDir, { recursive: true, force: true })
mkdirSync(outputDir, { recursive: true })
for (const filename of commonArtifacts) {
copy(
join(buildDir, filename),
join(outputDir, filename),
ORCAD_RIPGREP_ARTIFACTS.some((artifact) => artifact === filename && artifact.endsWith('/rg'))
)
}
const targets = Object.fromEntries(
await Promise.all(
ORCAD_TEMPLATE_TARGETS.map(async (target) => [target, await stageTarget(target)])
)
)
const commonSha256 = Object.fromEntries(
commonArtifacts.map((filename) => [filename, sha256(join(outputDir, filename))])
)
writeFileSync(
join(outputDir, ORCAD_TEMPLATE_MANIFEST_FILENAME),
`${JSON.stringify({ schemaVersion: 2, commonSha256, targets }, null, 2)}\n`
)
verifyPackagedOrcadTemplate(join(root, 'out'))
rmSync(buildDir, { recursive: true, force: true })
process.stdout.write(`[build-orcad-template] ok — ${ORCAD_TEMPLATE_TARGETS.length} targets\n`)
}
await main()
+149 -87
View File
@@ -1,85 +1,137 @@
#!/usr/bin/env node
/**
* Bundle `orcad` — the Orca runtime served from plain Node, no Electron.
*
* Variant B (see docs/design/node-only-runtime-backend.html): the browser-pane and
* speech clusters are excluded. That is not a size optimisation — those modules are
* the only ones that statically import `node:sqlite`, so dropping them is what keeps
* the host Node floor at 18 instead of 22.5+.
*/
// Ship Bun with orcad; keep module loading compatible with legacy Node launchers.
import { fork, spawnSync } from 'node:child_process'
import { build } from 'esbuild'
import {
buildOrcadEntry,
externalNativeAddons,
ORCAD_EXTERNAL_MODULES,
ORCAD_CHILD_ENTRY_POINTS
} from './orcad-entry-build.mjs'
import { createRequire } from 'node:module'
import {
chmodSync,
copyFileSync,
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync
} from 'node:fs'
import { arch, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import process from 'node:process'
import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs'
import { materializeWatcherPackage } from './orcad-watcher-package.mjs'
import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs'
import {
ORCAD_BUILD_TARGET_FILENAME,
ORCAD_EMOJI_SHORTCODE_DATASET,
orcadBunRuntimeFilename,
ORCAD_PARCEL_WATCHER_ENTRY,
ORCAD_PARCEL_WATCHER_NATIVE,
ORCAD_VERSION_FILENAME,
ORCAD_RIPGREP_ARTIFACTS
} from '../../src/shared/orcad-artifacts.ts'
import { computeOrcadFullVersion } from './orcad-artifact-version.mjs'
import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts'
import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = join(ROOT, 'out', 'orcad')
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
const OUT_DIR = process.env.ORCAD_OUT_DIR
? resolve(process.env.ORCAD_OUT_DIR)
: join(ROOT, 'out', 'orcad')
// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher
// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath`
// looks for it in the app root. A deployment has no desktop out/main to fall back to.
const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts')
const WATCHER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.watcher)
const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process,
// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every
// orcad restart would SIGKILL every running terminal.
const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts')
const DAEMON_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.daemon)
const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js')
const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
const PTY_GATE_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.ptyGate)
const PTY_GATE_OUT_FILE = join(OUT_DIR, 'windows-bun-pty-gate-entry.js')
const OUT_FILE = join(OUT_DIR, 'orcad.js')
const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET
if (!BUILD_TARGET) {
throw new Error('ORCAD_BUILD_TARGET is required; run `pnpm build:orcad`')
}
const [targetPlatform, targetArch] = BUILD_TARGET.split('-')
const targetIsWindows = targetPlatform === 'win32'
const targetIsCurrent = process.env.ORCAD_BUILD_TARGET_IS_CURRENT === '1'
const AGENT_BROWSER_NAME = orcadAgentBrowserNativeName(
targetPlatform,
targetArch,
BUILD_TARGET.endsWith('-musl') ? 'musl' : 'glibc'
)
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME)
const WATCHER_MODULE_DIR = join(OUT_DIR, 'node_modules', '@parcel', 'watcher')
// Native addons must exist on the host; they cannot be bundled.
// `electron` is external so a residual import fails loudly at require() time rather
// than silently bundling the npm package's installer shim, which is what happened the
// first time and made the bundle look clean while it was not.
// Why only these: measured, not guessed. `node-pty` is a hard `require.resolve` — orcad
// exits at startup without it. `@parcel/watcher` is a guarded dynamic import, so the
// server boots without it but every watch install fails. `fsevents` is macOS-only and
// optional upstream. better-sqlite3 / keytar / cpu-features were externalized here
// defensively and appear nowhere in the graph; listing them implied a shipping burden
// that does not exist.
const EXTERNAL = ['electron', 'node-pty', '@parcel/watcher', 'fsevents']
/** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */
const jsoncParserEsm = {
name: 'jsonc-parser-esm',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /^jsonc-parser$/ }, () => ({
path: join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
}))
}
}
/** Why: optional native deps reference prebuilt .node files that may not exist here. */
const externalNativeAddons = {
name: 'external-native-addons',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }))
}
async function stageParcelWatcher(target) {
const requireFromWatcher = createRequire(
join(ROOT, 'node_modules', '@parcel', 'watcher', 'index.js')
)
const nativeSource = await materializeWatcherPackage(target)
const wrapperSource = requireFromWatcher.resolve('@parcel/watcher/wrapper.js')
mkdirSync(WATCHER_MODULE_DIR, { recursive: true })
await build({
stdin: {
contents:
`const {createWrapper}=require(${JSON.stringify(wrapperSource)});` +
`module.exports=createWrapper(require('./watcher.node'));`,
resolveDir: ROOT,
sourcefile: 'orcad-parcel-watcher-entry.js'
},
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(OUT_DIR, ORCAD_PARCEL_WATCHER_ENTRY),
external: ['./watcher.node'],
minify: true,
sourcemap: false,
logLevel: 'error'
})
copyFileSync(nativeSource, join(OUT_DIR, ORCAD_PARCEL_WATCHER_NATIVE))
}
rmSync(OUT_DIR, { recursive: true, force: true })
mkdirSync(OUT_DIR, { recursive: true })
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
if (process.platform !== 'win32') {
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
const bunRuntimeSource = process.env.ORCAD_BUN_RUNTIME_PATH
if (!bunRuntimeSource) {
throw new Error('ORCAD_BUN_RUNTIME_PATH is required; run `pnpm build:orcad`')
}
if (targetIsCurrent) {
const version = spawnSync(bunRuntimeSource, ['--version'], { encoding: 'utf8' })
if (version.status !== 0 || version.stdout.trim() !== ORCAD_BUN_VERSION) {
throw new Error(
`ORCAD_BUN_RUNTIME_PATH must be Bun ${ORCAD_BUN_VERSION}; got ${version.stdout.trim() || version.stderr.trim()}`
)
}
}
const bunRuntimeOutput = join(OUT_DIR, orcadBunRuntimeFilename(BUILD_TARGET))
copyFileSync(bunRuntimeSource, bunRuntimeOutput)
writeFileSync(join(OUT_DIR, ORCAD_BUILD_TARGET_FILENAME), `${BUILD_TARGET}\n`)
if (!targetIsWindows) {
chmodSync(bunRuntimeOutput, 0o755)
}
await stageParcelWatcher(BUILD_TARGET)
stageOrcadWindowsProcessTree(ROOT, OUT_DIR, BUILD_TARGET)
const emojiDatasetOutput = join(OUT_DIR, ORCAD_EMOJI_SHORTCODE_DATASET)
mkdirSync(dirname(emojiDatasetOutput), { recursive: true })
copyFileSync(
createRequire(import.meta.url).resolve('emojibase-data/en/shortcodes/emojibase.json'),
emojiDatasetOutput
)
if (existsSync(AGENT_BROWSER_SOURCE)) {
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
if (!targetIsWindows) {
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
}
}
// Why every platform: an SSH deployment can target a different host than the build machine.
for (const artifact of ORCAD_RIPGREP_ARTIFACTS) {
@@ -110,36 +162,31 @@ function buildForkedChild(entryPoint, outfile) {
target: 'node18',
format: 'cjs',
outfile,
external: EXTERNAL,
external: ORCAD_EXTERNAL_MODULES,
plugins: [externalNativeAddons],
metafile: true,
minify: true,
sourcemap: false,
define: { 'process.env.NODE_ENV': '"production"' },
define: {
'process.env.NODE_ENV': '"production"'
},
logLevel: 'error'
})
}
const childResults = await Promise.all([
buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE)
buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE),
buildForkedChild(PTY_GATE_ENTRY, PTY_GATE_OUT_FILE),
...['writer', 'backup'].map((role) =>
buildForkedChild(
join(ROOT, ORCAD_CHILD_ENTRY_POINTS[role]),
join(OUT_DIR, `profile-state-${role}-worker-entry.js`)
)
)
])
const result = await build({
entryPoints: [ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: OUT_FILE,
external: EXTERNAL,
plugins: [jsoncParserEsm, externalNativeAddons],
metafile: true,
minify: true,
sourcemap: false,
define: { 'process.env.NODE_ENV': '"production"' },
logLevel: 'error'
})
const result = await buildOrcadEntry(OUT_FILE)
const output = Object.values(result.metafile.outputs).find(
(o) => o.entryPoint === 'src/main/orcad/main.ts'
@@ -147,8 +194,8 @@ const output = Object.values(result.metafile.outputs).find(
// Why check `original` and not just `path`: when electron is bundleable, esbuild
// rewrites `path` to the resolved file under node_modules and the naive check passes
// while the package is very much in the bundle.
// Why both metafiles: the forked children ship in the same deployment and run under the
// same plain Node. A daemon-entry that reached electron would fail at fork time, on the
// Why both metafiles: the forked children ship in the same deployment and runtime. A
// daemon-entry that reached electron would fail at fork time, on the
// path whose whole point is that terminals survive.
function collectImporters(metafiles, matches) {
const importers = new Set()
@@ -196,7 +243,7 @@ if (graphErrors.length > 0) {
process.exitCode = 1
} else {
// Why smoke-load and not just read the metafile: the import scan proves no module
// *names* electron, but a graph can still fail to resolve under plain Node — a
// *names* electron, but the rollback graph can still fail to resolve under plain Node — a
// dynamic require, a missing native, a top-level throw. The plain-node-entry-guard
// smoke-loads its entries for exactly this reason, and orcad cannot join that guard
// because it is an esbuild artifact rather than a rollup input.
@@ -211,7 +258,7 @@ if (graphErrors.length > 0) {
const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}`
if (smoke.error || smoke.signal || smoke.status !== 0) {
console.error(
`[build-orcad] the bundle did not load under plain Node.\n` +
`[build-orcad] the bundle lost Node load compatibility.\n` +
`Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` +
`signal=${smoke.signal ?? 'none'} ` +
`error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}`
@@ -240,47 +287,57 @@ if (graphErrors.length > 0) {
const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}`
if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) {
console.error(
`[build-orcad] the daemon child did not load under plain Node.\n` +
`[build-orcad] the daemon child lost Node load compatibility.\n` +
`Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` +
`signal=${daemonSmoke.signal ?? 'none'} ` +
`error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}`
)
process.exitCode = 1
}
const watcherFailure = await smokeLoadWatcherChild()
const watcherFailure = targetIsCurrent ? await smokeLoadWatcherChild(bunRuntimeOutput) : null
if (watcherFailure) {
console.error(
`[build-orcad] the watcher child did not run under plain Node.\n${watcherFailure}`
`[build-orcad] the watcher child failed under the bundled runtime.\n${watcherFailure}`
)
process.exitCode = 1
}
}
try {
await smokeProfileStateWorkers(OUT_DIR)
if (targetIsCurrent) {
await smokeProfileStateWorkers(OUT_DIR, { runtimePath: bunRuntimeOutput })
}
} catch (error) {
console.error('[build-orcad] profile state worker check failed:', error)
process.exitCode = 1
}
// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on
// this string, so two different builds carrying one version would share a directory — and an
// already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale
// bytes while reporting the new version.
if (process.exitCode !== 1) {
const fullVersion = computeOrcadFullVersion(OUT_DIR)
const fullVersion = computeOrcadFullVersion(OUT_DIR, {
target: BUILD_TARGET,
agentBrowserFilename: AGENT_BROWSER_NAME
})
writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion)
console.log(
`[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
`[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports, Bun ${ORCAD_BUN_VERSION} included.`
)
}
/**
* Fork the shipped watcher child and drive one message through it.
*
* Why a real fork and not existsSync: the file being present says nothing about whether
* its graph resolves under plain Node, and this child is only ever reached through
* `fork()` at runtime — a broken one degrades silently to in-process watching.
* `subscribe-started` is acked before the native module is touched, so this passes on a
* build machine with no compiled @parcel/watcher.
*/
async function smokeLoadWatcherChild() {
// Verify the shipped native watcher actually subscribes under the bundled runtime.
async function smokeLoadWatcherChild(runtimePath) {
const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-'))
const child = fork(WATCHER_OUT_FILE, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
const child = fork(WATCHER_OUT_FILE, [], {
execPath: runtimePath,
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
windowsHide: true
})
let stderr = ''
let subscribed = false
child.stderr?.on('data', (chunk) => {
stderr += String(chunk)
})
@@ -288,14 +345,15 @@ async function smokeLoadWatcherChild() {
return await new Promise((resolve) => {
const timer = setTimeout(() => {
child.kill('SIGKILL')
resolve(`No 'subscribe-started' ack within 30s.\n${stderr.slice(0, 2000)}`)
resolve(`Watcher did not complete its subscription within 30s.\n${stderr.slice(0, 2000)}`)
}, 30_000)
const settle = (failure) => {
clearTimeout(timer)
resolve(failure)
}
child.on('message', (message) => {
if (message?.op === 'subscribe-started') {
subscribed ||= message?.op === 'subscribed'
if (message?.op === 'subscribed' || message?.op === 'subscribe-failed') {
child.disconnect()
}
})
@@ -303,7 +361,11 @@ async function smokeLoadWatcherChild() {
// Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code
// or a signal here is a load failure rather than a clean teardown.
child.on('exit', (code, signal) =>
settle(code === 0 ? null : `exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`)
settle(
code === 0 && subscribed
? null
: `subscribed=${subscribed} exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`
)
)
child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} })
})
+60 -23
View File
@@ -24,6 +24,7 @@ import {
RELAY_BUILD_PLATFORMS,
RELAY_VERSION_FILENAME,
RELAY_WINDOWS_PROCESS_TREE_FILENAME,
RELAY_OPENCODE_SQLITE_READER_FILENAME,
relayOptionalArtifactFilenames,
isWindowsRelayPlatform,
relayArtifactFilenames
@@ -35,6 +36,13 @@ const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
const OPENCODE_SQLITE_READER_ENTRY = join(
ROOT,
'src',
'main',
'ai-vault',
'session-scanner-opencode-sqlite-process-entry.ts'
)
const WSL_TRANSCRIPT_FS_PROCESS_ENTRY = join(
ROOT,
'src',
@@ -110,13 +118,7 @@ const OUT_ROOT = process.env.ORCA_RELAY_OUT_ROOT ?? join(ROOT, 'out', 'relay')
const RELAY_VERSION = '0.1.0'
for (const platform of RELAY_BUILD_PLATFORMS) {
const outDir = join(OUT_ROOT, platform)
// Why: a stale companion left by an earlier build would otherwise satisfy the
// manifest check and be hashed into .version, shipping mixed-generation bytes.
rmSync(outDir, { recursive: true, force: true })
mkdirSync(outDir, { recursive: true })
async function buildRelayBundles(outDir) {
await build({
entryPoints: [RELAY_ENTRY],
bundle: true,
@@ -134,22 +136,6 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
}
})
if (isWindowsRelayPlatform(platform)) {
copyFileSync(
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
)
copyFileSync(
NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE,
join(outDir, NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME)
)
}
copyFileSync(
NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE,
join(outDir, NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)
)
stageWindowsProcessTreeAddon(platform, outDir)
await build({
entryPoints: [WATCHER_ENTRY],
bundle: true,
@@ -180,6 +166,19 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
}
})
await build({
entryPoints: [OPENCODE_SQLITE_READER_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, RELAY_OPENCODE_SQLITE_READER_FILENAME),
external: ['electron', 'bun:sqlite'],
sourcemap: false,
minify: true,
define: { 'process.env.NODE_ENV': '"production"' }
})
// Why beside the service: the spawn resolves this child next to its own
// bundle, and a relay host has no desktop out/main to fall back to.
await build({
@@ -213,6 +212,44 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
'process.env.NODE_ENV': '"production"'
}
})
}
let bundledSourceDir
let bundledFilenames = []
for (const platform of RELAY_BUILD_PLATFORMS) {
const outDir = join(OUT_ROOT, platform)
// Why: a stale companion left by an earlier build would otherwise satisfy the
// manifest check and be hashed into .version, shipping mixed-generation bytes.
rmSync(outDir, { recursive: true, force: true })
mkdirSync(outDir, { recursive: true })
// The JavaScript selects its host at runtime; only native addons and patches vary.
if (bundledSourceDir) {
for (const filename of bundledFilenames) {
copyFileSync(join(bundledSourceDir, filename), join(outDir, filename))
}
} else {
await buildRelayBundles(outDir)
bundledSourceDir = outDir
bundledFilenames = readdirSync(outDir)
}
if (isWindowsRelayPlatform(platform)) {
copyFileSync(
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
)
copyFileSync(
NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE,
join(outDir, NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME)
)
}
copyFileSync(
NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE,
join(outDir, NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)
)
stageWindowsProcessTreeAddon(platform, outDir)
// Why: include a content hash so the deploy check detects code changes even
// when RELAY_VERSION hasn't been bumped. Hashing the whole manifest means a
+59 -19
View File
@@ -1,24 +1,41 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, statSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
export function shouldReuseCompiledWindowsCliLauncher(
outputPath,
sourcePath,
{ reuseCached = false } = {}
) {
if (!existsSync(outputPath)) {
return false
export function windowsCliLauncherFingerprint(inputPaths, version) {
const hash = createHash('sha256').update(version)
for (const inputPath of inputPaths) {
hash.update('\0').update(readFileSync(inputPath))
}
// Why reuseCached: Actions cache keys already hash the C# source, but restore
// does not preserve mtimes, so a hit would look stale and recompile anyway.
if (reuseCached) {
return true
return hash.digest('hex')
}
export function shouldReuseCompiledWindowsCliLauncher(outputPath, fingerprint) {
const fingerprintPath = `${outputPath}.sha256`
return (
existsSync(outputPath) &&
existsSync(fingerprintPath) &&
readFileSync(fingerprintPath, 'utf8') === fingerprint
)
}
export function windowsCliLauncherVersionSource(version) {
const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(version)
if (!match || match.slice(1).some((part) => Number(part) > 65534)) {
throw new Error(`Invalid Windows CLI launcher version: ${version}`)
}
return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs
const fileVersion = `${match.slice(1).join('.')}.0`
return [
'using System.Reflection;',
`[assembly: AssemblyVersion("${fileVersion}")]`,
`[assembly: AssemblyFileVersion("${fileVersion}")]`,
`[assembly: AssemblyInformationalVersion("${version}")]`,
''
].join('\n')
}
function defaultOutputPath(projectRoot) {
@@ -53,6 +70,19 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
const repoRoot = resolve(import.meta.dirname, '../..')
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
const manifestPath = join(repoRoot, 'native', 'windows-cli-launcher', 'app.manifest')
const iconPath = join(repoRoot, 'resources', 'build', 'icon.ico')
const { version } = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'))
const versionSource = windowsCliLauncherVersionSource(version)
const fingerprint = windowsCliLauncherFingerprint(
[
sourcePath,
manifestPath,
iconPath,
join(repoRoot, 'config/scripts/build-windows-cli-launcher.mjs')
],
version
)
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
const compilerPath = findFrameworkCompiler(process.env)
@@ -61,17 +91,26 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
}
mkdirSync(dirname(outputPath), { recursive: true })
if (
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
})
) {
if (shouldReuseCompiledWindowsCliLauncher(outputPath, fingerprint)) {
console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
process.exit(0)
}
const versionPath = join(dirname(outputPath), 'OrcaCliLauncher.Version.cs')
writeFileSync(versionPath, versionSource)
rmSync(`${outputPath}.sha256`, { force: true })
const result = spawnSync(
compilerPath,
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
[
'/nologo',
'/target:exe',
'/optimize+',
'/warnaserror+',
`/win32manifest:${manifestPath}`,
`/win32icon:${iconPath}`,
`/out:${outputPath}`,
sourcePath,
versionPath
],
{ cwd: repoRoot, stdio: 'inherit' }
)
@@ -84,4 +123,5 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
writeFileSync(`${outputPath}.sha256`, fingerprint)
}
@@ -5,7 +5,6 @@ import {
mkdtempSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'node:fs'
@@ -13,7 +12,11 @@ import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
import {
shouldReuseCompiledWindowsCliLauncher,
windowsCliLauncherFingerprint,
windowsCliLauncherVersionSource
} from './build-windows-cli-launcher.mjs'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
@@ -40,33 +43,102 @@ function itWindows(name, test) {
}
describe('Windows CLI launcher', () => {
it('reuses a compiled launcher that is at least as new as the C# source', () => {
it('reuses restored builds only while all embedded inputs and the release version match', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
try {
const sourcePath = join(root, 'OrcaCliLauncher.cs')
const outputPath = join(root, '.build', 'orca.exe')
mkdirSync(join(root, '.build'))
writeFileSync(sourcePath, 'source\n')
writeFileSync(outputPath, 'binary\n')
const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
utimesSync(outputPath, later, later)
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
writeFileSync(sourcePath, 'changed\n')
const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
utimesSync(sourcePath, sourceLater, sourceLater)
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
const inputs = ['source.cs', 'app.manifest', 'icon.ico', 'build.mjs'].map((name) => {
const path = join(root, name)
writeFileSync(path, name)
return path
})
const outputPath = join(root, 'orca.exe')
const fingerprint = windowsCliLauncherFingerprint(inputs, '1.4.214')
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, fingerprint)).toBe(false)
writeFileSync(outputPath, 'binary')
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, fingerprint)).toBe(false)
writeFileSync(`${outputPath}.sha256`, fingerprint)
for (const input of inputs) {
utimesSync(input, new Date(), new Date())
}
expect(
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
shouldReuseCompiledWindowsCliLauncher(
outputPath,
windowsCliLauncherFingerprint(inputs, '1.4.214')
)
).toBe(true)
expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
false
)
expect(
shouldReuseCompiledWindowsCliLauncher(
outputPath,
windowsCliLauncherFingerprint(inputs, '1.4.215')
)
).toBe(false)
for (const input of inputs) {
const original = readFileSync(input)
writeFileSync(input, 'changed')
expect(
shouldReuseCompiledWindowsCliLauncher(
outputPath,
windowsCliLauncherFingerprint(inputs, '1.4.214')
)
).toBe(false)
writeFileSync(input, original)
}
} finally {
removeFixtureTree(root)
}
})
it('keeps prerelease identity while emitting a valid Windows numeric version', () => {
const source = windowsCliLauncherVersionSource('1.4.214-daily.202609281300')
expect(source).toContain('AssemblyFileVersion("1.4.214.0")')
expect(source).toContain('AssemblyInformationalVersion("1.4.214-daily.202609281300")')
for (const version of ['1.4.65535', '1.4', '1.4.214"', undefined]) {
expect(() => windowsCliLauncherVersionSource(version)).toThrow('Invalid Windows')
}
})
itWindows(
'embeds publisher, release version, icon and an unelevated application manifest',
() => {
const root = mkdtempSync(join(tmpdir(), 'orca launcher metadata '))
try {
const launcherPath = join(root, 'orca.exe')
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
const inspect = spawnSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
'[Diagnostics.FileVersionInfo]::GetVersionInfo($env:ORCA_TEST_LAUNCHER) | ConvertTo-Json -Compress'
],
{ encoding: 'utf8', env: { ...process.env, ORCA_TEST_LAUNCHER: launcherPath } }
)
expect(inspect.status, inspect.stderr).toBe(0)
const info = JSON.parse(inspect.stdout)
const { version } = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'))
expect(info.CompanyName).toBe('Stably AI')
expect(info.ProductName).toBe('Orca')
expect(info.FileDescription).toBe('Orca CLI Launcher')
expect(info.FileVersion).toBe(`${version.split(/[+-]/)[0]}.0`)
expect(info.ProductVersion).toBe(version)
const binary = readFileSync(launcherPath)
expect(binary.includes(Buffer.from('requestedExecutionLevel level="asInvoker"'))).toBe(true)
const icon = readFileSync(join(projectRoot, 'resources', 'build', 'icon.ico'))
const imageSize = icon.readUInt32LE(14)
const imageOffset = icon.readUInt32LE(18)
expect(binary.includes(icon.subarray(imageOffset, imageOffset + imageSize))).toBe(true)
} finally {
removeFixtureTree(root)
}
}
)
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
@@ -100,17 +100,21 @@ function assertPatchApplied() {
['src/process.cc', 'GetProcessTimes(hProcess, &creationTime'],
['src/process_worker.cc', 'object.Set("creationTimeMs"'],
['src/addon.cc', 'exports.Set("supportedProcessDataFlags"'],
['src/addon.cc', 'exports.Set("getProcessCreationTime"'],
['lib/index.js', '["CreationTime"] = 4'],
['lib/index.js', 'exports.supportedProcessDataFlags'],
['lib/index.js', 'exports.getProcessCreationTime'],
['lib/index.js', 'creationTimeMs,'],
['lib/index.ts', 'CreationTime = 4'],
['lib/index.ts', 'export const supportedProcessDataFlags'],
['lib/index.ts', 'export const getProcessCreationTime'],
['lib/index.ts', 'creationTimeMs,'],
['typings/windows-process-tree.d.ts', 'creationTimeMs?: number'],
// A regex because IProcessInfo declares the same field: only the tree node
// is followed by `children`, and that is the one buildNode fills.
['typings/windows-process-tree.d.ts', /creationTimeMs\?: number;\r?\n\s*children:/],
['typings/windows-process-tree.d.ts', 'export const supportedProcessDataFlags']
['typings/windows-process-tree.d.ts', 'export const supportedProcessDataFlags'],
['typings/windows-process-tree.d.ts', 'export const getProcessCreationTime']
]
for (const [relativePath, expected] of requiredCreationTimeSources) {
const source = readFileSync(join(PACKAGE_DIR, relativePath), 'utf8')
@@ -220,10 +224,41 @@ function repairCreationTimeSources() {
})
rewrite('src/addon.cc', (source, eol) => {
if (source.includes('exports.Set("supportedProcessDataFlags"')) {
return source
let next = source
if (!next.includes('Napi::Value ReadProcessCreationTime(')) {
const getter = [
'Napi::Value ReadProcessCreationTime(const Napi::CallbackInfo& args) {',
' Napi::Env env(args.Env());',
' if (args.Length() != 1 || !args[0].IsNumber()) {',
' return env.Undefined();',
' }',
' const double pid = args[0].As<Napi::Number>().DoubleValue();',
' if (!(pid >= 1 && pid <= MAXDWORD) || pid != static_cast<DWORD>(pid)) {',
' return env.Undefined();',
' }',
' ProcessInfo pinfo{};',
' pinfo.pid = static_cast<DWORD>(pid);',
' GetProcessCreationTime(pinfo);',
' if (pinfo.creationTimeMs == 0) {',
' return env.Undefined();',
' }',
' return Napi::Number::New(env, static_cast<double>(pinfo.creationTimeMs));',
'}',
''
].join(eol)
next = next.replace('Napi::Object Init(', `${getter}${eol}Napi::Object Init(`)
}
return source.replace(
if (!next.includes('exports.Set("getProcessCreationTime"')) {
next = next.replace(
' exports.Set("getProcessList",',
` exports.Set("getProcessCreationTime", Napi::Function::New(env, ReadProcessCreationTime));${eol}` +
' exports.Set("getProcessList",'
)
}
if (next.includes('exports.Set("supportedProcessDataFlags"')) {
return next
}
return next.replace(
/( exports\.Set\("getProcessCpuUsage", Napi::Function::New\(env, GetProcessCpuUsage\)\);\r?\n)/,
`$1 exports.Set("supportedProcessDataFlags",${eol}` +
` Napi::Number::New(env, MEMORY | COMMANDLINE | CREATIONTIME));${eol}`
@@ -254,6 +289,12 @@ function repairCreationTimeSources() {
: 'exports.supportedProcessDataFlags = native === undefined ? undefined : native.supportedProcessDataFlags;'
next = next.replace(NATIVE_CONST, `${NATIVE_CONST}${eol}${reExport}`)
}
if (!next.includes('getProcessCreationTime')) {
const reExport = isTs
? 'export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined = native?.getProcessCreationTime;'
: 'exports.getProcessCreationTime = native === undefined ? undefined : native.getProcessCreationTime;'
next = next.replace(NATIVE_CONST, `${NATIVE_CONST}${eol}${reExport}`)
}
// buildNode drops any field it does not name, so the destructure and the
// splat have to move together.
next = next.replace(/(memory, commandLine)( \}, children \})/, '$1, creationTimeMs$2')
@@ -279,6 +320,13 @@ function repairCreationTimeSources() {
` export const supportedProcessDataFlags: number | undefined;${eol}`
)
}
if (!next.includes('export const getProcessCreationTime')) {
next = next.replace(
' export const supportedProcessDataFlags: number | undefined;',
' export const supportedProcessDataFlags: number | undefined;' +
`${eol} export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined;`
)
}
if (!next.includes('creationTimeMs?: number')) {
next = next.replace(
/ commandLine\?: string;\r?\n/,
+135
View File
@@ -0,0 +1,135 @@
import { build } from 'esbuild'
import { appendFileSync, globSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
externalNativeAddons,
ORCAD_CHILD_ENTRY_POINTS,
ORCAD_ENTRY_POINT
} from './orcad-entry-build.mjs'
import { bunProfileTestPaths } from './bun-profile-test-paths.mjs'
import { bunProfileQualification } from './bun-profile-qualification.mjs'
const ROOT = resolve(import.meta.dirname, '../..')
const BUILD_SCRIPTS = [
'config/scripts/build-orcad-bun.mjs',
'config/scripts/build-orcad.mjs',
'config/scripts/build-windows-process-tree-relay-addon.mjs',
'config/scripts/run-bun-profile-tests.mjs',
'config/vitest.config.ts',
'config/scripts/happy-dom-offscreen-canvas.ts',
'config/scripts/happy-dom-mutation-observer-retention.ts',
'config/scripts/vitest-host-ports-setup.ts'
]
const ALWAYS_FILES = new Set([
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
'.npmrc',
'.pnpmfile.cjs',
'tsconfig.json',
'.github/workflows/bun-profile-tests.yml',
'config/scripts/bun-profile-change-scope.mjs',
'config/scripts/bun-profile-change-scope.test.mjs',
'config/scripts/bun-profile-qualification.mjs',
'config/scripts/bun-profile-qualification.test.mjs'
])
const ALWAYS_PREFIXES = [
'.github/actions/install-node-dependencies/',
// These areas also contain worker paths and fixtures opened without an import.
'src/main/persistence/',
'src/main/sqlite/',
'src/main/orcad/',
'src/main/daemon/pty-subprocess/',
'src/main/providers/',
'config/patches/',
'config/tsconfig',
'native/',
'resources/licenses/ripgrep/'
]
export function discoverBunProfileTests(root = ROOT) {
const selectors = bunProfileTestPaths({ artifact: true })
return globSync(
['src/**/*.test.{ts,tsx}', 'config/scripts/**/*.test.{ts,mjs}', 'tests/e2e/**/*.unit.test.ts'],
{ cwd: root }
)
.map((file) => file.replaceAll('\\', '/'))
.filter((file) => selectors.some((selector) => file.includes(selector)))
.sort()
}
export async function collectBunProfileInputs({ root = ROOT, entryPoints } = {}) {
const entries = entryPoints ?? [
ORCAD_ENTRY_POINT,
...Object.values(ORCAD_CHILD_ENTRY_POINTS),
...BUILD_SCRIPTS,
...discoverBunProfileTests(root)
]
const result = await build({
absWorkingDir: root,
entryPoints: entries,
bundle: true,
write: false,
outdir: resolve(root, '.bun-profile-scope'),
platform: 'node',
format: 'esm',
splitting: true,
packages: 'external',
loader: { '.svg': 'empty', '.png': 'empty', '.webp': 'empty', '.css': 'empty' },
plugins: [externalNativeAddons],
metafile: true,
logLevel: 'silent'
})
if (result.warnings.length > 0) {
throw new Error(result.warnings.map((warning) => warning.text).join('\n'))
}
return new Set(
Object.keys(result.metafile.inputs).map((file) =>
file.replaceAll('\\', '/').replace(/\?.*$/, '')
)
)
}
export async function classifyBunProfileChanges(changedFiles, collect = collectBunProfileInputs) {
if (changedFiles.length === 0) {
return { shouldRun: true, reason: 'No complete changed-file evidence' }
}
const selectors = bunProfileTestPaths({ artifact: true })
const forced = changedFiles.find(
(file) =>
ALWAYS_FILES.has(file) ||
ALWAYS_PREFIXES.some((prefix) => file.startsWith(prefix)) ||
selectors.some((selector) => file.includes(selector))
)
if (forced) {
return { shouldRun: true, reason: `Build or CI input changed: ${forced}` }
}
try {
const inputs = await collect()
const matched = changedFiles.find((file) => inputs.has(file))
return {
shouldRun: Boolean(matched),
reason: matched ? `Runtime or test dependency changed: ${matched}` : 'No Bun inputs changed'
}
} catch (error) {
return {
shouldRun: true,
graphUnavailable: true,
reason: `Dependency graph unavailable: ${String(error)}`
}
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean)
const result = await classifyBunProfileChanges(changedFiles)
console.log(result.reason)
const policy = bunProfileQualification(changedFiles, result)
const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n`
if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, output)
} else {
process.stdout.write(output)
}
}

Some files were not shown because too many files have changed in this diff Show More