mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
Merge remote-tracking branch 'origin/main' into OrcaWin/node-rt-phase3
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
name: Prepare server native prebuilds
|
||||
description: Builds and smokes the current server slot, optionally restoring a validated Windows slot.
|
||||
|
||||
inputs:
|
||||
resolve-windows-cache:
|
||||
description: Resolve the exact Windows cache identity for restoration or later qualified publication.
|
||||
default: 'false'
|
||||
restore-windows-cache:
|
||||
description: Restore the exact Windows slot when its identity is available.
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
cache-key:
|
||||
description: Exact Windows server cache key.
|
||||
value: ${{ steps.orcad-prebuild-cache-identity.outputs.key }}
|
||||
cache-path:
|
||||
description: Directory containing the Windows slot and its manifest.
|
||||
value: ${{ steps.orcad-prebuild-cache-identity.outputs.path }}
|
||||
cache-identity-outcome:
|
||||
description: Whether Windows cache identity resolution succeeded.
|
||||
value: ${{ steps.orcad-prebuild-cache-identity.outcome }}
|
||||
cache-hit:
|
||||
description: Whether the exact Windows slot was restored.
|
||||
value: ${{ steps.orcad-prebuild-cache-restore.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve this Windows server prebuild cache
|
||||
id: orcad-prebuild-cache-identity
|
||||
if: >-
|
||||
inputs.resolve-windows-cache == 'true' && runner.os == 'Windows' &&
|
||||
(runner.arch == 'X64' || runner.arch == 'ARM64')
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: node config/scripts/orcad-windows-prebuild-cache.mjs --fingerprint
|
||||
|
||||
- name: Restore the exact Windows server prebuild
|
||||
id: orcad-prebuild-cache-restore
|
||||
if: >-
|
||||
inputs.restore-windows-cache == 'true' &&
|
||||
steps.orcad-prebuild-cache-identity.outcome == 'success' &&
|
||||
steps.orcad-prebuild-cache-identity.outputs.key != ''
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ steps.orcad-prebuild-cache-identity.outputs.path }}
|
||||
key: ${{ steps.orcad-prebuild-cache-identity.outputs.key }}
|
||||
|
||||
- name: Build and smoke this runner's node-pty prebuild slot under the pinned Node
|
||||
shell: bash
|
||||
env:
|
||||
WINDOWS_PREBUILD_CACHE_HIT: ${{ steps.orcad-prebuild-cache-restore.outputs.cache-hit }}
|
||||
WINDOWS_PREBUILD_CACHE_RESTORE_OUTCOME: ${{ steps.orcad-prebuild-cache-restore.outcome }}
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Windows ] && [ "$WINDOWS_PREBUILD_CACHE_HIT" = true ] &&
|
||||
[ "$WINDOWS_PREBUILD_CACHE_RESTORE_OUTCOME" = success ] &&
|
||||
node config/scripts/orcad-windows-prebuild-cache.mjs --validate; then
|
||||
echo 'Using the validated Windows server prebuild'
|
||||
else
|
||||
if [ "$RUNNER_OS" = Windows ]; then
|
||||
rm -rf -- out/orcad-prebuilds
|
||||
fi
|
||||
pnpm build:orcad-prebuilds
|
||||
fi
|
||||
pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)"
|
||||
pnpm build:orcad-prebuilds --smoke
|
||||
@@ -51,7 +51,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
# Hourly retries must let an active warmer finish publishing its caches.
|
||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||
|
||||
jobs:
|
||||
warm:
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
# v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10.
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.24.0
|
||||
version: 10.34.6
|
||||
package_json_file: docs/site/package.json
|
||||
run_install: false
|
||||
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
# v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10.
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.24.0
|
||||
version: 10.34.6
|
||||
package_json_file: docs/site/package.json
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -119,7 +119,22 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
env:
|
||||
ORCA_E2E_APT_PACKAGES: build-essential python3
|
||||
run: &install_e2e_tools |
|
||||
read -r -a packages <<< "$ORCA_E2E_APT_PACKAGES"
|
||||
for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
|
||||
if [ -f "$source" ]; then
|
||||
sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source"
|
||||
fi
|
||||
done
|
||||
sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF'
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
Acquire::Retries "1";
|
||||
APTCONF
|
||||
timeout 120 sudo apt-get update
|
||||
timeout 300 sudo apt-get install -y "${packages[@]}"
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -176,7 +191,11 @@ jobs:
|
||||
# Native cache misses need the compiler, Electron needs Xvfb, and paired
|
||||
# Quick Open needs ripgrep. Install them in one apt transaction per shard.
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh openbox x11-utils
|
||||
# The Azure archive took 16 minutes for one font package; bound setup
|
||||
# separately so a slow mirror cannot consume the shard's test budget.
|
||||
env: &e2e_tool_packages
|
||||
ORCA_E2E_APT_PACKAGES: build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils
|
||||
run: *install_e2e_tools
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -241,7 +260,7 @@ jobs:
|
||||
# them as an artifact makes post-mortem debugging on CI possible without
|
||||
# re-running locally.
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
if: failure() || cancelled()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-traces-${{ matrix.shard_name }}
|
||||
@@ -267,7 +286,8 @@ jobs:
|
||||
# unbounded inventory fallback; the paired fixture exercises that real boundary.
|
||||
# Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this
|
||||
# lane now receives those specs from pr.yml's SSH source mapping.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils
|
||||
env: *e2e_tool_packages
|
||||
run: *install_e2e_tools
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -345,7 +365,7 @@ jobs:
|
||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
if: failure() || cancelled()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-traces-changed
|
||||
@@ -404,7 +424,8 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils
|
||||
env: *e2e_tool_packages
|
||||
run: *install_e2e_tools
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -460,7 +481,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload watcher isolation traces
|
||||
if: failure()
|
||||
if: failure() || cancelled()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }}
|
||||
@@ -587,7 +608,7 @@ jobs:
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1'
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
if: failure() || cancelled()
|
||||
with:
|
||||
name: localhost-ssh-traces
|
||||
path: test-results/
|
||||
|
||||
@@ -17,9 +17,9 @@ on:
|
||||
- '.pnpmfile.cjs'
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/actions/prepare-native-runtime/**'
|
||||
- '.github/actions/prepare-orcad-prebuilds/**'
|
||||
- '.github/workflows/node-server-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.
|
||||
# Relevant main pushes qualify every platform after the dependency check.
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
@@ -36,6 +36,7 @@ on:
|
||||
- '.pnpmfile.cjs'
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/actions/prepare-native-runtime/**'
|
||||
- '.github/actions/prepare-orcad-prebuilds/**'
|
||||
- '.github/workflows/node-server-tests.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -60,15 +61,16 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Why a run-scoped group for template builds: a release call shares github.ref with main's push
|
||||
# runs, and cancelling either would drop a release's template or a main qualification.
|
||||
# Main pushes must finish detection before they can supersede relevant qualification.
|
||||
concurrency:
|
||||
group: node-server-${{ inputs.build_template && format('template-{0}', github.run_id) || github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ !inputs.build_template }}
|
||||
group: node-server-${{ (inputs.build_template || github.event_name == 'push') && format('run-{0}', github.run_id) || github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ !inputs.build_template && github.event_name != 'push' }}
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft != true)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -84,7 +86,20 @@ jobs:
|
||||
- name: Detect headless-server build and test inputs
|
||||
id: scope
|
||||
shell: bash
|
||||
env:
|
||||
PUSH_BASE: ${{ github.event.before }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = push ]; then
|
||||
# Compare the entire push, including multi-commit pushes and removed files.
|
||||
if git fetch --no-tags --depth=1 origin "$PUSH_BASE" &&
|
||||
git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes"; then
|
||||
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification
|
||||
else
|
||||
echo 'should_run=true' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
# 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/node-server-changes"; then
|
||||
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes"
|
||||
@@ -94,6 +109,9 @@ jobs:
|
||||
|
||||
persistence:
|
||||
needs: changes
|
||||
concurrency:
|
||||
group: node-server-persistence-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }}
|
||||
# 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.
|
||||
@@ -116,48 +134,15 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }}
|
||||
- name: Resolve this Windows server prebuild cache
|
||||
id: orcad-prebuild-cache-identity
|
||||
if: >-
|
||||
runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64') &&
|
||||
!inputs.build_template && inputs.ref == '' &&
|
||||
(github.event_name == 'pull_request' ||
|
||||
(github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name)))
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: node config/scripts/orcad-windows-prebuild-cache.mjs --fingerprint
|
||||
- name: Restore the exact Windows server prebuild for this pull request
|
||||
id: orcad-prebuild-cache-restore
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
steps.orcad-prebuild-cache-identity.outcome == 'success' &&
|
||||
steps.orcad-prebuild-cache-identity.outputs.key != ''
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@v5
|
||||
# Linux release slots come from the floor and Alpine lanes; this slot serves local tests.
|
||||
- uses: ./.github/actions/prepare-orcad-prebuilds
|
||||
id: orcad-prebuild
|
||||
with:
|
||||
path: ${{ steps.orcad-prebuild-cache-identity.outputs.path }}
|
||||
key: ${{ steps.orcad-prebuild-cache-identity.outputs.key }}
|
||||
# Before setup-node 18: the scripts import the TypeScript runtime pin.
|
||||
# Linux release slots come from the Ubuntu 20.04 and Alpine lanes below, where their libc
|
||||
# floors live; this runner's slot only packages orcad for its own tests.
|
||||
- name: Build and smoke this runner's node-pty prebuild slot under the pinned Node
|
||||
shell: bash
|
||||
env:
|
||||
WINDOWS_PREBUILD_CACHE_HIT: ${{ steps.orcad-prebuild-cache-restore.outputs.cache-hit }}
|
||||
WINDOWS_PREBUILD_CACHE_RESTORE_OUTCOME: ${{ steps.orcad-prebuild-cache-restore.outcome }}
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Windows ] && [ "$WINDOWS_PREBUILD_CACHE_HIT" = true ] &&
|
||||
[ "$WINDOWS_PREBUILD_CACHE_RESTORE_OUTCOME" = success ] &&
|
||||
node config/scripts/orcad-windows-prebuild-cache.mjs --validate; then
|
||||
echo 'Using the validated Windows server prebuild'
|
||||
else
|
||||
if [ "$RUNNER_OS" = Windows ]; then
|
||||
rm -rf -- out/orcad-prebuilds
|
||||
fi
|
||||
pnpm build:orcad-prebuilds
|
||||
fi
|
||||
pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)"
|
||||
pnpm build:orcad-prebuilds --smoke
|
||||
resolve-windows-cache: >-
|
||||
${{ !inputs.build_template && inputs.ref == '' &&
|
||||
(github.event_name == 'pull_request' ||
|
||||
(github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name))) }}
|
||||
restore-windows-cache: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
|
||||
- run: pnpm build:orcad
|
||||
# Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped
|
||||
# it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION
|
||||
@@ -213,13 +198,13 @@ jobs:
|
||||
success() && runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64') &&
|
||||
!inputs.build_template && inputs.ref == '' && github.ref == 'refs/heads/main' &&
|
||||
contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name) &&
|
||||
steps.orcad-prebuild-cache-identity.outcome == 'success' &&
|
||||
steps.orcad-prebuild-cache-identity.outputs.key != ''
|
||||
steps.orcad-prebuild.outputs.cache-identity-outcome == 'success' &&
|
||||
steps.orcad-prebuild.outputs.cache-key != ''
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: ${{ steps.orcad-prebuild-cache-identity.outputs.path }}
|
||||
key: ${{ steps.orcad-prebuild-cache-identity.outputs.key }}
|
||||
path: ${{ steps.orcad-prebuild.outputs.cache-path }}
|
||||
key: ${{ steps.orcad-prebuild.outputs.cache-key }}
|
||||
# Linux release slots come from the floor and Alpine lanes; these runners own the rest.
|
||||
- name: Keep this runner's qualified slot for the desktop template
|
||||
if: inputs.build_template && runner.os != 'Linux'
|
||||
@@ -234,6 +219,9 @@ jobs:
|
||||
|
||||
linux_glibc_floor:
|
||||
needs: [changes, persistence]
|
||||
concurrency:
|
||||
group: node-server-linux_glibc_floor-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }}
|
||||
# A failed smoke already blocks qualification; missing scope still selects every platform.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.persistence.result == 'success' &&
|
||||
@@ -262,7 +250,17 @@ jobs:
|
||||
PYTHON: /opt/python/cp312-cp312/bin/python3
|
||||
steps:
|
||||
- name: Install glibc 2.28 prerequisites
|
||||
run: dnf install -y git procps-ng unzip which xz
|
||||
run: |
|
||||
missing_tool=false
|
||||
for tool in git ps unzip which xz; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing_tool=true
|
||||
fi
|
||||
done
|
||||
if [ "$missing_tool" = true ]; then
|
||||
# The image's source-built Git needs no RPM; missing tools come from AlmaLinux.
|
||||
dnf --disablerepo='epel*' install -y git procps-ng unzip which xz
|
||||
fi
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
@@ -289,6 +287,9 @@ jobs:
|
||||
|
||||
linux_glibc217_compat:
|
||||
needs: [changes, persistence]
|
||||
concurrency:
|
||||
group: node-server-linux_glibc217_compat-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }}
|
||||
# A failed smoke already blocks qualification; missing scope still selects every platform.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.persistence.result == 'success' &&
|
||||
@@ -341,6 +342,9 @@ jobs:
|
||||
|
||||
linux_musl:
|
||||
needs: [changes, persistence]
|
||||
concurrency:
|
||||
group: node-server-linux_musl-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }}
|
||||
# A failed smoke already blocks qualification; missing scope still selects every platform.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.persistence.result == 'success' &&
|
||||
|
||||
@@ -56,7 +56,17 @@ jobs:
|
||||
PYTHON: /opt/python/cp312-cp312/bin/python3
|
||||
steps:
|
||||
- name: Install glibc 2.28 prerequisites
|
||||
run: dnf install -y git procps-ng unzip which xz
|
||||
run: |
|
||||
missing_tool=false
|
||||
for tool in git ps unzip which xz; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing_tool=true
|
||||
fi
|
||||
done
|
||||
if [ "$missing_tool" = true ]; then
|
||||
# The image's source-built Git needs no RPM; missing tools come from AlmaLinux.
|
||||
dnf --disablerepo='epel*' install -y git procps-ng unzip which xz
|
||||
fi
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -28,6 +28,7 @@ on:
|
||||
- 'config/patches/@vscode__windows-process-tree*'
|
||||
- 'config/scripts/build-windows-process-tree-relay-addon.mjs'
|
||||
- 'config/scripts/relay-windows-process-tree-staging.mjs'
|
||||
- 'config/scripts/relay-windows-process-tree-prepared-addon*.mjs'
|
||||
- 'config/scripts/windows-process-tree-gyp-rebuild.mjs'
|
||||
- 'src/shared/relay-windows-breakaway-launch.ts'
|
||||
- 'src/shared/windows-breakaway-launch*.ts'
|
||||
@@ -36,6 +37,8 @@ on:
|
||||
- 'src/main/ssh/orcad-windows-host-lane.test.ts'
|
||||
- 'config/ci/windows-ssh-provider/**'
|
||||
- '.github/workflows/ssh-windows-hosts.yml'
|
||||
- '.github/actions/prepare-orcad-prebuilds/**'
|
||||
- 'config/scripts/orcad-windows-prebuild-cache.mjs'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cells:
|
||||
@@ -83,6 +86,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
id: dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
- name: Self-test the provisioning scripts before touching the machine
|
||||
@@ -97,15 +101,26 @@ jobs:
|
||||
# The deploy materializes rung A from this template; only this runner's slot exists here.
|
||||
# The process-tree addon carries the launcher that starts the relay outside sshd's job; the
|
||||
# orcad slot and the relay both stage it, and a standard-user host has no other launch route.
|
||||
- name: Build this runner's orcad slot, the win32 template and the relay
|
||||
- name: Build this runner's Windows process-table addon
|
||||
shell: bash
|
||||
env:
|
||||
REUSE_PREPARED_RUNTIME: ${{ github.event_name == 'pull_request' && steps.dependencies.outputs.native-cache-hit == 'true' }}
|
||||
run: |
|
||||
reuse_args=()
|
||||
if [ "$REUSE_PREPARED_RUNTIME" = true ]; then
|
||||
reuse_args+=(--reuse-prepared-runtime)
|
||||
fi
|
||||
node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${{ matrix.arch }} "${reuse_args[@]}"
|
||||
- uses: ./.github/actions/prepare-orcad-prebuilds
|
||||
with:
|
||||
resolve-windows-cache: ${{ github.event_name == 'pull_request' }}
|
||||
restore-windows-cache: ${{ github.event_name == 'pull_request' }}
|
||||
- name: Build the win32 template and the relay
|
||||
shell: bash
|
||||
env:
|
||||
ORCA_REQUIRE_RELAY_NATIVE_ADDONS: ${{ matrix.arch }}
|
||||
run: |
|
||||
node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${{ matrix.arch }}
|
||||
pnpm build:orcad-prebuilds
|
||||
pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}"
|
||||
pnpm build:orcad-prebuilds --smoke
|
||||
node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}"
|
||||
pnpm run build:relay
|
||||
- name: Run the Windows host cells against a private ${{ matrix.server }} sshd
|
||||
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }}
|
||||
runs-on: ${{ inputs.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -82,6 +83,7 @@ jobs:
|
||||
matrix:
|
||||
node: ${{ fromJSON(inputs.node_versions) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -103,8 +105,8 @@ jobs:
|
||||
- name: Install relay integration dependencies
|
||||
working-directory: cloud
|
||||
run: |
|
||||
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts
|
||||
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build
|
||||
npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts
|
||||
npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay^...' build
|
||||
|
||||
- name: Test relay integration contracts
|
||||
env:
|
||||
|
||||
@@ -15,20 +15,20 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"@orca-cloud/postgres-schema": "workspace:*",
|
||||
"@orca-cloud/push-contract": "workspace:*",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"hono": "^4.13.7",
|
||||
"hono": "^4.13.10",
|
||||
"pg": "^8.22.0",
|
||||
"pg-connection-string": "2.14.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"tsx": "^4.21.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"hono": "^4.13.7",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"hono": "^4.13.10",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"tsx": "^4.21.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"hono": "^4.13.7",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"hono": "^4.13.10",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"tsx": "^4.21.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -15,21 +15,21 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"@orca-cloud/postgres-schema": "workspace:*",
|
||||
"@orca-cloud/relay-contract": "workspace:*",
|
||||
"hono": "^4.13.7",
|
||||
"jose": "^6.1.3",
|
||||
"hono": "^4.13.10",
|
||||
"jose": "^6.2.12",
|
||||
"pg": "^8.22.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.21.3",
|
||||
"ws": "^8.22.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const CELL_TABLES = [
|
||||
// The target-row statement locks exactly these, held from it to COMMIT.
|
||||
const TARGET_LOCKED = ['target:relay_cells', 'target:relay_cell_admission']
|
||||
|
||||
type Trip = { sql: string; lockable: Record<string, boolean> }
|
||||
type Trip = { sql: string; lockable: Record<string, boolean>; probeMs: number }
|
||||
|
||||
type DelayControl = StatementDelay & { beforeTrip: (sql: string) => Promise<void> }
|
||||
|
||||
@@ -159,11 +159,12 @@ describePostgres('PostgreSQL regional rehome target-row lock', () => {
|
||||
bystander: context.bystander.id
|
||||
}
|
||||
control.beforeTrip = async (sql) => {
|
||||
const probeStartedAt = performance.now()
|
||||
const state: Record<string, boolean> = {}
|
||||
for (const [role, cellId] of Object.entries(probed)) {
|
||||
for (const table of CELL_TABLES) state[`${role}:${table}`] = await lockable(table, cellId)
|
||||
}
|
||||
trips.push({ sql, lockable: state })
|
||||
trips.push({ sql, lockable: state, probeMs: performance.now() - probeStartedAt })
|
||||
}
|
||||
consumeRelayCellInventoryHold(delayed)
|
||||
control.enabled = true
|
||||
@@ -188,13 +189,22 @@ describePostgres('PostgreSQL regional rehome target-row lock', () => {
|
||||
])
|
||||
}
|
||||
const counts = consumeRelayCellInventoryHold(delayed)
|
||||
const commitProbeMs = trips.at(-1)!.probeMs
|
||||
console.info(
|
||||
JSON.stringify({ event: 'rehome_target_row_hold', trips: trips.length, ...counts })
|
||||
JSON.stringify({
|
||||
event: 'rehome_target_row_hold',
|
||||
trips: trips.length,
|
||||
commitProbeMs,
|
||||
...counts
|
||||
})
|
||||
)
|
||||
expect(counts.rehomeTargetRowHolds).toBe(1)
|
||||
expect(counts.cellInventoryHoldMaxSite).toBe('rehome-target-row')
|
||||
expect(counts.rehomeTargetRowHoldMsMax).toBeGreaterThanOrEqual(STATEMENT_DELAY_MS)
|
||||
expect(counts.rehomeTargetRowHoldMsMax).toBeLessThanOrEqual(2 * STATEMENT_DELAY_MS)
|
||||
// The COMMIT observer probes run under the lock, but are absent in production.
|
||||
expect(counts.rehomeTargetRowHoldMsMax - commitProbeMs).toBeLessThanOrEqual(
|
||||
2 * STATEMENT_DELAY_MS
|
||||
)
|
||||
expect(await reservedRequests(context.target.id)).toBe(context.targetReservedBefore + 2)
|
||||
})
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"name": "orca-cloud",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"packageManager": "pnpm@10.34.6",
|
||||
"engines": {
|
||||
"node": ">=24 <27",
|
||||
"pnpm": ">=10"
|
||||
@@ -26,7 +26,7 @@
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
Generated
+93
-410
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,4 @@ packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
|
||||
minimumReleaseAge: 4320
|
||||
|
||||
@@ -56,6 +56,7 @@ const PLAIN_NODE_ENTRY_NAMES = [
|
||||
const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'stt-worker',
|
||||
'warp-theme-parser-worker',
|
||||
'cursor-desktop-profile-worker-entry',
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'session-scanner-worker-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
|
||||
@@ -3,10 +3,26 @@ FROM node:22-bookworm-slim
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV NODE_PATH=/usr/local/lib/node_modules
|
||||
|
||||
RUN apt-get update \
|
||||
RUN set -eu \
|
||||
&& savedAptMark="$(apt-mark showmanual)" \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential python3 procps util-linux \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& npm install --global --no-fund --no-audit node-pty@1.1.0
|
||||
&& npm install --global --no-fund --no-audit node-pty@1.1.0 \
|
||||
&& ptyModule="$(node -e "require('node-pty'); const addon = Object.keys(require.cache).find((path) => path.endsWith('/pty.node')); if (!addon) process.exit(1); process.stdout.write(addon)")" \
|
||||
&& ldd /usr/local/bin/node "$ptyModule" > /tmp/daemon-runtime-libraries \
|
||||
&& ! grep -q 'not found' /tmp/daemon-runtime-libraries \
|
||||
&& apt-mark auto '.*' > /dev/null \
|
||||
&& { [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; } \
|
||||
&& apt-mark manual procps util-linux > /dev/null \
|
||||
&& awk '/=>/ { so = $(NF-1); if (index(so, "/usr/local/") == 1) next; gsub("^/(usr/)?", "", so); print so }' /tmp/daemon-runtime-libraries \
|
||||
| sort -u > /tmp/daemon-runtime-library-paths \
|
||||
&& xargs -r dpkg-query --search < /tmp/daemon-runtime-library-paths > /tmp/daemon-runtime-packages \
|
||||
&& cut -d: -f1 /tmp/daemon-runtime-packages | sort -u | xargs -r apt-mark manual \
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& ldd /usr/local/bin/node "$ptyModule" > /tmp/daemon-runtime-libraries \
|
||||
&& ! grep -q 'not found' /tmp/daemon-runtime-libraries \
|
||||
&& ORCA_BACKGROUND_LAUNCH=1 node -e "const pty = require('node-pty'); const terminal = pty.spawn('/bin/sh', ['-c', 'exit 0'], { cwd: '/tmp', env: process.env }); const timer = setTimeout(() => process.exit(1), 5000); terminal.onExit(({ exitCode }) => { clearTimeout(timer); process.exit(exitCode); });" \
|
||||
&& rm -rf /root/.npm /root/.cache/node-gyp /var/lib/apt/lists/* /tmp/daemon-runtime-libraries /tmp/daemon-runtime-library-paths /tmp/daemon-runtime-packages
|
||||
|
||||
COPY run-case.sh /usr/local/bin/run-daemon-shutdown-descendants
|
||||
COPY fixture.cjs /opt/daemon-shutdown-descendants/fixture.cjs
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable \
|
||||
&& corepack prepare pnpm@12.0.0 --activate \
|
||||
&& corepack prepare pnpm@12.8.1 --activate \
|
||||
&& pnpm --version
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
@@ -310,6 +310,7 @@ module.exports = {
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/session-scanner-service-entry.js',
|
||||
'out/main/wsl-transcript-fs-process-entry.js',
|
||||
'out/main/cursor-desktop-profile-worker-entry.js',
|
||||
'out/main/session-scanner-opencode-sqlite-worker-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"src/main/computer/sidecar-entry.ts",
|
||||
"src/main/speech/stt-worker.ts",
|
||||
"src/main/warp-themes/warp-theme-parser-worker.ts",
|
||||
"src/main/rate-limits/cursor-desktop-profile-worker-entry.ts",
|
||||
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
|
||||
"src/main/ai-vault/session-scanner-worker-entry.ts",
|
||||
"src/main/ports/port-scan-command-worker-entry.ts",
|
||||
|
||||
@@ -22,6 +22,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { canReusePreparedRelayAddon } from './relay-windows-process-tree-prepared-addon.mjs'
|
||||
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
|
||||
import {
|
||||
ensureWindowsProcessTreeCommandLinePatch,
|
||||
@@ -410,14 +411,15 @@ function applyWindowsProcessTreeBuildFixes() {
|
||||
const repairedCreationTime = repairCreationTimeSources()
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR)
|
||||
const repairedCommandLine = ensureWindowsProcessTreeCommandLinePatch(PACKAGE_DIR)
|
||||
if (
|
||||
const repaired =
|
||||
bindingGyp !== originalBinding ||
|
||||
processCc !== originalProcess ||
|
||||
repairedCommandLine ||
|
||||
repairedCreationTime
|
||||
) {
|
||||
if (repaired) {
|
||||
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
|
||||
}
|
||||
return repaired
|
||||
}
|
||||
|
||||
function main() {
|
||||
@@ -431,14 +433,24 @@ function main() {
|
||||
if (!existsSync(PACKAGE_DIR)) {
|
||||
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
|
||||
}
|
||||
applyWindowsProcessTreeBuildFixes()
|
||||
const sourceRepaired = applyWindowsProcessTreeBuildFixes()
|
||||
assertPatchApplied()
|
||||
|
||||
const gyp = nodeGypRebuildInvocation(arch)
|
||||
console.log(`[windows-process-tree] building ${arch} from ${gyp.cwd}`)
|
||||
execFileSync(process.execPath, gyp.args, { cwd: gyp.cwd, stdio: 'inherit' })
|
||||
|
||||
const built = join(PACKAGE_DIR, 'build', 'Release', 'windows_process_tree.node')
|
||||
if (
|
||||
canReusePreparedRelayAddon({
|
||||
enabled: process.argv.includes('--reuse-prepared-runtime'),
|
||||
arch,
|
||||
addonPath: built,
|
||||
sourceRepaired
|
||||
})
|
||||
) {
|
||||
console.log(`[windows-process-tree] reusing the prepared ${arch} addon`)
|
||||
} else {
|
||||
const gyp = nodeGypRebuildInvocation(arch)
|
||||
console.log(`[windows-process-tree] building ${arch} from ${gyp.cwd}`)
|
||||
execFileSync(process.execPath, gyp.args, { cwd: gyp.cwd, stdio: 'inherit' })
|
||||
}
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`node-gyp reported success but ${built} is missing.`)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const result = spawnSync(
|
||||
[
|
||||
...prefixArgs,
|
||||
'dlx',
|
||||
'react-doctor@0.9.1',
|
||||
'react-doctor@0.9.14',
|
||||
'.',
|
||||
'--yes',
|
||||
'--scope',
|
||||
|
||||
@@ -74,11 +74,16 @@ it('bounds warming to the required platforms and validates changes without grant
|
||||
expect(workflow.on.push.paths).toContain('.github/actions/prepare-native-runtime/**')
|
||||
expect(workflow.on.schedule).toEqual([{ cron: '41 * * * *' }])
|
||||
expect(workflow.on.pull_request.paths).toContain('.github/workflows/ci-cache-warmup.yml')
|
||||
expect(workflow.concurrency['cancel-in-progress']).toBe(true)
|
||||
expect(workflow.concurrency.group).toContain('github.event.pull_request.number || github.ref')
|
||||
expect(steps[0].with['persist-credentials']).toBe(false)
|
||||
})
|
||||
|
||||
it('lets hourly warmers wait while pushes, PR updates, and manual runs can replace active work', () => {
|
||||
expect(workflow.concurrency).toEqual({
|
||||
group: 'ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}',
|
||||
'cancel-in-progress': "${{ github.event_name != 'schedule' }}"
|
||||
})
|
||||
})
|
||||
|
||||
it('warms and probes both Windows images with the persistence job runtime', () => {
|
||||
const job = workflow.jobs['warm-windows']
|
||||
expect(job.strategy.matrix.os).toEqual(
|
||||
|
||||
@@ -8,9 +8,11 @@ import { basename, dirname, resolve } from 'node:path'
|
||||
import {
|
||||
ensureWindowsProcessTreeCommandLinePatch,
|
||||
inspectWindowsProcessTreeAddon,
|
||||
nodeGypRebuildInvocation,
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders,
|
||||
windowsProcessTreeAddonPath
|
||||
} from './windows-process-tree-gyp-rebuild.mjs'
|
||||
import { describeProcessFailure, runProcessSync } from './script-child-process.mjs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership, nodePtyAddonPath } = require('./node-pty-job-ownership.cjs')
|
||||
@@ -400,33 +402,38 @@ function rebuildNodeRuntimeModules(moduleNames) {
|
||||
moduleDir = realpathSync(moduleDir)
|
||||
}
|
||||
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
|
||||
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
|
||||
// pnpm exec inside an installed addon cannot discover the root build tool.
|
||||
runNodeGyp(
|
||||
nodeGypRebuildInvocation(
|
||||
process.arch,
|
||||
moduleDir,
|
||||
process.env.npm_config_node_gyp || undefined
|
||||
)
|
||||
)
|
||||
if (moduleName === 'node-pty' && process.platform === 'win32') {
|
||||
runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runPnpm(args, { cwd = projectDir } = {}) {
|
||||
// cmd.exe resolves both Corepack's pnpm.cmd and pnpm 12's native pnpm.exe.
|
||||
const command = 'pnpm'
|
||||
function runNodeGyp({ args, cwd }) {
|
||||
const env =
|
||||
process.platform === 'linux' && args.includes('node-gyp')
|
||||
process.platform === 'linux'
|
||||
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
|
||||
: process.env
|
||||
const result = spawnSync(command, args, {
|
||||
const result = runProcessSync({
|
||||
program: process.execPath,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
env
|
||||
timeoutMs: 300_000
|
||||
})
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
|
||||
if (result.error) {
|
||||
console.error(formatError(result.error))
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
if (result.code !== 0) {
|
||||
console.error(
|
||||
`[native-runtime] node-gyp rebuild failed in ${cwd}: ${describeProcessFailure(result)}`
|
||||
)
|
||||
process.exit(result.code ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
@@ -9,10 +9,18 @@ import {
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { isAbsolute, join, parse } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
runProcessSync
|
||||
} from '../../src/shared/child-process/run-process.ts'
|
||||
import { resolveCliCommand } from '../../src/shared/node-cli-command-resolution.ts'
|
||||
import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts'
|
||||
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
|
||||
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
|
||||
import { nodeGypRebuildInvocation } from './windows-process-tree-gyp-rebuild.mjs'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
|
||||
// The import walk sees `from './x.mjs'` only, so the createRequire'd CJS
|
||||
@@ -30,23 +38,28 @@ describe('ensure-native-runtime', () => {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
writeFakeNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
writeFakeNodeGyp(projectDir)
|
||||
const verboseOutputBytes = DEFAULT_MAX_OUTPUT_BYTES + 1024 * 1024
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
maxBuffer: DEFAULT_MAX_OUTPUT_BYTES * 4,
|
||||
env: envForNativeFixture(projectDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath,
|
||||
ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES: String(verboseOutputBytes)
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout.indexOf('node-gyp stdout complete\n')).toBe(verboseOutputBytes)
|
||||
expect(/^x+$/.test(result.stdout.slice(0, verboseOutputBytes))).toBe(true)
|
||||
expect(result.stderr).toContain('node-gyp stderr complete\n')
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(log).toContain(`node-gyp rebuild --arch=${process.arch}\n`)
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
if (process.platform === 'linux') {
|
||||
expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
|
||||
@@ -69,15 +82,14 @@ describe('ensure-native-runtime', () => {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
writeFakeNodeGyp(projectDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
env: envForNativeFixture(projectDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
@@ -85,7 +97,9 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2)
|
||||
expect(
|
||||
log.split('\n').filter((line) => line === `node-gyp rebuild --arch=${process.arch}`)
|
||||
).toHaveLength(2)
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
expect(log).toContain(join('node_modules', '@orca', 'windows-registry'))
|
||||
} finally {
|
||||
@@ -103,15 +117,14 @@ describe('ensure-native-runtime', () => {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
writeLoadableNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
writeFakeNodeGyp(projectDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
env: envForNativeFixture(projectDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
@@ -121,7 +134,7 @@ describe('ensure-native-runtime', () => {
|
||||
expect(result.stderr).toContain(
|
||||
'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
||||
)
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(readFileSync(logPath, 'utf8')).toContain(`node-gyp rebuild --arch=${process.arch}\n`)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -137,16 +150,15 @@ describe('ensure-native-runtime', () => {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
writeLoadableNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writePatchedNodePtyBuildArtifacts(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
writeFakeNodeGyp(projectDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
env: envForNativeFixture(projectDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
@@ -154,7 +166,7 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(readFileSync(logPath, 'utf8')).toContain(`node-gyp rebuild --arch=${process.arch}\n`)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -170,16 +182,15 @@ describe('ensure-native-runtime', () => {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' })
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writePatchedNodePtyBuildArtifacts(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
writeFakeNodeGyp(projectDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
env: envForNativeFixture(projectDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
@@ -187,12 +198,64 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('node-gyp rebuild')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
it('finds the installed node-gyp entry from an addon without build tools on PATH', () => {
|
||||
const { command, prefixArgs } = resolvePnpmCliInvocation()
|
||||
const program = isAbsolute(command) ? command : resolveCliCommand(parse(command).name)
|
||||
expect(isAbsolute(program), 'pnpm must be installed for the native rebuild contract').toBe(true)
|
||||
const projectDir = mkTempProject()
|
||||
try {
|
||||
writeFakeNativeModules(projectDir)
|
||||
const addonDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
const env = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.toLowerCase() === 'path') {
|
||||
env[key] = ''
|
||||
}
|
||||
}
|
||||
const oldInvocation = runProcessSync({
|
||||
program,
|
||||
args: [
|
||||
...prefixArgs,
|
||||
'--config.verify-deps-before-run=false',
|
||||
'exec',
|
||||
'node-gyp',
|
||||
'--version'
|
||||
],
|
||||
cwd: addonDir,
|
||||
env,
|
||||
timeoutMs: 20_000
|
||||
})
|
||||
expect(oldInvocation.code).not.toBe(0)
|
||||
expect(`${oldInvocation.stdout}\n${oldInvocation.stderr}`).toContain(
|
||||
'Command "node-gyp" not found'
|
||||
)
|
||||
const { args, cwd } = nodeGypRebuildInvocation(process.arch, addonDir)
|
||||
const installedInvocation = runProcessSync({
|
||||
program: process.execPath,
|
||||
args: [args[0], '--version'],
|
||||
cwd,
|
||||
env,
|
||||
timeoutMs: 20_000
|
||||
})
|
||||
expect(
|
||||
installedInvocation.code,
|
||||
`${installedInvocation.stdout}\n${installedInvocation.stderr}`
|
||||
).toBe(0)
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(new URL('../../node_modules/node-gyp/package.json', import.meta.url), 'utf8')
|
||||
)
|
||||
expect(installedInvocation.stdout.trim()).toBe(`v${manifest.version}`)
|
||||
expect(existsSync(join(addonDir, 'pnpm-lock.yaml'))).toBe(false)
|
||||
} finally {
|
||||
removeTreeSync(projectDir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function mkTempProject() {
|
||||
@@ -200,6 +263,10 @@ function mkTempProject() {
|
||||
// Walked, not listed: the script imports windows-process-tree-gyp-rebuild.mjs, and a fixture
|
||||
// missing it fails every case with a module-resolution error instead of the defect under test.
|
||||
copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts'))
|
||||
writeFileSync(
|
||||
join(projectDir, 'config', 'scripts', 'script-child-process.mjs'),
|
||||
`export { describeProcessFailure, runProcessSync } from ${JSON.stringify(new URL('./script-child-process.mjs', import.meta.url).href)}\n`
|
||||
)
|
||||
for (const name of REQUIRED_CJS_SIBLINGS) {
|
||||
copyFileSync(
|
||||
fileURLToPath(new URL(`./${name}`, import.meta.url)),
|
||||
@@ -209,15 +276,11 @@ function mkTempProject() {
|
||||
return projectDir
|
||||
}
|
||||
|
||||
function envWithPrependedPath(binDir, extraEnv) {
|
||||
const pathKey =
|
||||
process.platform === 'win32'
|
||||
? (Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'Path')
|
||||
: 'PATH'
|
||||
function envForNativeFixture(projectDir, extraEnv) {
|
||||
return {
|
||||
...process.env,
|
||||
...extraEnv,
|
||||
[pathKey]: `${binDir}${delimiter}${process.env[pathKey] ?? ''}`
|
||||
npm_config_node_gyp: join(projectDir, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +321,11 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
|
||||
if (process.platform === 'win32') {
|
||||
const buildDir = join(nodePtyDir, 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
writeFileSync(join(buildDir, 'conpty.node'), Buffer.from('msys-2.0.dll', 'utf16le'))
|
||||
}
|
||||
}
|
||||
|
||||
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
|
||||
@@ -314,7 +382,10 @@ function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
|
||||
)
|
||||
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
mkdirSync(processTreeDir, { recursive: true })
|
||||
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'index.js'),
|
||||
'exports.supportedProcessDataFlags = 4; exports.getProcessCreationTime = () => 1\n'
|
||||
)
|
||||
}
|
||||
|
||||
function writeNodePtyPatchFile(projectDir) {
|
||||
@@ -338,33 +409,25 @@ function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeFakePnpm(binDir) {
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
const shimPath = join(binDir, 'pnpm-shim.cjs')
|
||||
function writeFakeNodeGyp(projectDir) {
|
||||
const toolDir = join(projectDir, 'node_modules', 'node-gyp', 'bin')
|
||||
mkdirSync(toolDir, { recursive: true })
|
||||
writeFileSync(
|
||||
shimPath,
|
||||
join(toolDir, 'node-gyp.js'),
|
||||
`
|
||||
const { appendFileSync, writeFileSync } = require('node:fs')
|
||||
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
|
||||
const { appendFileSync, writeFileSync, writeSync } = require('node:fs')
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-gyp \${process.argv.slice(2).join(' ')}\\n\`)
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
|
||||
)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`
|
||||
)
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`)
|
||||
if (process.env.ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES) {
|
||||
const output = Buffer.alloc(Number(process.env.ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES), 'x')
|
||||
for (let offset = 0; offset < output.length;) {
|
||||
offset += writeSync(1, output.subarray(offset))
|
||||
}
|
||||
writeSync(1, 'node-gyp stdout complete\\n')
|
||||
writeSync(2, 'node-gyp stderr complete\\n')
|
||||
}
|
||||
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
|
||||
`
|
||||
)
|
||||
|
||||
const posixPnpmPath = join(binDir, 'pnpm')
|
||||
writeFileSync(posixPnpmPath, `#!/usr/bin/env node\nrequire(${JSON.stringify(shimPath)})\n`)
|
||||
chmodSync(posixPnpmPath, 0o755)
|
||||
writeFileSync(
|
||||
join(binDir, 'pnpm.cmd'),
|
||||
`@echo off\r\n"${process.execPath}" "%~dp0\\pnpm-shim.cjs" %*\r\n`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { build } from 'vite'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { electronViteConfig } from '../../electron.vite.config'
|
||||
import { runProcess } from '../../src/shared/child-process/run-process'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const require = createRequire(import.meta.url)
|
||||
let outputDir
|
||||
|
||||
const smokeScript = `
|
||||
const assert = require('node:assert/strict')
|
||||
const { createRequire } = require('node:module')
|
||||
const loaderPath = process.argv[1]
|
||||
const requireFromLoader = createRequire(loaderPath)
|
||||
const sdkEntry = requireFromLoader.resolve('@linear/sdk')
|
||||
const { loadLinearSdk } = require(loaderPath)
|
||||
assert.equal(require.cache[sdkEntry], undefined, 'SDK must remain lazy')
|
||||
|
||||
const sdk = loadLinearSdk()
|
||||
assert.equal(sdk, requireFromLoader('@linear/sdk'))
|
||||
assert.equal(loadLinearSdk(), sdk, 'repeat loads must reuse the SDK')
|
||||
const client = new sdk.LinearClient({ apiKey: 'orca-offline-smoke-test' })
|
||||
assert.equal(typeof client.issues, 'function')
|
||||
assert.equal(typeof client.teams, 'function')
|
||||
assert.ok(sdk.AuthenticationLinearError.prototype instanceof Error)
|
||||
console.log('Linear SDK loaded')
|
||||
`
|
||||
|
||||
beforeAll(async () => {
|
||||
// Keep normal dependency resolution while executing outside Vitest's module loader.
|
||||
outputDir = await mkdtemp(join(projectDir, 'node_modules', 'orca-linear-sdk-runtime-'))
|
||||
const mainBuild = electronViteConfig.main.build
|
||||
await build({
|
||||
configFile: false,
|
||||
publicDir: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
ssr: true,
|
||||
minify: mainBuild.minify,
|
||||
outDir: outputDir,
|
||||
rollupOptions: {
|
||||
external: mainBuild.rollupOptions.external,
|
||||
input: join(projectDir, 'src/main/linear/linear-sdk.ts'),
|
||||
output: { format: 'cjs', entryFileNames: 'linear-sdk.cjs' }
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (outputDir) {
|
||||
await rm(outputDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
describe('resolved Linear SDK through the production CommonJS loader', () => {
|
||||
it.each([
|
||||
{ name: 'Node', program: process.execPath },
|
||||
{ name: 'Electron', program: require('electron') }
|
||||
])(
|
||||
'loads and constructs the real SDK under $name without network access',
|
||||
async ({ program }) => {
|
||||
const result = await runProcess({
|
||||
program,
|
||||
args: ['-e', smokeScript, join(outputDir, 'linear-sdk.cjs')],
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ELECTRON_RUN_AS_NODE: '1' },
|
||||
timeoutMs: 20000
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.stdout.trim()).toBe('Linear SDK loaded')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -17,6 +17,7 @@ const BUILD_SCRIPTS = [
|
||||
'config/scripts/pinned-node-downloads.mjs',
|
||||
'config/scripts/build-orcad.mjs',
|
||||
'config/scripts/build-orcad-prebuilds.mjs',
|
||||
'config/scripts/orcad-windows-prebuild-cache.mjs',
|
||||
'config/scripts/orcad-prebuild-smoke-child.cjs',
|
||||
'config/scripts/build-windows-process-tree-relay-addon.mjs',
|
||||
'config/scripts/run-node-server-tests.mjs',
|
||||
@@ -42,6 +43,7 @@ const ALWAYS_FILES = new Set([
|
||||
const ALWAYS_PREFIXES = [
|
||||
'.github/actions/install-node-dependencies/',
|
||||
'.github/actions/prepare-native-runtime/',
|
||||
'.github/actions/prepare-orcad-prebuilds/',
|
||||
// These areas also contain worker paths and fixtures opened without an import.
|
||||
'src/main/persistence/',
|
||||
'src/main/sqlite/',
|
||||
@@ -133,7 +135,9 @@ 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 classifyNodeServerChanges(changedFiles)
|
||||
console.log(result.reason)
|
||||
const policy = nodeServerQualification(changedFiles, result)
|
||||
const policy = nodeServerQualification(changedFiles, result, {
|
||||
fullQualification: process.argv.includes('--full-qualification')
|
||||
})
|
||||
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)
|
||||
|
||||
@@ -84,6 +84,7 @@ it.each([
|
||||
'native/windows-registry/src/addon.cc',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'.github/actions/prepare-native-runtime/action.yml',
|
||||
'.github/actions/prepare-orcad-prebuilds/action.yml',
|
||||
'.github/workflows/node-server-tests.yml',
|
||||
'src/main/persistence/profile-state/new-worker.ts'
|
||||
])('always selects build, native and dynamically opened inputs: %s', async (file) => {
|
||||
@@ -113,6 +114,8 @@ describe('the actual Bun build and profile-test dependency graph', () => {
|
||||
'src/main/worker-thread-entry-path.ts',
|
||||
'config/scripts/zip-extractor-command.mjs',
|
||||
'config/scripts/windows-process-tree-gyp-rebuild.mjs',
|
||||
'config/scripts/relay-windows-process-tree-prepared-addon.mjs',
|
||||
'config/scripts/orcad-windows-prebuild-cache.mjs',
|
||||
'config/scripts/profile-state-worker-smoke.mjs',
|
||||
'config/scripts/vitest-host-ports-setup.ts',
|
||||
'tests/e2e/daemon-running-work-probe.unit.test.ts'
|
||||
@@ -131,16 +134,22 @@ describe('the actual Bun build and profile-test dependency graph', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps all ten platform jobs and runs them when detection is skipped or fails', () => {
|
||||
it('keeps every platform job and runs them when detection is skipped or fails', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8')
|
||||
)
|
||||
expect(workflow.on).toHaveProperty('workflow_dispatch')
|
||||
expect(workflow.jobs.changes.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(workflow.jobs.changes.if).toBe(
|
||||
"github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft != true)"
|
||||
)
|
||||
expect(workflow.jobs.changes.steps[0].with['fetch-depth']).toBe(2)
|
||||
expect(workflow.jobs.changes.steps[0].with['persist-credentials']).toBe(false)
|
||||
const detect = workflow.jobs.changes.steps.find((step) => step.id === 'scope')
|
||||
expect(detect.run).toContain('git diff --name-only --no-renames -z HEAD^1 HEAD')
|
||||
expect(detect.env.PUSH_BASE).toBe('${{ github.event.before }}')
|
||||
expect(detect.run).toContain('git fetch --no-tags --depth=1 origin "$PUSH_BASE"')
|
||||
expect(detect.run).toContain('git diff --name-only --no-renames -z "$PUSH_BASE" HEAD')
|
||||
expect(detect.run).toContain('node-server-changes" --full-qualification')
|
||||
expect(workflow.on.pull_request.types).toContain('ready_for_review')
|
||||
expect(workflow.on.schedule).toHaveLength(1)
|
||||
// A pull request may qualify one platform, so the merged commit must re-qualify all six.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8'))
|
||||
|
||||
function context(event, runId, inputs = {}) {
|
||||
return {
|
||||
github: {
|
||||
event_name: event,
|
||||
run_id: runId,
|
||||
ref: 'refs/heads/main',
|
||||
event: { pull_request: { number: 123 } }
|
||||
},
|
||||
inputs: { build_template: false, ref: '', ...inputs },
|
||||
matrix: { os: 'windows-2022' },
|
||||
format: (template, value) => template.replace('{0}', value)
|
||||
}
|
||||
}
|
||||
|
||||
function expression(source, ctx) {
|
||||
return runInNewContext(source.slice(3, -2).trim(), ctx)
|
||||
}
|
||||
|
||||
function group(policy, ctx) {
|
||||
return policy.group.replace(/\$\{\{([\s\S]*?)\}\}/g, (_match, source) =>
|
||||
String(runInNewContext(source, ctx))
|
||||
)
|
||||
}
|
||||
|
||||
it('skips draft detection and rechecks the same draft once it is ready', () => {
|
||||
const draft = context('pull_request', 1)
|
||||
draft.github.event.pull_request.draft = true
|
||||
expect(runInNewContext(workflow.jobs.changes.if, draft)).toBe(false)
|
||||
draft.github.event.pull_request.draft = false
|
||||
expect(runInNewContext(workflow.jobs.changes.if, draft)).toBe(true)
|
||||
expect(workflow.on.pull_request.types).toContain('ready_for_review')
|
||||
expect(runInNewContext(workflow.jobs.changes.if, context('push', 2))).toBe(true)
|
||||
for (const event of ['schedule', 'workflow_dispatch', 'workflow_call']) {
|
||||
expect(runInNewContext(workflow.jobs.changes.if, context(event, 2))).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('lets main pushes finish detection without cancelling another push', () => {
|
||||
const first = context('push', 1)
|
||||
const second = context('push', 2)
|
||||
expect(group(workflow.concurrency, first)).not.toBe(group(workflow.concurrency, second))
|
||||
expect(expression(workflow.concurrency['cancel-in-progress'], first)).toBe(false)
|
||||
})
|
||||
|
||||
it('still replaces superseded pull requests at workflow level', () => {
|
||||
const first = context('pull_request', 1)
|
||||
const second = context('pull_request', 2)
|
||||
expect(group(workflow.concurrency, first)).toBe(group(workflow.concurrency, second))
|
||||
expect(expression(workflow.concurrency['cancel-in-progress'], first)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['persistence', 'linux_glibc_floor', 'linux_glibc217_compat', 'linux_musl'])(
|
||||
'%s only supersedes eligible main qualification, isolating releases and nightly runs',
|
||||
(name) => {
|
||||
const job = workflow.jobs[name]
|
||||
expect(job.if).toContain("needs.changes.outputs.should_run != 'false'")
|
||||
const policy = job.concurrency
|
||||
const first = context('push', 1)
|
||||
const second = context('push', 2)
|
||||
expect(group(policy, first)).toBe(group(policy, second))
|
||||
expect(expression(policy['cancel-in-progress'], first)).toBe(true)
|
||||
for (const [event, inputs] of [
|
||||
['schedule', {}],
|
||||
['workflow_dispatch', {}],
|
||||
['push', { build_template: true }],
|
||||
['push', { ref: 'refs/tags/v1' }]
|
||||
]) {
|
||||
const isolated = context(event, 2, inputs)
|
||||
expect(group(policy, isolated)).not.toBe(group(policy, first))
|
||||
expect(expression(policy['cancel-in-progress'], isolated)).toBe(false)
|
||||
expect(group(policy, isolated)).not.toBe(group(policy, context(event, 3, inputs)))
|
||||
}
|
||||
if (job.strategy?.matrix) {
|
||||
second.matrix.os = 'windows-11-arm'
|
||||
expect(group(policy, second)).not.toBe(group(policy, first))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -7,42 +7,67 @@ export const NODE_SERVER_RUNNERS = [
|
||||
'windows-11-arm'
|
||||
]
|
||||
|
||||
// Only surfaces whose behaviour actually differs per platform. Escalating on `config/`,
|
||||
// `resources/` and `.github/` wholesale took 36.5% of the last 1100 commits through all six
|
||||
// platforms where a platform-flavoured predicate takes 19%.
|
||||
const PLATFORM_PREFIXES = [
|
||||
// Shared execution and storage changes need every host; explicit platform paths need their family.
|
||||
const BUILD_PREFIXES = [
|
||||
'native/',
|
||||
'config/patches/',
|
||||
'.github/actions/install-node-dependencies/',
|
||||
'.github/actions/prepare-native-runtime/',
|
||||
'.github/actions/prepare-orcad-prebuilds/'
|
||||
]
|
||||
// A remote target's OS does not identify the client platform that builds its commands.
|
||||
const CROSS_HOST_PREFIXES = ['src/main/ssh/', 'src/main/providers/', 'src/relay/']
|
||||
const PLATFORM_PREFIXES = [
|
||||
'src/main/persistence/',
|
||||
'src/main/sqlite/',
|
||||
'src/main/orcad/',
|
||||
'src/main/providers/',
|
||||
'src/main/daemon/',
|
||||
'src/main/ssh/',
|
||||
'src/main/wsl/',
|
||||
'src/relay/',
|
||||
'src/shared/child-process/',
|
||||
// Every native prebuild slot is compiled and smoked against the pinned runtime.
|
||||
'src/shared/node-runtime-pin.ts'
|
||||
'src/shared/child-process/'
|
||||
]
|
||||
|
||||
export function nodeServerQualification(changedFiles, scope) {
|
||||
const platformSpecific = changedFiles.some(
|
||||
(file) =>
|
||||
// A root manifest can move a native dependency on every platform at once.
|
||||
const PLATFORM_FAMILIES = [
|
||||
{ pattern: /(?:^|[/.-])(?:windows|win32|wsl)(?:[/.-]|$)/i, prefix: 'windows-' },
|
||||
{ pattern: /(?:^|[/.-])(?:macos|darwin|posix)(?:[/.-]|$)/i, prefix: 'macos-' },
|
||||
{ pattern: /(?:^|[/.-])(?:linux|posix)(?:[/.-]|$)/i, prefix: 'ubuntu-' }
|
||||
]
|
||||
|
||||
export function nodeServerQualification(changedFiles, scope, { fullQualification = false } = {}) {
|
||||
const selected = new Set(['ubuntu-22.04'])
|
||||
let qualification = false
|
||||
let full = fullQualification || changedFiles.length === 0 || scope.graphUnavailable === true
|
||||
for (const file of changedFiles) {
|
||||
// Build policy and native sources can change every slot, even with a platform in the name.
|
||||
if (
|
||||
!file.includes('/') ||
|
||||
PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix)) ||
|
||||
/(?:^|[/.-])(?:windows|win32|wsl|macos|darwin|linux|posix|bun|prebuilds?)(?:[/.-]|$)/i.test(
|
||||
file
|
||||
)
|
||||
)
|
||||
// A pull request qualifies one platform unless the change is platform-flavoured; the push to
|
||||
// main re-qualifies all six, so an unescalated miss surfaces minutes after merge, not a day.
|
||||
const full = changedFiles.length === 0 || scope.graphUnavailable === true || platformSpecific
|
||||
BUILD_PREFIXES.some((prefix) => file.startsWith(prefix)) ||
|
||||
CROSS_HOST_PREFIXES.some((prefix) => file.startsWith(prefix)) ||
|
||||
(/(?:^|[/.-])(?:remote|ssh)(?:[/.-]|$)/i.test(file) &&
|
||||
PLATFORM_FAMILIES.some(({ pattern }) => pattern.test(file))) ||
|
||||
file === 'src/shared/node-runtime-pin.ts' ||
|
||||
file === '.github/workflows/node-server-tests.yml' ||
|
||||
file.startsWith('config/scripts/node-server-') ||
|
||||
/(?:^|[/.-])(?:bun|prebuilds?)(?:[/.-]|$)/i.test(file)
|
||||
) {
|
||||
full = true
|
||||
continue
|
||||
}
|
||||
const families = PLATFORM_FAMILIES.filter(({ pattern }) => pattern.test(file))
|
||||
if (families.length > 0) {
|
||||
for (const { prefix } of families) {
|
||||
for (const runner of NODE_SERVER_RUNNERS.filter((runner) => runner.startsWith(prefix))) {
|
||||
selected.add(runner)
|
||||
}
|
||||
qualification ||= prefix === 'ubuntu-'
|
||||
}
|
||||
} else if (PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix))) {
|
||||
full = true
|
||||
}
|
||||
}
|
||||
return {
|
||||
qualification: full,
|
||||
runners: full ? NODE_SERVER_RUNNERS : ['ubuntu-22.04']
|
||||
qualification: full || qualification,
|
||||
runners: full
|
||||
? NODE_SERVER_RUNNERS
|
||||
: NODE_SERVER_RUNNERS.filter((runner) => selected.has(runner))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,17 +28,15 @@ it.each([
|
||||
'config/patches/node-pty.patch',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'.github/actions/prepare-native-runtime/action.yml',
|
||||
'.github/actions/prepare-orcad-prebuilds/action.yml',
|
||||
'src/main/ssh/ssh-provider.ts',
|
||||
'src/main/providers/local-pty-provider.ts',
|
||||
'src/shared/child-process/run-process.ts',
|
||||
'src/main/persistence/profile-state/store.ts',
|
||||
'src/main/sqlite/database.ts',
|
||||
'src/main/orcad/entry.ts',
|
||||
'src/main/runtime/windows-terminal.ts',
|
||||
'src/shared/linux-glibc.ts',
|
||||
'src/main/daemon/entry.ts',
|
||||
'src/relay/index.ts',
|
||||
'src/main/wsl/runner.ts',
|
||||
'config/scripts/build-orcad-prebuilds.mjs',
|
||||
'config/scripts/orcad-prebuild-slot-contents.mjs',
|
||||
'src/shared/node-runtime-pin.ts'
|
||||
@@ -58,3 +56,85 @@ it('fails closed to every platform when the evidence is incomplete', () => {
|
||||
}).qualification
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'src/main/runtime/windows-terminal.ts',
|
||||
['ubuntu-22.04', 'windows-2022', 'windows-11-arm'],
|
||||
false
|
||||
],
|
||||
[
|
||||
'src/main/windows/windows-process-table.ts',
|
||||
['ubuntu-22.04', 'windows-2022', 'windows-11-arm'],
|
||||
false
|
||||
],
|
||||
['src/main/wsl/runner.ts', ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], false],
|
||||
[
|
||||
'src/main/orcad/orcad-launcher.win32.test.ts',
|
||||
['ubuntu-22.04', 'windows-2022', 'windows-11-arm'],
|
||||
false
|
||||
],
|
||||
['src/main/daemon/darwin-process.ts', ['ubuntu-22.04', 'macos-14', 'macos-15-intel'], false],
|
||||
['src/shared/linux-glibc.ts', ['ubuntu-22.04', 'ubuntu-24.04-arm'], true],
|
||||
[
|
||||
'src/main/daemon/posix-process.ts',
|
||||
['ubuntu-22.04', 'ubuntu-24.04-arm', 'macos-14', 'macos-15-intel'],
|
||||
true
|
||||
]
|
||||
])('selects both architectures and a Linux smoke for %s', (file, runners, qualification) => {
|
||||
expect(nodeServerQualification([file], scope)).toEqual({ runners, qualification })
|
||||
})
|
||||
|
||||
it('combines platform families without adding Linux compatibility work', () => {
|
||||
expect(
|
||||
nodeServerQualification(
|
||||
['src/main/windows/windows-process-table.ts', 'src/main/daemon/darwin-process.ts'],
|
||||
scope
|
||||
)
|
||||
).toEqual({
|
||||
runners: ['ubuntu-22.04', 'macos-14', 'macos-15-intel', 'windows-2022', 'windows-11-arm'],
|
||||
qualification: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps all hosts for shared changes alongside a platform-specific change', () => {
|
||||
expect(
|
||||
nodeServerQualification(
|
||||
['src/main/windows/windows-process-table.ts', 'src/main/daemon/entry.ts'],
|
||||
scope
|
||||
)
|
||||
).toEqual({ runners: NODE_SERVER_RUNNERS, qualification: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'src/main/ssh/remote-node-runtime-store-windows.ts',
|
||||
'src/main/ssh/orcad-remote-node-runtime-windows.ts',
|
||||
'src/main/ssh/ssh-posix-command-wrapper.test.ts',
|
||||
'src/main/providers/agent-foreground-process-git-bash.win32.test.ts',
|
||||
'src/relay/windows-port-scan.ts',
|
||||
'src/main/runtime/windows-firewall-remote-scope.ts',
|
||||
'src/shared/remote-windows-path.ts'
|
||||
])('qualifies every client platform for a remote execution input: %s', (file) => {
|
||||
expect(nodeServerQualification([file], scope)).toEqual({
|
||||
runners: NODE_SERVER_RUNNERS,
|
||||
qualification: true
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'.github/workflows/node-server-tests.yml',
|
||||
'config/scripts/node-server-qualification.mjs'
|
||||
])('qualifies all hosts when the selection policy changes: %s', (file) => {
|
||||
expect(nodeServerQualification([file], scope)).toEqual({
|
||||
runners: NODE_SERVER_RUNNERS,
|
||||
qualification: true
|
||||
})
|
||||
})
|
||||
|
||||
it('fully qualifies relevant main pushes even for an unflavoured change', () => {
|
||||
expect(
|
||||
nodeServerQualification(['src/main/runtime/rpc/methods/example.ts'], scope, {
|
||||
fullQualification: true
|
||||
})
|
||||
).toEqual({ runners: NODE_SERVER_RUNNERS, qualification: true })
|
||||
})
|
||||
|
||||
@@ -25,7 +25,9 @@ describe('orcad template release wiring (design D2)', () => {
|
||||
build_template: { type: 'boolean', default: false }
|
||||
})
|
||||
// A release call shares github.ref with main's push runs; neither may cancel the other.
|
||||
expect(nodeServer.concurrency['cancel-in-progress']).toBe('${{ !inputs.build_template }}')
|
||||
expect(nodeServer.concurrency['cancel-in-progress']).toBe(
|
||||
"${{ !inputs.build_template && github.event_name != 'push' }}"
|
||||
)
|
||||
expect(nodeServer.concurrency.group).toContain('github.run_id')
|
||||
for (const lane of LANES) {
|
||||
const steps = nodeServer.jobs[lane].steps
|
||||
|
||||
@@ -19,6 +19,7 @@ const root = resolve(import.meta.dirname, '../..')
|
||||
const ownership = require('./node-pty-job-ownership.cjs')
|
||||
export const WINDOWS_PREBUILD_CACHE_INPUTS = [
|
||||
'package.json',
|
||||
'.github/actions/prepare-orcad-prebuilds/action.yml',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml',
|
||||
'config/patches/node-pty@1.1.0.patch',
|
||||
|
||||
@@ -8,28 +8,54 @@ import { runProcessSync } from './script-child-process.mjs'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8'))
|
||||
const steps = workflow.jobs.persistence.steps
|
||||
const identity = steps.find((step) => step.id === 'orcad-prebuild-cache-identity')
|
||||
const restore = steps.find((step) => step.id === 'orcad-prebuild-cache-restore')
|
||||
const build = steps.find((step) => step.name?.startsWith('Build and smoke this runner'))
|
||||
const prepare = steps.find((step) => step.id === 'orcad-prebuild')
|
||||
const action = parse(readFileSync('.github/actions/prepare-orcad-prebuilds/action.yml', 'utf8'))
|
||||
const actionSteps = action.runs.steps
|
||||
const identity = actionSteps.find((step) => step.id === 'orcad-prebuild-cache-identity')
|
||||
const restore = actionSteps.find((step) => step.id === 'orcad-prebuild-cache-restore')
|
||||
const build = actionSteps.find((step) => step.name?.startsWith('Build and smoke this runner'))
|
||||
const save = steps.find((step) => step.uses === 'actions/cache/save@v5')
|
||||
|
||||
function evaluate(expression, context) {
|
||||
return runInNewContext(
|
||||
expression.replaceAll(
|
||||
'steps.orcad-prebuild-cache-identity',
|
||||
'steps["orcad-prebuild-cache-identity"]'
|
||||
),
|
||||
expression
|
||||
.replaceAll('steps.orcad-prebuild-cache-identity', 'steps["orcad-prebuild-cache-identity"]')
|
||||
.replaceAll('steps.orcad-prebuild', 'steps["orcad-prebuild"]')
|
||||
.replace(
|
||||
/\.(resolve-windows-cache|restore-windows-cache|cache-identity-outcome|cache-key)\b/g,
|
||||
(_match, name) => `["${name}"]`
|
||||
),
|
||||
context
|
||||
)
|
||||
}
|
||||
|
||||
function actionContext(ctx, caller = prepare) {
|
||||
return {
|
||||
...ctx,
|
||||
inputs: Object.fromEntries(
|
||||
Object.entries(caller.with).map(([key, value]) => [
|
||||
key,
|
||||
value.startsWith('${{') ? String(evaluate(value.slice(3, -2).trim(), ctx)) : value
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function setIdentity(ctx, outcome, key = 'exact-key') {
|
||||
ctx.steps['orcad-prebuild-cache-identity'] = { outcome, outputs: { key } }
|
||||
ctx.steps['orcad-prebuild'].outputs = { 'cache-identity-outcome': outcome, 'cache-key': key }
|
||||
}
|
||||
|
||||
function context(os, arch, event, ref, template = false) {
|
||||
return {
|
||||
runner: { os, arch },
|
||||
github: { event_name: event, ref },
|
||||
inputs: { build_template: template, ref: '' },
|
||||
steps: {
|
||||
'orcad-prebuild-cache-identity': { outcome: 'success', outputs: { key: 'exact-key' } }
|
||||
'orcad-prebuild-cache-identity': { outcome: 'success', outputs: { key: 'exact-key' } },
|
||||
'orcad-prebuild': {
|
||||
outputs: { 'cache-identity-outcome': 'success', 'cache-key': 'exact-key' }
|
||||
}
|
||||
},
|
||||
success: () => true,
|
||||
fromJSON: JSON.parse,
|
||||
@@ -40,7 +66,7 @@ function context(os, arch, event, ref, template = false) {
|
||||
describe('Windows server prebuild cache workflow', () => {
|
||||
it.each([
|
||||
['pull_request', 'refs/pull/1/merge', false, true, false],
|
||||
['push', 'refs/heads/main', false, false, true],
|
||||
['push', 'refs/heads/main', false, true, true],
|
||||
['schedule', 'refs/heads/main', false, false, true],
|
||||
['workflow_dispatch', 'refs/heads/main', false, false, true],
|
||||
['workflow_call', 'refs/heads/main', false, false, false],
|
||||
@@ -58,10 +84,10 @@ describe('Windows server prebuild cache workflow', () => {
|
||||
]) {
|
||||
const ctx = context(os, arch, event, ref, template)
|
||||
const windows = os === 'Windows' && ['X64', 'ARM64'].includes(arch)
|
||||
const resolves = evaluate(identity.if, ctx)
|
||||
const resolves = evaluate(identity.if, actionContext(ctx))
|
||||
expect(resolves).toBe(windows && (reads || writes))
|
||||
ctx.steps['orcad-prebuild-cache-identity'].outcome = resolves ? 'success' : 'skipped'
|
||||
expect(evaluate(restore.if, ctx)).toBe(windows && reads)
|
||||
setIdentity(ctx, resolves ? 'success' : 'skipped')
|
||||
expect(evaluate(restore.if, actionContext(ctx))).toBe(windows && reads)
|
||||
expect(evaluate(save.if, ctx)).toBe(windows && writes)
|
||||
ctx.success = () => false
|
||||
expect(evaluate(save.if, ctx)).toBe(false)
|
||||
@@ -72,15 +98,15 @@ describe('Windows server prebuild cache workflow', () => {
|
||||
for (const event of ['pull_request', 'push']) {
|
||||
const ctx = context('Windows', 'X64', event, 'refs/heads/main')
|
||||
for (const outcome of ['failure', 'skipped']) {
|
||||
ctx.steps['orcad-prebuild-cache-identity'].outcome = outcome
|
||||
expect(evaluate(restore.if, ctx)).toBe(false)
|
||||
setIdentity(ctx, outcome)
|
||||
expect(evaluate(restore.if, actionContext(ctx))).toBe(false)
|
||||
expect(evaluate(save.if, ctx)).toBe(false)
|
||||
}
|
||||
ctx.steps['orcad-prebuild-cache-identity'] = { outcome: 'success', outputs: { key: '' } }
|
||||
expect(evaluate(restore.if, ctx)).toBe(false)
|
||||
setIdentity(ctx, 'success', '')
|
||||
expect(evaluate(restore.if, actionContext(ctx))).toBe(false)
|
||||
expect(evaluate(save.if, ctx)).toBe(false)
|
||||
ctx.inputs.ref = 'refs/tags/v1'
|
||||
expect(evaluate(identity.if, ctx)).toBe(false)
|
||||
expect(evaluate(identity.if, actionContext(ctx))).toBe(false)
|
||||
expect(evaluate(save.if, ctx)).toBe(false)
|
||||
}
|
||||
})
|
||||
@@ -95,10 +121,10 @@ describe('Windows server prebuild cache workflow', () => {
|
||||
]) {
|
||||
const ctx = context('Windows', 'X64', event, 'refs/heads/main')
|
||||
ctx.inputs = inputs
|
||||
expect(evaluate(identity.if, ctx)).toBe(false)
|
||||
expect(evaluate(identity.if, actionContext(ctx))).toBe(false)
|
||||
expect(evaluate(save.if, ctx)).toBe(false)
|
||||
ctx.steps['orcad-prebuild-cache-identity'].outcome = 'skipped'
|
||||
expect(evaluate(restore.if, ctx)).toBe(false)
|
||||
setIdentity(ctx, 'skipped')
|
||||
expect(evaluate(restore.if, actionContext(ctx))).toBe(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -109,11 +135,17 @@ describe('Windows server prebuild cache workflow', () => {
|
||||
expect(restore.uses).toBe('actions/cache/restore@v5')
|
||||
expect(restore['continue-on-error']).toBe(true)
|
||||
expect(restore.with['restore-keys']).toBeUndefined()
|
||||
expect(restore.with).toEqual(save.with)
|
||||
expect(action.outputs['cache-path'].value).toBe(restore.with.path)
|
||||
expect(action.outputs['cache-key'].value).toBe(restore.with.key)
|
||||
expect(save.with.path).toBe('${{ steps.orcad-prebuild.outputs.cache-path }}')
|
||||
expect(save.with.key).toBe('${{ steps.orcad-prebuild.outputs.cache-key }}')
|
||||
expect(prepare.uses).toBe('./.github/actions/prepare-orcad-prebuilds')
|
||||
expect(restore.with.key).toBe('${{ steps.orcad-prebuild-cache-identity.outputs.key }}')
|
||||
expect(restore.with.path).toBe('${{ steps.orcad-prebuild-cache-identity.outputs.path }}')
|
||||
expect(steps.indexOf(restore)).toBeLessThan(steps.indexOf(build))
|
||||
expect(actionSteps.indexOf(restore)).toBeLessThan(actionSteps.indexOf(build))
|
||||
expect(save['continue-on-error']).toBe(true)
|
||||
expect(steps.indexOf(save)).toBeGreaterThan(steps.indexOf(prepare))
|
||||
expect(actionSteps.some((step) => step.uses === 'actions/cache/save@v5')).toBe(false)
|
||||
expect(build.if).toBeUndefined()
|
||||
expect(build['continue-on-error']).toBeUndefined()
|
||||
for (const gate of steps.filter((step) =>
|
||||
@@ -137,6 +169,55 @@ describe('Windows server prebuild cache workflow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSH Windows consumers of qualified server slots', () => {
|
||||
const sshWorkflow = parse(readFileSync('.github/workflows/ssh-windows-hosts.yml', 'utf8'))
|
||||
const sshSteps = sshWorkflow.jobs.hosts.steps
|
||||
const sshPrepare = sshSteps.find((step) => step.uses === prepare.uses)
|
||||
|
||||
it.each(['pull_request', 'workflow_dispatch'])(
|
||||
'restores only PR slots and keeps manual %s qualification fresh',
|
||||
(event) => {
|
||||
for (const arch of ['X64', 'ARM64']) {
|
||||
const ctx = context('Windows', arch, event, 'refs/heads/main')
|
||||
expect(evaluate(identity.if, actionContext(ctx, sshPrepare))).toBe(event === 'pull_request')
|
||||
expect(evaluate(restore.if, actionContext(ctx, sshPrepare))).toBe(event === 'pull_request')
|
||||
}
|
||||
expect(sshSteps.some((step) => step.uses === 'actions/cache/save@v5')).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps both architectures, both sshd versions and the existing build order', () => {
|
||||
expect(
|
||||
sshWorkflow.jobs.hosts.strategy.matrix.include.map(({ arch, server }) => [arch, server])
|
||||
).toEqual([
|
||||
['x64', 'inbox'],
|
||||
['x64', 'preview'],
|
||||
['arm64', 'inbox'],
|
||||
['arm64', 'preview']
|
||||
])
|
||||
const addon = sshSteps.findIndex((step) =>
|
||||
step.run?.includes('build-windows-process-tree-relay-addon.mjs')
|
||||
)
|
||||
const template = sshSteps.findIndex((step) => step.run?.includes('build-orcad-template.mjs'))
|
||||
const hosts = sshSteps.findIndex((step) => step.name?.startsWith('Run the Windows host cells'))
|
||||
expect(addon).toBeLessThan(sshSteps.indexOf(sshPrepare))
|
||||
expect(sshSteps.indexOf(sshPrepare)).toBeLessThan(template)
|
||||
expect(template).toBeLessThan(hosts)
|
||||
expect(sshSteps[template].env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS).toBe('${{ matrix.arch }}')
|
||||
expect(sshSteps[template].run).toContain('--require-slots "win32-${{ matrix.arch }}"')
|
||||
expect(sshSteps[hosts].run).toContain("@('pinned-cmd','pinned-powershell','legacy-opt-out')")
|
||||
for (const workflowPaths of [
|
||||
workflow.on.pull_request.paths,
|
||||
sshWorkflow.on.pull_request.paths
|
||||
]) {
|
||||
expect(workflowPaths).toContain('.github/actions/prepare-orcad-prebuilds/**')
|
||||
}
|
||||
expect(sshWorkflow.on.pull_request.paths).toContain(
|
||||
'config/scripts/orcad-windows-prebuild-cache.mjs'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const commandMocks = `
|
||||
node() {
|
||||
echo "node $*" >> "$COMMAND_LOG"
|
||||
|
||||
@@ -61,6 +61,11 @@ describe('patched dependencies', () => {
|
||||
for (const file of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) {
|
||||
copyFileSync(join(PROJECT_DIR, file), join(scratch, file))
|
||||
}
|
||||
mkdirSync(join(scratch, 'native', 'windows-registry'), { recursive: true })
|
||||
copyFileSync(
|
||||
join(PROJECT_DIR, 'native', 'windows-registry', 'package.json'),
|
||||
join(scratch, 'native', 'windows-registry', 'package.json')
|
||||
)
|
||||
mkdirSync(join(scratch, 'config'), { recursive: true })
|
||||
cpSync(join(PROJECT_DIR, 'config', 'patches'), join(scratch, 'config', 'patches'), {
|
||||
recursive: true
|
||||
|
||||
@@ -218,7 +218,7 @@ describe('PR E2E gate contract', () => {
|
||||
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
|
||||
step.name.startsWith('Install native build')
|
||||
)
|
||||
expect(installStep.run, jobName).toMatch(/\bzsh\b/)
|
||||
expect(installStep.env.ORCA_E2E_APT_PACKAGES.split(/\s+/), jobName).toContain('zsh')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -308,7 +308,7 @@ describe('PR E2E gate contract', () => {
|
||||
const changedInstall = e2eWorkflow.jobs['changed-e2e'].steps.find((step) =>
|
||||
step.name.startsWith('Install native build')
|
||||
)
|
||||
expect(changedInstall.run).toContain('openssh-client')
|
||||
expect(changedInstall.env.ORCA_E2E_APT_PACKAGES.split(/\s+/)).toContain('openssh-client')
|
||||
})
|
||||
|
||||
it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
|
||||
|
||||
@@ -285,7 +285,7 @@ describe('PR workflow parallelism', () => {
|
||||
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
||||
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
|
||||
const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
|
||||
expect(packageManagerVersion).toBe('12.0.0')
|
||||
expect(packageManagerVersion).toBe('12.8.1')
|
||||
expect(steps[pnpmIndex].uses).toBe('pnpm/setup@v2')
|
||||
expect(steps[pnpmIndex].with.version).toBeUndefined()
|
||||
expect(steps[pnpmIndex].with.install).toBe(false)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { join } from 'node:path'
|
||||
import { runProcessSync } from './script-child-process.mjs'
|
||||
import { relayWindowsProcessTreeAddonDefect } from './windows-process-tree-gyp-rebuild.mjs'
|
||||
|
||||
const root = join(import.meta.dirname, '../..')
|
||||
|
||||
function checkPreparedRuntime() {
|
||||
return runProcessSync({
|
||||
program: process.execPath,
|
||||
args: [join(root, 'config/scripts/ensure-native-runtime.mjs'), '--check-only'],
|
||||
cwd: root,
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
}
|
||||
|
||||
export function canReusePreparedRelayAddon({
|
||||
enabled = false,
|
||||
arch,
|
||||
addonPath,
|
||||
sourceRepaired,
|
||||
hostArch = process.arch,
|
||||
platform = process.platform,
|
||||
checkRuntime = checkPreparedRuntime
|
||||
}) {
|
||||
if (!enabled || sourceRepaired !== false || platform !== 'win32' || arch !== hostArch) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if (relayWindowsProcessTreeAddonDefect(addonPath, arch) !== null) {
|
||||
return false
|
||||
}
|
||||
const result = checkRuntime()
|
||||
return result.code === 0 && !result.timedOut && !result.outputTruncated
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { peImage } from './windows-pe-image-fixture.mjs'
|
||||
import { canReusePreparedRelayAddon } from './relay-windows-process-tree-prepared-addon.mjs'
|
||||
import { runProcessSync } from './script-child-process.mjs'
|
||||
|
||||
const directories = []
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function prepared(arch = 'x64', suffix = 'spawnOutsideJob') {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-prepared-relay-addon-'))
|
||||
directories.push(directory)
|
||||
const addonPath = join(directory, 'process-tree.node')
|
||||
writeFileSync(addonPath, Buffer.concat([peImage({ arch }), Buffer.from(suffix)]))
|
||||
return {
|
||||
enabled: true,
|
||||
arch,
|
||||
hostArch: arch,
|
||||
platform: 'win32',
|
||||
sourceRepaired: false,
|
||||
addonPath,
|
||||
checkRuntime: vi.fn(() => ({ code: 0, timedOut: false, outputTruncated: false }))
|
||||
}
|
||||
}
|
||||
|
||||
describe('reusing a prepared Windows relay addon', () => {
|
||||
it.each(['x64', 'arm64'])(
|
||||
'accepts a clean launcher-capable %s addon after its runtime probe',
|
||||
(arch) => {
|
||||
const options = prepared(arch)
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(true)
|
||||
expect(options.checkRuntime).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ enabled: false },
|
||||
{ enabled: undefined },
|
||||
{ platform: 'darwin' },
|
||||
{ platform: 'linux' },
|
||||
{ hostArch: 'arm64' },
|
||||
{ sourceRepaired: true },
|
||||
{ sourceRepaired: undefined }
|
||||
])('compiles freshly without current same-host preparation: %j', (overrides) => {
|
||||
const options = { ...prepared(), ...overrides }
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
expect(options.checkRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['upstream reader', 'ReadProcessMemory spawnOutsideJob'],
|
||||
['pre-launcher addon', 'getProcessCreationTime']
|
||||
])('refuses the %s before loading it', (_label, suffix) => {
|
||||
const options = prepared('x64', suffix)
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
expect(options.checkRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses missing, truncated and wrong-architecture binaries', () => {
|
||||
const options = prepared()
|
||||
for (const bytes of [Buffer.from('spawnOutsideJob'), peImage({ arch: 'arm64' })]) {
|
||||
writeFileSync(options.addonPath, bytes)
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
}
|
||||
rmSync(options.addonPath)
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
expect(options.checkRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ code: 1, timedOut: false, outputTruncated: false },
|
||||
{ code: null, timedOut: false, outputTruncated: false },
|
||||
{ code: 0, timedOut: true, outputTruncated: false },
|
||||
{ code: 0, timedOut: false, outputTruncated: true }
|
||||
])('falls back when native loading or CreationTime validation fails: %j', (result) => {
|
||||
const options = prepared()
|
||||
options.checkRuntime.mockReturnValue(result)
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back on probe exceptions', () => {
|
||||
const options = prepared()
|
||||
options.checkRuntime.mockImplementation(() => {
|
||||
throw new Error('probe spawn failed')
|
||||
})
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/ssh-windows-hosts.yml', 'utf8'))
|
||||
const steps = workflow.jobs.hosts.steps
|
||||
const preparation = steps.find((step) => step.id === 'dependencies')
|
||||
const build = steps.find((step) => step.name === "Build this runner's Windows process-table addon")
|
||||
|
||||
it.each(['pull_request', 'workflow_dispatch'])(
|
||||
'requests reuse only after an exact prepared PR hit under %s',
|
||||
(event) => {
|
||||
expect(workflow.on.pull_request.paths).toContain(
|
||||
'config/scripts/relay-windows-process-tree-prepared-addon*.mjs'
|
||||
)
|
||||
expect(preparation.with['native-runtime']).toBe('node')
|
||||
expect(steps.indexOf(preparation)).toBeLessThan(steps.indexOf(build))
|
||||
for (const cacheHit of ['true', 'false', '', undefined]) {
|
||||
const expression = build.env.REUSE_PREPARED_RUNTIME.slice(3, -2).replace(
|
||||
'outputs.native-cache-hit',
|
||||
'outputs["native-cache-hit"]'
|
||||
)
|
||||
const enabled = runInNewContext(expression, {
|
||||
github: { event_name: event },
|
||||
steps: { dependencies: { outputs: { 'native-cache-hit': cacheHit } } }
|
||||
})
|
||||
expect(enabled).toBe(event === 'pull_request' && cacheHit === 'true')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['x64', 'arm64'])('passes the optional reuse flag safely to the %s builder', (arch) => {
|
||||
for (const enabled of ['true', 'false']) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-relay-build-args-'))
|
||||
directories.push(directory)
|
||||
const output = join(directory, 'arguments.txt')
|
||||
const script = `node() { printf '%s\n' "$@" > "$ARGUMENTS_FILE"; }\n${build.run.replaceAll('${{ matrix.arch }}', arch)}`
|
||||
const result = runProcessSync({
|
||||
program: 'bash',
|
||||
args: ['-e', '-c', script],
|
||||
cwd: directory,
|
||||
env: { ...process.env, ARGUMENTS_FILE: output, REUSE_PREPARED_RUNTIME: enabled },
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(readFileSync(output, 'utf8').trim().split('\n')).toEqual([
|
||||
'config/scripts/build-windows-process-tree-relay-addon.mjs',
|
||||
`--arch=${arch}`,
|
||||
...(enabled === 'true' ? ['--reuse-prepared-runtime'] : [])
|
||||
])
|
||||
}
|
||||
})
|
||||
@@ -29,7 +29,6 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [
|
||||
'config/scripts/dev-cli-terminal-wrapper.mjs',
|
||||
'config/scripts/dev-cli-terminal-wrapper.test.mjs',
|
||||
'config/scripts/electron-builder-config.test.mjs',
|
||||
'config/scripts/ensure-native-runtime.test.mjs',
|
||||
'config/scripts/live-remote-freeze-rpc.mjs',
|
||||
'config/scripts/pty-transcript-secret-scan.test.mjs',
|
||||
'config/scripts/remote-agent-session-authority-repro.mjs',
|
||||
|
||||
@@ -54,13 +54,13 @@ export const WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS = [
|
||||
'napi-inl.deprecated.h'
|
||||
]
|
||||
|
||||
export function nodeGypRebuildInvocation(arch, packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) {
|
||||
export function nodeGypRebuildInvocation(
|
||||
arch,
|
||||
packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR,
|
||||
nodeGypEntry = join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js')
|
||||
) {
|
||||
return {
|
||||
args: [
|
||||
join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'),
|
||||
'rebuild',
|
||||
`--arch=${arch}`
|
||||
],
|
||||
args: [nodeGypEntry, 'rebuild', `--arch=${arch}`],
|
||||
cwd: realpathSync(packageDir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ describe('windows-process-tree node-gyp rebuild', () => {
|
||||
expect(args).toContain('--arch=arm64')
|
||||
})
|
||||
|
||||
it('preserves an external node-gyp entry and the physical addon cwd', () => {
|
||||
const entry = join(tmpdir(), 'external-node-gyp', 'bin', 'node-gyp.js')
|
||||
expect(nodeGypRebuildInvocation('arm64', import.meta.dirname, entry)).toEqual({
|
||||
args: [entry, 'rebuild', '--arch=arm64'],
|
||||
cwd: realpathSync(import.meta.dirname)
|
||||
})
|
||||
})
|
||||
|
||||
it('copies node-addon-api headers into the patched include dir', () => {
|
||||
const packageDir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-headers-'))
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 87m">
|
||||
<title>downloads: 87m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 88m">
|
||||
<title>downloads: 88m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">87m</text>
|
||||
<text x="90" y="14">87m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">88m</text>
|
||||
<text x="90" y="14">88m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -3,6 +3,79 @@
|
||||
The [September 28 demand rollout](ci-demand-rollout.md) documents staged checks,
|
||||
unit-selection evidence, headless runtime qualification, review cancellation and daily occupancy reports.
|
||||
|
||||
## Headless server follow-up
|
||||
|
||||
[PR #24527](https://github.com/stablyai/orca/pull/24527) adds dependency detection to
|
||||
main pushes. Unrelated pushes skip qualification; relevant pushes still run all
|
||||
six persistence targets and five Linux compatibility jobs. Explicit Windows or
|
||||
Mac PR paths select both architectures plus a Linux smoke, while shared
|
||||
execution/storage changes, SSH/provider/relay inputs, native inputs, manifests, and incomplete evidence
|
||||
retain the full matrix. Main pushes use the same validated exact Windows slot
|
||||
cache as PRs; nightly and release builds still compile freshly.
|
||||
|
||||
Main detection runs cannot cancel each other. Only eligible qualification jobs
|
||||
share main concurrency groups, so an unrelated push cannot cancel needed tests.
|
||||
Release templates, explicit refs, and nightly runs remain isolated.
|
||||
|
||||
Draft PRs have no server verdict, so their detector is also skipped. The existing
|
||||
`ready_for_review` event performs detection and qualification once the PR is ready.
|
||||
This removes the checkout and dependency installation for a result whose platform
|
||||
jobs were already ineligible.
|
||||
|
||||
The glibc 2.28 prerequisite step checks all five tools before installing anything.
|
||||
The pinned ARM image already supplies them, including Git 2.55.0 built under
|
||||
`/usr/local/bin`; installing the Git RPM does not change the Git on PATH. A
|
||||
missing-tool fallback still installs the original package list and disables EPEL
|
||||
for that one command. In
|
||||
the baseline x64 log, EPEL metadata took 4 minutes 50 seconds to download although
|
||||
every installed package came from AlmaLinux BaseOS or AppStream. The package list,
|
||||
compiler image, libc floor, native smoke, and persistence tests stay unchanged.
|
||||
The [DNF command reference](https://dnf.readthedocs.io/en/stable/command_ref.html)
|
||||
defines `--disablerepo` as a temporary command-level filter, so later commands
|
||||
retain the image's repository configuration.
|
||||
|
||||
The [PR qualification run](https://github.com/stablyai/orca/actions/runs/36972824276/job/110734327685)
|
||||
passed the x64 floor native smoke and persistence suite. Its prerequisite step
|
||||
finished within GitHub's one-second timing resolution, compared with 5 minutes
|
||||
37 seconds in the baseline job; the supplied tools needed no package install.
|
||||
This measures that step, not the complete workflow or its queue time.
|
||||
|
||||
A [completed main run](https://github.com/stablyai/orca/actions/runs/36962172614)
|
||||
used 42 aggregate runner-minutes across 11 test jobs. The
|
||||
[latest daily demand report](https://github.com/stablyai/orca/actions/runs/36965354205)
|
||||
estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are
|
||||
baseline observations; post-merge savings have not yet been measured.
|
||||
|
||||
## SSH Windows slot reuse
|
||||
|
||||
The SSH Windows host workflow uses the same server-slot preparation action as
|
||||
headless qualification. Its four PR jobs can restore the exact slot published by
|
||||
fully qualified main runs, then validate its inventory and hashes and run the
|
||||
required-slot and pinned-Node smoke checks. Misses or invalid payloads compile
|
||||
freshly. Only main headless qualification publishes; manual SSH qualification
|
||||
still builds freshly. Both sshd versions, both architectures, all three host
|
||||
cells, the process-table addon build, and the template/relay builds remain.
|
||||
|
||||
The action is part of the cache fingerprint, so this extraction starts a new
|
||||
namespace that needs a successful main seed. The existing hosted measurements
|
||||
below suggest about 160 aggregate runner-seconds saved across four warm SSH jobs;
|
||||
that is a conditional estimate, not a measured improvement of this consumer.
|
||||
Private sshd installation and host execution still dominate this workflow.
|
||||
|
||||
## Prepared relay addon reuse
|
||||
|
||||
A [completed SSH Windows run](https://github.com/stablyai/orca/actions/runs/36972043877)
|
||||
rebuilt the process-table relay addon after native dependency preparation. From
|
||||
its builder's start message to the validated staged artifact, x64 took 85.5 seconds
|
||||
and ARM64 took 135.6 seconds. These are single-run observations, not medians.
|
||||
|
||||
An opt-in reuse path checks the same binary architecture, patched reader and
|
||||
launcher exports as staging, then runs the existing native-load and CreationTime
|
||||
probe. Repaired source or incomplete evidence requires a fresh build. SSH PRs
|
||||
request reuse only following an exact prepared native-cache hit; manual SSH and
|
||||
all release builders retain fresh compilation. Subsequent staging checks still
|
||||
run. Hosted validation and the reuse interval remain to be measured.
|
||||
|
||||
## October 1 Windows and dependency cache follow-up
|
||||
|
||||
[PR #24355](https://github.com/stablyai/orca/pull/24355) merged at `197ea3a3`.
|
||||
@@ -17,8 +90,8 @@ tooling is removed from ordinary PR CI.
|
||||
The existing dependency-native cache and the server's N-API 8 slot serve different
|
||||
consumers. Cache the small server slot separately, using the exact compiler image,
|
||||
architecture, dependency/patch/runtime inputs and compilation/validation source.
|
||||
Only PR qualification restores it. Main qualification still compiles freshly and
|
||||
saves after persistence/lifecycle tests and the existing x64 Node 18 handoff.
|
||||
PR and main-push qualification restore it. Nightly qualification still compiles
|
||||
freshly; main saves after persistence/lifecycle tests and the existing x64 Node 18 handoff.
|
||||
Templates and explicit-ref calls continue to compile freshly.
|
||||
|
||||
| Hosted runner | Fresh build median | Restore median | Difference |
|
||||
@@ -1195,3 +1268,127 @@ crash. The last control also proved that crash returns enter the capture.
|
||||
This measures the three-file oracle cohort. Whole-shard timings include other
|
||||
test bodies, imports and transforms, so a whole-suite saving needs separate
|
||||
measurement.
|
||||
|
||||
## Cache warming: let hourly ticks wait for active work
|
||||
|
||||
The hourly warmer previously cancelled an active warmer, even when both used
|
||||
the same source. On October 2, the [merge-triggered run](https://github.com/stablyai/orca/actions/runs/36965832780)
|
||||
at 8ff6296 was interrupted by the [hourly run](https://github.com/stablyai/orca/actions/runs/36966367896)
|
||||
at the same commit. The Windows ARM dependency installation had run for 356
|
||||
seconds before cancellation; its native verification was skipped. The other
|
||||
four lanes had already succeeded.
|
||||
|
||||
Scheduled events now wait in the existing concurrency group. Push, PR and manual
|
||||
events still replace active work. This keeps one active workflow and the default
|
||||
single pending slot, using GitHub's documented
|
||||
[conditional cancellation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency).
|
||||
All cache probes, platforms and publication rules remain.
|
||||
|
||||
This avoids the observed discarded installation. It does not remove the next
|
||||
scheduled run or its repeated successful lanes, and pending replacement still
|
||||
applies regardless of the cancellation expression. The bounded 20-run sample
|
||||
contains this collision; it does not establish a recurring or whole-CI saving.
|
||||
|
||||
## Daemon shutdown fixture: remove build tools after compilation
|
||||
|
||||
The fixture now removes compiler and Python build dependencies, plus npm and
|
||||
node-gyp caches, in the same Docker layer that installs node-pty. It restores the base image's manual
|
||||
package marks, keeps procps and util-linux, and retains the packages owning the
|
||||
shared libraries used by Node and the actually loaded PTY addon. This extends
|
||||
the [official Node image's package-ownership approach](https://github.com/nodejs/docker-node/blob/main/22/bookworm-slim/Dockerfile)
|
||||
to native addons. Dependency checks and a real PTY spawn fail the build if cleanup
|
||||
breaks the runtime.
|
||||
|
||||
The [hosted comparison](https://github.com/stablyai/orca/actions/runs/36969120786)
|
||||
used Ubuntu x64, Docker 28.0.4 and the same resolved Node 22.23.3 base digest for
|
||||
both images. All 3,418 common entries under `/usr/local` retained their bytes,
|
||||
modes and symlink targets; all seven resolved runtime libraries matched. Only
|
||||
two directory-only Python paths disappeared. The 52 removed Debian packages
|
||||
were build dependencies; retained package versions stayed identical.
|
||||
|
||||
| Image payload | Baseline | Candidate | Reduction |
|
||||
| ------------------ | ------------- | ------------- | --------- |
|
||||
| Docker archive | 714,643,456 B | 329,967,616 B | 53.8% |
|
||||
| Compressed archive | 205,819,558 B | 91,888,840 B | 55.4% |
|
||||
|
||||
Each timed arm started a separate Docker daemon with an empty image store,
|
||||
decompressed the archive, loaded it, rebuilt from its inline cache and ran the
|
||||
unchanged descendant/canary shutdown check. Every provisioning layer was cached.
|
||||
|
||||
| Pair/order | Baseline | Candidate | Saving |
|
||||
| ------------------ | -------- | --------- | ------- |
|
||||
| 1: baseline first | 16.320 s | 10.198 s | 6.122 s |
|
||||
| 2: candidate first | 16.389 s | 10.141 s | 6.248 s |
|
||||
| 3: baseline first | 16.351 s | 10.223 s | 6.129 s |
|
||||
| Median | 16.351 s | 10.198 s | 6.153 s |
|
||||
|
||||
Median decompression fell from 1.059 to 0.493 seconds and loading from 10.723 to
|
||||
5.125 seconds. Cached rebuild and shutdown times stayed close. Both seed images
|
||||
and all six restored consumers passed the original shutdown/canary assertions;
|
||||
both deliberate no-op disposal controls failed with the descendant still live.
|
||||
Byte and retained-directory mode faults also failed the inventory comparator.
|
||||
All six owned daemons stopped gracefully without a forced kill.
|
||||
|
||||
These private daemons used separate classic overlay2 stores and the untouched
|
||||
host containerd service. Production storage settings were not captured, the
|
||||
filesystem cache was not flushed, and network transfer is excluded. Production
|
||||
restores overlap dependency installation, so this 37.6% fixture-sequence saving
|
||||
does not establish a six-second PR wall-time improvement. Single cold image
|
||||
builds took 19.313 and 22.623 seconds. The Dockerfile change creates one new
|
||||
fixture key; the existing main warmer seeds it after merge.
|
||||
|
||||
## WebRTC egress fixture: avoid GPU initialization for the data channel
|
||||
|
||||
The Linux-only probe disables hardware acceleration before Electron readiness.
|
||||
It still creates two independent processes/profiles, a real data channel, offer
|
||||
and local description, and checks the exact proxy and UDP policy. The three-second
|
||||
host observation, 500 ms drain and 20/30/45-second deadlines remain unchanged.
|
||||
|
||||
Two fresh Ubuntu x64 runners compared three alternating pairs each, with identical
|
||||
phase instrumentation. The [first trial](https://github.com/stablyai/orca/actions/runs/36967511524)
|
||||
started with the baseline; the [second trial](https://github.com/stablyai/orca/actions/runs/36969120786)
|
||||
started with the candidate. Their first baseline peer constructors took 4.059
|
||||
and 2.546 seconds and logged the GPU command-buffer error seen in an earlier
|
||||
package timeout. In the second trial, that baseline delay followed the cold
|
||||
candidate's 2.3 ms constructor. Every candidate constructor took 2.0–2.6 ms.
|
||||
|
||||
Typical process time stayed near 8.3 seconds: baseline/candidate medians were
|
||||
8.288/8.273 seconds in the first trial and 8.298/8.380 in the reverse trial.
|
||||
The evidence supports removing an avoidable startup delay, without a measured
|
||||
typical throughput gain or an estimate of future timeout frequency.
|
||||
|
||||
All 12 full case invocations preserved actual unprotected UDP and zero protected
|
||||
UDP. Both trials rejected seven faults: missing policy, a packet at 2.9 seconds,
|
||||
a packet during the drain, a missing peer factory or local description, a broken
|
||||
packet counter and a hung renderer. The original assertions and deadlines caught
|
||||
each fault. The normal package gate runs the uninstrumented fixture.
|
||||
|
||||
## Remote resync fixture: keep coalesced frames in one decoder pass
|
||||
|
||||
The first [full PR run](https://github.com/stablyai/orca/actions/runs/36971375340)
|
||||
passed both package jobs but failed one remote-workspace ordering assertion:
|
||||
it observed revisions `[2, 3]` where the fixture expected `[3]`. The decoder can
|
||||
yield between two frames after its four-millisecond work budget. Under slow
|
||||
scheduling, the first response's promise can publish revision 2 before the
|
||||
second frame's revision 3 notification is dispatched.
|
||||
|
||||
The fixture now holds its delivery clock at the actual timestamp from multiplexer
|
||||
construction through the first coalesced-buffer delivery, following the existing
|
||||
decoder test pattern. It restores the clock before asynchronous assertions and
|
||||
again before disposal. All four source/order cases retain their exact cache,
|
||||
publication, client-identity and follow-up-read assertions. Production decoding,
|
||||
its fairness budget and remote messages are unchanged.
|
||||
|
||||
Normal focused runs passed all 18 tests. Advancing the clock by four milliseconds
|
||||
per call reproduced `[2, 3]` in both original response-first cases; the fixed
|
||||
fixture passed all 18 under the same control. Removing the freeze reproduced both
|
||||
failures. Bypassing the production read-safety guard still caused `[3, 2]` rollback
|
||||
in all four ordering cases and eight failing tests overall. All controls preserved
|
||||
the same 18 test identities. This corrects a reproducible fixture assumption;
|
||||
one CI failure does not establish a failure-rate reduction.
|
||||
|
||||
## Windows server cache metadata: retain the current key
|
||||
|
||||
The bounded 50-head main sample ending at 8ff6296 contained no root package
|
||||
metadata changes. Removing app-version metadata from the Windows server cache
|
||||
key would not improve reuse in that sample, so the key remains unchanged.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# DeepSeek Harness integration
|
||||
|
||||
Orca detects the community `@deepseek-harness-tui/dsh-tui` launcher (`dsh-tui`, alias
|
||||
`dst`) and requires the official `@deepseek-ai/dsh` executable too. The launcher
|
||||
boots the `dsh-tui` profile; Orca passes `.` to select the current workspace and
|
||||
reach its composer on the first launch. The official Harness does not bundle this community TUI.
|
||||
DSH Console and DeepSeek Build are separate products and are not interchangeable
|
||||
with this launch contract.
|
||||
|
||||
The official DSH 0.2 CLI accepts both `dsh --profile headless` and `dsh headless`.
|
||||
Orca excludes the known `web`, `headless`, `sdk`, `sdk-minimal`, `acp`, and `desktop`
|
||||
profiles from interactive process recognition, along with plugin management and
|
||||
configuration dumps. Custom profile names remain eligible because profiles are
|
||||
user configurable. Only launcher arguments are inspected; app prompts, resume IDs,
|
||||
and patch filenames cannot change the selected profile's identity.
|
||||
|
||||
Status hooks use the official `@deepseek-ai/dsh-hooks-claude-code` plugin, installed
|
||||
as an owned block in `$DSH_HOME/cordis.patch.yml`. User entries outside the block are
|
||||
preserved. Local installation respects `DSH_HOME`; the existing SSH installer uses
|
||||
the execution host's default `~/.dsh` because SFTP cannot read its environment.
|
||||
Hooks report session start, prompt submission, tool start/end, and stopping through
|
||||
Orca's host status store. Approval has no dedicated hook; it is not inferred from
|
||||
an uncaptured screen. Subagent lifecycle events are ignored for parent-pane status.
|
||||
|
||||
DSH 0.2 still emits an empty `transcript_path` in Claude-compatible hooks. Its
|
||||
session persistence defaults to compressed JSONL under `$DSH_HOME/sessions`.
|
||||
Orca can resume a hook-associated session through `dsh-tui --resume <id>`, but
|
||||
currently does not discover DSH logs in Agent Session History. Resume support alone
|
||||
does not establish transcript-history support.
|
||||
|
||||
## Reproduce the official launcher check
|
||||
|
||||
Install `@deepseek-ai/dsh@0.2.0-rc.2` into a disposable prefix, then run:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 ORCA_REAL_DSH_CLI=/path/to/prefix/node_modules/.bin/dsh \
|
||||
pnpm test src/shared/dsh-real-cli.test.ts
|
||||
```
|
||||
|
||||
The opt-in test checks published version, composed profile configurations, and
|
||||
headless help in an isolated home and working folder without a model request.
|
||||
Interactive readiness is separately pinned to the captured community TUI transcript
|
||||
in `src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt`; that older capture is
|
||||
not proof of current TUI compatibility or paid generation.
|
||||
@@ -8,4 +8,4 @@ next-env.d.ts
|
||||
# Keep local deployment credentials out of the publication boundary.
|
||||
.env
|
||||
.env.*
|
||||
.npmrc
|
||||
# Project .npmrc contains package policy; keep credentials in user-level npm config.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
minimum-release-age=4320
|
||||
+20
-12
@@ -18,21 +18,21 @@
|
||||
"fumadocs-mdx": "^14.3.1",
|
||||
"fumadocs-ui": "^16.8.4",
|
||||
"lucide-react": "^1.6.0",
|
||||
"next": "16.3.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"next": "16.3.6",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/node": "^22.20.4",
|
||||
"@types/react": "~19.2.18",
|
||||
"@types/react-dom": "~19.2.7",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.4",
|
||||
"eslint-config-next": "16.3.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vercel": "59.11.1"
|
||||
@@ -40,7 +40,7 @@
|
||||
"engines": {
|
||||
"node": "22.x"
|
||||
},
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"packageManager": "pnpm@10.34.6",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
@@ -50,13 +50,21 @@
|
||||
"overrides": {
|
||||
"@vercel/fun>tar": "7.5.22",
|
||||
"@vercel/fun>@tootallnate/once": "2.0.1",
|
||||
"@vercel/node>undici": "6.28.0",
|
||||
"@vercel/node>undici": "6.28.1",
|
||||
"@vercel/python-analysis>js-yaml": "4.3.2",
|
||||
"@vercel/python-analysis>minimatch": "10.2.6",
|
||||
"vercel>smol-toml": "1.8.0",
|
||||
"vercel>undici": "6.28.0",
|
||||
"vercel>undici": "6.28.1",
|
||||
"@vercel/python-analysis>smol-toml": "1.8.0",
|
||||
"@vercel/rust>smol-toml": "1.8.0"
|
||||
"@vercel/rust>smol-toml": "1.8.0",
|
||||
"@vercel/sandbox>undici": "7.29.1",
|
||||
"@vercel/static-config>ajv": "8.18.0",
|
||||
"@vercel/backends>path-to-regexp": "8.4.0",
|
||||
"@vercel/fun>path-to-regexp": "8.4.0",
|
||||
"brace-expansion@<2": "1.1.21",
|
||||
"brace-expansion@>=4 <5.0.12": "5.0.12",
|
||||
"@vercel/express>path-to-regexp": "8.4.0",
|
||||
"@vercel/hono>path-to-regexp": "8.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+830
-732
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
export function DocsFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-background pb-8 pt-12">
|
||||
@@ -78,7 +80,7 @@ export function DocsFooter() {
|
||||
|
||||
<div className="flex flex-col items-center justify-between gap-4 border-t border-border pt-6 font-mono text-sm text-muted-foreground md:flex-row">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Lovecast Inc. ·{' '}
|
||||
© {currentYear} Lovecast Inc. ·{' '}
|
||||
<a
|
||||
href="https://github.com/stablyai/orca/blob/main/LICENSE"
|
||||
className="rounded-sm underline decoration-border underline-offset-4 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
|
||||
@@ -45,7 +45,7 @@ test('docs package has an isolated, reproducible app contract', async () => {
|
||||
|
||||
assert.equal(packageJson.name, '@orca/docs')
|
||||
assert.equal(packageJson.private, true)
|
||||
assert.equal(packageJson.packageManager, 'pnpm@10.24.0')
|
||||
assert.equal(packageJson.packageManager, 'pnpm@10.34.6')
|
||||
assert.equal(packageJson.engines.node, '22.x')
|
||||
assert.equal(packageJson.scripts.build, 'next build')
|
||||
assert.equal(packageJson.scripts.postinstall, 'fumadocs-mdx')
|
||||
|
||||
@@ -234,6 +234,9 @@ export const electronViteConfig: UserConfig = {
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
'cursor-desktop-profile-worker-entry': resolve(
|
||||
'src/main/rate-limits/cursor-desktop-profile-worker-entry.ts'
|
||||
),
|
||||
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
|
||||
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
|
||||
'session-scanner-opencode-sqlite-worker-entry': resolve(
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@
|
||||
# in a PR so the Fastfile smoke check runs against the new version first.
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane", "2.238.0"
|
||||
gem "fastlane", "2.240.1"
|
||||
|
||||
+15
-13
@@ -30,6 +30,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
cgi (0.5.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
@@ -61,7 +62,7 @@ GEM
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.238.0)
|
||||
fastlane (2.240.1)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
@@ -71,6 +72,7 @@ GEM
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
cgi (~> 0.4)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
@@ -85,12 +87,11 @@ GEM
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-androidpublisher_v3 (~> 0.99)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.3.0)
|
||||
google-cloud-env (>= 1.6.0, < 2.4.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
irb (>= 1.8)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
@@ -104,8 +105,8 @@ GEM
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
rubyzip (>= 3.4.0, < 4.0.0)
|
||||
security (~> 0.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 4)
|
||||
@@ -212,8 +213,8 @@ GEM
|
||||
retriable (4.2.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
rubyzip (3.7.0)
|
||||
security (0.3.0)
|
||||
signet (0.22.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
@@ -257,7 +258,7 @@ PLATFORMS
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (= 2.238.0)
|
||||
fastlane (= 2.240.1)
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
@@ -275,6 +276,7 @@ CHECKSUMS
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
|
||||
cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
@@ -294,7 +296,7 @@ CHECKSUMS
|
||||
faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
|
||||
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.238.0) sha256=78e9252df2224c7e427012638e41051edfa29b133a40fda883fd2f1c13a77b98
|
||||
fastlane (2.240.1) sha256=cf792bc99ec6af6574ea416cda6530f519c8b4b3a46b1a3b52488ebf1696aa5c
|
||||
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.106.0) sha256=b90b5312b70f8f731def88f24a2b8fb791fe1b979a7c2c14a905cb6cae19cf3f
|
||||
@@ -342,8 +344,8 @@ CHECKSUMS
|
||||
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
|
||||
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
|
||||
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
|
||||
security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
|
||||
signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
|
||||
@@ -363,4 +365,4 @@ CHECKSUMS
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.11
|
||||
4.0.16
|
||||
|
||||
+15
-15
@@ -29,13 +29,13 @@
|
||||
"@xterm/addon-webgl": "0.20.0-beta.299",
|
||||
"@xterm/xterm": "6.1.0-beta.303",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^55.0.30",
|
||||
"expo": "^55.0.31",
|
||||
"expo-build-properties": "^55.0.18",
|
||||
"expo-camera": "^55.0.23",
|
||||
"expo-clipboard": "^55.0.17",
|
||||
"expo-constants": "^55.0.17",
|
||||
"expo-crypto": "^55.0.19",
|
||||
"expo-dev-client": "~55.0.39",
|
||||
"expo-dev-client": "~55.0.40",
|
||||
"expo-document-picker": "^55.0.17",
|
||||
"expo-file-system": "55.0.26",
|
||||
"expo-haptics": "^55.0.18",
|
||||
@@ -43,7 +43,7 @@
|
||||
"expo-image-picker": "^55.0.24",
|
||||
"expo-keep-awake": "~55.0.8",
|
||||
"expo-linking": "^55.0.17",
|
||||
"expo-modules-core": "~55.0.25",
|
||||
"expo-modules-core": "~55.0.26",
|
||||
"expo-network": "~55.0.18",
|
||||
"expo-notifications": "^55.0.27",
|
||||
"expo-router": "^55.0.18",
|
||||
@@ -55,7 +55,7 @@
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react-native": "^1.14.0",
|
||||
"mermaid": "11.17.2",
|
||||
"react": "^19.2.8",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-native": "^0.83.10",
|
||||
"react-native-gesture-handler": "^2.31.2",
|
||||
@@ -64,29 +64,29 @@
|
||||
"react-native-screens": "^4.24.0",
|
||||
"react-native-svg": "^15.15.5",
|
||||
"react-native-uitextview": "2.2.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-web": "^0.21.3",
|
||||
"react-native-webview": "13.16.2",
|
||||
"react-native-worklets": "^0.8.3",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.21.3",
|
||||
"ws": "^8.22.0",
|
||||
"zod": "~4.6.5",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"@types/react": "~19.2.18",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"acorn": "8.18.0",
|
||||
"esbuild": "0.25.4",
|
||||
"esbuild": "0.28.2",
|
||||
"expo-module-scripts": "^55.0.2",
|
||||
"happy-dom": "^20.11.8",
|
||||
"oxfmt": "^0.70.0",
|
||||
"oxlint": "^1.85.0",
|
||||
"happy-dom": "^20.14.5",
|
||||
"oxfmt": "^0.71.0",
|
||||
"oxlint": "^1.86.0",
|
||||
"react-test-renderer": "19.2.8",
|
||||
"tsx": "^4.22.4",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vite": "^8.3.1",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45"
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
"expo-module": "expo-module"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
"expo-module-scripts": "^3.5.2",
|
||||
"expo-modules-core": "^2.0.4"
|
||||
"@types/react": "~19.2.18",
|
||||
"expo-module-scripts": "^55.0.2",
|
||||
"expo-modules-core": "~55.0.26"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
|
||||
Generated
+1110
-1168
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,9 @@ allowBuilds:
|
||||
minimumReleaseAge: 4320
|
||||
|
||||
overrides:
|
||||
brace-expansion@>=1.0.0 <1.1.21: 1.1.21
|
||||
brace-expansion@>=5.0.0 <5.0.12: 5.0.12
|
||||
dompurify@>=3.4.13 <3.4.16: 3.4.16
|
||||
xcode>uuid: 11.1.1
|
||||
query-string: 9.5.1
|
||||
|
||||
|
||||
@@ -36,7 +36,10 @@ async function main() {
|
||||
format: 'esm',
|
||||
// The whole point: one file, so the app bundle emits one deferred chunk for it.
|
||||
splitting: false,
|
||||
minify: true,
|
||||
// Metro transforms this module again; preserve enum assignments for its export analysis.
|
||||
minifyWhitespace: true,
|
||||
minifyIdentifiers: true,
|
||||
minifySyntax: false,
|
||||
// The floor the shell's WebViews hold, the same pair the terminal engine is built for.
|
||||
target: ['chrome74', 'safari15'],
|
||||
write: false,
|
||||
|
||||
@@ -81,7 +81,10 @@ async function buildEngineJs() {
|
||||
},
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
minify: true,
|
||||
// esbuild 0.25 syntax folding drops xterm's local DECRQM enum declaration.
|
||||
minifyWhitespace: true,
|
||||
minifyIdentifiers: true,
|
||||
minifySyntax: false,
|
||||
platform: 'browser',
|
||||
target,
|
||||
legalComments: 'none',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startTerminalDocument, stopTerminalDocument } from './create-terminal-document'
|
||||
import { createTerminalDocumentScope, type TerminalDocumentScope } from './document-scope'
|
||||
import type { TerminalDocumentHost, TerminalViewportChange } from './document-host-seams'
|
||||
@@ -49,6 +49,7 @@ afterEach(() => {
|
||||
while (started.length > 0) {
|
||||
stopTerminalDocument(started.pop()!)
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/** A started document over a grid, with the page's seams the case names. */
|
||||
@@ -128,9 +129,13 @@ describe("the document's frame on the page", () => {
|
||||
})
|
||||
const scales: string[] = []
|
||||
const style = scope.surface!.style
|
||||
Object.defineProperty(style, 'transform', {
|
||||
set: (value: string) => scales.push(/scale\(([^)]*)\)/.exec(value)?.[1] ?? value),
|
||||
get: () => ''
|
||||
const write = style.setProperty
|
||||
vi.spyOn(style, 'setProperty').mockImplementation((name, value, priority) => {
|
||||
if (name === 'transform') {
|
||||
const transform = String(value)
|
||||
scales.push(/scale\(([^)]*)\)/.exec(transform)?.[1] ?? transform)
|
||||
}
|
||||
write.call(style, name, value, priority)
|
||||
})
|
||||
// The fit's attempt ran and read the hidden host, and committed nothing.
|
||||
await framesUntil(() => widthsRead.includes(0))
|
||||
|
||||
@@ -338,23 +338,24 @@ async function mountedOverGrid({ laidOutFirst = true } = {}) {
|
||||
let box = { width: 390, height: 600 }
|
||||
host.getBoundingClientRect = () => new DOMRect(0, 134, box.width, box.height)
|
||||
const mounted = mountTerminalWebDocument(host, () => {})
|
||||
// Every surface transform the document writes; a refit writes one even when nothing moved. Read
|
||||
// off the style prototype because an init swaps the surface element for a fresh one.
|
||||
// Trace the public writer because init replaces the surface and Happy DOM proxies CSS properties.
|
||||
const scales: number[] = []
|
||||
const proto: object = Object.getPrototypeOf(host.style)
|
||||
const own = Object.getOwnPropertyDescriptor(proto, 'transform')!
|
||||
const write = own.set!
|
||||
Object.defineProperty(proto, 'transform', {
|
||||
...own,
|
||||
set(this: CSSStyleDeclaration, value: string) {
|
||||
const write = CSSStyleDeclaration.prototype.setProperty
|
||||
const trace = vi.spyOn(CSSStyleDeclaration.prototype, 'setProperty').mockImplementation(function (
|
||||
this: CSSStyleDeclaration,
|
||||
name,
|
||||
value,
|
||||
priority
|
||||
) {
|
||||
if (name === 'transform') {
|
||||
const scale = /scale\(([^)]*)\)/.exec(String(value))
|
||||
if (scale) {
|
||||
scales.push(Number(scale[1]))
|
||||
}
|
||||
write.call(this, value)
|
||||
}
|
||||
write.call(this, name, value, priority)
|
||||
})
|
||||
restoreTransform = () => Object.defineProperty(proto, 'transform', own)
|
||||
restoreTransform = () => trace.mockRestore()
|
||||
let id = 0
|
||||
const send = (command: TerminalWebViewCommand) => mounted.send({ ...command, id: ++id })
|
||||
const layOut = (width: number, height: number) => {
|
||||
|
||||
@@ -91,6 +91,35 @@ describe('terminal WebView bundled engine', () => {
|
||||
expect(() => parse(XTERM_ENGINE_JS, { ecmaVersion: 2019 })).not.toThrow()
|
||||
})
|
||||
|
||||
it('answers the mode query OpenCode sends during startup', async () => {
|
||||
vi.useFakeTimers()
|
||||
const reply = vi.fn()
|
||||
const window = {}
|
||||
try {
|
||||
new Script(
|
||||
`${XTERM_ENGINE_JS}; const terminal = new window.Terminal();
|
||||
terminal.onData(reply); terminal.write('\\x1b[?1004$p');`
|
||||
).runInNewContext({
|
||||
window,
|
||||
self: window,
|
||||
document: {},
|
||||
navigator: { platform: 'Linux', userAgent: 'Chrome/74' },
|
||||
structuredClone,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
queueMicrotask,
|
||||
console,
|
||||
URL,
|
||||
reply
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
expect(reply).toHaveBeenCalledWith('\x1b[?1004;2$y')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the context deliberately omits WeakRef (Chrome 84+) / structuredClone
|
||||
// (Chrome 98+) and supplies an Element without replaceChildren (Chrome 86+) —
|
||||
// the engine must evaluate on older WebViews via its own guarded runtime shims,
|
||||
|
||||
@@ -127,10 +127,3 @@ describe.runIf(RUN_LIVE)('issue #10119 — real socket, handshake slower than th
|
||||
expect(labels.slice(firstEscalated).every((l) => l !== 'Connecting…')).toBe(true)
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
// Why: vitest fails a file with zero tests; keep a sentinel for default runs.
|
||||
describe.runIf(!RUN_LIVE)('real-socket handshake stall (skipped)', () => {
|
||||
it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -199,10 +199,3 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Why: vitest fails a file with zero tests; keep a sentinel for default runs.
|
||||
describe.runIf(!RUN_LIVE)('live foreground recovery (skipped)', () => {
|
||||
it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
+31
-31
@@ -18,14 +18,14 @@
|
||||
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
|
||||
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
|
||||
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
|
||||
"audit:react-doctor": "pnpm dlx react-doctor@0.9.1 . --yes --no-supply-chain --no-telemetry --blocking none",
|
||||
"audit:react-doctor": "pnpm dlx react-doctor@0.9.14 . --yes --no-supply-chain --no-telemetry --blocking none",
|
||||
"audit:dead-code": "pnpm dlx knip@5.88.1 --config config/knip.json",
|
||||
"check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs",
|
||||
"check:dead-classes": "oxlint --config config/oxlint-dead-classes.json src/renderer",
|
||||
"lint:design-system": "oxlint --config config/oxlint-design-system.json src/renderer",
|
||||
"check:react-doctor:changed": "node config/scripts/check-react-doctor-changed.mjs",
|
||||
"check:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs --check",
|
||||
"doctor": "pnpm dlx react-doctor@0.9.1 . --no-telemetry",
|
||||
"doctor": "pnpm dlx react-doctor@0.9.14 . --no-telemetry",
|
||||
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
|
||||
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
|
||||
"prepare": "husky",
|
||||
@@ -177,17 +177,17 @@
|
||||
"sync:anti-slop-plugin": "node config/scripts/sync-anti-slop-plugin.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.251",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.284",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@floating-ui/dom": "1.8.0",
|
||||
"@linear/sdk": "^82.1.0",
|
||||
"@linear/sdk": "^97.0.0",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@streamparser/json": "0.0.26",
|
||||
"@xterm/addon-serialize": "0.15.0-beta.300",
|
||||
"@xterm/headless": "6.1.0-beta.302",
|
||||
"agent-browser": "~0.27.0",
|
||||
"electron-updater": "^6.8.9",
|
||||
"i18next": "^26.3.1",
|
||||
"electron-updater": "^6.8.10",
|
||||
"i18next": "^26.4.2",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"posthog-node": "^5.52.5",
|
||||
@@ -197,9 +197,9 @@
|
||||
"serve-sim": "0.1.47",
|
||||
"sherpa-onnx": "1.12.37",
|
||||
"ssh2": "^1.17.0",
|
||||
"tldts": "7.4.14",
|
||||
"tldts": "7.4.16",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.21.3",
|
||||
"ws": "^8.22.0",
|
||||
"yaml": "^2.9.1",
|
||||
"zod": "~4.6.5"
|
||||
},
|
||||
@@ -209,10 +209,10 @@
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@electron/rebuild": "^4.2.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@oxlint/plugins": "1.85.0",
|
||||
"@oxlint/plugins": "1.86.0",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sanity/diff-match-patch": "^3.2.1",
|
||||
"@shadcn/lint": "^0.1.0",
|
||||
"@shadcn/lint": "~0.1.5",
|
||||
"@stablyai/playwright-test": "^2.1.16",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
@@ -240,9 +240,9 @@
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/react": "~19.2.18",
|
||||
"@types/react-dom": "~19.2.7",
|
||||
"@types/ssh2": "^1.15.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@vscode/ripgrep-universal": "1.18.0",
|
||||
@@ -258,36 +258,36 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"diff": "8.0.4",
|
||||
"dompurify": "3.4.15",
|
||||
"dompurify": "3.4.16",
|
||||
"electron": "43.7.5",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-builder-squirrel-windows": "^26.15.3",
|
||||
"electron-builder": "26.15.3",
|
||||
"electron-builder-squirrel-windows": "26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
"emoji-picker-react": "^4.22.3",
|
||||
"emojibase-data": "17.0.0",
|
||||
"esbuild": "^0.25.12",
|
||||
"happy-dom": "^20.11.8",
|
||||
"esbuild": "^0.28.2",
|
||||
"happy-dom": "^20.14.5",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"i18next-cli": "1.74.2",
|
||||
"katex": "^0.16.45",
|
||||
"katex": "^0.16.47",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.17.2",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"oxfmt": "^0.70.0",
|
||||
"oxlint": "^1.85.0",
|
||||
"oxfmt": "^0.71.0",
|
||||
"oxlint": "^1.86.0",
|
||||
"oxlint-plugin-anti-slop": "github:dmmulroy/anti-slop#c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b",
|
||||
"oxlint-plugin-react-doctor": "0.9.1",
|
||||
"oxlint-tsgolint": "7.0.2001",
|
||||
"oxlint-plugin-react-doctor": "0.9.14",
|
||||
"oxlint-tsgolint": "7.0.2003",
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"pngjs": "^7.0.0",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.8",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "~19.2.8",
|
||||
"react-colorful": "^5.7.0",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-dom": "~19.2.8",
|
||||
"react-grab": "^0.1.33",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-refresh": "^0.18.0",
|
||||
@@ -303,8 +303,8 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"shadcn": "^4.13.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tar": "7.5.22",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -315,7 +315,7 @@
|
||||
"vitest": "^4.1.11",
|
||||
"vscode-oniguruma": "^2.0.1",
|
||||
"vscode-textmate": "^9.3.2",
|
||||
"zustand": "^5.0.14"
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@orca/windows-registry": "workspace:*",
|
||||
@@ -339,7 +339,7 @@
|
||||
"engines": {
|
||||
"node": "24"
|
||||
},
|
||||
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
|
||||
"packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45",
|
||||
"reactDoctor": {
|
||||
"ignore": {
|
||||
"overrides": [
|
||||
|
||||
Generated
+2126
-1734
File diff suppressed because it is too large
Load Diff
+1
-8
@@ -11,13 +11,6 @@ packages:
|
||||
- native/windows-registry
|
||||
|
||||
minimumReleaseAge: 4320
|
||||
# 6.3.289 was published 2026-08-29T12:48Z; it clears the 3-day gate on 2026-09-01T12:48Z.
|
||||
# zod 4.5.4 was published 2026-08-29T17:55Z; it clears the 3-day gate on 2026-09-01T17:55Z.
|
||||
minimumReleaseAgeExclude:
|
||||
- pdfjs-dist@6.3.289
|
||||
- zod@4.5.4
|
||||
- '@shadcn/lint@0.1.0'
|
||||
- i18next-cli@1.74.2
|
||||
shamefullyHoist: true
|
||||
|
||||
# Orca always launches the user's own resolved Claude CLI via
|
||||
@@ -57,7 +50,7 @@ allowBuilds:
|
||||
'@orca/windows-registry': false
|
||||
|
||||
overrides:
|
||||
monaco-editor>dompurify: 3.4.15
|
||||
monaco-editor>dompurify: 3.4.16
|
||||
|
||||
patchedDependencies:
|
||||
'@xterm/addon-image@0.10.0-beta.300': config/patches/@xterm__addon-image@0.10.0-beta.300.patch
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { tokenizeCommandLine } from '../../shared/agent-command-line-entrypoint'
|
||||
import { qoderHookService } from '../qoder/hook-service'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parse as parseJsonc } from 'jsonc-parser'
|
||||
@@ -347,23 +348,36 @@ describe('remote hook service installers', () => {
|
||||
}
|
||||
for (const eventName of ['PreInvocation', 'PostInvocation', 'Stop']) {
|
||||
const command = antigravityConfig['orca-status'][eventName]?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
|
||||
expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`)
|
||||
expect(tokenizeCommandLine(command ?? '').slice(0, 2)).toEqual(['/bin/sh', '-c'])
|
||||
expect(tokenizeCommandLine(command ?? '')[2]).toContain(
|
||||
'/home/dev/.orca/agent-hooks/antigravity-hook.sh'
|
||||
)
|
||||
expect(tokenizeCommandLine(command ?? '')[2]).toContain(
|
||||
`ORCA_ANTIGRAVITY_EVENT='${eventName}'`
|
||||
)
|
||||
}
|
||||
for (const eventName of ['PreToolUse', 'PostToolUse']) {
|
||||
const definition = antigravityConfig['orca-status'][eventName]?.[0]
|
||||
const command = definition?.hooks?.[0]?.command
|
||||
expect(definition?.matcher).toBe('*')
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
|
||||
expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`)
|
||||
expect(tokenizeCommandLine(command ?? '')[2]).toContain(
|
||||
'/home/dev/.orca/agent-hooks/antigravity-hook.sh'
|
||||
)
|
||||
expect(tokenizeCommandLine(command ?? '')[2]).toContain(
|
||||
`ORCA_ANTIGRAVITY_EVENT='${eventName}'`
|
||||
)
|
||||
}
|
||||
// Why: #2426 was an SSH report — a remote host missing the script must still answer the gate, not deny every tool.
|
||||
expect(antigravityConfig['orca-status'].PreToolUse[0].hooks?.[0]?.command).toContain(
|
||||
`printf '%s\\n' '{"decision":"ask"}'`
|
||||
)
|
||||
expect(antigravityConfig['orca-status'].PostToolUse[0].hooks?.[0]?.command).not.toContain(
|
||||
'{"decision"'
|
||||
)
|
||||
expect(
|
||||
tokenizeCommandLine(
|
||||
antigravityConfig['orca-status'].PreToolUse[0].hooks?.[0]?.command ?? ''
|
||||
)[2]
|
||||
).toContain(`printf '%s\\n' '{"decision":"ask"}'`)
|
||||
expect(
|
||||
tokenizeCommandLine(
|
||||
antigravityConfig['orca-status'].PostToolUse[0].hooks?.[0]?.command ?? ''
|
||||
)[2]
|
||||
).not.toContain('{"decision"')
|
||||
|
||||
const ampPlugin = amp.fs.files.get('/home/dev/.config/amp/plugins/orca-agent-status.ts')
|
||||
expect(ampPlugin).toContain('/hook/amp')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -317,4 +317,47 @@ describe('listOpenCodeDbSessions', () => {
|
||||
it('returns [] for a missing database instead of throwing', () => {
|
||||
expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([])
|
||||
})
|
||||
|
||||
it('stays quiet for a missing database and detects it once it appears', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const absent = join(dir, 'later.db')
|
||||
expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toEqual([])
|
||||
expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toEqual([])
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
writeDb(absent, 'session')
|
||||
expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toHaveLength(1)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('still warns when the database exists but cannot be read', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
writeFileSync(dbPath, 'this is not a sqlite database'.repeat(100))
|
||||
expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })).toEqual([])
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
'warns when an existing database is behind an inaccessible directory',
|
||||
() => {
|
||||
writeDb(dbPath, 'session')
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
chmodSync(dir, 0o000)
|
||||
expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })).toEqual([])
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
expect(warn.mock.calls[0]?.[1]).toMatchObject({ code: 'EACCES' })
|
||||
} finally {
|
||||
chmodSync(dir, 0o700)
|
||||
warn.mockRestore()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -63,6 +63,46 @@ describe.each(['opencode', 'opencode2'] as const)('%s hook normalization', (sour
|
||||
expect(result?.payload.agentType).toBe(source)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['UnknownError', 'failure', undefined],
|
||||
['MessageAbortedError', 'cancellation', true]
|
||||
])('retains the reported root outcome for %s', (errorName, outcome, interrupted) => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
source,
|
||||
buildBody({
|
||||
hook_event_name: 'SessionIdle',
|
||||
root_state: 'done',
|
||||
root_turn_error_name: errorName
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.mainAgent).toMatchObject({ state: 'done', outcome })
|
||||
expect(result?.payload.interrupted).toBe(interrupted)
|
||||
})
|
||||
|
||||
it('does not infer an outcome for an older plugin', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
source,
|
||||
buildBody({ hook_event_name: 'SessionIdle' }),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.mainAgent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a root failure beside still-working child work', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
source,
|
||||
buildBody({
|
||||
hook_event_name: 'SessionBusy',
|
||||
root_state: 'done',
|
||||
root_turn_error_name: 'api'
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('working')
|
||||
expect(result?.payload.mainAgent).toMatchObject({ state: 'done', outcome: 'failure' })
|
||||
})
|
||||
|
||||
it('PermissionRequest maps to waiting', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
source,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { canReadOpenCodeMessageParts } from './session-scanner-opencode-sqlite-s
|
||||
import type { TranscriptMessage, TranscriptMessageRole } from './session-transcript-consumers'
|
||||
import { boundedText, toolCallText } from './session-transcript-message-content'
|
||||
import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility'
|
||||
import { zcodeTranscriptOrder } from './session-scanner-zcode-order'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
|
||||
// Why: the session list needs the newest few messages, and the search index
|
||||
@@ -120,7 +121,7 @@ function captureRole(role: string | null): TranscriptMessageRole | null {
|
||||
return role === 'user' || role === 'assistant' ? role : null
|
||||
}
|
||||
|
||||
function buildCaptureQuery(agent: 'opencode' | 'zcode'): string {
|
||||
function buildCaptureQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): string {
|
||||
// Message order, then part order within a message: the same key the preview
|
||||
// read uses, run forwards and without the newest-N window.
|
||||
return `SELECT m.id AS message_id,
|
||||
@@ -134,7 +135,8 @@ function buildCaptureQuery(agent: 'opencode' | 'zcode'): string {
|
||||
${zcodeVisibleMessageFilter(agent)}
|
||||
AND json_extract(m.data, '$.role') IN ('user','assistant')
|
||||
AND json_extract(p.data, '$.type') IN ${OPENCODE_CAPTURE_PART_TYPES}
|
||||
ORDER BY m.time_created ASC, m.id ASC, p.time_created ASC, p.rowid ASC
|
||||
ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'ASC')},
|
||||
${zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC')}
|
||||
LIMIT ?`
|
||||
}
|
||||
|
||||
@@ -164,7 +166,7 @@ export function readOpenCodeSessionMessages(
|
||||
)
|
||||
}
|
||||
const rows = db
|
||||
.prepare(buildCaptureQuery(agent))
|
||||
.prepare(buildCaptureQuery(db, agent))
|
||||
.all(sessionId, OPENCODE_CAPTURE_RECORD_LIMIT + 1)
|
||||
if (rows.length > OPENCODE_CAPTURE_RECORD_LIMIT) {
|
||||
throw new Error(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './session-scanner-opencode-sqlite-schema'
|
||||
import { normalizeTitleText } from './session-scanner-values'
|
||||
import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility'
|
||||
import { zcodeTranscriptOrder } from './session-scanner-zcode-order'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
|
||||
|
||||
@@ -162,11 +163,11 @@ function readFirstUserPromptFromOpenCodeDb(
|
||||
${zcodeVisibleMessageFilter(agent)}
|
||||
AND json_extract(m.data, '$.role') = 'user'
|
||||
AND json_extract(fp.data, '$.type') = 'text'
|
||||
ORDER BY m.time_created ASC, m.id ASC
|
||||
ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'ASC')}
|
||||
LIMIT 1
|
||||
)
|
||||
AND json_extract(p.data, '$.type') = 'text'
|
||||
ORDER BY p.time_created ASC, p.rowid ASC
|
||||
ORDER BY ${zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC')}
|
||||
LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}`
|
||||
)
|
||||
.all(sessionId)
|
||||
@@ -198,15 +199,19 @@ function buildPreviewQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): strin
|
||||
p.time_created,
|
||||
json_extract(m.data, '$.summary.title') AS summary_title,
|
||||
json_extract(m.data, '$.summary.body') AS summary_body
|
||||
FROM (SELECT id, data FROM message
|
||||
WHERE session_id = ?
|
||||
${zcodeVisibleMessageFilter(agent, 'data')}
|
||||
ORDER BY time_created DESC, id DESC
|
||||
FROM (SELECT ${agent === 'zcode' ? 'm.rowid AS rowid, m.*' : 'm.id, m.data'} FROM message m
|
||||
WHERE m.session_id = ?
|
||||
${zcodeVisibleMessageFilter(agent)}
|
||||
ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'DESC')}
|
||||
LIMIT ${OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW}) m
|
||||
JOIN part p ON p.message_id = m.id
|
||||
WHERE json_extract(m.data, '$.role') IN ('user','assistant')
|
||||
AND json_extract(p.data, '$.type') = 'text'
|
||||
ORDER BY p.time_created DESC
|
||||
ORDER BY ${
|
||||
agent === 'zcode'
|
||||
? `${zcodeTranscriptOrder(db, agent, 'message', 'm', 'DESC')}, ${zcodeTranscriptOrder(db, agent, 'part', 'p', 'DESC')}`
|
||||
: 'p.time_created DESC'
|
||||
}
|
||||
LIMIT ?`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { columnExists } from '../opencode-usage/schema-helpers'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
|
||||
export function zcodeTranscriptOrder(
|
||||
db: SyncDatabase,
|
||||
agent: 'opencode' | 'zcode',
|
||||
table: 'message' | 'part',
|
||||
alias: 'm' | 'p',
|
||||
direction: 'ASC' | 'DESC'
|
||||
): string {
|
||||
const sequenced = agent === 'zcode' && columnExists(db, table, 'sequence')
|
||||
const sequence = sequenced
|
||||
? `${alias}.sequence IS NULL ${direction}, ${alias}.sequence ${direction}, `
|
||||
: ''
|
||||
// ZCode orders imported/forked transcripts by sequence, with legacy NULL rows last.
|
||||
const tieBreaker = table === 'message' ? (sequenced ? 'rowid' : 'id') : sequenced ? 'id' : 'rowid'
|
||||
return `${sequence}${alias}.time_created ${direction}, ${alias}.${tieBreaker} ${direction}`
|
||||
}
|
||||
@@ -57,6 +57,122 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ZCode AI Vault SQLite worker', () => {
|
||||
it.each(['message', 'part', 'both'] as const)(
|
||||
'reads partially migrated %s sequence columns and keeps legacy NULL rows last',
|
||||
async (tables) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-legacy-sequence-'))
|
||||
directories.push(directory)
|
||||
const dbPath = join(directory, 'db.sqlite')
|
||||
writeOpenCodeSqliteDatabase(dbPath, [
|
||||
{
|
||||
id: 'legacy-session',
|
||||
turns: [
|
||||
{ role: 'user', parts: ['first', 'second', 'legacy part'] },
|
||||
{ role: 'user', parts: ['legacy message'] }
|
||||
]
|
||||
}
|
||||
])
|
||||
const db = new SyncDatabase(dbPath)
|
||||
try {
|
||||
if (tables !== 'part') {
|
||||
db.exec(`ALTER TABLE message ADD COLUMN sequence INTEGER;
|
||||
UPDATE message SET sequence = 0 WHERE rowid = 1;
|
||||
UPDATE message SET time_created = 1 WHERE rowid = 2;`)
|
||||
}
|
||||
if (tables !== 'message') {
|
||||
db.exec(`ALTER TABLE part ADD COLUMN sequence INTEGER;
|
||||
UPDATE part SET sequence = rowid WHERE rowid <= 2;
|
||||
UPDATE part SET time_created = 1 WHERE rowid = 3;`)
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
const captured = await handleOpenCodeSqliteRequest({
|
||||
id: 8,
|
||||
kind: 'capture',
|
||||
agent: 'zcode',
|
||||
dbPath,
|
||||
sessionId: 'legacy-session',
|
||||
platform: 'win32'
|
||||
})
|
||||
expect(captured.ok).toBe(true)
|
||||
if (!captured.ok) {
|
||||
return
|
||||
}
|
||||
expect(readMessageTexts(captured.value)).toEqual([
|
||||
{ text: 'first\nsecond\nlegacy part' },
|
||||
{ text: 'legacy message' }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('uses stored message and part sequence for first prompt, preview, and search', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-sequence-'))
|
||||
directories.push(directory)
|
||||
const dbPath = join(directory, 'db.sqlite')
|
||||
writeOpenCodeSqliteDatabase(dbPath, [
|
||||
{
|
||||
id: 'ordered-session',
|
||||
turns: [
|
||||
{ role: 'user', parts: ['first prompt start', 'first prompt end'] },
|
||||
{ role: 'assistant', parts: ['first response'] },
|
||||
{ role: 'user', parts: ['second prompt'] },
|
||||
{ role: 'assistant', parts: ['second response start', 'second response end'] }
|
||||
]
|
||||
}
|
||||
])
|
||||
const db = new SyncDatabase(dbPath)
|
||||
try {
|
||||
db.exec(`ALTER TABLE message ADD COLUMN sequence INTEGER;
|
||||
ALTER TABLE part ADD COLUMN sequence INTEGER;
|
||||
UPDATE message SET sequence = rowid, time_created = time_created - rowid * 120000;
|
||||
UPDATE part SET sequence = rowid, time_created = time_created - rowid * 120000;`)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
const parsed = await handleOpenCodeSqliteRequest({
|
||||
id: 6,
|
||||
kind: 'parse',
|
||||
agent: 'zcode',
|
||||
dbPath,
|
||||
sessionId: 'ordered-session',
|
||||
platform: 'darwin',
|
||||
fullFirstUserPrompt: true
|
||||
})
|
||||
expect(parsed).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
firstUserPrompt: 'first prompt start\nfirst prompt end',
|
||||
previewMessagesTruncated: true,
|
||||
previewMessages: [
|
||||
{ text: 'first prompt end' },
|
||||
{ text: 'first response' },
|
||||
{ text: 'second prompt' },
|
||||
{ text: 'second response start' },
|
||||
{ text: 'second response end' }
|
||||
]
|
||||
}
|
||||
})
|
||||
const captured = await handleOpenCodeSqliteRequest({
|
||||
id: 7,
|
||||
kind: 'capture',
|
||||
agent: 'zcode',
|
||||
dbPath,
|
||||
sessionId: 'ordered-session',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(captured.ok).toBe(true)
|
||||
if (!captured.ok) {
|
||||
return
|
||||
}
|
||||
expect(readMessageTexts(captured.value)).toEqual([
|
||||
{ text: 'first prompt start\nfirst prompt end' },
|
||||
{ text: 'first response' },
|
||||
{ text: 'second prompt' },
|
||||
{ text: 'second response start\nsecond response end' }
|
||||
])
|
||||
})
|
||||
|
||||
it('lists, parses, and captures ZCode sessions without labelling them OpenCode', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-ai-vault-'))
|
||||
directories.push(directory)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
POSIX_HOOK_JSON_STDIN,
|
||||
buildPosixHookSpoolLines,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
@@ -55,7 +56,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
'esac',
|
||||
// Why: some Antigravity events arrive without stdin but still need a
|
||||
// status post, so the shared capture maps empty input to an object.
|
||||
...buildPosixHookPayloadCapture('empty-object'),
|
||||
...buildPosixHookPayloadCapture('empty-object', POSIX_HOOK_JSON_STDIN),
|
||||
...buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT'),
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
|
||||
@@ -16,8 +16,9 @@ vi.mock('os', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
import { tokenizeCommandLine } from '../../shared/agent-command-line-entrypoint'
|
||||
import { AntigravityHookService } from './hook-service'
|
||||
import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract'
|
||||
import { POSIX_HOOK_JSON_STDIN_READER } from '../agent-hooks/hook-stdin-contract'
|
||||
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
|
||||
|
||||
const ANTIGRAVITY_SCRIPT_FILE_NAME =
|
||||
@@ -86,10 +87,12 @@ describe('AntigravityHookService', () => {
|
||||
if (process.platform === 'win32') {
|
||||
expect(config['orca-status'].PreInvocation[0].command).not.toContain('ORCA_ANTIGRAVITY_EVENT')
|
||||
} else {
|
||||
expect(config['orca-status'].PreInvocation[0].command).toContain(
|
||||
"ORCA_ANTIGRAVITY_EVENT='PreInvocation'"
|
||||
expect(
|
||||
tokenizeCommandLine(config['orca-status'].PreInvocation[0].command ?? '')[2]
|
||||
).toContain("ORCA_ANTIGRAVITY_EVENT='PreInvocation'")
|
||||
expect(tokenizeCommandLine(config['orca-status'].Stop[0].command ?? '')[2]).toContain(
|
||||
"ORCA_ANTIGRAVITY_EVENT='Stop'"
|
||||
)
|
||||
expect(config['orca-status'].Stop[0].command).toContain("ORCA_ANTIGRAVITY_EVENT='Stop'")
|
||||
}
|
||||
|
||||
const script = readFileSync(
|
||||
@@ -106,7 +109,7 @@ describe('AntigravityHookService', () => {
|
||||
expect(script).toContain('setlocal DisableDelayedExpansion')
|
||||
} else {
|
||||
expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}')
|
||||
expect(script).toContain(`payload=$(${POSIX_HOOK_STDIN_READER})`)
|
||||
expect(script).toContain(`payload=$(${POSIX_HOOK_JSON_STDIN_READER})`)
|
||||
expect(script).toContain("payload='{}'")
|
||||
expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi')
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ANTIGRAVITY_PRE_TOOL_USE_DECISION,
|
||||
type AntigravityEvent
|
||||
} from './hook-events'
|
||||
import { quotePosixShellString } from '../agent-hooks/posix-hook-command'
|
||||
import { getManagedScript, getWindowsWrapperScript } from './hook-script'
|
||||
import {
|
||||
buildInstalledConfig,
|
||||
@@ -51,12 +52,14 @@ function getWindowsWrapperScriptPath(event: AntigravityEvent): string {
|
||||
}
|
||||
|
||||
function getPosixManagedCommand(scriptPath: string, event: AntigravityEvent): string {
|
||||
return wrapPosixHookCommand(
|
||||
const command = wrapPosixHookCommand(
|
||||
scriptPath,
|
||||
{ ORCA_ANTIGRAVITY_EVENT: event.eventName },
|
||||
// Why: a missing managed script must not brick tools; the guard answers PreToolUse itself instead of staying silent.
|
||||
event.eventName === 'PreToolUse' ? { fallbackStdout: ANTIGRAVITY_PRE_TOOL_USE_DECISION } : {}
|
||||
)
|
||||
// ACP hosts tokenize the command into argv; the shell must be the executable, not `if`.
|
||||
return `/bin/sh -c ${quotePosixShellString(command)}`
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string, event: AntigravityEvent): string {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as OsModule from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'node:http'
|
||||
import { z } from 'zod'
|
||||
import { tokenizeCommandLine } from '../../shared/agent-command-line-entrypoint'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import { AntigravityHookService } from './hook-service'
|
||||
|
||||
const { homeMock } = vi.hoisted(() => ({ homeMock: vi.fn<() => string>() }))
|
||||
vi.mock('node:os', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof OsModule>()),
|
||||
homedir: homeMock
|
||||
}))
|
||||
|
||||
const hooksSchema = z.object({
|
||||
'orca-status': z.record(
|
||||
z.string(),
|
||||
z.array(
|
||||
z.object({
|
||||
command: z.string().optional(),
|
||||
hooks: z.array(z.object({ command: z.string() })).optional()
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
function installedCommand(home: string, event: string): string {
|
||||
homeMock.mockReturnValue(home)
|
||||
expect(new AntigravityHookService().install().state).toBe('installed')
|
||||
const config = hooksSchema.parse(
|
||||
JSON.parse(readFileSync(join(home, '.gemini', 'config', 'hooks.json'), 'utf8'))
|
||||
)
|
||||
const hook = config['orca-status'][event]?.[0]
|
||||
const command = hook?.command ?? hook?.hooks?.[0]?.command
|
||||
if (!command) {
|
||||
throw new Error(`Missing ${event} hook`)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
function invoke(command: string, input: string, env: NodeJS.ProcessEnv) {
|
||||
const [program, ...args] = tokenizeCommandLine(command)
|
||||
if (!program) {
|
||||
throw new Error('Missing executable')
|
||||
}
|
||||
expect(program).toBe('/bin/sh')
|
||||
return new Promise<{ code: number | null; stdout: string; elapsedMs: number }>(
|
||||
(resolve, reject) => {
|
||||
const start = Date.now()
|
||||
const child = spawnProcess({ program, args, env })
|
||||
let stdout = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
child.stdin.on('error', () => {})
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 8000)
|
||||
child.on('error', reject)
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
resolve({ code, stdout: stdout.trim(), elapsedMs: Date.now() - start })
|
||||
})
|
||||
// agy may leave stdin open after sending one JSON value.
|
||||
if (input) {
|
||||
child.stdin.write(Buffer.from(input))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let home = ''
|
||||
afterEach(() => {
|
||||
if (home) {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('Antigravity POSIX hook executable contract', () => {
|
||||
it('delivers UTF-8 JSON with open stdin through a directly spawned command', async () => {
|
||||
home = mkdtempSync(join(tmpdir(), "orca-agy-hook-'quoted-"))
|
||||
const payload = JSON.stringify({
|
||||
tool_name: 'run_command',
|
||||
tool_input: { command: 'echo café 日本語 😀' }
|
||||
})
|
||||
const posts: URLSearchParams[] = []
|
||||
const server = createServer((req, res) => {
|
||||
let body = ''
|
||||
req.setEncoding('utf8')
|
||||
req.on('data', (chunk: string) => {
|
||||
body += chunk
|
||||
})
|
||||
req.on('end', () => {
|
||||
posts.push(new URLSearchParams(body))
|
||||
res.end('{}')
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (typeof address !== 'object' || address === null) {
|
||||
throw new Error('Missing listener port')
|
||||
}
|
||||
try {
|
||||
const result = await invoke(installedCommand(home, 'PreToolUse'), payload, {
|
||||
...process.env,
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_AGENT_HOOK_PORT: String(address.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: 'test-only-token',
|
||||
ORCA_PANE_KEY: 'test-pane'
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stdout).toBe('{"decision":"ask"}')
|
||||
expect(result.elapsedMs).toBeLessThan(8000)
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]?.get('payload')).toBe(payload)
|
||||
expect(posts[0]?.get('hook_event_name')).toBe('PreToolUse')
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
it('returns after a bounded wait when stdin has no bytes and remains open', async () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'orca-agy-empty-hook-'))
|
||||
const result = await invoke(installedCommand(home, 'PreInvocation'), '', {
|
||||
...process.env,
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_AGENT_HOOK_PORT: '',
|
||||
ORCA_AGENT_HOOK_TOKEN: '',
|
||||
ORCA_PANE_KEY: ''
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stdout).toBe('{}')
|
||||
expect(result.elapsedMs).toBeLessThan(8000)
|
||||
}, 10000)
|
||||
})
|
||||
@@ -32,6 +32,8 @@ afterAll(() => {
|
||||
function probeMain(resultPath: string, protectedGuest: boolean): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
// This data-channel probe does not need Linux GPU initialization.
|
||||
if (process.platform === 'linux') app.disableHardwareAcceleration()
|
||||
const dgram = require('node:dgram')
|
||||
const net = require('node:net')
|
||||
const os = require('node:os')
|
||||
|
||||
@@ -28,7 +28,7 @@ import { createClaudeStructuredLaunchResolver } from './claude-structured-launch
|
||||
const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs')
|
||||
const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f'
|
||||
const LEAF_UUID = 'ad0f7c9e-1b2c-4d3e-8f90-abc123def456'
|
||||
const PINNED_SDK_VERSION = '0.3.251'
|
||||
const PINNED_SDK_VERSION = '0.3.284'
|
||||
const SDK_PLATFORM_PACKAGE_BASENAMES = [
|
||||
'claude-agent-sdk-darwin-arm64',
|
||||
'claude-agent-sdk-darwin-x64',
|
||||
|
||||
@@ -114,11 +114,13 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
|
||||
leafUuid: null
|
||||
})
|
||||
expect(observedSubtypes).toContain('hook_started')
|
||||
expect(adapter.readCommands('real-cli-handshake')).toContainEqual({
|
||||
name: 'orca-init-catalog-proof',
|
||||
kind: 'command',
|
||||
kindUnspecified: true
|
||||
})
|
||||
expect(adapter.readCommands('real-cli-handshake')).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'orca-init-catalog-proof',
|
||||
kind: 'command',
|
||||
kindUnspecified: true
|
||||
})
|
||||
)
|
||||
expect(
|
||||
adapter.readCommands('real-cli-handshake')?.some(({ name }) => name === 'help')
|
||||
).toBe(false)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { withPlatform } from '../window/createMainWindow-test-harness'
|
||||
import {
|
||||
createLowCommitOomRecoveryGate,
|
||||
LOW_COMMIT_REPEAT_OOM_WINDOW_MS
|
||||
} from './low-commit-oom-recovery-gate'
|
||||
|
||||
const OOM: Electron.RenderProcessGoneDetails = { reason: 'oom', exitCode: -536870904 }
|
||||
const CRASHED: Electron.RenderProcessGoneDetails = { reason: 'crashed', exitCode: 5 }
|
||||
// Launch 22912 (Scan-30 1790622432/1790622459): OOM at 19:06:54.6, reload OOMed again at 19:07:28.7.
|
||||
const FIRST_OOM = Date.parse('2026-09-28T19:06:54.600Z')
|
||||
const RELOAD_OOM = Date.parse('2026-09-28T19:07:28.700Z')
|
||||
|
||||
function sample(swapFreeMB: number | undefined, ageMs = 4_000) {
|
||||
return () => ({
|
||||
systemMemoryPreGoneSampleAgeMs: ageMs,
|
||||
...(swapFreeMB === undefined ? {} : { systemMemoryPreGoneSwapFreeMB: swapFreeMB })
|
||||
})
|
||||
}
|
||||
|
||||
// A gone-time read that resolved no commit field, as off-Electron.
|
||||
const NO_GONE_TIME_READING = () => ({})
|
||||
|
||||
function goneTime(swapFreeMB: number) {
|
||||
return () => ({ systemMemorySwapFreeMB: swapFreeMB })
|
||||
}
|
||||
|
||||
function observeTwice(
|
||||
read: ReturnType<typeof sample>,
|
||||
second = OOM,
|
||||
gapMs = RELOAD_OOM - FIRST_OOM,
|
||||
platform: NodeJS.Platform = 'win32',
|
||||
readGoneTime: () => Record<string, number> = NO_GONE_TIME_READING
|
||||
) {
|
||||
return withPlatform(platform, () => {
|
||||
const gate = createLowCommitOomRecoveryGate(read, readGoneTime)
|
||||
const first = gate.assess(OOM, FIRST_OOM)
|
||||
gate.recordRecoveredDeath(OOM, FIRST_OOM)
|
||||
return [first, gate.assess(second, FIRST_OOM + gapMs)]
|
||||
})
|
||||
}
|
||||
|
||||
describe('createLowCommitOomRecoveryGate', () => {
|
||||
it('holds the reload of a repeat OOM with 60 MB of commit left', () => {
|
||||
expect(observeTwice(sample(60))).toEqual([
|
||||
null,
|
||||
{ availableCommitMB: 60, sincePreviousOomMs: 34_100, commitReading: 'pre-gone' }
|
||||
])
|
||||
})
|
||||
|
||||
it('always lets the first OOM of the launch auto-reload, even with 5 MB left', () => {
|
||||
expect(observeTwice(sample(5))[0]).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['commit is healthy (744 MB)', sample(744), OOM, 34_100, 'win32'],
|
||||
[
|
||||
'the previous OOM was over 5 minutes ago',
|
||||
sample(60),
|
||||
OOM,
|
||||
LOW_COMMIT_REPEAT_OOM_WINDOW_MS + 1,
|
||||
'win32'
|
||||
],
|
||||
['the death is not an OOM', sample(60), CRASHED, 34_100, 'win32'],
|
||||
['no commit reading exists', sample(undefined), OOM, 34_100, 'win32'],
|
||||
['the reading is stale', sample(60, 31_000), OOM, 34_100, 'win32'],
|
||||
['the host is macOS', sample(60), OOM, 34_100, 'darwin'],
|
||||
['the host is Linux', sample(60), OOM, 34_100, 'linux']
|
||||
] as const)('reloads when %s', (_label, read, second, gapMs, platform) => {
|
||||
expect(observeTwice(read, second, gapMs, platform)[1]).toBeNull()
|
||||
})
|
||||
|
||||
// Launch 13084 (Scan-31): 12:20:37.207 then 12:22:59.975; each OOM restarts the window.
|
||||
it('measures the window from the most recent OOM', () => {
|
||||
const verdicts = withPlatform('win32', () => {
|
||||
const gate = createLowCommitOomRecoveryGate(sample(60, 2_000), NO_GONE_TIME_READING)
|
||||
return [
|
||||
'2026-09-29T12:06:19.869Z',
|
||||
'2026-09-29T12:20:37.207Z',
|
||||
'2026-09-29T12:20:40.665Z',
|
||||
'2026-09-29T12:22:59.975Z'
|
||||
].map((iso) => {
|
||||
const verdict = gate.assess(OOM, Date.parse(iso))
|
||||
gate.recordRecoveredDeath(OOM, Date.parse(iso))
|
||||
return verdict
|
||||
})
|
||||
})
|
||||
expect(verdicts.map((v) => v?.sincePreviousOomMs ?? null)).toEqual([null, null, 3_458, 139_310])
|
||||
})
|
||||
|
||||
// Launch 13084: the repeat OOM came 3.458 s after the previous one, inside one 10 s sampler tick.
|
||||
describe('when no sampler tick landed since the previous OOM', () => {
|
||||
const gapMs = 3_458
|
||||
|
||||
it.each([
|
||||
['taken before the previous OOM', 5_000],
|
||||
['taken exactly at the previous OOM', gapMs],
|
||||
['stale', 31_000]
|
||||
])('reads commit at gone time instead of trusting a reading %s', (_label, ageMs) => {
|
||||
expect(observeTwice(sample(744, ageMs), OOM, gapMs, 'win32', goneTime(60))[1]).toEqual({
|
||||
availableCommitMB: 60,
|
||||
sincePreviousOomMs: gapMs,
|
||||
commitReading: 'gone-time'
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads when the gone-time reading shows commit recovered (2029 MB)', () => {
|
||||
expect(observeTwice(sample(60, 5_000), OOM, gapMs, 'win32', goneTime(2_029))[1]).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers a reading taken after the previous OOM over the gone-time one', () => {
|
||||
expect(observeTwice(sample(60, 1_000), OOM, gapMs, 'win32', goneTime(2_029))[1]).toEqual({
|
||||
availableCommitMB: 60,
|
||||
sincePreviousOomMs: gapMs,
|
||||
commitReading: 'pre-gone'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads nothing on macOS or Linux', () => {
|
||||
const readGoneTime = vi.fn(goneTime(60))
|
||||
for (const platform of ['darwin', 'linux'] as const) {
|
||||
expect(observeTwice(sample(60, 5_000), OOM, gapMs, platform, readGoneTime)[1]).toBeNull()
|
||||
}
|
||||
expect(readGoneTime).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it.each([Number.NaN, Infinity, -1])(
|
||||
'does not block recovery on an invalid commit reading (%s)',
|
||||
(commitMB) => {
|
||||
expect(observeTwice(sample(commitMB), OOM, 34_100, 'win32', goneTime(commitMB))[1]).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([Number.NaN, Infinity, -1])('ignores an invalid sample age (%s)', (ageMs) => {
|
||||
expect(observeTwice(sample(60, ageMs), OOM, 34_100, 'win32', goneTime(2_029))[1]).toBeNull()
|
||||
})
|
||||
|
||||
it.each([0, -1])('does not block recovery when the clock fails to advance (%s)', (gapMs) => {
|
||||
expect(observeTwice(sample(60), OOM, gapMs, 'win32', goneTime(60))[1]).toBeNull()
|
||||
})
|
||||
|
||||
it('does not start the repeat window for an OOM that was never recovered', () => {
|
||||
const verdict = withPlatform('win32', () => {
|
||||
const gate = createLowCommitOomRecoveryGate(sample(60, 2_000), NO_GONE_TIME_READING)
|
||||
gate.assess(OOM, FIRST_OOM)
|
||||
return gate.assess(OOM, RELOAD_OOM)
|
||||
})
|
||||
expect(verdict).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
|
||||
import { preGoneSystemMemoryDetails } from './pre-gone-host-memory'
|
||||
import { getSystemMemoryDetails, SYSTEM_MEMORY_KEY_PREFIX } from './system-memory-details'
|
||||
|
||||
// Why: when Windows commit is exhausted by another program, a recovery reload OOMs again within seconds
|
||||
// (launch 13084: 3.5 s after the reload; launch 22912: 34 s), so a repeat OOM on a starved host asks the user instead.
|
||||
export const LOW_COMMIT_REPEAT_OOM_WINDOW_MS = 5 * 60_000
|
||||
export const LOW_COMMIT_AVAILABLE_MB_THRESHOLD = 512
|
||||
// Two missed 10 s sampler ticks: an older reading may predate the squeeze or its relief.
|
||||
const LOW_COMMIT_MAX_SAMPLE_AGE_MS = 30_000
|
||||
|
||||
export type LowCommitOomVerdict = {
|
||||
/** Pre-gone MEMORYSTATUSEX.ullAvailPageFile, i.e. commit still available. */
|
||||
availableCommitMB: number
|
||||
sincePreviousOomMs: number
|
||||
/** 'gone-time' when no sampler tick landed since the previous OOM and the gate read commit itself. */
|
||||
commitReading: 'pre-gone' | 'gone-time'
|
||||
}
|
||||
|
||||
export type LowCommitOomRecoveryGate = {
|
||||
/** Read at gone time; returns a verdict only when auto-reload would run straight back into the OOM. */
|
||||
assess: (details: Electron.RenderProcessGoneDetails, now: number) => LowCommitOomVerdict | null
|
||||
/** Call only once the death is actually recovered, so a skipped teardown OOM cannot start the repeat window. */
|
||||
recordRecoveredDeath: (details: Electron.RenderProcessGoneDetails, goneAt: number) => void
|
||||
}
|
||||
|
||||
type MemoryDetails = Record<string, CrashReportDetailValue>
|
||||
|
||||
function usablePreGoneCommitMB(sample: MemoryDetails, sincePreviousOomMs: number): number | null {
|
||||
const availableCommitMB = sample[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSwapFreeMB`]
|
||||
const sampleAgeMs = sample[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSampleAgeMs`]
|
||||
if (
|
||||
typeof availableCommitMB !== 'number' ||
|
||||
typeof sampleAgeMs !== 'number' ||
|
||||
!Number.isFinite(availableCommitMB) ||
|
||||
availableCommitMB < 0 ||
|
||||
!Number.isFinite(sampleAgeMs) ||
|
||||
sampleAgeMs < 0 ||
|
||||
sampleAgeMs > LOW_COMMIT_MAX_SAMPLE_AGE_MS ||
|
||||
// Why: a reading from before the previous OOM misses the commit that corpse released.
|
||||
sampleAgeMs >= sincePreviousOomMs
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return availableCommitMB
|
||||
}
|
||||
|
||||
export function createLowCommitOomRecoveryGate(
|
||||
readPreGoneDetails: (now: number) => MemoryDetails = preGoneSystemMemoryDetails,
|
||||
readGoneTimeDetails: () => MemoryDetails = getSystemMemoryDetails
|
||||
): LowCommitOomRecoveryGate {
|
||||
let previousOomAt: number | null = null
|
||||
return {
|
||||
assess: (details, now) => {
|
||||
const previous = previousOomAt
|
||||
if (
|
||||
// Only win32 swapFree is available commit; elsewhere it is not a verdict.
|
||||
process.platform !== 'win32' ||
|
||||
details.reason !== 'oom' ||
|
||||
previous === null ||
|
||||
!Number.isFinite(now) ||
|
||||
now <= previous ||
|
||||
now - previous > LOW_COMMIT_REPEAT_OOM_WINDOW_MS
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const sincePreviousOomMs = now - previous
|
||||
const preGoneMB = usablePreGoneCommitMB(readPreGoneDetails(now), sincePreviousOomMs)
|
||||
// Why fall back: a 10 s sampler misses most ~3.5 s repeat loops. A gone-time read sees commit the corpse
|
||||
// already released, so it can only over-report and miss a prompt, never raise a false one.
|
||||
const goneTimeMB =
|
||||
preGoneMB === null ? readGoneTimeDetails()[`${SYSTEM_MEMORY_KEY_PREFIX}SwapFreeMB`] : null
|
||||
const availableCommitMB = preGoneMB ?? goneTimeMB
|
||||
if (
|
||||
typeof availableCommitMB !== 'number' ||
|
||||
!Number.isFinite(availableCommitMB) ||
|
||||
availableCommitMB < 0 ||
|
||||
availableCommitMB >= LOW_COMMIT_AVAILABLE_MB_THRESHOLD
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
availableCommitMB,
|
||||
sincePreviousOomMs,
|
||||
commitReading: preGoneMB === null ? 'gone-time' : 'pre-gone'
|
||||
}
|
||||
},
|
||||
recordRecoveredDeath: (details, goneAt) => {
|
||||
if (details.reason === 'oom') {
|
||||
previousOomAt = goneAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
buildWindowsAgentHookPostCommand,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand
|
||||
buildWindowsHookPowerShellCommand,
|
||||
wrapWindowsPowerShellEncodedCommand
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
@@ -27,14 +28,19 @@ export function getPosixManagedCommand(scriptPath: string, eventName: CursorEven
|
||||
}
|
||||
|
||||
export function getManagedCommand(scriptPath: string, eventName: CursorEvent): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return getPosixManagedCommand(scriptPath, eventName)
|
||||
}
|
||||
const response = getCursorHookResponse(eventName)
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsHookCommand(
|
||||
scriptPath,
|
||||
{ [CURSOR_HOOK_RESPONSE_ENV]: response },
|
||||
{ fallbackStdout: response }
|
||||
)
|
||||
: getPosixManagedCommand(scriptPath, eventName)
|
||||
const command = buildWindowsHookPowerShellCommand(
|
||||
scriptPath,
|
||||
{ [CURSOR_HOOK_RESPONSE_ENV]: response },
|
||||
{ fallbackStdout: response }
|
||||
)
|
||||
// PowerShell 5.1 defaults to the ANSI code page when translating hook stdin.
|
||||
return wrapWindowsPowerShellEncodedCommand(
|
||||
`[Console]::InputEncoding = New-Object System.Text.UTF8Encoding; $OutputEncoding = [Console]::InputEncoding; ${command}`
|
||||
)
|
||||
}
|
||||
|
||||
export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { findGitBash } from '../agent-hooks/windows-git-bash-path.test-fixture'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
@@ -241,12 +243,15 @@ describe('CursorHookService', () => {
|
||||
HOOK_CASE_TIMEOUT_MS
|
||||
)
|
||||
|
||||
it(
|
||||
'emits protocol-valid JSON when the managed Cursor script is missing (#15462)',
|
||||
() => {
|
||||
it.each(['ordinary', 'spaced'] as const)(
|
||||
'emits protocol-valid JSON when the script is missing in a %s profile (#15462)',
|
||||
(profileKind) => {
|
||||
const installHome = profileKind === 'spaced' ? join(homeDir, 'profile with spaces') : homeDir
|
||||
mkdirSync(installHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(installHome)
|
||||
expect(new CursorHookService().install().state).toBe('installed')
|
||||
const config = readInstalledCursorHooks(homeDir)
|
||||
unlinkSync(join(homeDir, '.orca', 'agent-hooks', CURSOR_SCRIPT_FILE_NAME))
|
||||
const config = readInstalledCursorHooks(installHome)
|
||||
unlinkSync(join(installHome, '.orca', 'agent-hooks', CURSOR_SCRIPT_FILE_NAME))
|
||||
|
||||
for (const eventName of CURSOR_EVENTS) {
|
||||
const command = requireRegisteredCommand(config, eventName)
|
||||
@@ -287,6 +292,63 @@ describe('CursorHookService', () => {
|
||||
HOOK_CASE_TIMEOUT_MS
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32').each(['ordinary', 'spaced'] as const)(
|
||||
'posts UTF-8 stdin unchanged for a %s profile path',
|
||||
async (profileKind) => {
|
||||
const installHome = profileKind === 'spaced' ? join(homeDir, 'profile with spaces') : homeDir
|
||||
mkdirSync(installHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(installHome)
|
||||
expect(new CursorHookService().install().state).toBe('installed')
|
||||
const command = requireRegisteredCommand(
|
||||
readInstalledCursorHooks(installHome),
|
||||
'afterAgentResponse'
|
||||
)
|
||||
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
|
||||
const payload = JSON.stringify({ text: 'Сердце, почта в ЛК — 日本語 中文 한국어 😀 café' })
|
||||
const posts: string[] = []
|
||||
const server: Server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
req.on('end', () => {
|
||||
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
|
||||
const posted = form.get('payload')
|
||||
if (posted !== null) {
|
||||
posts.push(posted)
|
||||
}
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
resolve(typeof address === 'object' && address ? address.port : 0)
|
||||
})
|
||||
})
|
||||
try {
|
||||
const result = await runProcess({
|
||||
program: 'cmd.exe',
|
||||
args: ['/d', '/c', command],
|
||||
input: payload,
|
||||
timeoutMs: HOOK_RUN_TIMEOUT_MS,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_AGENT_HOOK_PORT: String(port),
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_PANE_KEY: 'tab:leaf'
|
||||
}
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(JSON.parse(result.stdout)).toEqual({})
|
||||
expect(posts).toEqual([payload])
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'emits parseable JSON through cmd.exe and Git Bash (#14825/#15462)',
|
||||
() => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { subscribe, type AsyncSubscription, type Event } from '@parcel/watcher'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
|
||||
|
||||
describe('filesystem watcher native ignores', () => {
|
||||
let root: string | null = null
|
||||
let subscription: AsyncSubscription | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
await subscription?.unsubscribe()
|
||||
subscription = null
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
root = null
|
||||
}
|
||||
})
|
||||
|
||||
it('drops root and nested generated-file storms while delivering source edits', async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-watch-ignore-')))
|
||||
const rootModules = join(root, 'node_modules')
|
||||
const nestedModules = join(root, 'packages', 'app', 'node_modules')
|
||||
const nestedSource = join(root, 'packages', 'app', 'src')
|
||||
await Promise.all(
|
||||
[rootModules, nestedModules, nestedSource].map((directory) =>
|
||||
mkdir(directory, { recursive: true })
|
||||
)
|
||||
)
|
||||
|
||||
const events: Event[] = []
|
||||
const errors: Error[] = []
|
||||
subscription = await subscribe(
|
||||
root,
|
||||
(error, batch) => {
|
||||
if (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
events.push(...batch)
|
||||
},
|
||||
buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
|
||||
)
|
||||
|
||||
const generatedNames = Array.from({ length: 20 }, (_, index) => `generated-${index}.js`)
|
||||
// Windows forbids control characters in filenames.
|
||||
if (process.platform !== 'win32') {
|
||||
generatedNames.push('generated\nnewline.js')
|
||||
}
|
||||
const generatedFiles = [rootModules, nestedModules].flatMap((directory) =>
|
||||
generatedNames.map((name) => join(directory, name))
|
||||
)
|
||||
await Promise.all(generatedFiles.map((file) => writeFile(file, 'generated')))
|
||||
const sourceFiles = [join(root, 'source.ts'), join(nestedSource, 'source.ts')]
|
||||
if (process.platform !== 'win32') {
|
||||
sourceFiles.push(join(root, 'source\nnewline.ts'), join(nestedSource, 'source\nnewline.ts'))
|
||||
}
|
||||
await Promise.all(sourceFiles.map((file) => writeFile(file, 'source')))
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
for (const sourceFile of sourceFiles) {
|
||||
expect(events.some((event) => event.path === sourceFile)).toBe(true)
|
||||
}
|
||||
},
|
||||
{ timeout: 8_000 }
|
||||
)
|
||||
// Why: ignored callbacks must stay absent after the native debounce has drained.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(errors).toEqual([])
|
||||
const generatedPaths = new Set(generatedFiles)
|
||||
expect(events.filter((event) => generatedPaths.has(event.path))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -27,12 +27,50 @@ describe('buildParcelWatcherIgnoreOptions', () => {
|
||||
expect(plainPaths.length).toBeLessThanOrEqual(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
|
||||
expect(option).toEqual(WATCHER_IGNORE_DIRS.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT))
|
||||
const fallbackRegex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
|
||||
for (const dir of WATCHER_IGNORE_DIRS.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)) {
|
||||
for (const dir of WATCHER_IGNORE_DIRS) {
|
||||
expect(fallbackRegex.test(dir)).toBe(true)
|
||||
expect(fallbackRegex.test(`${dir}/file.ts`)).toBe(true)
|
||||
expect(fallbackRegex.test(`packages/app/${dir}`)).toBe(true)
|
||||
expect(fallbackRegex.test(`packages/app/${dir}/file.ts`)).toBe(true)
|
||||
}
|
||||
expect(fallbackRegex.test('packages/app/node_modules-cache/file.ts')).toBe(false)
|
||||
expect(fallbackRegex.test('packages/app/.github/workflows/check.yml')).toBe(false)
|
||||
expect(fallbackRegex.test('project\\node_modules/file.ts')).toBe(false)
|
||||
expect(options.ignoreGlobs?.[0]).not.toContain('?!')
|
||||
})
|
||||
|
||||
it('filters nested macOS directories when all names fit in daemon exclusions', () => {
|
||||
setPlatform('darwin')
|
||||
const options = buildParcelWatcherIgnoreOptions(['node_modules', '.cache'])
|
||||
expect(options.ignore).toEqual(['node_modules', '.cache'])
|
||||
const regex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
|
||||
expect(regex.test('packages/app/node_modules/file.js')).toBe(true)
|
||||
expect(regex.test('packages/app/.cache/file.js')).toBe(true)
|
||||
expect(regex.test('packages/app/xcache/file.js')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps an empty ignore list empty', () => {
|
||||
for (const platform of ['darwin', 'linux', 'win32'] as const) {
|
||||
setPlatform(platform)
|
||||
expect(buildParcelWatcherIgnoreOptions([])).toEqual({})
|
||||
}
|
||||
})
|
||||
|
||||
it('matches newline-containing paths only under ignored names', () => {
|
||||
for (const platform of ['darwin', 'linux', 'win32'] as const) {
|
||||
setPlatform(platform)
|
||||
const options = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
|
||||
const regex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
|
||||
expect(regex.test('packages/app/node_modules/generated\nnewline.js')).toBe(true)
|
||||
expect(regex.test('packages/app/src/source\nnewline.ts')).toBe(false)
|
||||
expect(regex.test('packages/app/node_modules-cache/generated\nnewline.js')).toBe(false)
|
||||
expect(regex.test('packages\\app\\node_modules\\generated\nnewline.js')).toBe(
|
||||
platform === 'win32'
|
||||
)
|
||||
expect(regex.test('packages\\app\\src\\source\nnewline.ts')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses one lookahead-free native regex for nested ignores on Linux/Windows', () => {
|
||||
for (const platform of ['linux', 'win32'] as const) {
|
||||
setPlatform(platform)
|
||||
|
||||
@@ -18,9 +18,7 @@ export const WATCHER_IGNORE_DIRS: string[] = [
|
||||
// closed — one entry over the cap and @parcel/watcher silently loses ALL
|
||||
// daemon-side exclusions, so fseventsd delivers every node_modules/.git event
|
||||
// to this process (measured ~29x client CPU plus daemon-side delivery load).
|
||||
// Keep the 8 highest-churn dirs as plain paths (daemon-excluded) and demote
|
||||
// the rest to globs (userspace-filtered). Ordering of WATCHER_IGNORE_DIRS is
|
||||
// therefore meaningful: the first 8 get true daemon-side exclusion on macOS.
|
||||
// The first 8 names get root-level daemon exclusions; the regex covers all names at every depth.
|
||||
export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8
|
||||
|
||||
export type ParcelWatcherIgnoreOptions = {
|
||||
@@ -37,10 +35,10 @@ function escapeRegex(value: string): string {
|
||||
function buildNestedDirectoryRegex(ignoreDirs: readonly string[]): string {
|
||||
const alternatives = ignoreDirs.map(escapeRegex).join('|')
|
||||
if (process.platform === 'win32') {
|
||||
return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/].*)?$`
|
||||
return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/][\\s\\S]*)?$`
|
||||
}
|
||||
// Why: backslash is a legal POSIX filename character, not a path separator.
|
||||
return `^(?:[^/]+/)*(?:${alternatives})(?:/.*)?$`
|
||||
return `^(?:[^/]+/)*(?:${alternatives})(?:/[\\s\\S]*)?$`
|
||||
}
|
||||
|
||||
export function buildParcelWatcherIgnoreOptions(
|
||||
@@ -56,9 +54,9 @@ export function buildParcelWatcherIgnoreOptions(
|
||||
return { ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)] }
|
||||
}
|
||||
const daemonExcludedDirs = ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
|
||||
const remainingDirs = ignoreDirs.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
|
||||
return {
|
||||
ignore: [...daemonExcludedDirs],
|
||||
...(remainingDirs.length > 0 ? { ignoreGlobs: [buildNestedDirectoryRegex(remainingDirs)] } : {})
|
||||
// Why: plain exclusions resolve under the root, leaving nested generated directories unfiltered.
|
||||
ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +321,8 @@ describe('workspace.stale resync', () => {
|
||||
'never publishes an older read after a newer $source notification ($order)',
|
||||
async ({ source, order }) => {
|
||||
let receive: ((data: Buffer) => void) | undefined
|
||||
// Keep both frames in one decoder pass for the ordering assertions.
|
||||
const deliveryClock = vi.spyOn(Date, 'now').mockReturnValue(Date.now())
|
||||
const mux = new SshChannelMultiplexer({
|
||||
write: () => {},
|
||||
onData: (callback) => {
|
||||
@@ -349,6 +351,7 @@ describe('workspace.stale resync', () => {
|
||||
receive?.(
|
||||
Buffer.concat(messages.map((message, index) => encodeJsonRpcFrame(message, index + 1, 0)))
|
||||
)
|
||||
deliveryClock.mockRestore()
|
||||
if (source === 'own') {
|
||||
expect(getCachedRemoteWorkspaceSnapshot('target-1')?.hostObservationToken).toBe(
|
||||
initial.hostObservationToken
|
||||
@@ -365,6 +368,7 @@ describe('workspace.stale resync', () => {
|
||||
expect(sent.map((event) => event.snapshot.revision)).toEqual([3])
|
||||
expect(sent[0].sourceClientId).toBe(sourceClientId)
|
||||
} finally {
|
||||
deliveryClock.mockRestore()
|
||||
mux.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,18 @@ vi.mock('electron', () => ({
|
||||
}))
|
||||
|
||||
import { _internals } from './hook-service'
|
||||
import { fakeTui, type BusEvent } from './opencode-tui-session-fixture'
|
||||
|
||||
type Post = {
|
||||
paneKey?: string
|
||||
opencodeMajor?: number
|
||||
payload?: { hook_event_name?: string; sessionID?: string }
|
||||
payload?: {
|
||||
hook_event_name?: string
|
||||
sessionID?: string
|
||||
root_state?: string
|
||||
root_turn_error_name?: string
|
||||
}
|
||||
}
|
||||
type BusEvent = { type: string; data: Record<string, unknown> }
|
||||
type PluginModule = {
|
||||
default?: { setup?: (ctx: unknown) => Promise<(() => Promise<void>) | undefined> }
|
||||
}
|
||||
@@ -70,130 +75,6 @@ function turn(sessionID: string, text: string): { start: BusEvent[]; finish: Bus
|
||||
}
|
||||
}
|
||||
|
||||
type Blocker = { id: string; sessionID: string; [key: string]: unknown }
|
||||
|
||||
function toBlocker(value: unknown): Blocker {
|
||||
const record = typeof value === 'object' && value !== null ? { ...value } : {}
|
||||
const id = 'id' in record ? String(record.id) : ''
|
||||
const sessionID = 'sessionID' in record ? String(record.sessionID) : ''
|
||||
return { ...record, id, sessionID }
|
||||
}
|
||||
|
||||
/** One pane's TUI: its route, its view of the shared session store, and the shared event bus. */
|
||||
function fakeTui(version = '2.0.14') {
|
||||
const listeners = new Set<(event: { details: BusEvent }) => void>()
|
||||
const sessions = new Map<string, { id: string; parentID?: string }>()
|
||||
const running = new Set<string>()
|
||||
const permissions = new Map<string, Blocker[]>()
|
||||
const forms = new Map<string, Blocker[]>()
|
||||
// What the server answers when the TUI re-fetches a permission list (reconnect).
|
||||
const serverPermissions = new Map<string, Blocker[]>()
|
||||
let permissionFetch: Promise<void> = Promise.resolve()
|
||||
// Why: OpenCode keeps storage.memory across plugin hot reloads within one TUI process.
|
||||
const memories = new Map<string, unknown>()
|
||||
let route: { type: string; sessionID?: string } = { type: 'home' }
|
||||
const rootOf = (id: string): string => {
|
||||
let current = sessions.get(id)
|
||||
while (current?.parentID && sessions.has(current.parentID)) {
|
||||
current = sessions.get(current.parentID)
|
||||
}
|
||||
return current?.id ?? id
|
||||
}
|
||||
const without = (map: Map<string, Blocker[]>, sessionID: string, id: unknown): void => {
|
||||
map.set(
|
||||
sessionID,
|
||||
(map.get(sessionID) ?? []).filter((item) => item.id !== id)
|
||||
)
|
||||
}
|
||||
const listen = vi.fn((handler: (event: { details: BusEvent }) => void) => {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
})
|
||||
const ctx = {
|
||||
app: { version, channel: 'latest' },
|
||||
ui: { router: { current: () => route } },
|
||||
storage: {
|
||||
memory: (key: string, options: { initial: Record<string, unknown> }) => {
|
||||
if (!memories.has(key)) {
|
||||
const value = structuredClone(options.initial)
|
||||
memories.set(key, [value, (mutate: (draft: typeof value) => void) => mutate(value)])
|
||||
}
|
||||
return memories.get(key)
|
||||
}
|
||||
},
|
||||
client: {
|
||||
session: { get: async ({ sessionID }: { sessionID: string }) => sessions.get(sessionID) }
|
||||
},
|
||||
data: {
|
||||
listen,
|
||||
session: {
|
||||
get: (id: string) => sessions.get(id),
|
||||
root: rootOf,
|
||||
family: (id: string) =>
|
||||
[...sessions.keys()].filter((member) => rootOf(member) === rootOf(id)),
|
||||
status: (id: string) => (running.has(id) ? 'running' : 'idle'),
|
||||
permission: {
|
||||
list: (id: string) => permissions.get(id),
|
||||
sync: async (id: string) => {
|
||||
await permissionFetch
|
||||
permissions.set(id, [...(serverPermissions.get(id) ?? [])])
|
||||
}
|
||||
},
|
||||
form: { list: (id: string) => forms.get(id), sync: async () => {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ctx,
|
||||
listen,
|
||||
serverPermissions,
|
||||
permissions,
|
||||
navigate(sessionID: string) {
|
||||
route = { type: 'session', sessionID }
|
||||
},
|
||||
// The session data stops reporting a run without this TUI seeing its end event.
|
||||
loseEnd(sessionID: string) {
|
||||
running.delete(sessionID)
|
||||
},
|
||||
// The session data reports a run whose start this TUI never saw.
|
||||
loseStart(sessionID: string) {
|
||||
running.add(sessionID)
|
||||
},
|
||||
// Holds the next permission list fetch until the returned release is called.
|
||||
holdPermissionFetch(): () => void {
|
||||
let release = (): void => {}
|
||||
permissionFetch = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return release
|
||||
},
|
||||
// Applies an event to the session data first, then to plugin listeners, as OpenCode does.
|
||||
emit(event: BusEvent) {
|
||||
const sessionID = String(event.data.sessionID)
|
||||
if (event.type === 'session.created') {
|
||||
const parentID = typeof event.data.parentID === 'string' ? event.data.parentID : undefined
|
||||
sessions.set(sessionID, { id: sessionID, parentID })
|
||||
} else if (event.type === 'session.execution.started') {
|
||||
running.add(sessionID)
|
||||
} else if (event.type === 'session.execution.succeeded') {
|
||||
running.delete(sessionID)
|
||||
} else if (event.type === 'permission.asked') {
|
||||
permissions.set(sessionID, [...(permissions.get(sessionID) ?? []), toBlocker(event.data)])
|
||||
} else if (event.type === 'permission.replied') {
|
||||
without(permissions, sessionID, event.data.requestID)
|
||||
} else if (event.type === 'form.created') {
|
||||
const form = toBlocker(event.data.form)
|
||||
forms.set(form.sessionID, [...(forms.get(form.sessionID) ?? []), form])
|
||||
} else if (event.type === 'form.replied' || event.type === 'form.cancelled') {
|
||||
without(forms, sessionID, event.data.id)
|
||||
}
|
||||
for (const handler of listeners) {
|
||||
handler({ details: event })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
let tempDir: string
|
||||
let savedFetch: typeof globalThis.fetch
|
||||
@@ -290,6 +171,33 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
['session.execution.failed', { error: { type: 'api' } }, 'api'],
|
||||
['session.execution.failed', {}, 'UnknownError'],
|
||||
['session.execution.interrupted', { reason: 'user' }, 'MessageAbortedError'],
|
||||
['session.execution.interrupted', { reason: 'pause' }, undefined],
|
||||
['session.execution.succeeded', {}, undefined]
|
||||
])('carries the root verdict for %s', async (type, fields, errorName) => {
|
||||
const reported = await runPane(PANE_A, SES_A, async (tui) => {
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, turn(SES_A, 'root').start)
|
||||
await pump(tui, [{ type, data: { sessionID: SES_A, ...fields } }])
|
||||
})
|
||||
expect(reported.at(-1)?.payload).toMatchObject({ root_state: 'done' })
|
||||
expect(reported.at(-1)?.payload?.root_turn_error_name).toBe(errorName)
|
||||
})
|
||||
|
||||
it('clears a failed root verdict on its next turn', async () => {
|
||||
const reported = await runPane(PANE_A, SES_A, async (tui) => {
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, turn(SES_A, 'first').start)
|
||||
await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }])
|
||||
await pump(tui, [{ type: 'session.execution.started', data: { sessionID: SES_A } }])
|
||||
await pump(tui, turn(SES_A, 'next').finish)
|
||||
})
|
||||
expect(reported.at(-1)?.payload?.root_turn_error_name).toBeUndefined()
|
||||
})
|
||||
|
||||
// Captured s2 shape: pane A's long turn overlapped by pane B's short one on one server.
|
||||
it('gives each overlapping pane only its own session and its own Idle', async () => {
|
||||
const a = turn(SES_A, 'A long SLEEP=12')
|
||||
@@ -446,6 +354,75 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
data: { id: 'per_1', sessionID, action: 'bash', resources: ['rm -rf build'] }
|
||||
})
|
||||
|
||||
it('publishes a root failure while its child keeps the aggregate Working', async () => {
|
||||
const tui = fakeTui()
|
||||
const cleanup = await start(tui)
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, [...turn(SES_A, 'root').start, ...childStart(SES_A)])
|
||||
await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }])
|
||||
await vi.waitFor(() => {
|
||||
expect(posts.at(-1)?.payload).toMatchObject({
|
||||
hook_event_name: 'SessionBusy',
|
||||
root_state: 'done',
|
||||
root_turn_error_name: 'UnknownError'
|
||||
})
|
||||
})
|
||||
await pump(tui, [{ type: 'session.execution.succeeded', data: { sessionID: SES_CHILD } }])
|
||||
expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError')
|
||||
await cleanup?.()
|
||||
})
|
||||
|
||||
it('does not turn a child failure into a root failure', async () => {
|
||||
const reported = await runPane(PANE_A, SES_A, async (tui) => {
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, [...turn(SES_A, 'root').start, ...childStart(SES_A)])
|
||||
await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_CHILD } }])
|
||||
await pump(tui, turn(SES_A, 'root').finish)
|
||||
})
|
||||
expect(reported.at(-1)?.payload?.root_turn_error_name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a failed terminal verdict through plugin hot reload', async () => {
|
||||
const tui = fakeTui()
|
||||
const first = await start(tui)
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, turn(SES_A, 'root').start)
|
||||
await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }])
|
||||
await vi.waitFor(() =>
|
||||
expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError')
|
||||
)
|
||||
await first?.()
|
||||
const before = posts.length
|
||||
const second = await start(tui)
|
||||
await tick(150)
|
||||
expect(posts).toHaveLength(before)
|
||||
expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError')
|
||||
await second?.()
|
||||
})
|
||||
|
||||
it.each(['session.execution.succeeded', 'session.execution.interrupted'])(
|
||||
'repairs a failed verdict when %s follows a missed turn start during unload',
|
||||
async (finishType) => {
|
||||
const tui = fakeTui()
|
||||
const first = await start(tui)
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, turn(SES_A, 'first').start)
|
||||
await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }])
|
||||
await first?.()
|
||||
await pump(tui, [
|
||||
{ type: 'session.execution.started', data: { sessionID: SES_A } },
|
||||
{ type: finishType, data: { sessionID: SES_A, reason: 'user' } }
|
||||
])
|
||||
const second = await start(tui)
|
||||
await vi.waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBeUndefined())
|
||||
expect(posts.at(-1)?.payload).toMatchObject({
|
||||
hook_event_name: 'SessionIdle',
|
||||
root_state: 'done'
|
||||
})
|
||||
await second?.()
|
||||
}
|
||||
)
|
||||
|
||||
// Why: #23700 left this for TUI panes; a reload that misses the end must still reach Done.
|
||||
it('shows Done for a turn that ended while the plugin was reloading', async () => {
|
||||
const tui = fakeTui()
|
||||
@@ -591,13 +568,36 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
const a = turn(SES_A, 'A spawns a background task')
|
||||
await pump(tui, [...a.start, ...childStart(SES_A), ...a.finish])
|
||||
await tick(250)
|
||||
expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])
|
||||
expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`])
|
||||
expect(posts.at(-1)?.payload).toMatchObject({ root_state: 'done' })
|
||||
tui.loseEnd(SES_CHILD)
|
||||
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`))
|
||||
await cleanup?.()
|
||||
expect(posts.every((post) => post.payload?.sessionID === SES_A)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps automatically answered permissions Working without an attention post', async () => {
|
||||
const tui = fakeTui()
|
||||
const cleanup = await start(tui)
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, turn(SES_A, 'auto').start)
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
tui.emit({
|
||||
type: 'permission.asked',
|
||||
data: { ...permission(SES_A).data, id: `auto_${index}` }
|
||||
})
|
||||
await tick(20)
|
||||
tui.emit({
|
||||
type: 'permission.replied',
|
||||
data: { sessionID: SES_A, requestID: `auto_${index}` }
|
||||
})
|
||||
}
|
||||
await tick(650)
|
||||
expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])
|
||||
await pump(tui, turn(SES_A, 'auto').finish)
|
||||
await cleanup?.()
|
||||
})
|
||||
|
||||
it("shows a child's permission as Needs input on the root, then Working after the reply", async () => {
|
||||
const tui = fakeTui()
|
||||
const cleanup = await start(tui)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { resolveOpenCodeDataDirectory } from './opencode-data-directory'
|
||||
import {
|
||||
bindOpenCodeSession,
|
||||
@@ -225,6 +226,8 @@ export function listOpenCodeDbSessions(
|
||||
cursor: OpenCodeSessionCursor
|
||||
): BinderSessionRow[] {
|
||||
try {
|
||||
// Check absence without hiding permission failures as an unused store.
|
||||
statSync(dbPath)
|
||||
return readOpenCodeDatabase({
|
||||
dbPath,
|
||||
read: (db) => {
|
||||
@@ -269,6 +272,13 @@ export function listOpenCodeDbSessions(
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
'code' in err &&
|
||||
(err.code === 'ENOENT' || err.code === 'ENOTDIR')
|
||||
) {
|
||||
return []
|
||||
}
|
||||
console.warn('[opencode-binder] session store read failed; skipping round', err)
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
export type BusEvent = { type: string; data: Record<string, unknown>; created?: number }
|
||||
|
||||
type Blocker = { id: string; sessionID: string; [key: string]: unknown }
|
||||
|
||||
function toBlocker(value: unknown): Blocker {
|
||||
const record = typeof value === 'object' && value !== null ? { ...value } : {}
|
||||
const id = 'id' in record ? String(record.id) : ''
|
||||
const sessionID = 'sessionID' in record ? String(record.sessionID) : ''
|
||||
return { ...record, id, sessionID }
|
||||
}
|
||||
|
||||
/** One pane's TUI: its route, its view of the shared session store, and the shared event bus. */
|
||||
export function fakeTui(version = '2.0.14') {
|
||||
const listeners = new Set<(event: { details: BusEvent }) => void>()
|
||||
const sessions = new Map<
|
||||
string,
|
||||
{ id: string; parentID?: string; outcome?: string; time?: { idle: number } }
|
||||
>()
|
||||
let idleClock = 1
|
||||
const running = new Set<string>()
|
||||
const permissions = new Map<string, Blocker[]>()
|
||||
const forms = new Map<string, Blocker[]>()
|
||||
// What the server answers when the TUI re-fetches a permission list (reconnect).
|
||||
const serverPermissions = new Map<string, Blocker[]>()
|
||||
let permissionFetch: Promise<void> = Promise.resolve()
|
||||
// Why: OpenCode keeps storage.memory across plugin hot reloads within one TUI process.
|
||||
const memories = new Map<string, unknown>()
|
||||
let route: { type: string; sessionID?: string } = { type: 'home' }
|
||||
const rootOf = (id: string): string => {
|
||||
let current = sessions.get(id)
|
||||
while (current?.parentID && sessions.has(current.parentID)) {
|
||||
current = sessions.get(current.parentID)
|
||||
}
|
||||
return current?.id ?? id
|
||||
}
|
||||
const without = (map: Map<string, Blocker[]>, sessionID: string, id: unknown): void => {
|
||||
map.set(
|
||||
sessionID,
|
||||
(map.get(sessionID) ?? []).filter((item) => item.id !== id)
|
||||
)
|
||||
}
|
||||
const listen = vi.fn((handler: (event: { details: BusEvent }) => void) => {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
})
|
||||
const ctx = {
|
||||
app: { version, channel: 'latest' },
|
||||
ui: { router: { current: () => route } },
|
||||
storage: {
|
||||
memory: (key: string, options: { initial: Record<string, unknown> }) => {
|
||||
if (!memories.has(key)) {
|
||||
const value = structuredClone(options.initial)
|
||||
memories.set(key, [value, (mutate: (draft: typeof value) => void) => mutate(value)])
|
||||
}
|
||||
return memories.get(key)
|
||||
}
|
||||
},
|
||||
client: {
|
||||
session: { get: async ({ sessionID }: { sessionID: string }) => sessions.get(sessionID) }
|
||||
},
|
||||
data: {
|
||||
listen,
|
||||
session: {
|
||||
get: (id: string) => sessions.get(id),
|
||||
root: rootOf,
|
||||
family: (id: string) =>
|
||||
[...sessions.keys()].filter((member) => rootOf(member) === rootOf(id)),
|
||||
status: (id: string) => (running.has(id) ? 'running' : 'idle'),
|
||||
permission: {
|
||||
list: (id: string) => permissions.get(id),
|
||||
sync: async (id: string) => {
|
||||
await permissionFetch
|
||||
permissions.set(id, [...(serverPermissions.get(id) ?? [])])
|
||||
}
|
||||
},
|
||||
form: { list: (id: string) => forms.get(id), sync: async () => {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ctx,
|
||||
listen,
|
||||
serverPermissions,
|
||||
permissions,
|
||||
navigate(sessionID: string) {
|
||||
route = { type: 'session', sessionID }
|
||||
},
|
||||
// The session data stops reporting a run without this TUI seeing its end event.
|
||||
loseEnd(sessionID: string) {
|
||||
running.delete(sessionID)
|
||||
},
|
||||
// The session data reports a run whose start this TUI never saw.
|
||||
loseStart(sessionID: string) {
|
||||
running.add(sessionID)
|
||||
const session = sessions.get(sessionID)
|
||||
if (session) {
|
||||
session.outcome = undefined
|
||||
}
|
||||
},
|
||||
// Holds the next permission list fetch until the returned release is called.
|
||||
holdPermissionFetch(): () => void {
|
||||
let release = (): void => {}
|
||||
permissionFetch = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return release
|
||||
},
|
||||
// Ending events reach listeners before the session snapshot catches up.
|
||||
emit(event: BusEvent) {
|
||||
const sessionID = String(event.data.sessionID)
|
||||
let notified = false
|
||||
if (event.type === 'session.created') {
|
||||
const parentID = typeof event.data.parentID === 'string' ? event.data.parentID : undefined
|
||||
sessions.set(sessionID, { id: sessionID, parentID })
|
||||
} else if (event.type === 'session.execution.started') {
|
||||
running.add(sessionID)
|
||||
const session = sessions.get(sessionID)
|
||||
if (session) {
|
||||
session.outcome = undefined
|
||||
}
|
||||
} else if (
|
||||
event.type === 'session.execution.succeeded' ||
|
||||
event.type === 'session.execution.failed' ||
|
||||
event.type === 'session.execution.interrupted'
|
||||
) {
|
||||
event = { ...event, created: ++idleClock }
|
||||
for (const handler of listeners) {
|
||||
handler({ details: event })
|
||||
}
|
||||
notified = true
|
||||
running.delete(sessionID)
|
||||
const session = sessions.get(sessionID)
|
||||
if (session) {
|
||||
session.outcome = event.type.slice('session.execution.'.length)
|
||||
session.time = { idle: idleClock }
|
||||
}
|
||||
} else if (event.type === 'permission.asked') {
|
||||
permissions.set(sessionID, [...(permissions.get(sessionID) ?? []), toBlocker(event.data)])
|
||||
} else if (event.type === 'permission.replied') {
|
||||
without(permissions, sessionID, event.data.requestID)
|
||||
} else if (event.type === 'form.created') {
|
||||
const form = toBlocker(event.data.form)
|
||||
forms.set(form.sessionID, [...(forms.get(form.sessionID) ?? []), form])
|
||||
} else if (event.type === 'form.replied' || event.type === 'form.cancelled') {
|
||||
without(forms, sessionID, event.data.id)
|
||||
}
|
||||
if (!notified) {
|
||||
for (const handler of listeners) {
|
||||
handler({ details: event })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
export function getOpenCode2TuiSource(): string[] {
|
||||
return String.raw`
|
||||
const TUI_TICK_MS = 100;
|
||||
const TUI_PERMISSION_SETTLE_MS = 500;
|
||||
const TUI_EARLY_ROOTS_MAX = 32;
|
||||
const TUI_RESOLVED_REQUESTS_MAX = 256;
|
||||
const TUI_ENDPOINT_CHECK_TICKS = 50;
|
||||
@@ -22,10 +23,9 @@ function boundedSet(set, value, max) {
|
||||
if (set.size > max) set.delete(set.values().next().value);
|
||||
}
|
||||
|
||||
// Why storage.memory: OpenCode keeps it across plugin hot reloads and drops it when the TUI
|
||||
// exits, so a reload mid-turn keeps this pane's sessions and what it last reported.
|
||||
// Memory survives hot reloads and ends with the TUI.
|
||||
function paneStatusMemory(ctx) {
|
||||
const initial = { owned: [], last: "idle:", lastRoot: "", started: false };
|
||||
const initial = { owned: [], last: "idle:", lastRoot: "", started: false, endings: [] };
|
||||
if (typeof ctx.storage?.memory === "function") return ctx.storage.memory("pane-status", { initial });
|
||||
// Why started: without memory a reload looks like a TUI start, and must not reset the pane.
|
||||
const local = { ...initial, started: true };
|
||||
@@ -50,10 +50,10 @@ async function setupOpenCode2Tui(ctx) {
|
||||
let disposed = false;
|
||||
let lastLevel = null;
|
||||
let ticks = 0;
|
||||
// Root sessions this TUI saw start before it owned them: their SessionStart and prompt.
|
||||
const early = new Map();
|
||||
// Why: a permission/form list fetched on reconnect can land after the reply event and restore it.
|
||||
const resolved = new Set();
|
||||
const permissionSeenAt = new Map();
|
||||
|
||||
const rootOf = (sessionID) => data.root(sessionID) || sessionID;
|
||||
const family = (root) => new Set([root, ...(typeof data.family === "function" ? data.family(root) || [] : [])]);
|
||||
@@ -62,18 +62,41 @@ async function setupOpenCode2Tui(ctx) {
|
||||
const route = ctx.ui.router.current();
|
||||
return route?.type === "session" && typeof route.sessionID === "string" ? rootOf(route.sessionID) : undefined;
|
||||
};
|
||||
// First open permission, else first form, across the root's family.
|
||||
const blocker = (root) => {
|
||||
const blocker = (root, seenPermissions) => {
|
||||
let form;
|
||||
for (const member of family(root)) {
|
||||
const permission = (data.permission?.list?.(member) || []).find((request) => !resolved.has(request.id));
|
||||
const permission = (data.permission?.list?.(member) || []).find((request) => {
|
||||
if (resolved.has(request.id)) return false;
|
||||
seenPermissions.add(request.id);
|
||||
if (!permissionSeenAt.has(request.id)) permissionSeenAt.set(request.id, Date.now());
|
||||
// Auto replies arrive after the request; only an unanswered request needs attention.
|
||||
return Date.now() - permissionSeenAt.get(request.id) >= TUI_PERMISSION_SETTLE_MS;
|
||||
});
|
||||
if (permission) return { request: permission, isPermission: true };
|
||||
form ??= (data.form?.list?.(member) || []).find((request) => !resolved.has(request.id));
|
||||
}
|
||||
return form ? { request: form, isPermission: false } : null;
|
||||
};
|
||||
const levelKey = (level) =>
|
||||
level.kind === "waiting" ? "waiting:" + level.blocker.request.id + ":" + level.root : level.kind + ":" + level.root;
|
||||
(level.kind === "waiting" ? "waiting:" + level.blocker.request.id + ":" + level.root : level.kind + ":" + level.root) +
|
||||
(level.rootFields.root_state ? ":" + JSON.stringify(level.rootFields) : "");
|
||||
|
||||
function rootFields(level) {
|
||||
if (!level.root) return {};
|
||||
const rootRunning = data.status(level.root) === "running";
|
||||
const rootState = rootRunning
|
||||
? level.kind === "waiting" && level.blocker.request.sessionID === level.root ? "waiting" : "working"
|
||||
: "done";
|
||||
const session = data.get?.(level.root);
|
||||
const ending = (memory.endings || []).find(([id]) => id === level.root);
|
||||
const idleAt = session?.time?.idle;
|
||||
const sameTurn = Number.isFinite(idleAt) && ending?.[2] === idleAt;
|
||||
// Current session data repairs a whole turn missed while the plugin was unloaded.
|
||||
const errorName = !rootRunning && (session?.outcome === "failed"
|
||||
? (sameTurn && ending[1]) || "UnknownError"
|
||||
: session?.outcome === "interrupted" && sameTurn ? ending[1] : "");
|
||||
return { root_state: rootState, ...(errorName ? { root_turn_error_name: errorName } : {}) };
|
||||
}
|
||||
|
||||
function own(root, owned) {
|
||||
const seen = early.get(root);
|
||||
@@ -94,11 +117,17 @@ async function setupOpenCode2Tui(ctx) {
|
||||
owned = owned.filter((root) => root === route || running(root));
|
||||
if (owned.join("\n") !== memory.owned.join("\n")) setMemory((draft) => { draft.owned = owned; });
|
||||
const active = owned.filter(running);
|
||||
const seenPermissions = new Set();
|
||||
let waiting;
|
||||
for (const root of active) {
|
||||
// Why only while running: a blocker the session data kept after its turn ended is stale.
|
||||
const found = blocker(root);
|
||||
if (found) return { kind: "waiting", root, blocker: found };
|
||||
const found = blocker(root, seenPermissions);
|
||||
if (found && !waiting) waiting = { kind: "waiting", root, blocker: found };
|
||||
}
|
||||
for (const id of permissionSeenAt.keys()) {
|
||||
if (!seenPermissions.has(id)) permissionSeenAt.delete(id);
|
||||
}
|
||||
if (waiting) return waiting;
|
||||
const busy = active.at(-1);
|
||||
return busy ? { kind: "busy", root: busy } : { kind: "idle", root: memory.lastRoot };
|
||||
}
|
||||
@@ -108,6 +137,7 @@ async function setupOpenCode2Tui(ctx) {
|
||||
let level;
|
||||
try {
|
||||
level = derive();
|
||||
level.rootFields = rootFields(level);
|
||||
} catch {
|
||||
// Why: a data read that throws must not kill the listener or the tick.
|
||||
return;
|
||||
@@ -119,14 +149,13 @@ async function setupOpenCode2Tui(ctx) {
|
||||
if (level.kind !== "idle") draft.lastRoot = level.root;
|
||||
});
|
||||
lastLevel = level;
|
||||
// Why the lifecycle queue: posts keep the order the levels were derived in.
|
||||
void enqueueLifecycle(() => deliver(level, true));
|
||||
}
|
||||
|
||||
async function deliver(level, changed) {
|
||||
// Retires assistant text queued under the previous level (see flushPendingAssistantPart).
|
||||
if (changed) stateArrivalRevision += 1;
|
||||
const properties = level.root ? { sessionID: level.root } : {};
|
||||
const properties = level.root ? { sessionID: level.root, ...level.rootFields } : {};
|
||||
if (level.kind === "waiting") {
|
||||
const { request, isPermission } = level.blocker;
|
||||
const translated = isPermission
|
||||
@@ -134,12 +163,12 @@ async function setupOpenCode2Tui(ctx) {
|
||||
: translateOpenCode2Event("form.created", { form: request });
|
||||
if (!translated) return;
|
||||
const hookEventName = isPermission ? "PermissionRequest" : "AskUserQuestion";
|
||||
await setAttention(hookEventName, { ...translated.properties, ...properties }, factoryID, request.sessionID);
|
||||
await flushPendingAssistantPart(true);
|
||||
await setDeliveryTarget("waiting", levelKey(level), hookEventName, { ...translated.properties, ...properties }, factoryID);
|
||||
return;
|
||||
}
|
||||
// Why flush first: the done-state preview must show the completed reply.
|
||||
if (level.kind === "idle") await flushPendingAssistantPart(true);
|
||||
await setStatus(level.kind, properties, factoryID);
|
||||
await setDeliveryTarget(level.kind, levelKey(level), level.kind === "busy" ? "SessionBusy" : "SessionIdle", properties, factoryID);
|
||||
}
|
||||
|
||||
function postPrompt(root, prompt) {
|
||||
@@ -188,6 +217,18 @@ async function setupOpenCode2Tui(ctx) {
|
||||
}
|
||||
const root = rootOf(sessionID);
|
||||
const isOwned = memory.owned.includes(root);
|
||||
if (sessionID === root && (isOwned || currentRoute() === root)) {
|
||||
if (event.type === "session.execution.started" || event.type === "session.execution.succeeded" || event.type === "session.execution.failed" || event.type === "session.execution.interrupted") {
|
||||
const errorName = event.type === "session.execution.failed"
|
||||
? (typeof properties.error?.type === "string" && properties.error.type) || "UnknownError"
|
||||
: event.type === "session.execution.interrupted" && properties.reason === "user" ? "MessageAbortedError" : "";
|
||||
// A hot reload keeps the terminal verdict; only this root's next turn replaces it.
|
||||
setMemory((draft) => {
|
||||
draft.endings = (draft.endings || []).filter(([id]) => id !== root);
|
||||
if (errorName) draft.endings = [...draft.endings, [root, errorName, event.created ?? data.get?.(root)?.time?.idle]].slice(-TUI_EARLY_ROOTS_MAX);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (event.type === "session.inbox.enqueued") {
|
||||
// Why TUI-only: the server takes the prompt from session.hook("prompt"), which a TUI lacks.
|
||||
const item = properties.item;
|
||||
|
||||
@@ -36,6 +36,15 @@ export const ANTIGRAVITY_USAGE_ARGS: readonly string[] = [
|
||||
*/
|
||||
export const ANTIGRAVITY_USAGE_TIMEOUT_MS = 30_000
|
||||
|
||||
// Before agy 1.1.11, `/usage` in print mode spends a model turn (agy's bundled changelog).
|
||||
export const ANTIGRAVITY_MIN_USAGE_VERSION = '1.1.11'
|
||||
|
||||
/** The version probe; free in every sense — no conversation, no quota. */
|
||||
export const ANTIGRAVITY_VERSION_ARGS: readonly string[] = ['--version']
|
||||
|
||||
/** Why bound it: the probe runs before every quota read, so a wedged CLI must not stall the cycle. */
|
||||
export const ANTIGRAVITY_VERSION_TIMEOUT_MS = 5_000
|
||||
|
||||
/** Cap on captured output; the envelope is a single JSON line well under a kilobyte. */
|
||||
export const ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES = 512 * 1024
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyAntigravityUsageFailure } from './antigravity-usage-error'
|
||||
|
||||
/** Captured verbatim from agy 1.2.11 when the account had exhausted its weekly pool. */
|
||||
const REAL_RESOURCE_EXHAUSTED =
|
||||
'error: Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.\n' +
|
||||
'AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","retryable":true,"error_id":"6baa4ce9-c373-4086-bb0d-fbb8421bacf3-1"}'
|
||||
|
||||
function agyError(fields: Record<string, unknown>): string {
|
||||
return `AGY_ERROR: ${JSON.stringify(fields)}`
|
||||
}
|
||||
|
||||
describe('classifyAntigravityUsageFailure', () => {
|
||||
it('reads the real RESOURCE_EXHAUSTED diagnostic as rate limiting, not as a sign-out', () => {
|
||||
const failure = classifyAntigravityUsageFailure(REAL_RESOURCE_EXHAUSTED)
|
||||
|
||||
expect(failure).toEqual({ failureKind: 'rate-limited', signedOut: false })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['status', { status: 'UNAUTHENTICATED' }],
|
||||
['http code', { error_code: 401 }]
|
||||
])('reads an unauthenticated %s as a missing sign-in', (_label, fields) => {
|
||||
expect(classifyAntigravityUsageFailure(agyError(fields))).toEqual({
|
||||
failureKind: 'missing-credentials',
|
||||
signedOut: true
|
||||
})
|
||||
})
|
||||
|
||||
it('separates an unentitled account from a signed-out one', () => {
|
||||
// Why this matters: telling a signed-in user to sign in sends them to re-auth that fixes
|
||||
// nothing.
|
||||
expect(classifyAntigravityUsageFailure(agyError({ status: 'PERMISSION_DENIED' }))).toEqual({
|
||||
failureKind: 'no-subscription',
|
||||
signedOut: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a 5xx as a server failure', () => {
|
||||
expect(classifyAntigravityUsageFailure(agyError({ error_code: 503 }))).toEqual({
|
||||
failureKind: 'server',
|
||||
signedOut: false
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'You are not logged into Antigravity.',
|
||||
'Error: not signed in. Run `agy` to continue.',
|
||||
'please sign in to continue',
|
||||
'AUTHENTICATION REQUIRED'
|
||||
])('falls back to the sentence when no structured error is printed: %s', (output) => {
|
||||
// Why a fallback at all: the structured line is the stable signal, but not every path prints
|
||||
// one, and a signed-out account must never be reported as an unreadable payload.
|
||||
expect(classifyAntigravityUsageFailure(output)).toEqual({
|
||||
failureKind: 'missing-credentials',
|
||||
signedOut: true
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the structured status over a sentence that disagrees with it', () => {
|
||||
const output = `not logged in\n${agyError({ status: 'RESOURCE_EXHAUSTED' })}`
|
||||
|
||||
expect(classifyAntigravityUsageFailure(output)).toEqual({
|
||||
failureKind: 'rate-limited',
|
||||
signedOut: false
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a successful read', '{"status":"SUCCESS","command":{"name":"usage"}}'],
|
||||
['unrelated noise', 'I0926 quota_manager.go:45] doRefreshQuota: starting reload'],
|
||||
['an unparsable structured line', 'AGY_ERROR: {not json'],
|
||||
['an unrecognised status', agyError({ status: 'SOMETHING_NEW' })],
|
||||
['empty output', '']
|
||||
])('returns null for %s rather than inventing a cause', (_label, output) => {
|
||||
expect(classifyAntigravityUsageFailure(output)).toBeNull()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user