Merge current main into managed account corrections

Preserve current main dependency upgrades and the reviewed account source. Resolve the lockfile by adding only the existing smol-toml package as a direct root dependency.
This commit is contained in:
Neil
2026-10-02 07:01:10 -07:00
143 changed files with 7752 additions and 4763 deletions
@@ -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
+2 -1
View File
@@ -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:
+2 -2
View File
@@ -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
+21 -14
View File
@@ -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:
@@ -178,19 +193,9 @@ jobs:
- name: Install native build and headless UI tools
# The Azure archive took 16 minutes for one font package; bound setup
# separately so a slow mirror cannot consume the shard's test budget.
run: &install_e2e_tools |
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 build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils
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:
@@ -281,6 +286,7 @@ 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.
env: *e2e_tool_packages
run: *install_e2e_tools
- uses: ./.github/actions/install-node-dependencies
@@ -417,6 +423,7 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
env: *e2e_tool_packages
run: *install_e2e_tools
- uses: ./.github/actions/install-node-dependencies
+14 -45
View File
@@ -17,6 +17,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'
# Relevant main pushes qualify every platform after the dependency check.
push:
@@ -35,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:
@@ -132,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
id: orcad-prebuild-cache-restore
if: >-
(github.event_name == 'pull_request' || github.event_name == 'push') &&
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
@@ -229,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'
+19 -4
View File
@@ -28,12 +28,15 @@ 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/**/*.test.ts'
- 'src/main/ssh/ssh-relay-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:
@@ -81,6 +84,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
@@ -95,15 +99,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
+4 -2
View File
@@ -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:
+4 -4
View File
@@ -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"
}
+4 -4
View File
@@ -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"
}
+4 -4
View File
@@ -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"
}
+6 -6
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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"
}
+1 -1
View File
@@ -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"
}
+1 -1
View File
@@ -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"
}
+93 -410
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,3 +2,4 @@ packages:
- apps/*
- packages/*
minimumReleaseAge: 4320
@@ -55,6 +55,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
+1
View File
@@ -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',
+1
View File
@@ -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(
+22 -15
View File
@@ -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)
}
}
+116 -53
View File
@@ -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')
}
)
})
@@ -43,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/',
@@ -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,7 @@ 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',
+2 -1
View File
@@ -12,7 +12,8 @@ const BUILD_PREFIXES = [
'native/',
'config/patches/',
'.github/actions/install-node-dependencies/',
'.github/actions/prepare-native-runtime/'
'.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/']
@@ -28,6 +28,7 @@ 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',
@@ -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,
@@ -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
+2 -2
View File
@@ -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 {
+4 -4
View File
@@ -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

+160
View File
@@ -34,12 +34,48 @@ The [DNF command reference](https://dnf.readthedocs.io/en/stable/command_ref.htm
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`.
@@ -1232,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.
+44
View File
@@ -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.
+1 -1
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
minimum-release-age=4320
+20 -12
View File
@@ -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"
}
}
}
+830 -732
View File
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"
+1 -1
View File
@@ -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')
+3
View File
@@ -233,6 +233,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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "*",
+1110 -1168
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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
+4 -1
View File
@@ -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,
+31 -31
View File
@@ -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",
@@ -198,9 +198,9 @@
"sherpa-onnx": "1.12.37",
"smol-toml": "1.8.0",
"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"
},
@@ -210,10 +210,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",
@@ -241,9 +241,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",
@@ -259,36 +259,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",
@@ -304,8 +304,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",
@@ -316,7 +316,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:*",
@@ -340,7 +340,7 @@
"engines": {
"node": "24"
},
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45",
"reactDoctor": {
"ignore": {
"overrides": [
+2125 -1735
View File
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -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)
+2 -1
View File
@@ -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 || :',
+8 -5
View File
@@ -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
+4 -1
View File
@@ -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)
+14 -8
View File
@@ -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 {
+67 -5
View File
@@ -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([])
})
})
+39 -1
View File
@@ -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)
+5 -7
View File
@@ -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 })
}
}
}
}
}
+56 -15
View File
@@ -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()
})
})
@@ -0,0 +1,83 @@
import type { UsageRateLimitFailureKind } from '../../shared/rate-limit-types'
const AGY_ERROR_PREFIX = 'AGY_ERROR:'
const SIGNED_OUT_PHRASES = [
'not logged into antigravity',
'not logged in',
'not signed in',
'not authenticated',
'not-authenticated',
'unauthenticated',
'run agy login',
'please sign in',
'please log in',
'sign in to antigravity',
'no credentials',
'authentication required'
] as const
export type AntigravityUsageFailure = {
failureKind: UsageRateLimitFailureKind
/** Missing authentication requires sign-in guidance. */
signedOut: boolean
}
type StructuredAgyError = { status?: unknown; error_code?: unknown }
function parseStructuredErrors(output: string): StructuredAgyError[] {
const parsed: StructuredAgyError[] = []
for (const line of output.split('\n')) {
const index = line.indexOf(AGY_ERROR_PREFIX)
if (index === -1) {
continue
}
const payload = line.slice(index + AGY_ERROR_PREFIX.length).trim()
if (!payload.startsWith('{')) {
continue
}
try {
const value: unknown = JSON.parse(payload)
if (typeof value === 'object' && value !== null) {
parsed.push(value)
}
} catch {
continue
}
}
return parsed
}
function classifyStructured(error: StructuredAgyError): AntigravityUsageFailure | null {
const status = typeof error.status === 'string' ? error.status.toUpperCase() : ''
const code = typeof error.error_code === 'number' ? error.error_code : null
if (status === 'UNAUTHENTICATED' || code === 401) {
return { failureKind: 'missing-credentials', signedOut: true }
}
if (status === 'PERMISSION_DENIED' || code === 403) {
// The session is real; the account simply is not entitled to what was asked for.
return { failureKind: 'no-subscription', signedOut: false }
}
if (status === 'RESOURCE_EXHAUSTED' || code === 429) {
return { failureKind: 'rate-limited', signedOut: false }
}
if (code !== null && code >= 500 && code < 600) {
return { failureKind: 'server', signedOut: false }
}
return null
}
// Structured errors outrank prose; an unknown diagnostic supplies no failure cause.
export function classifyAntigravityUsageFailure(output: string): AntigravityUsageFailure | null {
for (const error of parseStructuredErrors(output)) {
const classified = classifyStructured(error)
if (classified) {
return classified
}
}
const lowered = output.toLowerCase()
if (SIGNED_OUT_PHRASES.some((phrase) => lowered.includes(phrase))) {
return { failureKind: 'missing-credentials', signedOut: true }
}
return null
}
@@ -3,7 +3,7 @@ import {
fetchAntigravityRateLimits,
resetAntigravityUsageSupportForTests
} from './antigravity-usage-fetcher'
import { ANTIGRAVITY_USAGE_ARGS } from './antigravity-usage-command'
import { ANTIGRAVITY_USAGE_ARGS, ANTIGRAVITY_VERSION_ARGS } from './antigravity-usage-command'
import type { ProcessResult } from '../../shared/child-process/process-spec'
const USAGE_ENVELOPE = JSON.stringify({
@@ -38,13 +38,24 @@ function processResult(overrides: Partial<ProcessResult> = {}): ProcessResult {
function harness(
options: {
result?: ProcessResult
versionResult?: ProcessResult
runCommand?: ReturnType<typeof vi.fn>
program?: string | null
env?: NodeJS.ProcessEnv
} = {}
) {
// Why the per-call split: the fetcher probes `--version` before the quota read, and a fixture
// that answers both alike either fakes a version line into `/usage` or a usage envelope into the
// version probe.
const runCommand =
options.runCommand ?? vi.fn().mockResolvedValue(options.result ?? processResult())
options.runCommand ??
vi
.fn()
.mockImplementation(async (spec: { args?: readonly string[] }) =>
spec.args?.[0] === '--version'
? (options.versionResult ?? processResult({ stdout: 'agy version 1.2.11\n' }))
: (options.result ?? processResult())
)
// Why the `in` check and not `??`: an explicit `program: null` is the absent-CLI case.
const resolveCommand = vi
.fn()
@@ -103,12 +114,13 @@ describe('fetchAntigravityRateLimits', () => {
}
})
it('runs the resolved absolute path with the quota arguments and the login-shell env', async () => {
it('probes the version, then runs the quota read with the resolved path and login-shell env', async () => {
const h = harness({ result: processResult({ stdout: USAGE_ENVELOPE }) })
await h.fetch()
expect(h.runCommand).toHaveBeenCalledTimes(1)
const spec = h.runCommand.mock.calls[0]![0]
expect(h.runCommand).toHaveBeenCalledTimes(2)
expect(h.runCommand.mock.calls[0]![0].args).toEqual(ANTIGRAVITY_VERSION_ARGS)
const spec = h.runCommand.mock.calls[1]![0]
expect(spec.program).toBe('/Users/x/.local/bin/agy')
expect(spec.args).toEqual(ANTIGRAVITY_USAGE_ARGS)
// Why the login-shell PATH: agy installs to ~/.local/bin, which Electron's inherited PATH omits.
@@ -141,6 +153,43 @@ describe('fetchAntigravityRateLimits', () => {
expect(result.error).toContain('Sign in with `agy`')
})
it('reports an auth failure on stderr as sign-in guidance, not as a broken read', async () => {
// Why pinned: the other shape of "signed out" — a non-zero exit with an auth diagnostic — used
// to land in the parse-failure branch as a generic refresh error.
const result = await harness({
result: processResult({ code: 1, stderr: 'Not authenticated. Run agy login.' })
}).fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
expect(result.error).toContain('Sign in with `agy`')
})
it('reports a bare "Not authenticated." failure on stderr as sign-in guidance', async () => {
const result = await harness({
result: processResult({ code: 1, stderr: 'Not authenticated.\n' })
}).fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
expect(result.error).toContain('Sign in with `agy`')
})
it('does not classify non-auth stderr as signed-out on non-zero exit', async () => {
// Why pinned: stderr containing unrelated words like "oauth" or "author" must not be misclassified
// as missing-credentials.
const result = await harness({
result: processResult({
code: 1,
stderr: 'Failed to contact https://oauth2.googleapis.com/token: connection refused'
})
}).fetch()
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('parse')
expect(result.error).toContain('did not report a quota (exit 1)')
})
it('reports a timeout as its own failure kind', async () => {
const result = await harness({ result: processResult({ timedOut: true }) }).fetch()
@@ -216,13 +265,13 @@ describe('agy versions that answer /usage as a prompt', () => {
it('never spawns agy again once a turn was spent', async () => {
const h = harness({ result: processResult({ stdout: MODEL_TURN_ENVELOPE }) })
await h.fetch()
expect(h.runCommand).toHaveBeenCalledTimes(1)
expect(h.runCommand).toHaveBeenCalledTimes(2)
// Why: the evidence costs a turn of the user's quota, so rediscovering it on a 15-minute
// cadence would keep paying for the same answer.
await h.fetch()
await h.fetch()
expect(h.runCommand).toHaveBeenCalledTimes(1)
expect(h.runCommand).toHaveBeenCalledTimes(2)
})
it('does not latch when the usage payload parsed, whatever else the envelope says', async () => {
@@ -234,7 +283,7 @@ describe('agy versions that answer /usage as a prompt', () => {
expect(first.status).toBe('ok')
expect(second.status).toBe('ok')
expect(h.runCommand).toHaveBeenCalledTimes(2)
expect(h.runCommand).toHaveBeenCalledTimes(4)
})
it('does not latch on an empty or unparsable answer', async () => {
@@ -245,6 +294,129 @@ describe('agy versions that answer /usage as a prompt', () => {
// Why: a transient failure is not evidence that the command is unsupported.
expect(first.status).toBe('error')
expect(second.status).toBe('error')
expect(h.runCommand).toHaveBeenCalledTimes(4)
})
})
describe('the agy version gate', () => {
beforeEach(() => {
resetAntigravityUsageSupportForTests()
})
it('never runs the quota read below the floor', async () => {
const h = harness({
versionResult: processResult({ stdout: 'agy version 1.1.10\n' }),
result: processResult({ stdout: USAGE_ENVELOPE })
})
const result = await h.fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
expect(result.error).toContain('needs agy 1.1.11 or newer (found 1.1.10)')
// Why the assertion: below the floor `/usage` is a billed model turn, so it must not run at all.
expect(h.runCommand).toHaveBeenCalledTimes(1)
expect(h.runCommand.mock.calls[0]![0].args).toEqual(ANTIGRAVITY_VERSION_ARGS)
})
it('holds a prerelease below the floor', async () => {
// Why pinned: `1.1.11-rc.1` sorts below stable `1.1.11`; reading the core alone would let a
// release candidate spawn the billable read early.
const h = harness({ versionResult: processResult({ stdout: 'agy version 1.1.11-rc.1\n' }) })
const result = await h.fetch()
expect(result.status).toBe('unavailable')
expect(result.error).toContain('found 1.1.11-rc.1')
expect(h.runCommand).toHaveBeenCalledTimes(1)
})
it.each([
['a garbled version line', processResult({ stdout: 'agy version unknown\n' })],
['a failed version probe', processResult({ code: 1, stderr: 'boom' })]
])('reports %s instead of guessing', async (_label, versionResult) => {
const h = harness({ versionResult })
const result = await h.fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
expect(result.error).toContain('version could not be read')
expect(h.runCommand).toHaveBeenCalledTimes(1)
})
it('runs the quota read at exactly the floor', async () => {
const h = harness({
versionResult: processResult({ stdout: 'agy version 1.1.11\n' }),
result: processResult({ stdout: USAGE_ENVELOPE })
})
const result = await h.fetch()
expect(result.status).toBe('ok')
expect(h.runCommand).toHaveBeenCalledTimes(2)
})
it('reads the version from stderr if stdout is empty on exit 0', async () => {
const h = harness({
versionResult: processResult({ stdout: '', stderr: 'agy version 1.1.11\n' }),
result: processResult({ stdout: USAGE_ENVELOPE })
})
const result = await h.fetch()
expect(result.status).toBe('ok')
expect(h.runCommand).toHaveBeenCalledTimes(2)
})
})
describe('quota read safety and diagnostic precedence', () => {
beforeEach(() => resetAntigravityUsageSupportForTests())
it.each([
processResult({ stdout: '1.2.14', timedOut: true }),
processResult({ stdout: '1.2.14', signal: 'SIGTERM' }),
processResult({ stdout: '1.2.14', code: 1 })
])('does not read quota after an unsuccessful version probe: %j', async (versionResult) => {
const h = harness({ versionResult })
const result = await h.fetch()
expect(result.status).toBe('unavailable')
expect(h.runCommand).toHaveBeenCalledTimes(1)
})
it('accepts a quota reading ahead of unrelated auth diagnostics', async () => {
const result = await harness({
result: processResult({
stdout: USAGE_ENVELOPE,
stderr: 'Not authenticated. Run agy login.'
})
}).fetch()
expect(result.status).toBe('ok')
})
it('latches a model turn ahead of authentication guidance', async () => {
const h = harness({
result: processResult({ stdout: MODEL_TURN_ENVELOPE, stderr: 'not logged into antigravity' })
})
expect((await h.fetch()).error).toContain('answers `/usage` as a prompt')
await h.fetch()
expect(h.runCommand).toHaveBeenCalledTimes(2)
})
it('prefers a structured quota failure over conflicting sign-in wording', async () => {
const result = await harness({
result: processResult({
stderr: 'not logged in\nAGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":429}'
})
}).fetch()
expect(result.usageMetadata?.failureKind).toBe('rate-limited')
expect(result.error).not.toContain('Sign in')
})
it('rechecks the version after an in-session CLI upgrade', async () => {
const runCommand = vi
.fn()
.mockResolvedValueOnce(processResult({ stdout: '1.1.10' }))
.mockResolvedValueOnce(processResult({ stdout: '1.2.14' }))
.mockResolvedValueOnce(processResult({ stdout: USAGE_ENVELOPE }))
const h = harness({ runCommand })
expect((await h.fetch()).status).toBe('unavailable')
expect((await h.fetch()).status).toBe('ok')
expect(runCommand).toHaveBeenCalledTimes(3)
})
})
@@ -1,21 +1,29 @@
import { runProcess } from '../../shared/child-process/run-process'
import { hasReachedAppVersion, parseCliVersion } from '../../shared/app-version'
import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver'
import { resolveLoginShellEnvironment } from '../startup/login-shell-environment'
import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared/rate-limit-types'
import {
ANTIGRAVITY_MIN_USAGE_VERSION,
ANTIGRAVITY_USAGE_ARGS,
ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES,
ANTIGRAVITY_USAGE_TIMEOUT_MS,
ANTIGRAVITY_VERSION_ARGS,
ANTIGRAVITY_VERSION_TIMEOUT_MS,
antigravityCommandName
} from './antigravity-usage-command'
import { parseAntigravityUsageStdout, stdoutShowsModelTurn } from './antigravity-usage-response'
/**
* Observed verbatim in agy's own log when the keyring holds no session. agy exits 0 and prints this
* instead of a usage envelope, so the text is the only thing that separates "signed out" from
* "answered nothing".
*/
const NOT_SIGNED_IN_MARKER = 'not logged into antigravity'
import { classifyAntigravityUsageFailure } from './antigravity-usage-error'
const FAILURE_REASONS: Partial<Record<UsageRateLimitFailureKind, string>> = {
'rate-limited':
'Antigravity usage is not available right now. The Antigravity API is rate-limiting this account.',
'no-subscription':
'Antigravity usage is not available. This account is signed in but not entitled to Antigravity quota.',
server:
'Antigravity usage is not available right now. The Antigravity API returned a server error.'
}
const UNSUPPORTED_USAGE_COMMAND_REASON =
'Antigravity usage is not available. This version of the Antigravity CLI answers `/usage` as a prompt instead of a command, so Orca stopped asking rather than spend quota on it. Update `agy` and restart Orca.'
@@ -66,6 +74,10 @@ function unavailable(
}
}
function spawnFailureMessage(error: unknown): string {
return `Antigravity usage is not available. The Antigravity CLI could not be started: ${error instanceof Error ? error.message : 'unknown error'}.`
}
function failed(
message: string,
failureKind: UsageRateLimitFailureKind,
@@ -120,6 +132,34 @@ export async function fetchAntigravityRateLimits(
)
}
// Recheck each read: the CLI can be replaced while Orca runs; unsupported reads spend quota.
let versionRun: Awaited<ReturnType<typeof runProcess>>
try {
versionRun = await run({
program,
args: ANTIGRAVITY_VERSION_ARGS,
env,
timeoutMs: ANTIGRAVITY_VERSION_TIMEOUT_MS,
maxOutputBytes: ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES,
signal: options.signal
})
} catch (error) {
return failed(spawnFailureMessage(error), 'cli-unavailable', now())
}
const version =
versionRun.code === 0 && !versionRun.timedOut && versionRun.signal === null
? parseCliVersion(versionRun.stdout.trim() ? versionRun.stdout : versionRun.stderr)
: null
if (!version || !hasReachedAppVersion(version, ANTIGRAVITY_MIN_USAGE_VERSION)) {
return unavailable(
version
? `Antigravity usage needs agy ${ANTIGRAVITY_MIN_USAGE_VERSION} or newer (found ${version}). Update the agy CLI to show quota in the status bar.`
: `Antigravity usage is unavailable because the agy CLI version could not be read. Update agy to ${ANTIGRAVITY_MIN_USAGE_VERSION} or newer.`,
'usage-unavailable',
now()
)
}
let result: Awaited<ReturnType<typeof runProcess>>
try {
result = await run({
@@ -131,11 +171,7 @@ export async function fetchAntigravityRateLimits(
signal: options.signal
})
} catch (error) {
return failed(
`Antigravity usage is not available. The Antigravity CLI could not be started: ${error instanceof Error ? error.message : 'unknown error'}.`,
'cli-unavailable',
now()
)
return failed(spawnFailureMessage(error), 'cli-unavailable', now())
}
if (result.timedOut) {
@@ -146,15 +182,6 @@ export async function fetchAntigravityRateLimits(
)
}
const output = `${result.stdout}\n${result.stderr}`
if (output.toLowerCase().includes(NOT_SIGNED_IN_MARKER)) {
return unavailable(
'Antigravity usage is not available. Sign in with `agy` to report this account’s quota.',
'missing-credentials',
now()
)
}
const reading = parseAntigravityUsageStdout(result.stdout)
// Why the successful read is checked first: a real reading can never be evidence of a prompt, so
// ordering it ahead of the turn check makes a false latch impossible.
@@ -163,8 +190,26 @@ export async function fetchAntigravityRateLimits(
return unavailable(UNSUPPORTED_USAGE_COMMAND_REASON, 'usage-unavailable', now())
}
if (!reading) {
// Why a non-zero exit is reported only here: `runProcess` treats the exit code as data, and agy
// exits 0 for a signed-out read, so the code only adds detail once the payload is missing.
const failure = classifyAntigravityUsageFailure(`${result.stdout}\n${result.stderr}`)
if (failure?.signedOut) {
return unavailable(
'Antigravity usage is not available. Sign in with `agy` to report this account’s quota.',
failure.failureKind,
now()
)
}
if (failure) {
return failed(
FAILURE_REASONS[failure.failureKind] ??
'Antigravity usage is not available. The CLI could not read this account’s quota.',
failure.failureKind,
now()
)
}
}
if (!reading) {
// Why a non-zero exit is reported only here: `runProcess` treats the exit code as data, and a
// signed-out read is classified above, so the code only adds detail once the payload is missing.
const exitDetail = result.code === 0 || result.code === null ? '' : ` (exit ${result.code})`
return failed(
`Antigravity usage is not available. The Antigravity CLI did not report a quota${exitDetail}.`,
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { runProcess } from '../../shared/child-process/run-process'
import { stdoutShowsModelTurn } from './antigravity-usage-response'
import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher'
/**
@@ -14,7 +16,23 @@ const enabled = process.env.ORCA_REAL_AGY_CLI_TEST === '1'
describe.skipIf(!enabled)('Antigravity usage against the real agy CLI', () => {
it('reports quota with at least one named pool', async () => {
const result = await fetchAntigravityRateLimits()
const calls: string[] = []
const result = await fetchAntigravityRateLimits({
runCommand: async (spec) => {
const output = await runProcess(spec)
calls.push(spec.args?.[0] ?? '')
if (spec.args?.[0] === '-p') {
expect(stdoutShowsModelTurn(output.stdout)).toBe(false)
const envelope: unknown = JSON.parse(output.stdout)
expect(envelope).toMatchObject({
conversation_id: '',
num_turns: 0
})
}
return output
}
})
expect(calls[0]).toBe('--version')
if (result.status !== 'ok') {
// A machine with no agy or no sign-in still proves the classification, not a crash.
@@ -23,6 +41,7 @@ describe.skipIf(!enabled)('Antigravity usage against the real agy CLI', () => {
return
}
expect(calls).toEqual(['--version', '-p'])
expect(result.provider).toBe('antigravity')
expect(result.error).toBeNull()
expect(result.buckets?.length).toBeGreaterThan(0)
@@ -0,0 +1,12 @@
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
// A null slot means loading to readers; a disabled meter must settle instead.
export function antigravityUsageDisabledSnapshot(now: number = Date.now()): ProviderRateLimits {
return {
provider: 'antigravity',
session: null,
weekly: null,
updatedAt: now,
error: null,
status: 'idle'
}
}
+2 -2
View File
@@ -35,8 +35,8 @@ vi.mock('node:fs', () => ({
}
}))
vi.mock('./cursor-desktop-state-db', () => ({
readCursorDesktopProfile: () => desktopState.result
vi.mock('./cursor-desktop-profile-worker', () => ({
readCursorDesktopProfileViaWorker: () => desktopState.result
}))
import { readCursorAuthSession, readCursorCliIdentity } from './cursor-auth'
+3 -2
View File
@@ -7,7 +7,8 @@ import {
getCursorDesktopStateDbPath,
type CursorAuthSource
} from './cursor-auth-paths'
import { readCursorDesktopProfile, type CursorDesktopProfile } from './cursor-desktop-state-db'
import type { CursorDesktopProfile } from './cursor-desktop-state-db'
import { readCursorDesktopProfileViaWorker } from './cursor-desktop-profile-worker'
import {
isCursorSessionTokenExpired,
parseCursorSessionToken,
@@ -190,7 +191,7 @@ export async function readCursorAuthSession(
// Why not pushed to `errors`: the IDE holds a lock on state.vscdb while it runs,
// so a busy open is transient. Reporting it would pin an alert-triangle bar on
// every Cursor IDE user who never set Cursor up in Orca.
const desktopRead = readCursorDesktopProfile(desktopDbPath)
const desktopRead = await readCursorDesktopProfileViaWorker(desktopDbPath)
if (desktopRead.status === 'ok' && desktopRead.profile.accessToken) {
const session = takeLive(
sessionFrom(desktopRead.profile.accessToken, 'desktop', desktopIdentity(desktopRead.profile))

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