mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
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:
@@ -0,0 +1,67 @@
|
||||
name: Prepare server native prebuilds
|
||||
description: Builds and smokes the current server slot, optionally restoring a validated Windows slot.
|
||||
|
||||
inputs:
|
||||
resolve-windows-cache:
|
||||
description: Resolve the exact Windows cache identity for restoration or later qualified publication.
|
||||
default: 'false'
|
||||
restore-windows-cache:
|
||||
description: Restore the exact Windows slot when its identity is available.
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
cache-key:
|
||||
description: Exact Windows server cache key.
|
||||
value: ${{ steps.orcad-prebuild-cache-identity.outputs.key }}
|
||||
cache-path:
|
||||
description: Directory containing the Windows slot and its manifest.
|
||||
value: ${{ steps.orcad-prebuild-cache-identity.outputs.path }}
|
||||
cache-identity-outcome:
|
||||
description: Whether Windows cache identity resolution succeeded.
|
||||
value: ${{ steps.orcad-prebuild-cache-identity.outcome }}
|
||||
cache-hit:
|
||||
description: Whether the exact Windows slot was restored.
|
||||
value: ${{ steps.orcad-prebuild-cache-restore.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve this Windows server prebuild cache
|
||||
id: orcad-prebuild-cache-identity
|
||||
if: >-
|
||||
inputs.resolve-windows-cache == 'true' && runner.os == 'Windows' &&
|
||||
(runner.arch == 'X64' || runner.arch == 'ARM64')
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: node config/scripts/orcad-windows-prebuild-cache.mjs --fingerprint
|
||||
|
||||
- name: Restore the exact Windows server prebuild
|
||||
id: orcad-prebuild-cache-restore
|
||||
if: >-
|
||||
inputs.restore-windows-cache == 'true' &&
|
||||
steps.orcad-prebuild-cache-identity.outcome == 'success' &&
|
||||
steps.orcad-prebuild-cache-identity.outputs.key != ''
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ steps.orcad-prebuild-cache-identity.outputs.path }}
|
||||
key: ${{ steps.orcad-prebuild-cache-identity.outputs.key }}
|
||||
|
||||
- name: Build and smoke this runner's node-pty prebuild slot under the pinned Node
|
||||
shell: bash
|
||||
env:
|
||||
WINDOWS_PREBUILD_CACHE_HIT: ${{ steps.orcad-prebuild-cache-restore.outputs.cache-hit }}
|
||||
WINDOWS_PREBUILD_CACHE_RESTORE_OUTCOME: ${{ steps.orcad-prebuild-cache-restore.outcome }}
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Windows ] && [ "$WINDOWS_PREBUILD_CACHE_HIT" = true ] &&
|
||||
[ "$WINDOWS_PREBUILD_CACHE_RESTORE_OUTCOME" = success ] &&
|
||||
node config/scripts/orcad-windows-prebuild-cache.mjs --validate; then
|
||||
echo 'Using the validated Windows server prebuild'
|
||||
else
|
||||
if [ "$RUNNER_OS" = Windows ]; then
|
||||
rm -rf -- out/orcad-prebuilds
|
||||
fi
|
||||
pnpm build:orcad-prebuilds
|
||||
fi
|
||||
pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)"
|
||||
pnpm build:orcad-prebuilds --smoke
|
||||
@@ -51,7 +51,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
# Hourly retries must let an active warmer finish publishing its caches.
|
||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||
|
||||
jobs:
|
||||
warm:
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
# v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10.
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.24.0
|
||||
version: 10.34.6
|
||||
package_json_file: docs/site/package.json
|
||||
run_install: false
|
||||
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
# v5 avoids the v6 bootstrap/shim regression when pinning pnpm 10.
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.24.0
|
||||
version: 10.34.6
|
||||
package_json_file: docs/site/package.json
|
||||
run_install: false
|
||||
|
||||
|
||||
+21
-14
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }}
|
||||
runs-on: ${{ inputs.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -82,6 +83,7 @@ jobs:
|
||||
matrix:
|
||||
node: ${{ fromJSON(inputs.node_versions) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -103,8 +105,8 @@ jobs:
|
||||
- name: Install relay integration dependencies
|
||||
working-directory: cloud
|
||||
run: |
|
||||
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts
|
||||
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build
|
||||
npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts
|
||||
npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay^...' build
|
||||
|
||||
- name: Test relay integration contracts
|
||||
env:
|
||||
|
||||
@@ -15,20 +15,20 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"@orca-cloud/postgres-schema": "workspace:*",
|
||||
"@orca-cloud/push-contract": "workspace:*",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"hono": "^4.13.7",
|
||||
"hono": "^4.13.10",
|
||||
"pg": "^8.22.0",
|
||||
"pg-connection-string": "2.14.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"tsx": "^4.21.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"hono": "^4.13.7",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"hono": "^4.13.10",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"tsx": "^4.21.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"hono": "^4.13.7",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"hono": "^4.13.10",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"tsx": "^4.21.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -15,21 +15,21 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.17",
|
||||
"@hono/node-server": "^2.1.2",
|
||||
"@orca-cloud/postgres-schema": "workspace:*",
|
||||
"@orca-cloud/relay-contract": "workspace:*",
|
||||
"hono": "^4.13.7",
|
||||
"jose": "^6.1.3",
|
||||
"hono": "^4.13.10",
|
||||
"jose": "^6.2.12",
|
||||
"pg": "^8.22.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.21.3",
|
||||
"ws": "^8.22.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const CELL_TABLES = [
|
||||
// The target-row statement locks exactly these, held from it to COMMIT.
|
||||
const TARGET_LOCKED = ['target:relay_cells', 'target:relay_cell_admission']
|
||||
|
||||
type Trip = { sql: string; lockable: Record<string, boolean> }
|
||||
type Trip = { sql: string; lockable: Record<string, boolean>; probeMs: number }
|
||||
|
||||
type DelayControl = StatementDelay & { beforeTrip: (sql: string) => Promise<void> }
|
||||
|
||||
@@ -159,11 +159,12 @@ describePostgres('PostgreSQL regional rehome target-row lock', () => {
|
||||
bystander: context.bystander.id
|
||||
}
|
||||
control.beforeTrip = async (sql) => {
|
||||
const probeStartedAt = performance.now()
|
||||
const state: Record<string, boolean> = {}
|
||||
for (const [role, cellId] of Object.entries(probed)) {
|
||||
for (const table of CELL_TABLES) state[`${role}:${table}`] = await lockable(table, cellId)
|
||||
}
|
||||
trips.push({ sql, lockable: state })
|
||||
trips.push({ sql, lockable: state, probeMs: performance.now() - probeStartedAt })
|
||||
}
|
||||
consumeRelayCellInventoryHold(delayed)
|
||||
control.enabled = true
|
||||
@@ -188,13 +189,22 @@ describePostgres('PostgreSQL regional rehome target-row lock', () => {
|
||||
])
|
||||
}
|
||||
const counts = consumeRelayCellInventoryHold(delayed)
|
||||
const commitProbeMs = trips.at(-1)!.probeMs
|
||||
console.info(
|
||||
JSON.stringify({ event: 'rehome_target_row_hold', trips: trips.length, ...counts })
|
||||
JSON.stringify({
|
||||
event: 'rehome_target_row_hold',
|
||||
trips: trips.length,
|
||||
commitProbeMs,
|
||||
...counts
|
||||
})
|
||||
)
|
||||
expect(counts.rehomeTargetRowHolds).toBe(1)
|
||||
expect(counts.cellInventoryHoldMaxSite).toBe('rehome-target-row')
|
||||
expect(counts.rehomeTargetRowHoldMsMax).toBeGreaterThanOrEqual(STATEMENT_DELAY_MS)
|
||||
expect(counts.rehomeTargetRowHoldMsMax).toBeLessThanOrEqual(2 * STATEMENT_DELAY_MS)
|
||||
// The COMMIT observer probes run under the lock, but are absent in production.
|
||||
expect(counts.rehomeTargetRowHoldMsMax - commitProbeMs).toBeLessThanOrEqual(
|
||||
2 * STATEMENT_DELAY_MS
|
||||
)
|
||||
expect(await reservedRequests(context.target.id)).toBe(context.targetReservedBefore + 2)
|
||||
})
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"name": "orca-cloud",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"packageManager": "pnpm@10.34.6",
|
||||
"engines": {
|
||||
"node": ">=24 <27",
|
||||
"pnpm": ">=10"
|
||||
@@ -26,7 +26,7 @@
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^24.19.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
|
||||
Generated
+93
-410
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,4 @@ packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
|
||||
minimumReleaseAge: 4320
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -310,6 +310,7 @@ module.exports = {
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/session-scanner-service-entry.js',
|
||||
'out/main/wsl-transcript-fs-process-entry.js',
|
||||
'out/main/cursor-desktop-profile-worker-entry.js',
|
||||
'out/main/session-scanner-opencode-sqlite-worker-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"src/main/computer/sidecar-entry.ts",
|
||||
"src/main/speech/stt-worker.ts",
|
||||
"src/main/warp-themes/warp-theme-parser-worker.ts",
|
||||
"src/main/rate-limits/cursor-desktop-profile-worker-entry.ts",
|
||||
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
|
||||
"src/main/ai-vault/session-scanner-worker-entry.ts",
|
||||
"src/main/ports/port-scan-command-worker-entry.ts",
|
||||
|
||||
@@ -22,6 +22,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { canReusePreparedRelayAddon } from './relay-windows-process-tree-prepared-addon.mjs'
|
||||
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
|
||||
import {
|
||||
ensureWindowsProcessTreeCommandLinePatch,
|
||||
@@ -410,14 +411,15 @@ function applyWindowsProcessTreeBuildFixes() {
|
||||
const repairedCreationTime = repairCreationTimeSources()
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR)
|
||||
const repairedCommandLine = ensureWindowsProcessTreeCommandLinePatch(PACKAGE_DIR)
|
||||
if (
|
||||
const repaired =
|
||||
bindingGyp !== originalBinding ||
|
||||
processCc !== originalProcess ||
|
||||
repairedCommandLine ||
|
||||
repairedCreationTime
|
||||
) {
|
||||
if (repaired) {
|
||||
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
|
||||
}
|
||||
return repaired
|
||||
}
|
||||
|
||||
function main() {
|
||||
@@ -431,14 +433,24 @@ function main() {
|
||||
if (!existsSync(PACKAGE_DIR)) {
|
||||
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
|
||||
}
|
||||
applyWindowsProcessTreeBuildFixes()
|
||||
const sourceRepaired = applyWindowsProcessTreeBuildFixes()
|
||||
assertPatchApplied()
|
||||
|
||||
const gyp = nodeGypRebuildInvocation(arch)
|
||||
console.log(`[windows-process-tree] building ${arch} from ${gyp.cwd}`)
|
||||
execFileSync(process.execPath, gyp.args, { cwd: gyp.cwd, stdio: 'inherit' })
|
||||
|
||||
const built = join(PACKAGE_DIR, 'build', 'Release', 'windows_process_tree.node')
|
||||
if (
|
||||
canReusePreparedRelayAddon({
|
||||
enabled: process.argv.includes('--reuse-prepared-runtime'),
|
||||
arch,
|
||||
addonPath: built,
|
||||
sourceRepaired
|
||||
})
|
||||
) {
|
||||
console.log(`[windows-process-tree] reusing the prepared ${arch} addon`)
|
||||
} else {
|
||||
const gyp = nodeGypRebuildInvocation(arch)
|
||||
console.log(`[windows-process-tree] building ${arch} from ${gyp.cwd}`)
|
||||
execFileSync(process.execPath, gyp.args, { cwd: gyp.cwd, stdio: 'inherit' })
|
||||
}
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`node-gyp reported success but ${built} is missing.`)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const result = spawnSync(
|
||||
[
|
||||
...prefixArgs,
|
||||
'dlx',
|
||||
'react-doctor@0.9.1',
|
||||
'react-doctor@0.9.14',
|
||||
'.',
|
||||
'--yes',
|
||||
'--scope',
|
||||
|
||||
@@ -74,11 +74,16 @@ it('bounds warming to the required platforms and validates changes without grant
|
||||
expect(workflow.on.push.paths).toContain('.github/actions/prepare-native-runtime/**')
|
||||
expect(workflow.on.schedule).toEqual([{ cron: '41 * * * *' }])
|
||||
expect(workflow.on.pull_request.paths).toContain('.github/workflows/ci-cache-warmup.yml')
|
||||
expect(workflow.concurrency['cancel-in-progress']).toBe(true)
|
||||
expect(workflow.concurrency.group).toContain('github.event.pull_request.number || github.ref')
|
||||
expect(steps[0].with['persist-credentials']).toBe(false)
|
||||
})
|
||||
|
||||
it('lets hourly warmers wait while pushes, PR updates, and manual runs can replace active work', () => {
|
||||
expect(workflow.concurrency).toEqual({
|
||||
group: 'ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}',
|
||||
'cancel-in-progress': "${{ github.event_name != 'schedule' }}"
|
||||
})
|
||||
})
|
||||
|
||||
it('warms and probes both Windows images with the persistence job runtime', () => {
|
||||
const job = workflow.jobs['warm-windows']
|
||||
expect(job.strategy.matrix.os).toEqual(
|
||||
|
||||
@@ -8,9 +8,11 @@ import { basename, dirname, resolve } from 'node:path'
|
||||
import {
|
||||
ensureWindowsProcessTreeCommandLinePatch,
|
||||
inspectWindowsProcessTreeAddon,
|
||||
nodeGypRebuildInvocation,
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders,
|
||||
windowsProcessTreeAddonPath
|
||||
} from './windows-process-tree-gyp-rebuild.mjs'
|
||||
import { describeProcessFailure, runProcessSync } from './script-child-process.mjs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership, nodePtyAddonPath } = require('./node-pty-job-ownership.cjs')
|
||||
@@ -400,33 +402,38 @@ function rebuildNodeRuntimeModules(moduleNames) {
|
||||
moduleDir = realpathSync(moduleDir)
|
||||
}
|
||||
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
|
||||
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
|
||||
// pnpm exec inside an installed addon cannot discover the root build tool.
|
||||
runNodeGyp(
|
||||
nodeGypRebuildInvocation(
|
||||
process.arch,
|
||||
moduleDir,
|
||||
process.env.npm_config_node_gyp || undefined
|
||||
)
|
||||
)
|
||||
if (moduleName === 'node-pty' && process.platform === 'win32') {
|
||||
runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runPnpm(args, { cwd = projectDir } = {}) {
|
||||
// cmd.exe resolves both Corepack's pnpm.cmd and pnpm 12's native pnpm.exe.
|
||||
const command = 'pnpm'
|
||||
function runNodeGyp({ args, cwd }) {
|
||||
const env =
|
||||
process.platform === 'linux' && args.includes('node-gyp')
|
||||
process.platform === 'linux'
|
||||
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
|
||||
: process.env
|
||||
const result = spawnSync(command, args, {
|
||||
const result = runProcessSync({
|
||||
program: process.execPath,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
env
|
||||
timeoutMs: 300_000
|
||||
})
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
|
||||
if (result.error) {
|
||||
console.error(formatError(result.error))
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
if (result.code !== 0) {
|
||||
console.error(
|
||||
`[native-runtime] node-gyp rebuild failed in ${cwd}: ${describeProcessFailure(result)}`
|
||||
)
|
||||
process.exit(result.code ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
@@ -9,10 +9,18 @@ import {
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { isAbsolute, join, parse } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
runProcessSync
|
||||
} from '../../src/shared/child-process/run-process.ts'
|
||||
import { resolveCliCommand } from '../../src/shared/node-cli-command-resolution.ts'
|
||||
import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts'
|
||||
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
|
||||
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
|
||||
import { nodeGypRebuildInvocation } from './windows-process-tree-gyp-rebuild.mjs'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
|
||||
// The import walk sees `from './x.mjs'` only, so the createRequire'd CJS
|
||||
@@ -30,23 +38,28 @@ describe('ensure-native-runtime', () => {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
writeFakeNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
writeFakeNodeGyp(projectDir)
|
||||
const verboseOutputBytes = DEFAULT_MAX_OUTPUT_BYTES + 1024 * 1024
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
maxBuffer: DEFAULT_MAX_OUTPUT_BYTES * 4,
|
||||
env: envForNativeFixture(projectDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath,
|
||||
ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES: String(verboseOutputBytes)
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout.indexOf('node-gyp stdout complete\n')).toBe(verboseOutputBytes)
|
||||
expect(/^x+$/.test(result.stdout.slice(0, verboseOutputBytes))).toBe(true)
|
||||
expect(result.stderr).toContain('node-gyp stderr complete\n')
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(log).toContain(`node-gyp rebuild --arch=${process.arch}\n`)
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
if (process.platform === 'linux') {
|
||||
expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
|
||||
@@ -69,15 +82,14 @@ describe('ensure-native-runtime', () => {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
writeFakeNodeGyp(projectDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
env: envForNativeFixture(projectDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
@@ -85,7 +97,9 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2)
|
||||
expect(
|
||||
log.split('\n').filter((line) => line === `node-gyp rebuild --arch=${process.arch}`)
|
||||
).toHaveLength(2)
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
expect(log).toContain(join('node_modules', '@orca', 'windows-registry'))
|
||||
} finally {
|
||||
@@ -103,15 +117,14 @@ describe('ensure-native-runtime', () => {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
writeLoadableNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
writeFakeNodeGyp(projectDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
env: envForNativeFixture(projectDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
@@ -121,7 +134,7 @@ describe('ensure-native-runtime', () => {
|
||||
expect(result.stderr).toContain(
|
||||
'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
||||
)
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(readFileSync(logPath, 'utf8')).toContain(`node-gyp rebuild --arch=${process.arch}\n`)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -137,16 +150,15 @@ describe('ensure-native-runtime', () => {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
writeLoadableNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writePatchedNodePtyBuildArtifacts(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
writeFakeNodeGyp(projectDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
env: envForNativeFixture(projectDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
@@ -154,7 +166,7 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(readFileSync(logPath, 'utf8')).toContain(`node-gyp rebuild --arch=${process.arch}\n`)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -170,16 +182,15 @@ describe('ensure-native-runtime', () => {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' })
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writePatchedNodePtyBuildArtifacts(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
writeFakeNodeGyp(projectDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
env: envForNativeFixture(projectDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
@@ -187,12 +198,64 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('node-gyp rebuild')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
it('finds the installed node-gyp entry from an addon without build tools on PATH', () => {
|
||||
const { command, prefixArgs } = resolvePnpmCliInvocation()
|
||||
const program = isAbsolute(command) ? command : resolveCliCommand(parse(command).name)
|
||||
expect(isAbsolute(program), 'pnpm must be installed for the native rebuild contract').toBe(true)
|
||||
const projectDir = mkTempProject()
|
||||
try {
|
||||
writeFakeNativeModules(projectDir)
|
||||
const addonDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
const env = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.toLowerCase() === 'path') {
|
||||
env[key] = ''
|
||||
}
|
||||
}
|
||||
const oldInvocation = runProcessSync({
|
||||
program,
|
||||
args: [
|
||||
...prefixArgs,
|
||||
'--config.verify-deps-before-run=false',
|
||||
'exec',
|
||||
'node-gyp',
|
||||
'--version'
|
||||
],
|
||||
cwd: addonDir,
|
||||
env,
|
||||
timeoutMs: 20_000
|
||||
})
|
||||
expect(oldInvocation.code).not.toBe(0)
|
||||
expect(`${oldInvocation.stdout}\n${oldInvocation.stderr}`).toContain(
|
||||
'Command "node-gyp" not found'
|
||||
)
|
||||
const { args, cwd } = nodeGypRebuildInvocation(process.arch, addonDir)
|
||||
const installedInvocation = runProcessSync({
|
||||
program: process.execPath,
|
||||
args: [args[0], '--version'],
|
||||
cwd,
|
||||
env,
|
||||
timeoutMs: 20_000
|
||||
})
|
||||
expect(
|
||||
installedInvocation.code,
|
||||
`${installedInvocation.stdout}\n${installedInvocation.stderr}`
|
||||
).toBe(0)
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(new URL('../../node_modules/node-gyp/package.json', import.meta.url), 'utf8')
|
||||
)
|
||||
expect(installedInvocation.stdout.trim()).toBe(`v${manifest.version}`)
|
||||
expect(existsSync(join(addonDir, 'pnpm-lock.yaml'))).toBe(false)
|
||||
} finally {
|
||||
removeTreeSync(projectDir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function mkTempProject() {
|
||||
@@ -200,6 +263,10 @@ function mkTempProject() {
|
||||
// Walked, not listed: the script imports windows-process-tree-gyp-rebuild.mjs, and a fixture
|
||||
// missing it fails every case with a module-resolution error instead of the defect under test.
|
||||
copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts'))
|
||||
writeFileSync(
|
||||
join(projectDir, 'config', 'scripts', 'script-child-process.mjs'),
|
||||
`export { describeProcessFailure, runProcessSync } from ${JSON.stringify(new URL('./script-child-process.mjs', import.meta.url).href)}\n`
|
||||
)
|
||||
for (const name of REQUIRED_CJS_SIBLINGS) {
|
||||
copyFileSync(
|
||||
fileURLToPath(new URL(`./${name}`, import.meta.url)),
|
||||
@@ -209,15 +276,11 @@ function mkTempProject() {
|
||||
return projectDir
|
||||
}
|
||||
|
||||
function envWithPrependedPath(binDir, extraEnv) {
|
||||
const pathKey =
|
||||
process.platform === 'win32'
|
||||
? (Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'Path')
|
||||
: 'PATH'
|
||||
function envForNativeFixture(projectDir, extraEnv) {
|
||||
return {
|
||||
...process.env,
|
||||
...extraEnv,
|
||||
[pathKey]: `${binDir}${delimiter}${process.env[pathKey] ?? ''}`
|
||||
npm_config_node_gyp: join(projectDir, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +321,11 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
|
||||
if (process.platform === 'win32') {
|
||||
const buildDir = join(nodePtyDir, 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
writeFileSync(join(buildDir, 'conpty.node'), Buffer.from('msys-2.0.dll', 'utf16le'))
|
||||
}
|
||||
}
|
||||
|
||||
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
|
||||
@@ -314,7 +382,10 @@ function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
|
||||
)
|
||||
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
mkdirSync(processTreeDir, { recursive: true })
|
||||
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'index.js'),
|
||||
'exports.supportedProcessDataFlags = 4; exports.getProcessCreationTime = () => 1\n'
|
||||
)
|
||||
}
|
||||
|
||||
function writeNodePtyPatchFile(projectDir) {
|
||||
@@ -338,33 +409,25 @@ function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeFakePnpm(binDir) {
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
const shimPath = join(binDir, 'pnpm-shim.cjs')
|
||||
function writeFakeNodeGyp(projectDir) {
|
||||
const toolDir = join(projectDir, 'node_modules', 'node-gyp', 'bin')
|
||||
mkdirSync(toolDir, { recursive: true })
|
||||
writeFileSync(
|
||||
shimPath,
|
||||
join(toolDir, 'node-gyp.js'),
|
||||
`
|
||||
const { appendFileSync, writeFileSync } = require('node:fs')
|
||||
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
|
||||
const { appendFileSync, writeFileSync, writeSync } = require('node:fs')
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-gyp \${process.argv.slice(2).join(' ')}\\n\`)
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
|
||||
)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`
|
||||
)
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`)
|
||||
if (process.env.ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES) {
|
||||
const output = Buffer.alloc(Number(process.env.ORCA_NATIVE_TEST_VERBOSE_OUTPUT_BYTES), 'x')
|
||||
for (let offset = 0; offset < output.length;) {
|
||||
offset += writeSync(1, output.subarray(offset))
|
||||
}
|
||||
writeSync(1, 'node-gyp stdout complete\\n')
|
||||
writeSync(2, 'node-gyp stderr complete\\n')
|
||||
}
|
||||
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
|
||||
`
|
||||
)
|
||||
|
||||
const posixPnpmPath = join(binDir, 'pnpm')
|
||||
writeFileSync(posixPnpmPath, `#!/usr/bin/env node\nrequire(${JSON.stringify(shimPath)})\n`)
|
||||
chmodSync(posixPnpmPath, 0o755)
|
||||
writeFileSync(
|
||||
join(binDir, 'pnpm.cmd'),
|
||||
`@echo off\r\n"${process.execPath}" "%~dp0\\pnpm-shim.cjs" %*\r\n`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { build } from 'vite'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { electronViteConfig } from '../../electron.vite.config'
|
||||
import { runProcess } from '../../src/shared/child-process/run-process'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const require = createRequire(import.meta.url)
|
||||
let outputDir
|
||||
|
||||
const smokeScript = `
|
||||
const assert = require('node:assert/strict')
|
||||
const { createRequire } = require('node:module')
|
||||
const loaderPath = process.argv[1]
|
||||
const requireFromLoader = createRequire(loaderPath)
|
||||
const sdkEntry = requireFromLoader.resolve('@linear/sdk')
|
||||
const { loadLinearSdk } = require(loaderPath)
|
||||
assert.equal(require.cache[sdkEntry], undefined, 'SDK must remain lazy')
|
||||
|
||||
const sdk = loadLinearSdk()
|
||||
assert.equal(sdk, requireFromLoader('@linear/sdk'))
|
||||
assert.equal(loadLinearSdk(), sdk, 'repeat loads must reuse the SDK')
|
||||
const client = new sdk.LinearClient({ apiKey: 'orca-offline-smoke-test' })
|
||||
assert.equal(typeof client.issues, 'function')
|
||||
assert.equal(typeof client.teams, 'function')
|
||||
assert.ok(sdk.AuthenticationLinearError.prototype instanceof Error)
|
||||
console.log('Linear SDK loaded')
|
||||
`
|
||||
|
||||
beforeAll(async () => {
|
||||
// Keep normal dependency resolution while executing outside Vitest's module loader.
|
||||
outputDir = await mkdtemp(join(projectDir, 'node_modules', 'orca-linear-sdk-runtime-'))
|
||||
const mainBuild = electronViteConfig.main.build
|
||||
await build({
|
||||
configFile: false,
|
||||
publicDir: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
ssr: true,
|
||||
minify: mainBuild.minify,
|
||||
outDir: outputDir,
|
||||
rollupOptions: {
|
||||
external: mainBuild.rollupOptions.external,
|
||||
input: join(projectDir, 'src/main/linear/linear-sdk.ts'),
|
||||
output: { format: 'cjs', entryFileNames: 'linear-sdk.cjs' }
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (outputDir) {
|
||||
await rm(outputDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
describe('resolved Linear SDK through the production CommonJS loader', () => {
|
||||
it.each([
|
||||
{ name: 'Node', program: process.execPath },
|
||||
{ name: 'Electron', program: require('electron') }
|
||||
])(
|
||||
'loads and constructs the real SDK under $name without network access',
|
||||
async ({ program }) => {
|
||||
const result = await runProcess({
|
||||
program,
|
||||
args: ['-e', smokeScript, join(outputDir, 'linear-sdk.cjs')],
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ELECTRON_RUN_AS_NODE: '1' },
|
||||
timeoutMs: 20000
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.stdout.trim()).toBe('Linear SDK loaded')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -218,7 +218,7 @@ describe('PR E2E gate contract', () => {
|
||||
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
|
||||
step.name.startsWith('Install native build')
|
||||
)
|
||||
expect(installStep.run, jobName).toMatch(/\bzsh\b/)
|
||||
expect(installStep.env.ORCA_E2E_APT_PACKAGES.split(/\s+/), jobName).toContain('zsh')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -308,7 +308,7 @@ describe('PR E2E gate contract', () => {
|
||||
const changedInstall = e2eWorkflow.jobs['changed-e2e'].steps.find((step) =>
|
||||
step.name.startsWith('Install native build')
|
||||
)
|
||||
expect(changedInstall.run).toContain('openssh-client')
|
||||
expect(changedInstall.env.ORCA_E2E_APT_PACKAGES.split(/\s+/)).toContain('openssh-client')
|
||||
})
|
||||
|
||||
it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
|
||||
|
||||
@@ -285,7 +285,7 @@ describe('PR workflow parallelism', () => {
|
||||
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
||||
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
|
||||
const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
|
||||
expect(packageManagerVersion).toBe('12.0.0')
|
||||
expect(packageManagerVersion).toBe('12.8.1')
|
||||
expect(steps[pnpmIndex].uses).toBe('pnpm/setup@v2')
|
||||
expect(steps[pnpmIndex].with.version).toBeUndefined()
|
||||
expect(steps[pnpmIndex].with.install).toBe(false)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { join } from 'node:path'
|
||||
import { runProcessSync } from './script-child-process.mjs'
|
||||
import { relayWindowsProcessTreeAddonDefect } from './windows-process-tree-gyp-rebuild.mjs'
|
||||
|
||||
const root = join(import.meta.dirname, '../..')
|
||||
|
||||
function checkPreparedRuntime() {
|
||||
return runProcessSync({
|
||||
program: process.execPath,
|
||||
args: [join(root, 'config/scripts/ensure-native-runtime.mjs'), '--check-only'],
|
||||
cwd: root,
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
}
|
||||
|
||||
export function canReusePreparedRelayAddon({
|
||||
enabled = false,
|
||||
arch,
|
||||
addonPath,
|
||||
sourceRepaired,
|
||||
hostArch = process.arch,
|
||||
platform = process.platform,
|
||||
checkRuntime = checkPreparedRuntime
|
||||
}) {
|
||||
if (!enabled || sourceRepaired !== false || platform !== 'win32' || arch !== hostArch) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if (relayWindowsProcessTreeAddonDefect(addonPath, arch) !== null) {
|
||||
return false
|
||||
}
|
||||
const result = checkRuntime()
|
||||
return result.code === 0 && !result.timedOut && !result.outputTruncated
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { peImage } from './windows-pe-image-fixture.mjs'
|
||||
import { canReusePreparedRelayAddon } from './relay-windows-process-tree-prepared-addon.mjs'
|
||||
import { runProcessSync } from './script-child-process.mjs'
|
||||
|
||||
const directories = []
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function prepared(arch = 'x64', suffix = 'spawnOutsideJob') {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-prepared-relay-addon-'))
|
||||
directories.push(directory)
|
||||
const addonPath = join(directory, 'process-tree.node')
|
||||
writeFileSync(addonPath, Buffer.concat([peImage({ arch }), Buffer.from(suffix)]))
|
||||
return {
|
||||
enabled: true,
|
||||
arch,
|
||||
hostArch: arch,
|
||||
platform: 'win32',
|
||||
sourceRepaired: false,
|
||||
addonPath,
|
||||
checkRuntime: vi.fn(() => ({ code: 0, timedOut: false, outputTruncated: false }))
|
||||
}
|
||||
}
|
||||
|
||||
describe('reusing a prepared Windows relay addon', () => {
|
||||
it.each(['x64', 'arm64'])(
|
||||
'accepts a clean launcher-capable %s addon after its runtime probe',
|
||||
(arch) => {
|
||||
const options = prepared(arch)
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(true)
|
||||
expect(options.checkRuntime).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ enabled: false },
|
||||
{ enabled: undefined },
|
||||
{ platform: 'darwin' },
|
||||
{ platform: 'linux' },
|
||||
{ hostArch: 'arm64' },
|
||||
{ sourceRepaired: true },
|
||||
{ sourceRepaired: undefined }
|
||||
])('compiles freshly without current same-host preparation: %j', (overrides) => {
|
||||
const options = { ...prepared(), ...overrides }
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
expect(options.checkRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['upstream reader', 'ReadProcessMemory spawnOutsideJob'],
|
||||
['pre-launcher addon', 'getProcessCreationTime']
|
||||
])('refuses the %s before loading it', (_label, suffix) => {
|
||||
const options = prepared('x64', suffix)
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
expect(options.checkRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses missing, truncated and wrong-architecture binaries', () => {
|
||||
const options = prepared()
|
||||
for (const bytes of [Buffer.from('spawnOutsideJob'), peImage({ arch: 'arm64' })]) {
|
||||
writeFileSync(options.addonPath, bytes)
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
}
|
||||
rmSync(options.addonPath)
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
expect(options.checkRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ code: 1, timedOut: false, outputTruncated: false },
|
||||
{ code: null, timedOut: false, outputTruncated: false },
|
||||
{ code: 0, timedOut: true, outputTruncated: false },
|
||||
{ code: 0, timedOut: false, outputTruncated: true }
|
||||
])('falls back when native loading or CreationTime validation fails: %j', (result) => {
|
||||
const options = prepared()
|
||||
options.checkRuntime.mockReturnValue(result)
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back on probe exceptions', () => {
|
||||
const options = prepared()
|
||||
options.checkRuntime.mockImplementation(() => {
|
||||
throw new Error('probe spawn failed')
|
||||
})
|
||||
expect(canReusePreparedRelayAddon(options)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/ssh-windows-hosts.yml', 'utf8'))
|
||||
const steps = workflow.jobs.hosts.steps
|
||||
const preparation = steps.find((step) => step.id === 'dependencies')
|
||||
const build = steps.find((step) => step.name === "Build this runner's Windows process-table addon")
|
||||
|
||||
it.each(['pull_request', 'workflow_dispatch'])(
|
||||
'requests reuse only after an exact prepared PR hit under %s',
|
||||
(event) => {
|
||||
expect(workflow.on.pull_request.paths).toContain(
|
||||
'config/scripts/relay-windows-process-tree-prepared-addon*.mjs'
|
||||
)
|
||||
expect(preparation.with['native-runtime']).toBe('node')
|
||||
expect(steps.indexOf(preparation)).toBeLessThan(steps.indexOf(build))
|
||||
for (const cacheHit of ['true', 'false', '', undefined]) {
|
||||
const expression = build.env.REUSE_PREPARED_RUNTIME.slice(3, -2).replace(
|
||||
'outputs.native-cache-hit',
|
||||
'outputs["native-cache-hit"]'
|
||||
)
|
||||
const enabled = runInNewContext(expression, {
|
||||
github: { event_name: event },
|
||||
steps: { dependencies: { outputs: { 'native-cache-hit': cacheHit } } }
|
||||
})
|
||||
expect(enabled).toBe(event === 'pull_request' && cacheHit === 'true')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['x64', 'arm64'])('passes the optional reuse flag safely to the %s builder', (arch) => {
|
||||
for (const enabled of ['true', 'false']) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-relay-build-args-'))
|
||||
directories.push(directory)
|
||||
const output = join(directory, 'arguments.txt')
|
||||
const script = `node() { printf '%s\n' "$@" > "$ARGUMENTS_FILE"; }\n${build.run.replaceAll('${{ matrix.arch }}', arch)}`
|
||||
const result = runProcessSync({
|
||||
program: 'bash',
|
||||
args: ['-e', '-c', script],
|
||||
cwd: directory,
|
||||
env: { ...process.env, ARGUMENTS_FILE: output, REUSE_PREPARED_RUNTIME: enabled },
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(readFileSync(output, 'utf8').trim().split('\n')).toEqual([
|
||||
'config/scripts/build-windows-process-tree-relay-addon.mjs',
|
||||
`--arch=${arch}`,
|
||||
...(enabled === 'true' ? ['--reuse-prepared-runtime'] : [])
|
||||
])
|
||||
}
|
||||
})
|
||||
@@ -29,7 +29,6 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [
|
||||
'config/scripts/dev-cli-terminal-wrapper.mjs',
|
||||
'config/scripts/dev-cli-terminal-wrapper.test.mjs',
|
||||
'config/scripts/electron-builder-config.test.mjs',
|
||||
'config/scripts/ensure-native-runtime.test.mjs',
|
||||
'config/scripts/live-remote-freeze-rpc.mjs',
|
||||
'config/scripts/pty-transcript-secret-scan.test.mjs',
|
||||
'config/scripts/remote-agent-session-authority-repro.mjs',
|
||||
|
||||
@@ -54,13 +54,13 @@ export const WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS = [
|
||||
'napi-inl.deprecated.h'
|
||||
]
|
||||
|
||||
export function nodeGypRebuildInvocation(arch, packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) {
|
||||
export function nodeGypRebuildInvocation(
|
||||
arch,
|
||||
packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR,
|
||||
nodeGypEntry = join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js')
|
||||
) {
|
||||
return {
|
||||
args: [
|
||||
join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'),
|
||||
'rebuild',
|
||||
`--arch=${arch}`
|
||||
],
|
||||
args: [nodeGypEntry, 'rebuild', `--arch=${arch}`],
|
||||
cwd: realpathSync(packageDir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ describe('windows-process-tree node-gyp rebuild', () => {
|
||||
expect(args).toContain('--arch=arm64')
|
||||
})
|
||||
|
||||
it('preserves an external node-gyp entry and the physical addon cwd', () => {
|
||||
const entry = join(tmpdir(), 'external-node-gyp', 'bin', 'node-gyp.js')
|
||||
expect(nodeGypRebuildInvocation('arm64', import.meta.dirname, entry)).toEqual({
|
||||
args: [entry, 'rebuild', '--arch=arm64'],
|
||||
cwd: realpathSync(import.meta.dirname)
|
||||
})
|
||||
})
|
||||
|
||||
it('copies node-addon-api headers into the patched include dir', () => {
|
||||
const packageDir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-headers-'))
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 87m">
|
||||
<title>downloads: 87m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 88m">
|
||||
<title>downloads: 88m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">87m</text>
|
||||
<text x="90" y="14">87m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">88m</text>
|
||||
<text x="90" y="14">88m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -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.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# DeepSeek Harness integration
|
||||
|
||||
Orca detects the community `@deepseek-harness-tui/dsh-tui` launcher (`dsh-tui`, alias
|
||||
`dst`) and requires the official `@deepseek-ai/dsh` executable too. The launcher
|
||||
boots the `dsh-tui` profile; Orca passes `.` to select the current workspace and
|
||||
reach its composer on the first launch. The official Harness does not bundle this community TUI.
|
||||
DSH Console and DeepSeek Build are separate products and are not interchangeable
|
||||
with this launch contract.
|
||||
|
||||
The official DSH 0.2 CLI accepts both `dsh --profile headless` and `dsh headless`.
|
||||
Orca excludes the known `web`, `headless`, `sdk`, `sdk-minimal`, `acp`, and `desktop`
|
||||
profiles from interactive process recognition, along with plugin management and
|
||||
configuration dumps. Custom profile names remain eligible because profiles are
|
||||
user configurable. Only launcher arguments are inspected; app prompts, resume IDs,
|
||||
and patch filenames cannot change the selected profile's identity.
|
||||
|
||||
Status hooks use the official `@deepseek-ai/dsh-hooks-claude-code` plugin, installed
|
||||
as an owned block in `$DSH_HOME/cordis.patch.yml`. User entries outside the block are
|
||||
preserved. Local installation respects `DSH_HOME`; the existing SSH installer uses
|
||||
the execution host's default `~/.dsh` because SFTP cannot read its environment.
|
||||
Hooks report session start, prompt submission, tool start/end, and stopping through
|
||||
Orca's host status store. Approval has no dedicated hook; it is not inferred from
|
||||
an uncaptured screen. Subagent lifecycle events are ignored for parent-pane status.
|
||||
|
||||
DSH 0.2 still emits an empty `transcript_path` in Claude-compatible hooks. Its
|
||||
session persistence defaults to compressed JSONL under `$DSH_HOME/sessions`.
|
||||
Orca can resume a hook-associated session through `dsh-tui --resume <id>`, but
|
||||
currently does not discover DSH logs in Agent Session History. Resume support alone
|
||||
does not establish transcript-history support.
|
||||
|
||||
## Reproduce the official launcher check
|
||||
|
||||
Install `@deepseek-ai/dsh@0.2.0-rc.2` into a disposable prefix, then run:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 ORCA_REAL_DSH_CLI=/path/to/prefix/node_modules/.bin/dsh \
|
||||
pnpm test src/shared/dsh-real-cli.test.ts
|
||||
```
|
||||
|
||||
The opt-in test checks published version, composed profile configurations, and
|
||||
headless help in an isolated home and working folder without a model request.
|
||||
Interactive readiness is separately pinned to the captured community TUI transcript
|
||||
in `src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt`; that older capture is
|
||||
not proof of current TUI compatibility or paid generation.
|
||||
@@ -8,4 +8,4 @@ next-env.d.ts
|
||||
# Keep local deployment credentials out of the publication boundary.
|
||||
.env
|
||||
.env.*
|
||||
.npmrc
|
||||
# Project .npmrc contains package policy; keep credentials in user-level npm config.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
minimum-release-age=4320
|
||||
+20
-12
@@ -18,21 +18,21 @@
|
||||
"fumadocs-mdx": "^14.3.1",
|
||||
"fumadocs-ui": "^16.8.4",
|
||||
"lucide-react": "^1.6.0",
|
||||
"next": "16.3.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"next": "16.3.6",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/node": "^22.20.4",
|
||||
"@types/react": "~19.2.18",
|
||||
"@types/react-dom": "~19.2.7",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.4",
|
||||
"eslint-config-next": "16.3.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vercel": "59.11.1"
|
||||
@@ -40,7 +40,7 @@
|
||||
"engines": {
|
||||
"node": "22.x"
|
||||
},
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"packageManager": "pnpm@10.34.6",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
@@ -50,13 +50,21 @@
|
||||
"overrides": {
|
||||
"@vercel/fun>tar": "7.5.22",
|
||||
"@vercel/fun>@tootallnate/once": "2.0.1",
|
||||
"@vercel/node>undici": "6.28.0",
|
||||
"@vercel/node>undici": "6.28.1",
|
||||
"@vercel/python-analysis>js-yaml": "4.3.2",
|
||||
"@vercel/python-analysis>minimatch": "10.2.6",
|
||||
"vercel>smol-toml": "1.8.0",
|
||||
"vercel>undici": "6.28.0",
|
||||
"vercel>undici": "6.28.1",
|
||||
"@vercel/python-analysis>smol-toml": "1.8.0",
|
||||
"@vercel/rust>smol-toml": "1.8.0"
|
||||
"@vercel/rust>smol-toml": "1.8.0",
|
||||
"@vercel/sandbox>undici": "7.29.1",
|
||||
"@vercel/static-config>ajv": "8.18.0",
|
||||
"@vercel/backends>path-to-regexp": "8.4.0",
|
||||
"@vercel/fun>path-to-regexp": "8.4.0",
|
||||
"brace-expansion@<2": "1.1.21",
|
||||
"brace-expansion@>=4 <5.0.12": "5.0.12",
|
||||
"@vercel/express>path-to-regexp": "8.4.0",
|
||||
"@vercel/hono>path-to-regexp": "8.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+830
-732
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
export function DocsFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-background pb-8 pt-12">
|
||||
@@ -78,7 +80,7 @@ export function DocsFooter() {
|
||||
|
||||
<div className="flex flex-col items-center justify-between gap-4 border-t border-border pt-6 font-mono text-sm text-muted-foreground md:flex-row">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Lovecast Inc. ·{' '}
|
||||
© {currentYear} Lovecast Inc. ·{' '}
|
||||
<a
|
||||
href="https://github.com/stablyai/orca/blob/main/LICENSE"
|
||||
className="rounded-sm underline decoration-border underline-offset-4 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
|
||||
@@ -45,7 +45,7 @@ test('docs package has an isolated, reproducible app contract', async () => {
|
||||
|
||||
assert.equal(packageJson.name, '@orca/docs')
|
||||
assert.equal(packageJson.private, true)
|
||||
assert.equal(packageJson.packageManager, 'pnpm@10.24.0')
|
||||
assert.equal(packageJson.packageManager, 'pnpm@10.34.6')
|
||||
assert.equal(packageJson.engines.node, '22.x')
|
||||
assert.equal(packageJson.scripts.build, 'next build')
|
||||
assert.equal(packageJson.scripts.postinstall, 'fumadocs-mdx')
|
||||
|
||||
@@ -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
@@ -9,4 +9,4 @@
|
||||
# in a PR so the Fastfile smoke check runs against the new version first.
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane", "2.238.0"
|
||||
gem "fastlane", "2.240.1"
|
||||
|
||||
+15
-13
@@ -30,6 +30,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
cgi (0.5.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
@@ -61,7 +62,7 @@ GEM
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.238.0)
|
||||
fastlane (2.240.1)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
@@ -71,6 +72,7 @@ GEM
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
cgi (~> 0.4)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
@@ -85,12 +87,11 @@ GEM
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-androidpublisher_v3 (~> 0.99)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.3.0)
|
||||
google-cloud-env (>= 1.6.0, < 2.4.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
irb (>= 1.8)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
@@ -104,8 +105,8 @@ GEM
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
rubyzip (>= 3.4.0, < 4.0.0)
|
||||
security (~> 0.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 4)
|
||||
@@ -212,8 +213,8 @@ GEM
|
||||
retriable (4.2.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
rubyzip (3.7.0)
|
||||
security (0.3.0)
|
||||
signet (0.22.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
@@ -257,7 +258,7 @@ PLATFORMS
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (= 2.238.0)
|
||||
fastlane (= 2.240.1)
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
@@ -275,6 +276,7 @@ CHECKSUMS
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
|
||||
cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
@@ -294,7 +296,7 @@ CHECKSUMS
|
||||
faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
|
||||
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.238.0) sha256=78e9252df2224c7e427012638e41051edfa29b133a40fda883fd2f1c13a77b98
|
||||
fastlane (2.240.1) sha256=cf792bc99ec6af6574ea416cda6530f519c8b4b3a46b1a3b52488ebf1696aa5c
|
||||
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.106.0) sha256=b90b5312b70f8f731def88f24a2b8fb791fe1b979a7c2c14a905cb6cae19cf3f
|
||||
@@ -342,8 +344,8 @@ CHECKSUMS
|
||||
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
|
||||
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
|
||||
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
|
||||
security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
|
||||
signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
|
||||
@@ -363,4 +365,4 @@ CHECKSUMS
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.11
|
||||
4.0.16
|
||||
|
||||
+15
-15
@@ -29,13 +29,13 @@
|
||||
"@xterm/addon-webgl": "0.20.0-beta.299",
|
||||
"@xterm/xterm": "6.1.0-beta.303",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^55.0.30",
|
||||
"expo": "^55.0.31",
|
||||
"expo-build-properties": "^55.0.18",
|
||||
"expo-camera": "^55.0.23",
|
||||
"expo-clipboard": "^55.0.17",
|
||||
"expo-constants": "^55.0.17",
|
||||
"expo-crypto": "^55.0.19",
|
||||
"expo-dev-client": "~55.0.39",
|
||||
"expo-dev-client": "~55.0.40",
|
||||
"expo-document-picker": "^55.0.17",
|
||||
"expo-file-system": "55.0.26",
|
||||
"expo-haptics": "^55.0.18",
|
||||
@@ -43,7 +43,7 @@
|
||||
"expo-image-picker": "^55.0.24",
|
||||
"expo-keep-awake": "~55.0.8",
|
||||
"expo-linking": "^55.0.17",
|
||||
"expo-modules-core": "~55.0.25",
|
||||
"expo-modules-core": "~55.0.26",
|
||||
"expo-network": "~55.0.18",
|
||||
"expo-notifications": "^55.0.27",
|
||||
"expo-router": "^55.0.18",
|
||||
@@ -55,7 +55,7 @@
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react-native": "^1.14.0",
|
||||
"mermaid": "11.17.2",
|
||||
"react": "^19.2.8",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-native": "^0.83.10",
|
||||
"react-native-gesture-handler": "^2.31.2",
|
||||
@@ -64,29 +64,29 @@
|
||||
"react-native-screens": "^4.24.0",
|
||||
"react-native-svg": "^15.15.5",
|
||||
"react-native-uitextview": "2.2.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-web": "^0.21.3",
|
||||
"react-native-webview": "13.16.2",
|
||||
"react-native-worklets": "^0.8.3",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.21.3",
|
||||
"ws": "^8.22.0",
|
||||
"zod": "~4.6.5",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"@types/react": "~19.2.18",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"acorn": "8.18.0",
|
||||
"esbuild": "0.25.4",
|
||||
"esbuild": "0.28.2",
|
||||
"expo-module-scripts": "^55.0.2",
|
||||
"happy-dom": "^20.11.8",
|
||||
"oxfmt": "^0.70.0",
|
||||
"oxlint": "^1.85.0",
|
||||
"happy-dom": "^20.14.5",
|
||||
"oxfmt": "^0.71.0",
|
||||
"oxlint": "^1.86.0",
|
||||
"react-test-renderer": "19.2.8",
|
||||
"tsx": "^4.22.4",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vite": "^8.3.1",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45"
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
"expo-module": "expo-module"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
"expo-module-scripts": "^3.5.2",
|
||||
"expo-modules-core": "^2.0.4"
|
||||
"@types/react": "~19.2.18",
|
||||
"expo-module-scripts": "^55.0.2",
|
||||
"expo-modules-core": "~55.0.26"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
|
||||
Generated
+1110
-1168
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,9 @@ allowBuilds:
|
||||
minimumReleaseAge: 4320
|
||||
|
||||
overrides:
|
||||
brace-expansion@>=1.0.0 <1.1.21: 1.1.21
|
||||
brace-expansion@>=5.0.0 <5.0.12: 5.0.12
|
||||
dompurify@>=3.4.13 <3.4.16: 3.4.16
|
||||
xcode>uuid: 11.1.1
|
||||
query-string: 9.5.1
|
||||
|
||||
|
||||
@@ -36,7 +36,10 @@ async function main() {
|
||||
format: 'esm',
|
||||
// The whole point: one file, so the app bundle emits one deferred chunk for it.
|
||||
splitting: false,
|
||||
minify: true,
|
||||
// Metro transforms this module again; preserve enum assignments for its export analysis.
|
||||
minifyWhitespace: true,
|
||||
minifyIdentifiers: true,
|
||||
minifySyntax: false,
|
||||
// The floor the shell's WebViews hold, the same pair the terminal engine is built for.
|
||||
target: ['chrome74', 'safari15'],
|
||||
write: false,
|
||||
|
||||
@@ -81,7 +81,10 @@ async function buildEngineJs() {
|
||||
},
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
minify: true,
|
||||
// esbuild 0.25 syntax folding drops xterm's local DECRQM enum declaration.
|
||||
minifyWhitespace: true,
|
||||
minifyIdentifiers: true,
|
||||
minifySyntax: false,
|
||||
platform: 'browser',
|
||||
target,
|
||||
legalComments: 'none',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startTerminalDocument, stopTerminalDocument } from './create-terminal-document'
|
||||
import { createTerminalDocumentScope, type TerminalDocumentScope } from './document-scope'
|
||||
import type { TerminalDocumentHost, TerminalViewportChange } from './document-host-seams'
|
||||
@@ -49,6 +49,7 @@ afterEach(() => {
|
||||
while (started.length > 0) {
|
||||
stopTerminalDocument(started.pop()!)
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/** A started document over a grid, with the page's seams the case names. */
|
||||
@@ -128,9 +129,13 @@ describe("the document's frame on the page", () => {
|
||||
})
|
||||
const scales: string[] = []
|
||||
const style = scope.surface!.style
|
||||
Object.defineProperty(style, 'transform', {
|
||||
set: (value: string) => scales.push(/scale\(([^)]*)\)/.exec(value)?.[1] ?? value),
|
||||
get: () => ''
|
||||
const write = style.setProperty
|
||||
vi.spyOn(style, 'setProperty').mockImplementation((name, value, priority) => {
|
||||
if (name === 'transform') {
|
||||
const transform = String(value)
|
||||
scales.push(/scale\(([^)]*)\)/.exec(transform)?.[1] ?? transform)
|
||||
}
|
||||
write.call(style, name, value, priority)
|
||||
})
|
||||
// The fit's attempt ran and read the hidden host, and committed nothing.
|
||||
await framesUntil(() => widthsRead.includes(0))
|
||||
|
||||
@@ -338,23 +338,24 @@ async function mountedOverGrid({ laidOutFirst = true } = {}) {
|
||||
let box = { width: 390, height: 600 }
|
||||
host.getBoundingClientRect = () => new DOMRect(0, 134, box.width, box.height)
|
||||
const mounted = mountTerminalWebDocument(host, () => {})
|
||||
// Every surface transform the document writes; a refit writes one even when nothing moved. Read
|
||||
// off the style prototype because an init swaps the surface element for a fresh one.
|
||||
// Trace the public writer because init replaces the surface and Happy DOM proxies CSS properties.
|
||||
const scales: number[] = []
|
||||
const proto: object = Object.getPrototypeOf(host.style)
|
||||
const own = Object.getOwnPropertyDescriptor(proto, 'transform')!
|
||||
const write = own.set!
|
||||
Object.defineProperty(proto, 'transform', {
|
||||
...own,
|
||||
set(this: CSSStyleDeclaration, value: string) {
|
||||
const write = CSSStyleDeclaration.prototype.setProperty
|
||||
const trace = vi.spyOn(CSSStyleDeclaration.prototype, 'setProperty').mockImplementation(function (
|
||||
this: CSSStyleDeclaration,
|
||||
name,
|
||||
value,
|
||||
priority
|
||||
) {
|
||||
if (name === 'transform') {
|
||||
const scale = /scale\(([^)]*)\)/.exec(String(value))
|
||||
if (scale) {
|
||||
scales.push(Number(scale[1]))
|
||||
}
|
||||
write.call(this, value)
|
||||
}
|
||||
write.call(this, name, value, priority)
|
||||
})
|
||||
restoreTransform = () => Object.defineProperty(proto, 'transform', own)
|
||||
restoreTransform = () => trace.mockRestore()
|
||||
let id = 0
|
||||
const send = (command: TerminalWebViewCommand) => mounted.send({ ...command, id: ++id })
|
||||
const layOut = (width: number, height: number) => {
|
||||
|
||||
@@ -91,6 +91,35 @@ describe('terminal WebView bundled engine', () => {
|
||||
expect(() => parse(XTERM_ENGINE_JS, { ecmaVersion: 2019 })).not.toThrow()
|
||||
})
|
||||
|
||||
it('answers the mode query OpenCode sends during startup', async () => {
|
||||
vi.useFakeTimers()
|
||||
const reply = vi.fn()
|
||||
const window = {}
|
||||
try {
|
||||
new Script(
|
||||
`${XTERM_ENGINE_JS}; const terminal = new window.Terminal();
|
||||
terminal.onData(reply); terminal.write('\\x1b[?1004$p');`
|
||||
).runInNewContext({
|
||||
window,
|
||||
self: window,
|
||||
document: {},
|
||||
navigator: { platform: 'Linux', userAgent: 'Chrome/74' },
|
||||
structuredClone,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
queueMicrotask,
|
||||
console,
|
||||
URL,
|
||||
reply
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
expect(reply).toHaveBeenCalledWith('\x1b[?1004;2$y')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the context deliberately omits WeakRef (Chrome 84+) / structuredClone
|
||||
// (Chrome 98+) and supplies an Element without replaceChildren (Chrome 86+) —
|
||||
// the engine must evaluate on older WebViews via its own guarded runtime shims,
|
||||
|
||||
+31
-31
@@ -18,14 +18,14 @@
|
||||
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
|
||||
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
|
||||
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
|
||||
"audit:react-doctor": "pnpm dlx react-doctor@0.9.1 . --yes --no-supply-chain --no-telemetry --blocking none",
|
||||
"audit:react-doctor": "pnpm dlx react-doctor@0.9.14 . --yes --no-supply-chain --no-telemetry --blocking none",
|
||||
"audit:dead-code": "pnpm dlx knip@5.88.1 --config config/knip.json",
|
||||
"check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs",
|
||||
"check:dead-classes": "oxlint --config config/oxlint-dead-classes.json src/renderer",
|
||||
"lint:design-system": "oxlint --config config/oxlint-design-system.json src/renderer",
|
||||
"check:react-doctor:changed": "node config/scripts/check-react-doctor-changed.mjs",
|
||||
"check:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs --check",
|
||||
"doctor": "pnpm dlx react-doctor@0.9.1 . --no-telemetry",
|
||||
"doctor": "pnpm dlx react-doctor@0.9.14 . --no-telemetry",
|
||||
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
|
||||
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
|
||||
"prepare": "husky",
|
||||
@@ -177,17 +177,17 @@
|
||||
"sync:anti-slop-plugin": "node config/scripts/sync-anti-slop-plugin.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.251",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.284",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@floating-ui/dom": "1.8.0",
|
||||
"@linear/sdk": "^82.1.0",
|
||||
"@linear/sdk": "^97.0.0",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@streamparser/json": "0.0.26",
|
||||
"@xterm/addon-serialize": "0.15.0-beta.300",
|
||||
"@xterm/headless": "6.1.0-beta.302",
|
||||
"agent-browser": "~0.27.0",
|
||||
"electron-updater": "^6.8.9",
|
||||
"i18next": "^26.3.1",
|
||||
"electron-updater": "^6.8.10",
|
||||
"i18next": "^26.4.2",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"posthog-node": "^5.52.5",
|
||||
@@ -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": [
|
||||
|
||||
Generated
+2125
-1735
File diff suppressed because it is too large
Load Diff
+1
-8
@@ -11,13 +11,6 @@ packages:
|
||||
- native/windows-registry
|
||||
|
||||
minimumReleaseAge: 4320
|
||||
# 6.3.289 was published 2026-08-29T12:48Z; it clears the 3-day gate on 2026-09-01T12:48Z.
|
||||
# zod 4.5.4 was published 2026-08-29T17:55Z; it clears the 3-day gate on 2026-09-01T17:55Z.
|
||||
minimumReleaseAgeExclude:
|
||||
- pdfjs-dist@6.3.289
|
||||
- zod@4.5.4
|
||||
- '@shadcn/lint@0.1.0'
|
||||
- i18next-cli@1.74.2
|
||||
shamefullyHoist: true
|
||||
|
||||
# Orca always launches the user's own resolved Claude CLI via
|
||||
@@ -57,7 +50,7 @@ allowBuilds:
|
||||
'@orca/windows-registry': false
|
||||
|
||||
overrides:
|
||||
monaco-editor>dompurify: 3.4.15
|
||||
monaco-editor>dompurify: 3.4.16
|
||||
|
||||
patchedDependencies:
|
||||
'@xterm/addon-image@0.10.0-beta.300': config/patches/@xterm__addon-image@0.10.0-beta.300.patch
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { tokenizeCommandLine } from '../../shared/agent-command-line-entrypoint'
|
||||
import { qoderHookService } from '../qoder/hook-service'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parse as parseJsonc } from 'jsonc-parser'
|
||||
@@ -347,23 +348,36 @@ describe('remote hook service installers', () => {
|
||||
}
|
||||
for (const eventName of ['PreInvocation', 'PostInvocation', 'Stop']) {
|
||||
const command = antigravityConfig['orca-status'][eventName]?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
|
||||
expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`)
|
||||
expect(tokenizeCommandLine(command ?? '').slice(0, 2)).toEqual(['/bin/sh', '-c'])
|
||||
expect(tokenizeCommandLine(command ?? '')[2]).toContain(
|
||||
'/home/dev/.orca/agent-hooks/antigravity-hook.sh'
|
||||
)
|
||||
expect(tokenizeCommandLine(command ?? '')[2]).toContain(
|
||||
`ORCA_ANTIGRAVITY_EVENT='${eventName}'`
|
||||
)
|
||||
}
|
||||
for (const eventName of ['PreToolUse', 'PostToolUse']) {
|
||||
const definition = antigravityConfig['orca-status'][eventName]?.[0]
|
||||
const command = definition?.hooks?.[0]?.command
|
||||
expect(definition?.matcher).toBe('*')
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
|
||||
expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`)
|
||||
expect(tokenizeCommandLine(command ?? '')[2]).toContain(
|
||||
'/home/dev/.orca/agent-hooks/antigravity-hook.sh'
|
||||
)
|
||||
expect(tokenizeCommandLine(command ?? '')[2]).toContain(
|
||||
`ORCA_ANTIGRAVITY_EVENT='${eventName}'`
|
||||
)
|
||||
}
|
||||
// Why: #2426 was an SSH report — a remote host missing the script must still answer the gate, not deny every tool.
|
||||
expect(antigravityConfig['orca-status'].PreToolUse[0].hooks?.[0]?.command).toContain(
|
||||
`printf '%s\\n' '{"decision":"ask"}'`
|
||||
)
|
||||
expect(antigravityConfig['orca-status'].PostToolUse[0].hooks?.[0]?.command).not.toContain(
|
||||
'{"decision"'
|
||||
)
|
||||
expect(
|
||||
tokenizeCommandLine(
|
||||
antigravityConfig['orca-status'].PreToolUse[0].hooks?.[0]?.command ?? ''
|
||||
)[2]
|
||||
).toContain(`printf '%s\\n' '{"decision":"ask"}'`)
|
||||
expect(
|
||||
tokenizeCommandLine(
|
||||
antigravityConfig['orca-status'].PostToolUse[0].hooks?.[0]?.command ?? ''
|
||||
)[2]
|
||||
).not.toContain('{"decision"')
|
||||
|
||||
const ampPlugin = amp.fs.files.get('/home/dev/.config/amp/plugins/orca-agent-status.ts')
|
||||
expect(ampPlugin).toContain('/hook/amp')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -317,4 +317,47 @@ describe('listOpenCodeDbSessions', () => {
|
||||
it('returns [] for a missing database instead of throwing', () => {
|
||||
expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([])
|
||||
})
|
||||
|
||||
it('stays quiet for a missing database and detects it once it appears', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const absent = join(dir, 'later.db')
|
||||
expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toEqual([])
|
||||
expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toEqual([])
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
writeDb(absent, 'session')
|
||||
expect(listOpenCodeDbSessions(absent, { ms: 0, id: '' })).toHaveLength(1)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('still warns when the database exists but cannot be read', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
writeFileSync(dbPath, 'this is not a sqlite database'.repeat(100))
|
||||
expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })).toEqual([])
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
'warns when an existing database is behind an inaccessible directory',
|
||||
() => {
|
||||
writeDb(dbPath, 'session')
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
chmodSync(dir, 0o000)
|
||||
expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })).toEqual([])
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
expect(warn.mock.calls[0]?.[1]).toMatchObject({ code: 'EACCES' })
|
||||
} finally {
|
||||
chmodSync(dir, 0o700)
|
||||
warn.mockRestore()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -63,6 +63,46 @@ describe.each(['opencode', 'opencode2'] as const)('%s hook normalization', (sour
|
||||
expect(result?.payload.agentType).toBe(source)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['UnknownError', 'failure', undefined],
|
||||
['MessageAbortedError', 'cancellation', true]
|
||||
])('retains the reported root outcome for %s', (errorName, outcome, interrupted) => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
source,
|
||||
buildBody({
|
||||
hook_event_name: 'SessionIdle',
|
||||
root_state: 'done',
|
||||
root_turn_error_name: errorName
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.mainAgent).toMatchObject({ state: 'done', outcome })
|
||||
expect(result?.payload.interrupted).toBe(interrupted)
|
||||
})
|
||||
|
||||
it('does not infer an outcome for an older plugin', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
source,
|
||||
buildBody({ hook_event_name: 'SessionIdle' }),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.mainAgent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a root failure beside still-working child work', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
source,
|
||||
buildBody({
|
||||
hook_event_name: 'SessionBusy',
|
||||
root_state: 'done',
|
||||
root_turn_error_name: 'api'
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(result?.payload.state).toBe('working')
|
||||
expect(result?.payload.mainAgent).toMatchObject({ state: 'done', outcome: 'failure' })
|
||||
})
|
||||
|
||||
it('PermissionRequest maps to waiting', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
source,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { canReadOpenCodeMessageParts } from './session-scanner-opencode-sqlite-s
|
||||
import type { TranscriptMessage, TranscriptMessageRole } from './session-transcript-consumers'
|
||||
import { boundedText, toolCallText } from './session-transcript-message-content'
|
||||
import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility'
|
||||
import { zcodeTranscriptOrder } from './session-scanner-zcode-order'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
|
||||
// Why: the session list needs the newest few messages, and the search index
|
||||
@@ -120,7 +121,7 @@ function captureRole(role: string | null): TranscriptMessageRole | null {
|
||||
return role === 'user' || role === 'assistant' ? role : null
|
||||
}
|
||||
|
||||
function buildCaptureQuery(agent: 'opencode' | 'zcode'): string {
|
||||
function buildCaptureQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): string {
|
||||
// Message order, then part order within a message: the same key the preview
|
||||
// read uses, run forwards and without the newest-N window.
|
||||
return `SELECT m.id AS message_id,
|
||||
@@ -134,7 +135,8 @@ function buildCaptureQuery(agent: 'opencode' | 'zcode'): string {
|
||||
${zcodeVisibleMessageFilter(agent)}
|
||||
AND json_extract(m.data, '$.role') IN ('user','assistant')
|
||||
AND json_extract(p.data, '$.type') IN ${OPENCODE_CAPTURE_PART_TYPES}
|
||||
ORDER BY m.time_created ASC, m.id ASC, p.time_created ASC, p.rowid ASC
|
||||
ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'ASC')},
|
||||
${zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC')}
|
||||
LIMIT ?`
|
||||
}
|
||||
|
||||
@@ -164,7 +166,7 @@ export function readOpenCodeSessionMessages(
|
||||
)
|
||||
}
|
||||
const rows = db
|
||||
.prepare(buildCaptureQuery(agent))
|
||||
.prepare(buildCaptureQuery(db, agent))
|
||||
.all(sessionId, OPENCODE_CAPTURE_RECORD_LIMIT + 1)
|
||||
if (rows.length > OPENCODE_CAPTURE_RECORD_LIMIT) {
|
||||
throw new Error(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './session-scanner-opencode-sqlite-schema'
|
||||
import { normalizeTitleText } from './session-scanner-values'
|
||||
import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility'
|
||||
import { zcodeTranscriptOrder } from './session-scanner-zcode-order'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
|
||||
|
||||
@@ -162,11 +163,11 @@ function readFirstUserPromptFromOpenCodeDb(
|
||||
${zcodeVisibleMessageFilter(agent)}
|
||||
AND json_extract(m.data, '$.role') = 'user'
|
||||
AND json_extract(fp.data, '$.type') = 'text'
|
||||
ORDER BY m.time_created ASC, m.id ASC
|
||||
ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'ASC')}
|
||||
LIMIT 1
|
||||
)
|
||||
AND json_extract(p.data, '$.type') = 'text'
|
||||
ORDER BY p.time_created ASC, p.rowid ASC
|
||||
ORDER BY ${zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC')}
|
||||
LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}`
|
||||
)
|
||||
.all(sessionId)
|
||||
@@ -198,15 +199,19 @@ function buildPreviewQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): strin
|
||||
p.time_created,
|
||||
json_extract(m.data, '$.summary.title') AS summary_title,
|
||||
json_extract(m.data, '$.summary.body') AS summary_body
|
||||
FROM (SELECT id, data FROM message
|
||||
WHERE session_id = ?
|
||||
${zcodeVisibleMessageFilter(agent, 'data')}
|
||||
ORDER BY time_created DESC, id DESC
|
||||
FROM (SELECT ${agent === 'zcode' ? 'm.rowid AS rowid, m.*' : 'm.id, m.data'} FROM message m
|
||||
WHERE m.session_id = ?
|
||||
${zcodeVisibleMessageFilter(agent)}
|
||||
ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'DESC')}
|
||||
LIMIT ${OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW}) m
|
||||
JOIN part p ON p.message_id = m.id
|
||||
WHERE json_extract(m.data, '$.role') IN ('user','assistant')
|
||||
AND json_extract(p.data, '$.type') = 'text'
|
||||
ORDER BY p.time_created DESC
|
||||
ORDER BY ${
|
||||
agent === 'zcode'
|
||||
? `${zcodeTranscriptOrder(db, agent, 'message', 'm', 'DESC')}, ${zcodeTranscriptOrder(db, agent, 'part', 'p', 'DESC')}`
|
||||
: 'p.time_created DESC'
|
||||
}
|
||||
LIMIT ?`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { columnExists } from '../opencode-usage/schema-helpers'
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
|
||||
export function zcodeTranscriptOrder(
|
||||
db: SyncDatabase,
|
||||
agent: 'opencode' | 'zcode',
|
||||
table: 'message' | 'part',
|
||||
alias: 'm' | 'p',
|
||||
direction: 'ASC' | 'DESC'
|
||||
): string {
|
||||
const sequenced = agent === 'zcode' && columnExists(db, table, 'sequence')
|
||||
const sequence = sequenced
|
||||
? `${alias}.sequence IS NULL ${direction}, ${alias}.sequence ${direction}, `
|
||||
: ''
|
||||
// ZCode orders imported/forked transcripts by sequence, with legacy NULL rows last.
|
||||
const tieBreaker = table === 'message' ? (sequenced ? 'rowid' : 'id') : sequenced ? 'id' : 'rowid'
|
||||
return `${sequence}${alias}.time_created ${direction}, ${alias}.${tieBreaker} ${direction}`
|
||||
}
|
||||
@@ -57,6 +57,122 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ZCode AI Vault SQLite worker', () => {
|
||||
it.each(['message', 'part', 'both'] as const)(
|
||||
'reads partially migrated %s sequence columns and keeps legacy NULL rows last',
|
||||
async (tables) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-legacy-sequence-'))
|
||||
directories.push(directory)
|
||||
const dbPath = join(directory, 'db.sqlite')
|
||||
writeOpenCodeSqliteDatabase(dbPath, [
|
||||
{
|
||||
id: 'legacy-session',
|
||||
turns: [
|
||||
{ role: 'user', parts: ['first', 'second', 'legacy part'] },
|
||||
{ role: 'user', parts: ['legacy message'] }
|
||||
]
|
||||
}
|
||||
])
|
||||
const db = new SyncDatabase(dbPath)
|
||||
try {
|
||||
if (tables !== 'part') {
|
||||
db.exec(`ALTER TABLE message ADD COLUMN sequence INTEGER;
|
||||
UPDATE message SET sequence = 0 WHERE rowid = 1;
|
||||
UPDATE message SET time_created = 1 WHERE rowid = 2;`)
|
||||
}
|
||||
if (tables !== 'message') {
|
||||
db.exec(`ALTER TABLE part ADD COLUMN sequence INTEGER;
|
||||
UPDATE part SET sequence = rowid WHERE rowid <= 2;
|
||||
UPDATE part SET time_created = 1 WHERE rowid = 3;`)
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
const captured = await handleOpenCodeSqliteRequest({
|
||||
id: 8,
|
||||
kind: 'capture',
|
||||
agent: 'zcode',
|
||||
dbPath,
|
||||
sessionId: 'legacy-session',
|
||||
platform: 'win32'
|
||||
})
|
||||
expect(captured.ok).toBe(true)
|
||||
if (!captured.ok) {
|
||||
return
|
||||
}
|
||||
expect(readMessageTexts(captured.value)).toEqual([
|
||||
{ text: 'first\nsecond\nlegacy part' },
|
||||
{ text: 'legacy message' }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('uses stored message and part sequence for first prompt, preview, and search', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-sequence-'))
|
||||
directories.push(directory)
|
||||
const dbPath = join(directory, 'db.sqlite')
|
||||
writeOpenCodeSqliteDatabase(dbPath, [
|
||||
{
|
||||
id: 'ordered-session',
|
||||
turns: [
|
||||
{ role: 'user', parts: ['first prompt start', 'first prompt end'] },
|
||||
{ role: 'assistant', parts: ['first response'] },
|
||||
{ role: 'user', parts: ['second prompt'] },
|
||||
{ role: 'assistant', parts: ['second response start', 'second response end'] }
|
||||
]
|
||||
}
|
||||
])
|
||||
const db = new SyncDatabase(dbPath)
|
||||
try {
|
||||
db.exec(`ALTER TABLE message ADD COLUMN sequence INTEGER;
|
||||
ALTER TABLE part ADD COLUMN sequence INTEGER;
|
||||
UPDATE message SET sequence = rowid, time_created = time_created - rowid * 120000;
|
||||
UPDATE part SET sequence = rowid, time_created = time_created - rowid * 120000;`)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
const parsed = await handleOpenCodeSqliteRequest({
|
||||
id: 6,
|
||||
kind: 'parse',
|
||||
agent: 'zcode',
|
||||
dbPath,
|
||||
sessionId: 'ordered-session',
|
||||
platform: 'darwin',
|
||||
fullFirstUserPrompt: true
|
||||
})
|
||||
expect(parsed).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
firstUserPrompt: 'first prompt start\nfirst prompt end',
|
||||
previewMessagesTruncated: true,
|
||||
previewMessages: [
|
||||
{ text: 'first prompt end' },
|
||||
{ text: 'first response' },
|
||||
{ text: 'second prompt' },
|
||||
{ text: 'second response start' },
|
||||
{ text: 'second response end' }
|
||||
]
|
||||
}
|
||||
})
|
||||
const captured = await handleOpenCodeSqliteRequest({
|
||||
id: 7,
|
||||
kind: 'capture',
|
||||
agent: 'zcode',
|
||||
dbPath,
|
||||
sessionId: 'ordered-session',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(captured.ok).toBe(true)
|
||||
if (!captured.ok) {
|
||||
return
|
||||
}
|
||||
expect(readMessageTexts(captured.value)).toEqual([
|
||||
{ text: 'first prompt start\nfirst prompt end' },
|
||||
{ text: 'first response' },
|
||||
{ text: 'second prompt' },
|
||||
{ text: 'second response start\nsecond response end' }
|
||||
])
|
||||
})
|
||||
|
||||
it('lists, parses, and captures ZCode sessions without labelling them OpenCode', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-ai-vault-'))
|
||||
directories.push(directory)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
POSIX_HOOK_JSON_STDIN,
|
||||
buildPosixHookSpoolLines,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
@@ -55,7 +56,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
'esac',
|
||||
// Why: some Antigravity events arrive without stdin but still need a
|
||||
// status post, so the shared capture maps empty input to an object.
|
||||
...buildPosixHookPayloadCapture('empty-object'),
|
||||
...buildPosixHookPayloadCapture('empty-object', POSIX_HOOK_JSON_STDIN),
|
||||
...buildPosixHookSpoolLines('antigravity', 'ORCA_ANTIGRAVITY_EVENT'),
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
|
||||
@@ -16,8 +16,9 @@ vi.mock('os', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
import { tokenizeCommandLine } from '../../shared/agent-command-line-entrypoint'
|
||||
import { AntigravityHookService } from './hook-service'
|
||||
import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract'
|
||||
import { POSIX_HOOK_JSON_STDIN_READER } from '../agent-hooks/hook-stdin-contract'
|
||||
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
|
||||
|
||||
const ANTIGRAVITY_SCRIPT_FILE_NAME =
|
||||
@@ -86,10 +87,12 @@ describe('AntigravityHookService', () => {
|
||||
if (process.platform === 'win32') {
|
||||
expect(config['orca-status'].PreInvocation[0].command).not.toContain('ORCA_ANTIGRAVITY_EVENT')
|
||||
} else {
|
||||
expect(config['orca-status'].PreInvocation[0].command).toContain(
|
||||
"ORCA_ANTIGRAVITY_EVENT='PreInvocation'"
|
||||
expect(
|
||||
tokenizeCommandLine(config['orca-status'].PreInvocation[0].command ?? '')[2]
|
||||
).toContain("ORCA_ANTIGRAVITY_EVENT='PreInvocation'")
|
||||
expect(tokenizeCommandLine(config['orca-status'].Stop[0].command ?? '')[2]).toContain(
|
||||
"ORCA_ANTIGRAVITY_EVENT='Stop'"
|
||||
)
|
||||
expect(config['orca-status'].Stop[0].command).toContain("ORCA_ANTIGRAVITY_EVENT='Stop'")
|
||||
}
|
||||
|
||||
const script = readFileSync(
|
||||
@@ -106,7 +109,7 @@ describe('AntigravityHookService', () => {
|
||||
expect(script).toContain('setlocal DisableDelayedExpansion')
|
||||
} else {
|
||||
expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}')
|
||||
expect(script).toContain(`payload=$(${POSIX_HOOK_STDIN_READER})`)
|
||||
expect(script).toContain(`payload=$(${POSIX_HOOK_JSON_STDIN_READER})`)
|
||||
expect(script).toContain("payload='{}'")
|
||||
expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi')
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ANTIGRAVITY_PRE_TOOL_USE_DECISION,
|
||||
type AntigravityEvent
|
||||
} from './hook-events'
|
||||
import { quotePosixShellString } from '../agent-hooks/posix-hook-command'
|
||||
import { getManagedScript, getWindowsWrapperScript } from './hook-script'
|
||||
import {
|
||||
buildInstalledConfig,
|
||||
@@ -51,12 +52,14 @@ function getWindowsWrapperScriptPath(event: AntigravityEvent): string {
|
||||
}
|
||||
|
||||
function getPosixManagedCommand(scriptPath: string, event: AntigravityEvent): string {
|
||||
return wrapPosixHookCommand(
|
||||
const command = wrapPosixHookCommand(
|
||||
scriptPath,
|
||||
{ ORCA_ANTIGRAVITY_EVENT: event.eventName },
|
||||
// Why: a missing managed script must not brick tools; the guard answers PreToolUse itself instead of staying silent.
|
||||
event.eventName === 'PreToolUse' ? { fallbackStdout: ANTIGRAVITY_PRE_TOOL_USE_DECISION } : {}
|
||||
)
|
||||
// ACP hosts tokenize the command into argv; the shell must be the executable, not `if`.
|
||||
return `/bin/sh -c ${quotePosixShellString(command)}`
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string, event: AntigravityEvent): string {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as OsModule from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'node:http'
|
||||
import { z } from 'zod'
|
||||
import { tokenizeCommandLine } from '../../shared/agent-command-line-entrypoint'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import { AntigravityHookService } from './hook-service'
|
||||
|
||||
const { homeMock } = vi.hoisted(() => ({ homeMock: vi.fn<() => string>() }))
|
||||
vi.mock('node:os', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof OsModule>()),
|
||||
homedir: homeMock
|
||||
}))
|
||||
|
||||
const hooksSchema = z.object({
|
||||
'orca-status': z.record(
|
||||
z.string(),
|
||||
z.array(
|
||||
z.object({
|
||||
command: z.string().optional(),
|
||||
hooks: z.array(z.object({ command: z.string() })).optional()
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
function installedCommand(home: string, event: string): string {
|
||||
homeMock.mockReturnValue(home)
|
||||
expect(new AntigravityHookService().install().state).toBe('installed')
|
||||
const config = hooksSchema.parse(
|
||||
JSON.parse(readFileSync(join(home, '.gemini', 'config', 'hooks.json'), 'utf8'))
|
||||
)
|
||||
const hook = config['orca-status'][event]?.[0]
|
||||
const command = hook?.command ?? hook?.hooks?.[0]?.command
|
||||
if (!command) {
|
||||
throw new Error(`Missing ${event} hook`)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
function invoke(command: string, input: string, env: NodeJS.ProcessEnv) {
|
||||
const [program, ...args] = tokenizeCommandLine(command)
|
||||
if (!program) {
|
||||
throw new Error('Missing executable')
|
||||
}
|
||||
expect(program).toBe('/bin/sh')
|
||||
return new Promise<{ code: number | null; stdout: string; elapsedMs: number }>(
|
||||
(resolve, reject) => {
|
||||
const start = Date.now()
|
||||
const child = spawnProcess({ program, args, env })
|
||||
let stdout = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
child.stdin.on('error', () => {})
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 8000)
|
||||
child.on('error', reject)
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
resolve({ code, stdout: stdout.trim(), elapsedMs: Date.now() - start })
|
||||
})
|
||||
// agy may leave stdin open after sending one JSON value.
|
||||
if (input) {
|
||||
child.stdin.write(Buffer.from(input))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let home = ''
|
||||
afterEach(() => {
|
||||
if (home) {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('Antigravity POSIX hook executable contract', () => {
|
||||
it('delivers UTF-8 JSON with open stdin through a directly spawned command', async () => {
|
||||
home = mkdtempSync(join(tmpdir(), "orca-agy-hook-'quoted-"))
|
||||
const payload = JSON.stringify({
|
||||
tool_name: 'run_command',
|
||||
tool_input: { command: 'echo café 日本語 😀' }
|
||||
})
|
||||
const posts: URLSearchParams[] = []
|
||||
const server = createServer((req, res) => {
|
||||
let body = ''
|
||||
req.setEncoding('utf8')
|
||||
req.on('data', (chunk: string) => {
|
||||
body += chunk
|
||||
})
|
||||
req.on('end', () => {
|
||||
posts.push(new URLSearchParams(body))
|
||||
res.end('{}')
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (typeof address !== 'object' || address === null) {
|
||||
throw new Error('Missing listener port')
|
||||
}
|
||||
try {
|
||||
const result = await invoke(installedCommand(home, 'PreToolUse'), payload, {
|
||||
...process.env,
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_AGENT_HOOK_PORT: String(address.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: 'test-only-token',
|
||||
ORCA_PANE_KEY: 'test-pane'
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stdout).toBe('{"decision":"ask"}')
|
||||
expect(result.elapsedMs).toBeLessThan(8000)
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]?.get('payload')).toBe(payload)
|
||||
expect(posts[0]?.get('hook_event_name')).toBe('PreToolUse')
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
it('returns after a bounded wait when stdin has no bytes and remains open', async () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'orca-agy-empty-hook-'))
|
||||
const result = await invoke(installedCommand(home, 'PreInvocation'), '', {
|
||||
...process.env,
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_AGENT_HOOK_PORT: '',
|
||||
ORCA_AGENT_HOOK_TOKEN: '',
|
||||
ORCA_PANE_KEY: ''
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stdout).toBe('{}')
|
||||
expect(result.elapsedMs).toBeLessThan(8000)
|
||||
}, 10000)
|
||||
})
|
||||
@@ -32,6 +32,8 @@ afterAll(() => {
|
||||
function probeMain(resultPath: string, protectedGuest: boolean): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
// This data-channel probe does not need Linux GPU initialization.
|
||||
if (process.platform === 'linux') app.disableHardwareAcceleration()
|
||||
const dgram = require('node:dgram')
|
||||
const net = require('node:net')
|
||||
const os = require('node:os')
|
||||
|
||||
@@ -28,7 +28,7 @@ import { createClaudeStructuredLaunchResolver } from './claude-structured-launch
|
||||
const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs')
|
||||
const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f'
|
||||
const LEAF_UUID = 'ad0f7c9e-1b2c-4d3e-8f90-abc123def456'
|
||||
const PINNED_SDK_VERSION = '0.3.251'
|
||||
const PINNED_SDK_VERSION = '0.3.284'
|
||||
const SDK_PLATFORM_PACKAGE_BASENAMES = [
|
||||
'claude-agent-sdk-darwin-arm64',
|
||||
'claude-agent-sdk-darwin-x64',
|
||||
|
||||
@@ -114,11 +114,13 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
|
||||
leafUuid: null
|
||||
})
|
||||
expect(observedSubtypes).toContain('hook_started')
|
||||
expect(adapter.readCommands('real-cli-handshake')).toContainEqual({
|
||||
name: 'orca-init-catalog-proof',
|
||||
kind: 'command',
|
||||
kindUnspecified: true
|
||||
})
|
||||
expect(adapter.readCommands('real-cli-handshake')).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'orca-init-catalog-proof',
|
||||
kind: 'command',
|
||||
kindUnspecified: true
|
||||
})
|
||||
)
|
||||
expect(
|
||||
adapter.readCommands('real-cli-handshake')?.some(({ name }) => name === 'help')
|
||||
).toBe(false)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
buildWindowsAgentHookPostCommand,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand
|
||||
buildWindowsHookPowerShellCommand,
|
||||
wrapWindowsPowerShellEncodedCommand
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
@@ -27,14 +28,19 @@ export function getPosixManagedCommand(scriptPath: string, eventName: CursorEven
|
||||
}
|
||||
|
||||
export function getManagedCommand(scriptPath: string, eventName: CursorEvent): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return getPosixManagedCommand(scriptPath, eventName)
|
||||
}
|
||||
const response = getCursorHookResponse(eventName)
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsHookCommand(
|
||||
scriptPath,
|
||||
{ [CURSOR_HOOK_RESPONSE_ENV]: response },
|
||||
{ fallbackStdout: response }
|
||||
)
|
||||
: getPosixManagedCommand(scriptPath, eventName)
|
||||
const command = buildWindowsHookPowerShellCommand(
|
||||
scriptPath,
|
||||
{ [CURSOR_HOOK_RESPONSE_ENV]: response },
|
||||
{ fallbackStdout: response }
|
||||
)
|
||||
// PowerShell 5.1 defaults to the ANSI code page when translating hook stdin.
|
||||
return wrapWindowsPowerShellEncodedCommand(
|
||||
`[Console]::InputEncoding = New-Object System.Text.UTF8Encoding; $OutputEncoding = [Console]::InputEncoding; ${command}`
|
||||
)
|
||||
}
|
||||
|
||||
export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { findGitBash } from '../agent-hooks/windows-git-bash-path.test-fixture'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
@@ -241,12 +243,15 @@ describe('CursorHookService', () => {
|
||||
HOOK_CASE_TIMEOUT_MS
|
||||
)
|
||||
|
||||
it(
|
||||
'emits protocol-valid JSON when the managed Cursor script is missing (#15462)',
|
||||
() => {
|
||||
it.each(['ordinary', 'spaced'] as const)(
|
||||
'emits protocol-valid JSON when the script is missing in a %s profile (#15462)',
|
||||
(profileKind) => {
|
||||
const installHome = profileKind === 'spaced' ? join(homeDir, 'profile with spaces') : homeDir
|
||||
mkdirSync(installHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(installHome)
|
||||
expect(new CursorHookService().install().state).toBe('installed')
|
||||
const config = readInstalledCursorHooks(homeDir)
|
||||
unlinkSync(join(homeDir, '.orca', 'agent-hooks', CURSOR_SCRIPT_FILE_NAME))
|
||||
const config = readInstalledCursorHooks(installHome)
|
||||
unlinkSync(join(installHome, '.orca', 'agent-hooks', CURSOR_SCRIPT_FILE_NAME))
|
||||
|
||||
for (const eventName of CURSOR_EVENTS) {
|
||||
const command = requireRegisteredCommand(config, eventName)
|
||||
@@ -287,6 +292,63 @@ describe('CursorHookService', () => {
|
||||
HOOK_CASE_TIMEOUT_MS
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32').each(['ordinary', 'spaced'] as const)(
|
||||
'posts UTF-8 stdin unchanged for a %s profile path',
|
||||
async (profileKind) => {
|
||||
const installHome = profileKind === 'spaced' ? join(homeDir, 'profile with spaces') : homeDir
|
||||
mkdirSync(installHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(installHome)
|
||||
expect(new CursorHookService().install().state).toBe('installed')
|
||||
const command = requireRegisteredCommand(
|
||||
readInstalledCursorHooks(installHome),
|
||||
'afterAgentResponse'
|
||||
)
|
||||
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
|
||||
const payload = JSON.stringify({ text: 'Сердце, почта в ЛК — 日本語 中文 한국어 😀 café' })
|
||||
const posts: string[] = []
|
||||
const server: Server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
req.on('end', () => {
|
||||
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
|
||||
const posted = form.get('payload')
|
||||
if (posted !== null) {
|
||||
posts.push(posted)
|
||||
}
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
resolve(typeof address === 'object' && address ? address.port : 0)
|
||||
})
|
||||
})
|
||||
try {
|
||||
const result = await runProcess({
|
||||
program: 'cmd.exe',
|
||||
args: ['/d', '/c', command],
|
||||
input: payload,
|
||||
timeoutMs: HOOK_RUN_TIMEOUT_MS,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_AGENT_HOOK_PORT: String(port),
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_PANE_KEY: 'tab:leaf'
|
||||
}
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(JSON.parse(result.stdout)).toEqual({})
|
||||
expect(posts).toEqual([payload])
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'emits parseable JSON through cmd.exe and Git Bash (#14825/#15462)',
|
||||
() => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { subscribe, type AsyncSubscription, type Event } from '@parcel/watcher'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
|
||||
|
||||
describe('filesystem watcher native ignores', () => {
|
||||
let root: string | null = null
|
||||
let subscription: AsyncSubscription | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
await subscription?.unsubscribe()
|
||||
subscription = null
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
root = null
|
||||
}
|
||||
})
|
||||
|
||||
it('drops root and nested generated-file storms while delivering source edits', async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-watch-ignore-')))
|
||||
const rootModules = join(root, 'node_modules')
|
||||
const nestedModules = join(root, 'packages', 'app', 'node_modules')
|
||||
const nestedSource = join(root, 'packages', 'app', 'src')
|
||||
await Promise.all(
|
||||
[rootModules, nestedModules, nestedSource].map((directory) =>
|
||||
mkdir(directory, { recursive: true })
|
||||
)
|
||||
)
|
||||
|
||||
const events: Event[] = []
|
||||
const errors: Error[] = []
|
||||
subscription = await subscribe(
|
||||
root,
|
||||
(error, batch) => {
|
||||
if (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
events.push(...batch)
|
||||
},
|
||||
buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
|
||||
)
|
||||
|
||||
const generatedNames = Array.from({ length: 20 }, (_, index) => `generated-${index}.js`)
|
||||
// Windows forbids control characters in filenames.
|
||||
if (process.platform !== 'win32') {
|
||||
generatedNames.push('generated\nnewline.js')
|
||||
}
|
||||
const generatedFiles = [rootModules, nestedModules].flatMap((directory) =>
|
||||
generatedNames.map((name) => join(directory, name))
|
||||
)
|
||||
await Promise.all(generatedFiles.map((file) => writeFile(file, 'generated')))
|
||||
const sourceFiles = [join(root, 'source.ts'), join(nestedSource, 'source.ts')]
|
||||
if (process.platform !== 'win32') {
|
||||
sourceFiles.push(join(root, 'source\nnewline.ts'), join(nestedSource, 'source\nnewline.ts'))
|
||||
}
|
||||
await Promise.all(sourceFiles.map((file) => writeFile(file, 'source')))
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
for (const sourceFile of sourceFiles) {
|
||||
expect(events.some((event) => event.path === sourceFile)).toBe(true)
|
||||
}
|
||||
},
|
||||
{ timeout: 8_000 }
|
||||
)
|
||||
// Why: ignored callbacks must stay absent after the native debounce has drained.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(errors).toEqual([])
|
||||
const generatedPaths = new Set(generatedFiles)
|
||||
expect(events.filter((event) => generatedPaths.has(event.path))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -27,12 +27,50 @@ describe('buildParcelWatcherIgnoreOptions', () => {
|
||||
expect(plainPaths.length).toBeLessThanOrEqual(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
|
||||
expect(option).toEqual(WATCHER_IGNORE_DIRS.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT))
|
||||
const fallbackRegex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
|
||||
for (const dir of WATCHER_IGNORE_DIRS.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)) {
|
||||
for (const dir of WATCHER_IGNORE_DIRS) {
|
||||
expect(fallbackRegex.test(dir)).toBe(true)
|
||||
expect(fallbackRegex.test(`${dir}/file.ts`)).toBe(true)
|
||||
expect(fallbackRegex.test(`packages/app/${dir}`)).toBe(true)
|
||||
expect(fallbackRegex.test(`packages/app/${dir}/file.ts`)).toBe(true)
|
||||
}
|
||||
expect(fallbackRegex.test('packages/app/node_modules-cache/file.ts')).toBe(false)
|
||||
expect(fallbackRegex.test('packages/app/.github/workflows/check.yml')).toBe(false)
|
||||
expect(fallbackRegex.test('project\\node_modules/file.ts')).toBe(false)
|
||||
expect(options.ignoreGlobs?.[0]).not.toContain('?!')
|
||||
})
|
||||
|
||||
it('filters nested macOS directories when all names fit in daemon exclusions', () => {
|
||||
setPlatform('darwin')
|
||||
const options = buildParcelWatcherIgnoreOptions(['node_modules', '.cache'])
|
||||
expect(options.ignore).toEqual(['node_modules', '.cache'])
|
||||
const regex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
|
||||
expect(regex.test('packages/app/node_modules/file.js')).toBe(true)
|
||||
expect(regex.test('packages/app/.cache/file.js')).toBe(true)
|
||||
expect(regex.test('packages/app/xcache/file.js')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps an empty ignore list empty', () => {
|
||||
for (const platform of ['darwin', 'linux', 'win32'] as const) {
|
||||
setPlatform(platform)
|
||||
expect(buildParcelWatcherIgnoreOptions([])).toEqual({})
|
||||
}
|
||||
})
|
||||
|
||||
it('matches newline-containing paths only under ignored names', () => {
|
||||
for (const platform of ['darwin', 'linux', 'win32'] as const) {
|
||||
setPlatform(platform)
|
||||
const options = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
|
||||
const regex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
|
||||
expect(regex.test('packages/app/node_modules/generated\nnewline.js')).toBe(true)
|
||||
expect(regex.test('packages/app/src/source\nnewline.ts')).toBe(false)
|
||||
expect(regex.test('packages/app/node_modules-cache/generated\nnewline.js')).toBe(false)
|
||||
expect(regex.test('packages\\app\\node_modules\\generated\nnewline.js')).toBe(
|
||||
platform === 'win32'
|
||||
)
|
||||
expect(regex.test('packages\\app\\src\\source\nnewline.ts')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses one lookahead-free native regex for nested ignores on Linux/Windows', () => {
|
||||
for (const platform of ['linux', 'win32'] as const) {
|
||||
setPlatform(platform)
|
||||
|
||||
@@ -18,9 +18,7 @@ export const WATCHER_IGNORE_DIRS: string[] = [
|
||||
// closed — one entry over the cap and @parcel/watcher silently loses ALL
|
||||
// daemon-side exclusions, so fseventsd delivers every node_modules/.git event
|
||||
// to this process (measured ~29x client CPU plus daemon-side delivery load).
|
||||
// Keep the 8 highest-churn dirs as plain paths (daemon-excluded) and demote
|
||||
// the rest to globs (userspace-filtered). Ordering of WATCHER_IGNORE_DIRS is
|
||||
// therefore meaningful: the first 8 get true daemon-side exclusion on macOS.
|
||||
// The first 8 names get root-level daemon exclusions; the regex covers all names at every depth.
|
||||
export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8
|
||||
|
||||
export type ParcelWatcherIgnoreOptions = {
|
||||
@@ -37,10 +35,10 @@ function escapeRegex(value: string): string {
|
||||
function buildNestedDirectoryRegex(ignoreDirs: readonly string[]): string {
|
||||
const alternatives = ignoreDirs.map(escapeRegex).join('|')
|
||||
if (process.platform === 'win32') {
|
||||
return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/].*)?$`
|
||||
return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/][\\s\\S]*)?$`
|
||||
}
|
||||
// Why: backslash is a legal POSIX filename character, not a path separator.
|
||||
return `^(?:[^/]+/)*(?:${alternatives})(?:/.*)?$`
|
||||
return `^(?:[^/]+/)*(?:${alternatives})(?:/[\\s\\S]*)?$`
|
||||
}
|
||||
|
||||
export function buildParcelWatcherIgnoreOptions(
|
||||
@@ -56,9 +54,9 @@ export function buildParcelWatcherIgnoreOptions(
|
||||
return { ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)] }
|
||||
}
|
||||
const daemonExcludedDirs = ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
|
||||
const remainingDirs = ignoreDirs.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
|
||||
return {
|
||||
ignore: [...daemonExcludedDirs],
|
||||
...(remainingDirs.length > 0 ? { ignoreGlobs: [buildNestedDirectoryRegex(remainingDirs)] } : {})
|
||||
// Why: plain exclusions resolve under the root, leaving nested generated directories unfiltered.
|
||||
ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +321,8 @@ describe('workspace.stale resync', () => {
|
||||
'never publishes an older read after a newer $source notification ($order)',
|
||||
async ({ source, order }) => {
|
||||
let receive: ((data: Buffer) => void) | undefined
|
||||
// Keep both frames in one decoder pass for the ordering assertions.
|
||||
const deliveryClock = vi.spyOn(Date, 'now').mockReturnValue(Date.now())
|
||||
const mux = new SshChannelMultiplexer({
|
||||
write: () => {},
|
||||
onData: (callback) => {
|
||||
@@ -349,6 +351,7 @@ describe('workspace.stale resync', () => {
|
||||
receive?.(
|
||||
Buffer.concat(messages.map((message, index) => encodeJsonRpcFrame(message, index + 1, 0)))
|
||||
)
|
||||
deliveryClock.mockRestore()
|
||||
if (source === 'own') {
|
||||
expect(getCachedRemoteWorkspaceSnapshot('target-1')?.hostObservationToken).toBe(
|
||||
initial.hostObservationToken
|
||||
@@ -365,6 +368,7 @@ describe('workspace.stale resync', () => {
|
||||
expect(sent.map((event) => event.snapshot.revision)).toEqual([3])
|
||||
expect(sent[0].sourceClientId).toBe(sourceClientId)
|
||||
} finally {
|
||||
deliveryClock.mockRestore()
|
||||
mux.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,18 @@ vi.mock('electron', () => ({
|
||||
}))
|
||||
|
||||
import { _internals } from './hook-service'
|
||||
import { fakeTui, type BusEvent } from './opencode-tui-session-fixture'
|
||||
|
||||
type Post = {
|
||||
paneKey?: string
|
||||
opencodeMajor?: number
|
||||
payload?: { hook_event_name?: string; sessionID?: string }
|
||||
payload?: {
|
||||
hook_event_name?: string
|
||||
sessionID?: string
|
||||
root_state?: string
|
||||
root_turn_error_name?: string
|
||||
}
|
||||
}
|
||||
type BusEvent = { type: string; data: Record<string, unknown> }
|
||||
type PluginModule = {
|
||||
default?: { setup?: (ctx: unknown) => Promise<(() => Promise<void>) | undefined> }
|
||||
}
|
||||
@@ -70,130 +75,6 @@ function turn(sessionID: string, text: string): { start: BusEvent[]; finish: Bus
|
||||
}
|
||||
}
|
||||
|
||||
type Blocker = { id: string; sessionID: string; [key: string]: unknown }
|
||||
|
||||
function toBlocker(value: unknown): Blocker {
|
||||
const record = typeof value === 'object' && value !== null ? { ...value } : {}
|
||||
const id = 'id' in record ? String(record.id) : ''
|
||||
const sessionID = 'sessionID' in record ? String(record.sessionID) : ''
|
||||
return { ...record, id, sessionID }
|
||||
}
|
||||
|
||||
/** One pane's TUI: its route, its view of the shared session store, and the shared event bus. */
|
||||
function fakeTui(version = '2.0.14') {
|
||||
const listeners = new Set<(event: { details: BusEvent }) => void>()
|
||||
const sessions = new Map<string, { id: string; parentID?: string }>()
|
||||
const running = new Set<string>()
|
||||
const permissions = new Map<string, Blocker[]>()
|
||||
const forms = new Map<string, Blocker[]>()
|
||||
// What the server answers when the TUI re-fetches a permission list (reconnect).
|
||||
const serverPermissions = new Map<string, Blocker[]>()
|
||||
let permissionFetch: Promise<void> = Promise.resolve()
|
||||
// Why: OpenCode keeps storage.memory across plugin hot reloads within one TUI process.
|
||||
const memories = new Map<string, unknown>()
|
||||
let route: { type: string; sessionID?: string } = { type: 'home' }
|
||||
const rootOf = (id: string): string => {
|
||||
let current = sessions.get(id)
|
||||
while (current?.parentID && sessions.has(current.parentID)) {
|
||||
current = sessions.get(current.parentID)
|
||||
}
|
||||
return current?.id ?? id
|
||||
}
|
||||
const without = (map: Map<string, Blocker[]>, sessionID: string, id: unknown): void => {
|
||||
map.set(
|
||||
sessionID,
|
||||
(map.get(sessionID) ?? []).filter((item) => item.id !== id)
|
||||
)
|
||||
}
|
||||
const listen = vi.fn((handler: (event: { details: BusEvent }) => void) => {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
})
|
||||
const ctx = {
|
||||
app: { version, channel: 'latest' },
|
||||
ui: { router: { current: () => route } },
|
||||
storage: {
|
||||
memory: (key: string, options: { initial: Record<string, unknown> }) => {
|
||||
if (!memories.has(key)) {
|
||||
const value = structuredClone(options.initial)
|
||||
memories.set(key, [value, (mutate: (draft: typeof value) => void) => mutate(value)])
|
||||
}
|
||||
return memories.get(key)
|
||||
}
|
||||
},
|
||||
client: {
|
||||
session: { get: async ({ sessionID }: { sessionID: string }) => sessions.get(sessionID) }
|
||||
},
|
||||
data: {
|
||||
listen,
|
||||
session: {
|
||||
get: (id: string) => sessions.get(id),
|
||||
root: rootOf,
|
||||
family: (id: string) =>
|
||||
[...sessions.keys()].filter((member) => rootOf(member) === rootOf(id)),
|
||||
status: (id: string) => (running.has(id) ? 'running' : 'idle'),
|
||||
permission: {
|
||||
list: (id: string) => permissions.get(id),
|
||||
sync: async (id: string) => {
|
||||
await permissionFetch
|
||||
permissions.set(id, [...(serverPermissions.get(id) ?? [])])
|
||||
}
|
||||
},
|
||||
form: { list: (id: string) => forms.get(id), sync: async () => {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ctx,
|
||||
listen,
|
||||
serverPermissions,
|
||||
permissions,
|
||||
navigate(sessionID: string) {
|
||||
route = { type: 'session', sessionID }
|
||||
},
|
||||
// The session data stops reporting a run without this TUI seeing its end event.
|
||||
loseEnd(sessionID: string) {
|
||||
running.delete(sessionID)
|
||||
},
|
||||
// The session data reports a run whose start this TUI never saw.
|
||||
loseStart(sessionID: string) {
|
||||
running.add(sessionID)
|
||||
},
|
||||
// Holds the next permission list fetch until the returned release is called.
|
||||
holdPermissionFetch(): () => void {
|
||||
let release = (): void => {}
|
||||
permissionFetch = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return release
|
||||
},
|
||||
// Applies an event to the session data first, then to plugin listeners, as OpenCode does.
|
||||
emit(event: BusEvent) {
|
||||
const sessionID = String(event.data.sessionID)
|
||||
if (event.type === 'session.created') {
|
||||
const parentID = typeof event.data.parentID === 'string' ? event.data.parentID : undefined
|
||||
sessions.set(sessionID, { id: sessionID, parentID })
|
||||
} else if (event.type === 'session.execution.started') {
|
||||
running.add(sessionID)
|
||||
} else if (event.type === 'session.execution.succeeded') {
|
||||
running.delete(sessionID)
|
||||
} else if (event.type === 'permission.asked') {
|
||||
permissions.set(sessionID, [...(permissions.get(sessionID) ?? []), toBlocker(event.data)])
|
||||
} else if (event.type === 'permission.replied') {
|
||||
without(permissions, sessionID, event.data.requestID)
|
||||
} else if (event.type === 'form.created') {
|
||||
const form = toBlocker(event.data.form)
|
||||
forms.set(form.sessionID, [...(forms.get(form.sessionID) ?? []), form])
|
||||
} else if (event.type === 'form.replied' || event.type === 'form.cancelled') {
|
||||
without(forms, sessionID, event.data.id)
|
||||
}
|
||||
for (const handler of listeners) {
|
||||
handler({ details: event })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
let tempDir: string
|
||||
let savedFetch: typeof globalThis.fetch
|
||||
@@ -290,6 +171,33 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
['session.execution.failed', { error: { type: 'api' } }, 'api'],
|
||||
['session.execution.failed', {}, 'UnknownError'],
|
||||
['session.execution.interrupted', { reason: 'user' }, 'MessageAbortedError'],
|
||||
['session.execution.interrupted', { reason: 'pause' }, undefined],
|
||||
['session.execution.succeeded', {}, undefined]
|
||||
])('carries the root verdict for %s', async (type, fields, errorName) => {
|
||||
const reported = await runPane(PANE_A, SES_A, async (tui) => {
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, turn(SES_A, 'root').start)
|
||||
await pump(tui, [{ type, data: { sessionID: SES_A, ...fields } }])
|
||||
})
|
||||
expect(reported.at(-1)?.payload).toMatchObject({ root_state: 'done' })
|
||||
expect(reported.at(-1)?.payload?.root_turn_error_name).toBe(errorName)
|
||||
})
|
||||
|
||||
it('clears a failed root verdict on its next turn', async () => {
|
||||
const reported = await runPane(PANE_A, SES_A, async (tui) => {
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, turn(SES_A, 'first').start)
|
||||
await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }])
|
||||
await pump(tui, [{ type: 'session.execution.started', data: { sessionID: SES_A } }])
|
||||
await pump(tui, turn(SES_A, 'next').finish)
|
||||
})
|
||||
expect(reported.at(-1)?.payload?.root_turn_error_name).toBeUndefined()
|
||||
})
|
||||
|
||||
// Captured s2 shape: pane A's long turn overlapped by pane B's short one on one server.
|
||||
it('gives each overlapping pane only its own session and its own Idle', async () => {
|
||||
const a = turn(SES_A, 'A long SLEEP=12')
|
||||
@@ -446,6 +354,75 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
data: { id: 'per_1', sessionID, action: 'bash', resources: ['rm -rf build'] }
|
||||
})
|
||||
|
||||
it('publishes a root failure while its child keeps the aggregate Working', async () => {
|
||||
const tui = fakeTui()
|
||||
const cleanup = await start(tui)
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, [...turn(SES_A, 'root').start, ...childStart(SES_A)])
|
||||
await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }])
|
||||
await vi.waitFor(() => {
|
||||
expect(posts.at(-1)?.payload).toMatchObject({
|
||||
hook_event_name: 'SessionBusy',
|
||||
root_state: 'done',
|
||||
root_turn_error_name: 'UnknownError'
|
||||
})
|
||||
})
|
||||
await pump(tui, [{ type: 'session.execution.succeeded', data: { sessionID: SES_CHILD } }])
|
||||
expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError')
|
||||
await cleanup?.()
|
||||
})
|
||||
|
||||
it('does not turn a child failure into a root failure', async () => {
|
||||
const reported = await runPane(PANE_A, SES_A, async (tui) => {
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, [...turn(SES_A, 'root').start, ...childStart(SES_A)])
|
||||
await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_CHILD } }])
|
||||
await pump(tui, turn(SES_A, 'root').finish)
|
||||
})
|
||||
expect(reported.at(-1)?.payload?.root_turn_error_name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a failed terminal verdict through plugin hot reload', async () => {
|
||||
const tui = fakeTui()
|
||||
const first = await start(tui)
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, turn(SES_A, 'root').start)
|
||||
await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }])
|
||||
await vi.waitFor(() =>
|
||||
expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError')
|
||||
)
|
||||
await first?.()
|
||||
const before = posts.length
|
||||
const second = await start(tui)
|
||||
await tick(150)
|
||||
expect(posts).toHaveLength(before)
|
||||
expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError')
|
||||
await second?.()
|
||||
})
|
||||
|
||||
it.each(['session.execution.succeeded', 'session.execution.interrupted'])(
|
||||
'repairs a failed verdict when %s follows a missed turn start during unload',
|
||||
async (finishType) => {
|
||||
const tui = fakeTui()
|
||||
const first = await start(tui)
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, turn(SES_A, 'first').start)
|
||||
await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }])
|
||||
await first?.()
|
||||
await pump(tui, [
|
||||
{ type: 'session.execution.started', data: { sessionID: SES_A } },
|
||||
{ type: finishType, data: { sessionID: SES_A, reason: 'user' } }
|
||||
])
|
||||
const second = await start(tui)
|
||||
await vi.waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBeUndefined())
|
||||
expect(posts.at(-1)?.payload).toMatchObject({
|
||||
hook_event_name: 'SessionIdle',
|
||||
root_state: 'done'
|
||||
})
|
||||
await second?.()
|
||||
}
|
||||
)
|
||||
|
||||
// Why: #23700 left this for TUI panes; a reload that misses the end must still reach Done.
|
||||
it('shows Done for a turn that ended while the plugin was reloading', async () => {
|
||||
const tui = fakeTui()
|
||||
@@ -591,13 +568,36 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
|
||||
const a = turn(SES_A, 'A spawns a background task')
|
||||
await pump(tui, [...a.start, ...childStart(SES_A), ...a.finish])
|
||||
await tick(250)
|
||||
expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])
|
||||
expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`])
|
||||
expect(posts.at(-1)?.payload).toMatchObject({ root_state: 'done' })
|
||||
tui.loseEnd(SES_CHILD)
|
||||
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`))
|
||||
await cleanup?.()
|
||||
expect(posts.every((post) => post.payload?.sessionID === SES_A)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps automatically answered permissions Working without an attention post', async () => {
|
||||
const tui = fakeTui()
|
||||
const cleanup = await start(tui)
|
||||
tui.navigate(SES_A)
|
||||
await pump(tui, turn(SES_A, 'auto').start)
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
tui.emit({
|
||||
type: 'permission.asked',
|
||||
data: { ...permission(SES_A).data, id: `auto_${index}` }
|
||||
})
|
||||
await tick(20)
|
||||
tui.emit({
|
||||
type: 'permission.replied',
|
||||
data: { sessionID: SES_A, requestID: `auto_${index}` }
|
||||
})
|
||||
}
|
||||
await tick(650)
|
||||
expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])
|
||||
await pump(tui, turn(SES_A, 'auto').finish)
|
||||
await cleanup?.()
|
||||
})
|
||||
|
||||
it("shows a child's permission as Needs input on the root, then Working after the reply", async () => {
|
||||
const tui = fakeTui()
|
||||
const cleanup = await start(tui)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { resolveOpenCodeDataDirectory } from './opencode-data-directory'
|
||||
import {
|
||||
bindOpenCodeSession,
|
||||
@@ -225,6 +226,8 @@ export function listOpenCodeDbSessions(
|
||||
cursor: OpenCodeSessionCursor
|
||||
): BinderSessionRow[] {
|
||||
try {
|
||||
// Check absence without hiding permission failures as an unused store.
|
||||
statSync(dbPath)
|
||||
return readOpenCodeDatabase({
|
||||
dbPath,
|
||||
read: (db) => {
|
||||
@@ -269,6 +272,13 @@ export function listOpenCodeDbSessions(
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
'code' in err &&
|
||||
(err.code === 'ENOENT' || err.code === 'ENOTDIR')
|
||||
) {
|
||||
return []
|
||||
}
|
||||
console.warn('[opencode-binder] session store read failed; skipping round', err)
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
export type BusEvent = { type: string; data: Record<string, unknown>; created?: number }
|
||||
|
||||
type Blocker = { id: string; sessionID: string; [key: string]: unknown }
|
||||
|
||||
function toBlocker(value: unknown): Blocker {
|
||||
const record = typeof value === 'object' && value !== null ? { ...value } : {}
|
||||
const id = 'id' in record ? String(record.id) : ''
|
||||
const sessionID = 'sessionID' in record ? String(record.sessionID) : ''
|
||||
return { ...record, id, sessionID }
|
||||
}
|
||||
|
||||
/** One pane's TUI: its route, its view of the shared session store, and the shared event bus. */
|
||||
export function fakeTui(version = '2.0.14') {
|
||||
const listeners = new Set<(event: { details: BusEvent }) => void>()
|
||||
const sessions = new Map<
|
||||
string,
|
||||
{ id: string; parentID?: string; outcome?: string; time?: { idle: number } }
|
||||
>()
|
||||
let idleClock = 1
|
||||
const running = new Set<string>()
|
||||
const permissions = new Map<string, Blocker[]>()
|
||||
const forms = new Map<string, Blocker[]>()
|
||||
// What the server answers when the TUI re-fetches a permission list (reconnect).
|
||||
const serverPermissions = new Map<string, Blocker[]>()
|
||||
let permissionFetch: Promise<void> = Promise.resolve()
|
||||
// Why: OpenCode keeps storage.memory across plugin hot reloads within one TUI process.
|
||||
const memories = new Map<string, unknown>()
|
||||
let route: { type: string; sessionID?: string } = { type: 'home' }
|
||||
const rootOf = (id: string): string => {
|
||||
let current = sessions.get(id)
|
||||
while (current?.parentID && sessions.has(current.parentID)) {
|
||||
current = sessions.get(current.parentID)
|
||||
}
|
||||
return current?.id ?? id
|
||||
}
|
||||
const without = (map: Map<string, Blocker[]>, sessionID: string, id: unknown): void => {
|
||||
map.set(
|
||||
sessionID,
|
||||
(map.get(sessionID) ?? []).filter((item) => item.id !== id)
|
||||
)
|
||||
}
|
||||
const listen = vi.fn((handler: (event: { details: BusEvent }) => void) => {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
})
|
||||
const ctx = {
|
||||
app: { version, channel: 'latest' },
|
||||
ui: { router: { current: () => route } },
|
||||
storage: {
|
||||
memory: (key: string, options: { initial: Record<string, unknown> }) => {
|
||||
if (!memories.has(key)) {
|
||||
const value = structuredClone(options.initial)
|
||||
memories.set(key, [value, (mutate: (draft: typeof value) => void) => mutate(value)])
|
||||
}
|
||||
return memories.get(key)
|
||||
}
|
||||
},
|
||||
client: {
|
||||
session: { get: async ({ sessionID }: { sessionID: string }) => sessions.get(sessionID) }
|
||||
},
|
||||
data: {
|
||||
listen,
|
||||
session: {
|
||||
get: (id: string) => sessions.get(id),
|
||||
root: rootOf,
|
||||
family: (id: string) =>
|
||||
[...sessions.keys()].filter((member) => rootOf(member) === rootOf(id)),
|
||||
status: (id: string) => (running.has(id) ? 'running' : 'idle'),
|
||||
permission: {
|
||||
list: (id: string) => permissions.get(id),
|
||||
sync: async (id: string) => {
|
||||
await permissionFetch
|
||||
permissions.set(id, [...(serverPermissions.get(id) ?? [])])
|
||||
}
|
||||
},
|
||||
form: { list: (id: string) => forms.get(id), sync: async () => {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ctx,
|
||||
listen,
|
||||
serverPermissions,
|
||||
permissions,
|
||||
navigate(sessionID: string) {
|
||||
route = { type: 'session', sessionID }
|
||||
},
|
||||
// The session data stops reporting a run without this TUI seeing its end event.
|
||||
loseEnd(sessionID: string) {
|
||||
running.delete(sessionID)
|
||||
},
|
||||
// The session data reports a run whose start this TUI never saw.
|
||||
loseStart(sessionID: string) {
|
||||
running.add(sessionID)
|
||||
const session = sessions.get(sessionID)
|
||||
if (session) {
|
||||
session.outcome = undefined
|
||||
}
|
||||
},
|
||||
// Holds the next permission list fetch until the returned release is called.
|
||||
holdPermissionFetch(): () => void {
|
||||
let release = (): void => {}
|
||||
permissionFetch = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return release
|
||||
},
|
||||
// Ending events reach listeners before the session snapshot catches up.
|
||||
emit(event: BusEvent) {
|
||||
const sessionID = String(event.data.sessionID)
|
||||
let notified = false
|
||||
if (event.type === 'session.created') {
|
||||
const parentID = typeof event.data.parentID === 'string' ? event.data.parentID : undefined
|
||||
sessions.set(sessionID, { id: sessionID, parentID })
|
||||
} else if (event.type === 'session.execution.started') {
|
||||
running.add(sessionID)
|
||||
const session = sessions.get(sessionID)
|
||||
if (session) {
|
||||
session.outcome = undefined
|
||||
}
|
||||
} else if (
|
||||
event.type === 'session.execution.succeeded' ||
|
||||
event.type === 'session.execution.failed' ||
|
||||
event.type === 'session.execution.interrupted'
|
||||
) {
|
||||
event = { ...event, created: ++idleClock }
|
||||
for (const handler of listeners) {
|
||||
handler({ details: event })
|
||||
}
|
||||
notified = true
|
||||
running.delete(sessionID)
|
||||
const session = sessions.get(sessionID)
|
||||
if (session) {
|
||||
session.outcome = event.type.slice('session.execution.'.length)
|
||||
session.time = { idle: idleClock }
|
||||
}
|
||||
} else if (event.type === 'permission.asked') {
|
||||
permissions.set(sessionID, [...(permissions.get(sessionID) ?? []), toBlocker(event.data)])
|
||||
} else if (event.type === 'permission.replied') {
|
||||
without(permissions, sessionID, event.data.requestID)
|
||||
} else if (event.type === 'form.created') {
|
||||
const form = toBlocker(event.data.form)
|
||||
forms.set(form.sessionID, [...(forms.get(form.sessionID) ?? []), form])
|
||||
} else if (event.type === 'form.replied' || event.type === 'form.cancelled') {
|
||||
without(forms, sessionID, event.data.id)
|
||||
}
|
||||
if (!notified) {
|
||||
for (const handler of listeners) {
|
||||
handler({ details: event })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
export function getOpenCode2TuiSource(): string[] {
|
||||
return String.raw`
|
||||
const TUI_TICK_MS = 100;
|
||||
const TUI_PERMISSION_SETTLE_MS = 500;
|
||||
const TUI_EARLY_ROOTS_MAX = 32;
|
||||
const TUI_RESOLVED_REQUESTS_MAX = 256;
|
||||
const TUI_ENDPOINT_CHECK_TICKS = 50;
|
||||
@@ -22,10 +23,9 @@ function boundedSet(set, value, max) {
|
||||
if (set.size > max) set.delete(set.values().next().value);
|
||||
}
|
||||
|
||||
// Why storage.memory: OpenCode keeps it across plugin hot reloads and drops it when the TUI
|
||||
// exits, so a reload mid-turn keeps this pane's sessions and what it last reported.
|
||||
// Memory survives hot reloads and ends with the TUI.
|
||||
function paneStatusMemory(ctx) {
|
||||
const initial = { owned: [], last: "idle:", lastRoot: "", started: false };
|
||||
const initial = { owned: [], last: "idle:", lastRoot: "", started: false, endings: [] };
|
||||
if (typeof ctx.storage?.memory === "function") return ctx.storage.memory("pane-status", { initial });
|
||||
// Why started: without memory a reload looks like a TUI start, and must not reset the pane.
|
||||
const local = { ...initial, started: true };
|
||||
@@ -50,10 +50,10 @@ async function setupOpenCode2Tui(ctx) {
|
||||
let disposed = false;
|
||||
let lastLevel = null;
|
||||
let ticks = 0;
|
||||
// Root sessions this TUI saw start before it owned them: their SessionStart and prompt.
|
||||
const early = new Map();
|
||||
// Why: a permission/form list fetched on reconnect can land after the reply event and restore it.
|
||||
const resolved = new Set();
|
||||
const permissionSeenAt = new Map();
|
||||
|
||||
const rootOf = (sessionID) => data.root(sessionID) || sessionID;
|
||||
const family = (root) => new Set([root, ...(typeof data.family === "function" ? data.family(root) || [] : [])]);
|
||||
@@ -62,18 +62,41 @@ async function setupOpenCode2Tui(ctx) {
|
||||
const route = ctx.ui.router.current();
|
||||
return route?.type === "session" && typeof route.sessionID === "string" ? rootOf(route.sessionID) : undefined;
|
||||
};
|
||||
// First open permission, else first form, across the root's family.
|
||||
const blocker = (root) => {
|
||||
const blocker = (root, seenPermissions) => {
|
||||
let form;
|
||||
for (const member of family(root)) {
|
||||
const permission = (data.permission?.list?.(member) || []).find((request) => !resolved.has(request.id));
|
||||
const permission = (data.permission?.list?.(member) || []).find((request) => {
|
||||
if (resolved.has(request.id)) return false;
|
||||
seenPermissions.add(request.id);
|
||||
if (!permissionSeenAt.has(request.id)) permissionSeenAt.set(request.id, Date.now());
|
||||
// Auto replies arrive after the request; only an unanswered request needs attention.
|
||||
return Date.now() - permissionSeenAt.get(request.id) >= TUI_PERMISSION_SETTLE_MS;
|
||||
});
|
||||
if (permission) return { request: permission, isPermission: true };
|
||||
form ??= (data.form?.list?.(member) || []).find((request) => !resolved.has(request.id));
|
||||
}
|
||||
return form ? { request: form, isPermission: false } : null;
|
||||
};
|
||||
const levelKey = (level) =>
|
||||
level.kind === "waiting" ? "waiting:" + level.blocker.request.id + ":" + level.root : level.kind + ":" + level.root;
|
||||
(level.kind === "waiting" ? "waiting:" + level.blocker.request.id + ":" + level.root : level.kind + ":" + level.root) +
|
||||
(level.rootFields.root_state ? ":" + JSON.stringify(level.rootFields) : "");
|
||||
|
||||
function rootFields(level) {
|
||||
if (!level.root) return {};
|
||||
const rootRunning = data.status(level.root) === "running";
|
||||
const rootState = rootRunning
|
||||
? level.kind === "waiting" && level.blocker.request.sessionID === level.root ? "waiting" : "working"
|
||||
: "done";
|
||||
const session = data.get?.(level.root);
|
||||
const ending = (memory.endings || []).find(([id]) => id === level.root);
|
||||
const idleAt = session?.time?.idle;
|
||||
const sameTurn = Number.isFinite(idleAt) && ending?.[2] === idleAt;
|
||||
// Current session data repairs a whole turn missed while the plugin was unloaded.
|
||||
const errorName = !rootRunning && (session?.outcome === "failed"
|
||||
? (sameTurn && ending[1]) || "UnknownError"
|
||||
: session?.outcome === "interrupted" && sameTurn ? ending[1] : "");
|
||||
return { root_state: rootState, ...(errorName ? { root_turn_error_name: errorName } : {}) };
|
||||
}
|
||||
|
||||
function own(root, owned) {
|
||||
const seen = early.get(root);
|
||||
@@ -94,11 +117,17 @@ async function setupOpenCode2Tui(ctx) {
|
||||
owned = owned.filter((root) => root === route || running(root));
|
||||
if (owned.join("\n") !== memory.owned.join("\n")) setMemory((draft) => { draft.owned = owned; });
|
||||
const active = owned.filter(running);
|
||||
const seenPermissions = new Set();
|
||||
let waiting;
|
||||
for (const root of active) {
|
||||
// Why only while running: a blocker the session data kept after its turn ended is stale.
|
||||
const found = blocker(root);
|
||||
if (found) return { kind: "waiting", root, blocker: found };
|
||||
const found = blocker(root, seenPermissions);
|
||||
if (found && !waiting) waiting = { kind: "waiting", root, blocker: found };
|
||||
}
|
||||
for (const id of permissionSeenAt.keys()) {
|
||||
if (!seenPermissions.has(id)) permissionSeenAt.delete(id);
|
||||
}
|
||||
if (waiting) return waiting;
|
||||
const busy = active.at(-1);
|
||||
return busy ? { kind: "busy", root: busy } : { kind: "idle", root: memory.lastRoot };
|
||||
}
|
||||
@@ -108,6 +137,7 @@ async function setupOpenCode2Tui(ctx) {
|
||||
let level;
|
||||
try {
|
||||
level = derive();
|
||||
level.rootFields = rootFields(level);
|
||||
} catch {
|
||||
// Why: a data read that throws must not kill the listener or the tick.
|
||||
return;
|
||||
@@ -119,14 +149,13 @@ async function setupOpenCode2Tui(ctx) {
|
||||
if (level.kind !== "idle") draft.lastRoot = level.root;
|
||||
});
|
||||
lastLevel = level;
|
||||
// Why the lifecycle queue: posts keep the order the levels were derived in.
|
||||
void enqueueLifecycle(() => deliver(level, true));
|
||||
}
|
||||
|
||||
async function deliver(level, changed) {
|
||||
// Retires assistant text queued under the previous level (see flushPendingAssistantPart).
|
||||
if (changed) stateArrivalRevision += 1;
|
||||
const properties = level.root ? { sessionID: level.root } : {};
|
||||
const properties = level.root ? { sessionID: level.root, ...level.rootFields } : {};
|
||||
if (level.kind === "waiting") {
|
||||
const { request, isPermission } = level.blocker;
|
||||
const translated = isPermission
|
||||
@@ -134,12 +163,12 @@ async function setupOpenCode2Tui(ctx) {
|
||||
: translateOpenCode2Event("form.created", { form: request });
|
||||
if (!translated) return;
|
||||
const hookEventName = isPermission ? "PermissionRequest" : "AskUserQuestion";
|
||||
await setAttention(hookEventName, { ...translated.properties, ...properties }, factoryID, request.sessionID);
|
||||
await flushPendingAssistantPart(true);
|
||||
await setDeliveryTarget("waiting", levelKey(level), hookEventName, { ...translated.properties, ...properties }, factoryID);
|
||||
return;
|
||||
}
|
||||
// Why flush first: the done-state preview must show the completed reply.
|
||||
if (level.kind === "idle") await flushPendingAssistantPart(true);
|
||||
await setStatus(level.kind, properties, factoryID);
|
||||
await setDeliveryTarget(level.kind, levelKey(level), level.kind === "busy" ? "SessionBusy" : "SessionIdle", properties, factoryID);
|
||||
}
|
||||
|
||||
function postPrompt(root, prompt) {
|
||||
@@ -188,6 +217,18 @@ async function setupOpenCode2Tui(ctx) {
|
||||
}
|
||||
const root = rootOf(sessionID);
|
||||
const isOwned = memory.owned.includes(root);
|
||||
if (sessionID === root && (isOwned || currentRoute() === root)) {
|
||||
if (event.type === "session.execution.started" || event.type === "session.execution.succeeded" || event.type === "session.execution.failed" || event.type === "session.execution.interrupted") {
|
||||
const errorName = event.type === "session.execution.failed"
|
||||
? (typeof properties.error?.type === "string" && properties.error.type) || "UnknownError"
|
||||
: event.type === "session.execution.interrupted" && properties.reason === "user" ? "MessageAbortedError" : "";
|
||||
// A hot reload keeps the terminal verdict; only this root's next turn replaces it.
|
||||
setMemory((draft) => {
|
||||
draft.endings = (draft.endings || []).filter(([id]) => id !== root);
|
||||
if (errorName) draft.endings = [...draft.endings, [root, errorName, event.created ?? data.get?.(root)?.time?.idle]].slice(-TUI_EARLY_ROOTS_MAX);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (event.type === "session.inbox.enqueued") {
|
||||
// Why TUI-only: the server takes the prompt from session.hook("prompt"), which a TUI lacks.
|
||||
const item = properties.item;
|
||||
|
||||
@@ -36,6 +36,15 @@ export const ANTIGRAVITY_USAGE_ARGS: readonly string[] = [
|
||||
*/
|
||||
export const ANTIGRAVITY_USAGE_TIMEOUT_MS = 30_000
|
||||
|
||||
// Before agy 1.1.11, `/usage` in print mode spends a model turn (agy's bundled changelog).
|
||||
export const ANTIGRAVITY_MIN_USAGE_VERSION = '1.1.11'
|
||||
|
||||
/** The version probe; free in every sense — no conversation, no quota. */
|
||||
export const ANTIGRAVITY_VERSION_ARGS: readonly string[] = ['--version']
|
||||
|
||||
/** Why bound it: the probe runs before every quota read, so a wedged CLI must not stall the cycle. */
|
||||
export const ANTIGRAVITY_VERSION_TIMEOUT_MS = 5_000
|
||||
|
||||
/** Cap on captured output; the envelope is a single JSON line well under a kilobyte. */
|
||||
export const ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES = 512 * 1024
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyAntigravityUsageFailure } from './antigravity-usage-error'
|
||||
|
||||
/** Captured verbatim from agy 1.2.11 when the account had exhausted its weekly pool. */
|
||||
const REAL_RESOURCE_EXHAUSTED =
|
||||
'error: Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.\n' +
|
||||
'AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","retryable":true,"error_id":"6baa4ce9-c373-4086-bb0d-fbb8421bacf3-1"}'
|
||||
|
||||
function agyError(fields: Record<string, unknown>): string {
|
||||
return `AGY_ERROR: ${JSON.stringify(fields)}`
|
||||
}
|
||||
|
||||
describe('classifyAntigravityUsageFailure', () => {
|
||||
it('reads the real RESOURCE_EXHAUSTED diagnostic as rate limiting, not as a sign-out', () => {
|
||||
const failure = classifyAntigravityUsageFailure(REAL_RESOURCE_EXHAUSTED)
|
||||
|
||||
expect(failure).toEqual({ failureKind: 'rate-limited', signedOut: false })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['status', { status: 'UNAUTHENTICATED' }],
|
||||
['http code', { error_code: 401 }]
|
||||
])('reads an unauthenticated %s as a missing sign-in', (_label, fields) => {
|
||||
expect(classifyAntigravityUsageFailure(agyError(fields))).toEqual({
|
||||
failureKind: 'missing-credentials',
|
||||
signedOut: true
|
||||
})
|
||||
})
|
||||
|
||||
it('separates an unentitled account from a signed-out one', () => {
|
||||
// Why this matters: telling a signed-in user to sign in sends them to re-auth that fixes
|
||||
// nothing.
|
||||
expect(classifyAntigravityUsageFailure(agyError({ status: 'PERMISSION_DENIED' }))).toEqual({
|
||||
failureKind: 'no-subscription',
|
||||
signedOut: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a 5xx as a server failure', () => {
|
||||
expect(classifyAntigravityUsageFailure(agyError({ error_code: 503 }))).toEqual({
|
||||
failureKind: 'server',
|
||||
signedOut: false
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'You are not logged into Antigravity.',
|
||||
'Error: not signed in. Run `agy` to continue.',
|
||||
'please sign in to continue',
|
||||
'AUTHENTICATION REQUIRED'
|
||||
])('falls back to the sentence when no structured error is printed: %s', (output) => {
|
||||
// Why a fallback at all: the structured line is the stable signal, but not every path prints
|
||||
// one, and a signed-out account must never be reported as an unreadable payload.
|
||||
expect(classifyAntigravityUsageFailure(output)).toEqual({
|
||||
failureKind: 'missing-credentials',
|
||||
signedOut: true
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the structured status over a sentence that disagrees with it', () => {
|
||||
const output = `not logged in\n${agyError({ status: 'RESOURCE_EXHAUSTED' })}`
|
||||
|
||||
expect(classifyAntigravityUsageFailure(output)).toEqual({
|
||||
failureKind: 'rate-limited',
|
||||
signedOut: false
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a successful read', '{"status":"SUCCESS","command":{"name":"usage"}}'],
|
||||
['unrelated noise', 'I0926 quota_manager.go:45] doRefreshQuota: starting reload'],
|
||||
['an unparsable structured line', 'AGY_ERROR: {not json'],
|
||||
['an unrecognised status', agyError({ status: 'SOMETHING_NEW' })],
|
||||
['empty output', '']
|
||||
])('returns null for %s rather than inventing a cause', (_label, output) => {
|
||||
expect(classifyAntigravityUsageFailure(output)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user