mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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:
@@ -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') }}
|
||||
@@ -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 #
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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' }}
|
||||
@@ -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')"
|
||||
@@ -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
|
||||
@@ -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') }}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}.`);
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/"
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://freebuff.com"><kbd><img src="src/shared/agent-icons/freebuff.png" alt="Freebuff logo" width="16" valign="middle" /> Freebuff</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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] }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+344
-17
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
ARG BASE_IMAGE=ubuntu:24.04
|
||||
ARG BASE_IMAGE=ubuntu@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG LIBASOUND_PACKAGE=libasound2t64
|
||||
|
||||
@@ -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'] }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
;
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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);
|
||||
@@ -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');
|
||||
|
||||
+1163
-25
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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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: {} })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user