mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/publish-submission-positions
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
name: macOS updater regression tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/macos-updater-tests.yml'
|
||||
- 'src/main/macos-update-running-instances*'
|
||||
- 'src/main/updater*'
|
||||
- 'src/main/updater/**'
|
||||
- 'src/main/startup/main-process-quit*'
|
||||
- 'src/main/window/main-window-state-lifecycle*'
|
||||
- 'src/main/window/dashboard-popout-window*'
|
||||
- 'src/shared/child-process/**'
|
||||
- 'src/shared/update-status-types.ts'
|
||||
- 'pnpm-lock.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: macos-updater-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
updater:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
- name: Exercise native application registry and update shutdown
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
src/main/macos-update-running-instances.test.ts
|
||||
src/main/macos-update-running-instances.integration.test.ts
|
||||
src/main/updater.mac-install.test.ts
|
||||
src/main/updater.headless-serve-install.test.ts
|
||||
src/main/updater-mac-quit-guard.test.ts
|
||||
src/main/startup/desktop-startup-ordering.test.ts
|
||||
src/main/startup/main-process-quit-update-veto.test.ts
|
||||
src/main/window/main-window-state-lifecycle.test.ts
|
||||
src/main/window/dashboard-popout-window.test.ts
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
cache-pnpm-store-lookup-only: 'true'
|
||||
# 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
|
||||
# moves past the Bun daemon's. Its build uses this checkout's installed dependencies.
|
||||
# moves past the Bun daemon's. Install its pinned dependencies independently of this checkout.
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
if [ "$RUNNER_OS" != Linux ]; then exit 0; fi
|
||||
git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT"
|
||||
git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT"
|
||||
ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules"
|
||||
pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts
|
||||
node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad"
|
||||
echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"
|
||||
echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
+80
-42
@@ -154,15 +154,17 @@ jobs:
|
||||
echo "No specs requiring the reusable E2E workflow"
|
||||
fi
|
||||
|
||||
static_analysis:
|
||||
name: static analysis
|
||||
preflight:
|
||||
name: static analysis and typecheck
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
if: needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true'
|
||||
# Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster --
|
||||
# type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke
|
||||
# 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so
|
||||
# the whole toolchain resolves. Free for public repositories, same as the typecheck job.
|
||||
# the whole toolchain resolves. Free for public repositories.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
shards: ${{ steps.unit-plan.outputs.shards }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -197,21 +199,35 @@ jobs:
|
||||
|
||||
# Keep each check in its own log while sharing this runner.
|
||||
- name: Lint
|
||||
if: '!cancelled()'
|
||||
id: root-lint
|
||||
background: true
|
||||
run: pnpm exec oxlint --format github
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm exec oxlint --format github
|
||||
|
||||
- name: Reject low-evidence patterns
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run audit:anti-slop
|
||||
|
||||
- wait: root-lint
|
||||
|
||||
- name: Enforce focused code-quality plugins
|
||||
if: '!cancelled()'
|
||||
id: native-code-quality
|
||||
background: true
|
||||
run: pnpm run audit:code-quality:native
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm run audit:code-quality:native
|
||||
|
||||
- name: Enforce type-aware code-quality baseline
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run audit:code-quality:type-aware
|
||||
|
||||
# Mobile installation changes import resolution for the native cycle check.
|
||||
@@ -221,28 +237,39 @@ jobs:
|
||||
# resolves types from mobile/node_modules. Without the install every mobile type
|
||||
# degrades to an `error` type — reported as phantom findings against the changed lines.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
if: needs.code_paths.outputs.mobile_dependencies == 'true'
|
||||
if: needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'
|
||||
|
||||
- name: Enforce changed-code quality
|
||||
if: '!cancelled()'
|
||||
id: changed-code-quality
|
||||
background: true
|
||||
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
- name: Enforce React Doctor on changed lines
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
- wait: changed-code-quality
|
||||
|
||||
- name: Check Zustand selector fan-out budget
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:zustand-selector-fanout
|
||||
|
||||
- name: Check reliability gate manifest
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
- name: Enforce dead design-system classes
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:dead-classes
|
||||
|
||||
- name: Check VM runtime rollback compatibility
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
@@ -264,25 +291,33 @@ jobs:
|
||||
config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts
|
||||
|
||||
- name: Enforce max-lines ratchet
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
- name: Enforce ts-nocheck ratchet
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:ts-nocheck-ratchet
|
||||
|
||||
- name: Enforce runtime Electron-import ratchet
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:runtime-electron-ratchet
|
||||
|
||||
- name: Check Node runtime pin
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run check:node-runtime-pin
|
||||
|
||||
# Why: extraction writes sorted evidence to an isolated temporary path,
|
||||
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
|
||||
- name: Verify localization extraction
|
||||
if: '!cancelled()'
|
||||
id: localization-extraction
|
||||
background: true
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
# Detection failures run the full check; renames retain the removed input path.
|
||||
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
|
||||
if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" &&
|
||||
@@ -296,6 +331,7 @@ jobs:
|
||||
# which is a property of the import graph. This proves the Node artifact it enables
|
||||
# actually boots, pairs, creates a worktree and round-trips a real PTY.
|
||||
- name: Boot orcad and round-trip a terminal
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
@@ -310,20 +346,30 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify the generated RPC params catalog
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:rpc-params-catalog
|
||||
|
||||
- name: Verify bundled skill guides
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:bundled-skill-guides
|
||||
|
||||
- name: Verify skill freshness manifest
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:skill-bundle-manifest
|
||||
|
||||
- name: Verify localization catalogs
|
||||
if: '!cancelled()'
|
||||
id: localization-catalogs
|
||||
background: true
|
||||
run: pnpm run verify:localization-catalogs
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
pnpm run verify:localization-catalogs
|
||||
|
||||
- name: Verify localization coverage
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm run verify:localization-coverage
|
||||
|
||||
- wait: [localization-catalogs, localization-extraction]
|
||||
@@ -334,6 +380,7 @@ jobs:
|
||||
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
|
||||
# past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts.
|
||||
- name: Guard against project-owned .d.ts in preload/shared
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: |
|
||||
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
|
||||
if [ -n "$matches" ]; then
|
||||
@@ -346,33 +393,19 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check feature wall asset budget
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm check:feature-wall-assets
|
||||
|
||||
- name: Verify macOS entitlements
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
run: pnpm verify:macos-entitlements
|
||||
|
||||
typecheck:
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
# Typechecking uses no native runtime, so it can use the free public ARM runner.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
shards: ${{ steps.unit-plan.outputs.shards }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets
|
||||
# the next run skip unchanged files. Share one cache entry across commits while the
|
||||
# PR base stays stable; actions/cache keeps the first successful graph and the
|
||||
# compiler still invalidates stale files from its content hashes.
|
||||
- name: Cache TypeScript incremental state
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: config/*.tsbuildinfo
|
||||
@@ -382,17 +415,24 @@ jobs:
|
||||
|
||||
# Planning shares setup and stays off the compiler's critical path.
|
||||
- name: Plan unit selection
|
||||
if: '!cancelled()'
|
||||
id: unit-plan
|
||||
background: true
|
||||
env:
|
||||
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }}
|
||||
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
|
||||
ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }}
|
||||
run: node config/scripts/ci-unit-plan.mjs
|
||||
run: |
|
||||
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
|
||||
node config/scripts/ci-unit-plan.mjs
|
||||
|
||||
- run: pnpm run typecheck
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
|
||||
- wait: unit-plan
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
with:
|
||||
name: unit-selection-attempt-${{ github.run_attempt }}
|
||||
path: ci-shards/unit-selection.json
|
||||
@@ -680,6 +720,7 @@ jobs:
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/main/fish-xdg-data-dirs-handoff.test.ts \
|
||||
src/main/shell-startup-feature-channel.test.ts \
|
||||
src/main/zsh-deferred-startup-line-init.live-shell.test.ts \
|
||||
src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
@@ -691,20 +732,19 @@ jobs:
|
||||
src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
# Static analysis saves the Node cache; typecheck publishes the plan before tests fan out.
|
||||
# Preflight saves the Node cache and publishes the plan before tests fan out.
|
||||
test:
|
||||
needs: [code_paths, static_analysis, typecheck]
|
||||
needs: [code_paths, preflight]
|
||||
# Cancellation and failed prerequisites stop the expensive matrix.
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.outputs.test == 'true' &&
|
||||
needs.static_analysis.result == 'success' &&
|
||||
needs.typecheck.result == 'success'
|
||||
needs.preflight.result == 'success'
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24"]'
|
||||
runner: ubuntu-24.04-arm
|
||||
shards: ${{ needs.typecheck.outputs.shards }}
|
||||
shards: ${{ needs.preflight.outputs.shards }}
|
||||
|
||||
# Why a sibling and not part of the test workflow: it is advisory, so it must not delay the
|
||||
# gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s
|
||||
@@ -902,7 +942,7 @@ jobs:
|
||||
|
||||
package:
|
||||
name: package
|
||||
needs: [code_paths, static_analysis, typecheck]
|
||||
needs: [code_paths, preflight]
|
||||
if: needs.code_paths.outputs.package == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Let the serial Docker gates reach their own deadlines and report cleanup failures.
|
||||
@@ -1059,7 +1099,7 @@ jobs:
|
||||
|
||||
package_windows:
|
||||
name: package (windows)
|
||||
needs: [code_paths, static_analysis, typecheck]
|
||||
needs: [code_paths, preflight]
|
||||
if: needs.code_paths.outputs.package_windows == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
@@ -1168,6 +1208,8 @@ jobs:
|
||||
src/main/runtime/unreadable-secret-store-preservation.win32.test.ts
|
||||
src/main/ipc/pty-codex-account-attribution.test.ts
|
||||
src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts
|
||||
src/main/ipc/preflight-provider-command-selection.test.ts
|
||||
src/main/ipc/preflight-runnable-local-cli.test.ts
|
||||
src/relay/windows-port-scan.win32.test.ts
|
||||
src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts
|
||||
src/main/ssh/remote-node-runtime-store-windows.test.ts
|
||||
@@ -1265,8 +1307,7 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
needs:
|
||||
- code_paths
|
||||
- static_analysis
|
||||
- typecheck
|
||||
- preflight
|
||||
- git_compatibility
|
||||
- codex_index_heal_contract
|
||||
- xterm_patch_sync
|
||||
@@ -1295,10 +1336,8 @@ jobs:
|
||||
env:
|
||||
CODE_PATHS: ${{ needs.code_paths.result }}
|
||||
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
|
||||
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
|
||||
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
|
||||
PREFLIGHT: ${{ needs.preflight.result }}
|
||||
PREFLIGHT_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
|
||||
CODEX_INDEX_HEAL_CONTRACT: ${{ needs.codex_index_heal_contract.result }}
|
||||
@@ -1344,8 +1383,7 @@ jobs:
|
||||
fi
|
||||
}
|
||||
# Require success when the PR has code-relevant changes
|
||||
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
|
||||
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
|
||||
check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"
|
||||
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
|
||||
check_job codex_index_heal_contract "$CODEX_INDEX_HEAL_CONTRACT" "$CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN"
|
||||
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Download APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [Download APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { createHash, createHmac } from 'node:crypto'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import {
|
||||
buildHostProofMacInput,
|
||||
HostChallengeSchema,
|
||||
HOST_CHALLENGE_PLAINTEXT_DOMAIN,
|
||||
RELAY_CLOSE_CODE
|
||||
} from '@orca-cloud/relay-contract'
|
||||
import nacl from 'tweetnacl'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type WebSocket from 'ws'
|
||||
import { RelayAssignmentStore } from './assignment-store.js'
|
||||
import { loadRelayConfig } from './config.js'
|
||||
import { RelayCredentialStore } from './credential-store.js'
|
||||
import type { RelayDatabase } from './database.js'
|
||||
import { HostSessionRegistry } from './host-session-registry.js'
|
||||
import type { RelayTokenClaims } from './relay-token-verifier.js'
|
||||
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
|
||||
|
||||
class ProofSocket extends EventEmitter {
|
||||
readonly OPEN = 1
|
||||
readonly CLOSING = 2
|
||||
readonly CLOSED = 3
|
||||
readyState = this.OPEN
|
||||
readonly send = vi.fn<(frame: string) => void>()
|
||||
readonly close = vi.fn((code?: number, reason?: string) => {
|
||||
this.readyState = this.CLOSED
|
||||
this.emit('close', code, Buffer.from(reason ?? ''))
|
||||
})
|
||||
|
||||
peerClose(): void {
|
||||
this.readyState = this.CLOSED
|
||||
this.emit('close', 1000, Buffer.alloc(0))
|
||||
}
|
||||
|
||||
registrySocket(): WebSocket {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fake implements the registry's send, state, close and EventEmitter surface; no actual networking is invoked.
|
||||
return this as unknown as WebSocket
|
||||
}
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const database: RelayDatabase = {
|
||||
query: vi.fn(async () => []),
|
||||
queryLocked: vi.fn(async () => []),
|
||||
transaction: (operation) => operation(database),
|
||||
close: async () => undefined
|
||||
}
|
||||
const config = loadRelayConfig({
|
||||
ORCA_RELAY_PUBLIC_URL: 'http://127.0.0.1',
|
||||
ORCA_RELAY_CELL_URL: 'http://127.0.0.1',
|
||||
ORCA_RELAY_AUTH_ISSUER: 'https://auth.example.test',
|
||||
ORCA_RELAY_JWKS_URL: 'https://auth.example.test/jwks',
|
||||
ORCA_RELAY_ASSIGNMENT_SIGNING_KEY: 'synthetic-assignment-key-for-test-only',
|
||||
ORCA_RELAY_ROLE: 'cell',
|
||||
ORCA_RELAY_ADMIN_AUDIENCE: 'https://auth.example.test/admin',
|
||||
ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'deploy@example.test',
|
||||
ORCA_RELAY_CELL_CONNECTION_HARD_CAP: '600',
|
||||
ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND: '60'
|
||||
})
|
||||
const assignments = new RelayAssignmentStore(database)
|
||||
const verify = vi.spyOn(assignments, 'verifyCellAssignment').mockResolvedValue(true)
|
||||
const activate = vi.spyOn(assignments, 'activateControl').mockResolvedValue('control:1')
|
||||
vi.spyOn(assignments, 'markMigrationTargetRegistered').mockResolvedValue(true)
|
||||
const recordAuth = vi.fn()
|
||||
const registry = new HostSessionRegistry(
|
||||
config,
|
||||
async () => null,
|
||||
new RelayCredentialStore(database),
|
||||
assignments,
|
||||
new ProcessQueuedByteBudget(),
|
||||
{
|
||||
recordAuth,
|
||||
recordForwardedBytes: vi.fn(),
|
||||
recordHttp: vi.fn(),
|
||||
recordReconnect: vi.fn(),
|
||||
recordSql: vi.fn()
|
||||
}
|
||||
)
|
||||
const keyPair = nacl.box.keyPair()
|
||||
const identity = {
|
||||
sub: 'user-proof',
|
||||
prof: 'profile-proof',
|
||||
relayHostId: createHash('sha256').update(keyPair.publicKey).digest('base64url').slice(0, 16),
|
||||
purpose: 'host-control',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600
|
||||
} satisfies RelayTokenClaims
|
||||
const hello = JSON.stringify({
|
||||
type: 'host-hello',
|
||||
v: 1,
|
||||
relayHostId: identity.relayHostId,
|
||||
assignmentEpoch: 1,
|
||||
hostPublicKeyB64: Buffer.from(keyPair.publicKey).toString('base64'),
|
||||
appVersion: 'test'
|
||||
})
|
||||
return { registry, verify, activate, recordAuth, database, identity, keyPair, hello }
|
||||
}
|
||||
|
||||
async function openProof(h: ReturnType<typeof fixture>, socket = new ProofSocket()) {
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
socket.emit('message', Buffer.from(h.hello), false)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(h.verify).toHaveBeenCalled()
|
||||
expect(socket.send).toHaveBeenCalledOnce()
|
||||
return socket
|
||||
}
|
||||
|
||||
function answerProof(socket: ProofSocket, keyPair: nacl.BoxKeyPair): void {
|
||||
const frame = socket.send.mock.calls[0]?.[0]
|
||||
if (frame === undefined) {
|
||||
throw new Error('missing challenge')
|
||||
}
|
||||
const parsed: unknown = JSON.parse(frame)
|
||||
if (parsed === null || typeof parsed !== 'object' || !('type' in parsed)) {
|
||||
throw new Error('invalid challenge frame')
|
||||
}
|
||||
const { type, ...fields } = parsed
|
||||
expect(type).toBe('host-challenge')
|
||||
const challenge = HostChallengeSchema.parse(fields)
|
||||
const plaintext = nacl.box.open(
|
||||
Buffer.from(challenge.ciphertextB64, 'base64'),
|
||||
Buffer.from(challenge.nonceB64, 'base64'),
|
||||
Buffer.from(challenge.relayEphemeralPublicKeyB64, 'base64'),
|
||||
keyPair.secretKey
|
||||
)
|
||||
if (plaintext === null) {
|
||||
throw new Error('challenge did not decrypt')
|
||||
}
|
||||
const domain = new TextEncoder().encode(`${HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`)
|
||||
expect(plaintext.subarray(0, domain.length)).toEqual(domain)
|
||||
const transcriptLength = new DataView(
|
||||
plaintext.buffer,
|
||||
plaintext.byteOffset + domain.length,
|
||||
4
|
||||
).getUint32(0, false)
|
||||
const transcriptStart = domain.length + 4
|
||||
const transcript = plaintext.subarray(transcriptStart, transcriptStart + transcriptLength)
|
||||
const secret = plaintext.subarray(transcriptStart + transcriptLength)
|
||||
const proofB64 = createHmac('sha256', secret)
|
||||
.update(buildHostProofMacInput(transcript))
|
||||
.digest('base64')
|
||||
socket.emit(
|
||||
'message',
|
||||
Buffer.from(
|
||||
JSON.stringify({ type: 'host-challenge-ack', challengeId: challenge.challengeId, proofB64 })
|
||||
),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('host control proof cleanup', () => {
|
||||
it('allocates no hello stage for an already closed peer', () => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
socket.peerClose()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('message')).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
expect(h.verify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the host hello timer and listeners when its peer closes early', () => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
expect(socket.listenerCount('message')).toBe(1)
|
||||
socket.peerClose()
|
||||
expect({
|
||||
timers: vi.getTimerCount(),
|
||||
message: socket.listenerCount('message'),
|
||||
close: socket.listenerCount('close')
|
||||
}).toEqual({ timers: 0, message: 0, close: 0 })
|
||||
vi.advanceTimersByTime(2000)
|
||||
expect(socket.close).not.toHaveBeenCalled()
|
||||
expect(h.verify).not.toHaveBeenCalled()
|
||||
expect(h.database.query).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the exact hello deadline and refusal while releasing its message listener', () => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
vi.advanceTimersByTime(1999)
|
||||
expect(socket.close).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
|
||||
'host hello timeout'
|
||||
)
|
||||
expect(socket.listenerCount('message')).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('releases the challenge timer and listeners when its peer closes before proof', async () => {
|
||||
const h = fixture()
|
||||
const socket = await openProof(h)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
expect(socket.listenerCount('message')).toBe(1)
|
||||
socket.peerClose()
|
||||
expect({
|
||||
timers: vi.getTimerCount(),
|
||||
message: socket.listenerCount('message'),
|
||||
close: socket.listenerCount('close')
|
||||
}).toEqual({ timers: 0, message: 0, close: 0 })
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(socket.close).not.toHaveBeenCalled()
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
expect(h.database.query).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the exact proof deadline and refusal with no leftover listener', async () => {
|
||||
const h = fixture()
|
||||
const socket = await openProof(h)
|
||||
await vi.advanceTimersByTimeAsync(9999)
|
||||
expect(socket.close).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
|
||||
'host proof timeout'
|
||||
)
|
||||
expect(socket.listenerCount('message')).toBe(0)
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
expect(h.recordAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does no challenge crypto, send, timer or registration after a closed peer finishes verification', async () => {
|
||||
const h = fixture()
|
||||
let finish!: (valid: boolean) => void
|
||||
h.verify.mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const generateKey = vi.spyOn(nacl.box, 'keyPair')
|
||||
const socket = new ProofSocket()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
socket.emit('message', Buffer.from(h.hello), false)
|
||||
expect(h.verify).toHaveBeenCalledOnce()
|
||||
socket.peerClose()
|
||||
finish(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(socket.send).not.toHaveBeenCalled()
|
||||
expect(generateKey).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('message')).toBe(0)
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
expect(h.database.query).not.toHaveBeenCalled()
|
||||
expect(
|
||||
h.registry.get({ userId: h.identity.sub, relayHostId: h.identity.relayHostId })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it.each([false, true])('preserves invalid first-frame refusal (binary=%s)', (binary) => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
socket.emit('message', Buffer.from('{}'), binary)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
|
||||
binary ? 'host hello must be text' : 'invalid host hello'
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves invalid proof authentication failure (binary=%s)',
|
||||
async (binary) => {
|
||||
const h = fixture()
|
||||
const socket = await openProof(h)
|
||||
socket.emit('message', Buffer.from('{}'), binary)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
|
||||
'invalid host proof'
|
||||
)
|
||||
expect(h.recordAuth).toHaveBeenCalledExactlyOnceWith(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('allocates no proof wait when sending the challenge closes its peer', async () => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
socket.send.mockImplementation(() => socket.peerClose())
|
||||
await openProof(h, socket)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('message')).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
expect(h.activate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the existing diagnostic and refusal when challenge send throws', async () => {
|
||||
const h = fixture()
|
||||
const socket = new ProofSocket()
|
||||
socket.send.mockImplementation(() => {
|
||||
throw new Error('synthetic send failure')
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
await openProof(h, socket)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.LIMIT_EXCEEDED,
|
||||
'relay temporarily unavailable'
|
||||
)
|
||||
expect(warn).toHaveBeenCalledExactlyOnceWith(
|
||||
'[orca-relay] host hello proof failed: synthetic send failure'
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('contains assignment lookup rejection with its existing close and diagnostic', async () => {
|
||||
const h = fixture()
|
||||
h.verify.mockRejectedValueOnce(new Error('synthetic lookup failure'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const socket = new ProofSocket()
|
||||
h.registry.acceptControl(socket.registrySocket(), h.identity)
|
||||
socket.emit('message', Buffer.from(h.hello), false)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(socket.close).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_CLOSE_CODE.LIMIT_EXCEEDED,
|
||||
'relay temporarily unavailable'
|
||||
)
|
||||
expect(warn).toHaveBeenCalledExactlyOnceWith(
|
||||
'[orca-relay] host hello proof failed: synthetic lookup failure'
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a newer same-host peer live when the old proof peer closes', async () => {
|
||||
const h = fixture()
|
||||
const oldPeer = await openProof(h)
|
||||
const replacement = await openProof(h)
|
||||
oldPeer.peerClose()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
answerProof(replacement, h.keyPair)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(h.activate).toHaveBeenCalledOnce()
|
||||
expect(h.recordAuth).toHaveBeenCalledExactlyOnceWith(true)
|
||||
expect(
|
||||
h.registry.get({ userId: h.identity.sub, relayHostId: h.identity.relayHostId })?.socket
|
||||
).toBe(replacement)
|
||||
expect(replacement.send).toHaveBeenCalledTimes(2)
|
||||
expect(replacement.listenerCount('message')).toBe(1)
|
||||
expect(replacement.listenerCount('close')).toBe(2)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(oldPeer.close).not.toHaveBeenCalled()
|
||||
expect(replacement.close).not.toHaveBeenCalled()
|
||||
h.registry.drain(0)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -160,6 +160,30 @@ function send(socket: WebSocket, type: string, message: object): void {
|
||||
socket.send(JSON.stringify({ type, ...message }))
|
||||
}
|
||||
|
||||
function readControlFrame(
|
||||
socket: WebSocket,
|
||||
timeoutMs: number,
|
||||
timeoutReason: string,
|
||||
receive: (raw: RawData, isBinary: boolean) => void
|
||||
): void {
|
||||
if (socket.readyState !== socket.OPEN) return
|
||||
const timer = setTimeout(() => {
|
||||
finish()
|
||||
socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, timeoutReason)
|
||||
}, timeoutMs)
|
||||
function finish(): void {
|
||||
clearTimeout(timer)
|
||||
socket.off('message', onMessage)
|
||||
socket.off('close', finish)
|
||||
}
|
||||
function onMessage(raw: RawData, isBinary: boolean): void {
|
||||
finish()
|
||||
receive(raw, isBinary)
|
||||
}
|
||||
socket.once('message', onMessage)
|
||||
socket.once('close', finish)
|
||||
}
|
||||
|
||||
// Hosts abandon connects after 15s; waiting much longer than that behind a
|
||||
// stalled predecessor only accumulates doomed sockets.
|
||||
const ACTIVATION_QUEUE_WAIT_MS = 30_000
|
||||
@@ -794,12 +818,7 @@ export class HostSessionRegistry {
|
||||
socket.close(RELAY_CLOSE_CODE.DRAINING, 'relay draining')
|
||||
return
|
||||
}
|
||||
let firstFrameTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host hello timeout')
|
||||
}, 2_000)
|
||||
socket.once('message', (raw, isBinary) => {
|
||||
if (firstFrameTimer) clearTimeout(firstFrameTimer)
|
||||
firstFrameTimer = null
|
||||
readControlFrame(socket, 2_000, 'host hello timeout', (raw, isBinary) => {
|
||||
if (isBinary) {
|
||||
socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host hello must be text')
|
||||
return
|
||||
@@ -991,6 +1010,7 @@ export class HostSessionRegistry {
|
||||
socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'wrong assignment epoch')
|
||||
return
|
||||
}
|
||||
if (socket.readyState !== socket.OPEN) return
|
||||
|
||||
const key = this.key(identity.sub, identity.relayHostId)
|
||||
const existing = this.sessions.get(key)
|
||||
@@ -1035,11 +1055,7 @@ export class HostSessionRegistry {
|
||||
ciphertextB64: Buffer.from(ciphertext).toString('base64'),
|
||||
expiresAt
|
||||
})
|
||||
const proofTimer = setTimeout(() => {
|
||||
socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host proof timeout')
|
||||
}, 10_000)
|
||||
socket.once('message', (raw, isBinary) => {
|
||||
clearTimeout(proofTimer)
|
||||
readControlFrame(socket, 10_000, 'host proof timeout', (raw, isBinary) => {
|
||||
const ack = isBinary
|
||||
? null
|
||||
: HostChallengeAckSchema.safeParse(payload(raw, 'host-challenge-ack'))
|
||||
|
||||
@@ -122,10 +122,15 @@ function assertNoElectronRequire(
|
||||
entryName: string,
|
||||
entry: OutputChunk,
|
||||
byFileName: Map<string, OutputChunk>,
|
||||
electronFreeChunkCode: Map<OutputChunk, string>,
|
||||
runtime: EntryRuntime = 'plain-Node process'
|
||||
): void {
|
||||
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
||||
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
|
||||
const code = chunk.code
|
||||
if (electronFreeChunkCode.get(chunk) === code) {
|
||||
continue
|
||||
}
|
||||
if (ELECTRON_REQUIRE_RE.test(code)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
|
||||
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
|
||||
@@ -133,6 +138,7 @@ function assertNoElectronRequire(
|
||||
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
|
||||
)
|
||||
}
|
||||
electronFreeChunkCode.set(chunk, code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,17 +278,30 @@ export function createPlainNodeEntryGuardPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
const electronFreeChunkCode = new Map<OutputChunk, string>()
|
||||
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process')
|
||||
assertNoElectronRequire(
|
||||
entryName,
|
||||
entry,
|
||||
byFileName,
|
||||
electronFreeChunkCode,
|
||||
'plain-Node process'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'worker thread')
|
||||
assertNoElectronRequire(
|
||||
entryName,
|
||||
entry,
|
||||
byFileName,
|
||||
electronFreeChunkCode,
|
||||
'worker thread'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'node-pty',
|
||||
'posthog-node',
|
||||
'proper-lockfile',
|
||||
// serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too.
|
||||
'serve-sim',
|
||||
'qrcode',
|
||||
'ssh2',
|
||||
'tweetnacl',
|
||||
@@ -34,6 +32,9 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'yaml',
|
||||
'zod'
|
||||
]
|
||||
// Why macOS only: serve-sim drives the iOS Simulator, and its native addon is a Mach-O that
|
||||
// Windows signing rejects as a PE file.
|
||||
const DARWIN_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['serve-sim']
|
||||
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'@vscode/windows-process-tree',
|
||||
'@orca/windows-registry'
|
||||
@@ -180,6 +181,7 @@ function collectPackagedRuntimePackages(electronPlatformName = process.platform)
|
||||
// Why: cross-builds must select native dependencies from the artifact target, not the build host.
|
||||
const packageRoots = [
|
||||
...PACKAGED_RUNTIME_PACKAGE_ROOTS,
|
||||
...(electronPlatformName === 'darwin' ? DARWIN_PACKAGED_RUNTIME_PACKAGE_ROOTS : []),
|
||||
...(electronPlatformName === 'win32' ? WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS : [])
|
||||
]
|
||||
for (const packageName of packageRoots) {
|
||||
|
||||
@@ -21411,6 +21411,90 @@
|
||||
"A tombstone that exhausts its retries stays on disk until the next startup, unchanged from before."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if the reused listing strands a displaced root, crosses the admission cap, rearms an exhausted retry through another root, hands one tombstone to removal twice, or touches a recreated live history path."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.consumed-side-effect-retention",
|
||||
"title": "Terminal side-effect queues release successfully applied and evicted effects",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "renderer-unit",
|
||||
"surfaces": ["terminal output side effects", "renderer memory census"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "Provider-independent queue and mocked IPC output contracts run on macOS. Remote-runtime uses this processor but has no live session run. Native mobile uses another processor and is unaffected; host ownership, ACKs, wire, paths, folder/git identity, PTY lifecycle and output bytes are unchanged. Linux, Windows, WSL and live remote execution are gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/blob/main/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts"
|
||||
],
|
||||
"invariant": "Release consumed title/payload objects after successful apply or overflow carry, preserving callback order, 64-effect drains, the 512-effect pending cap, bell and payload carry, empty-tail coalescing, reentrant clear/flush/enqueue, thrown-apply behavior and output delivery.",
|
||||
"oracle": "Forced GC collects all 64 applied effects from a 100-effect bounded drain while the remaining 36 stay alive and deliver in order; it also collects the first evicted effect in a 513-effect burst while all 512 survivors remain alive. Clearing during apply immediately releases all 99 other pending effects, as the original queue did. Census reports 36 retained objects after the bounded drain. Explicit reentrant and error cases produce the same observations against the original queue.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts",
|
||||
"assertions": [
|
||||
"releases applied effects while preserving every pending effect and its delivery order",
|
||||
"releases an evicted effect before the compaction threshold",
|
||||
"releases every pending effect immediately when clear is called during apply"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts",
|
||||
"assertions": [
|
||||
"keeps empty-tail coalescing observable during the apply callback",
|
||||
"delivers the same effect requeued after clear without releasing its new slot",
|
||||
"preserves nested flush order and effects enqueued after the inner compaction",
|
||||
"preserves thrown apply errors and their existing empty-tail coalescing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-10-01",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.66,
|
||||
"summary": "46 tests across five files passed. Four reentrant/error cases also pass against an isolated original-queue copy; stock and candidate both release 99 pending effects when clear runs during apply."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "Focused renderer queue/output tests, including forced GC; p95 not established."
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Local author and independent review validation; no CI soak. Reference-lifetime tests require the test runner's existing --expose-gc."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Original queue fails both WeakRef collection assertions: all 64 applied effects and the evicted effect remain reachable. Clearing only consumed slots passes while all pending effects stay live. Baseline and candidate both pass all four reentrant/error observations."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Retained objects fall from 100 to 36 after one bounded drain and from 513 to 512 after one overflow eviction before compaction. Release is constant work per consumed effect; no changed drain limit, timer, polling, batching, cache, transport call or subprocess. Existing compaction cadence remains; clear truncates then replaces its backing array to preserve immediate release and protect reentrant same-object requeue."
|
||||
},
|
||||
"knownGaps": [
|
||||
"No real renderer heap-byte or input-latency measurement; references and complete delivery are the deterministic oracle.",
|
||||
"No live Linux, Windows, WSL, SSH or paired-runtime session run; these use provider-independent queue code.",
|
||||
"Thrown apply callbacks keep their consumed reference until the original compaction boundary to preserve exception/coalescing behavior."
|
||||
],
|
||||
"promotionCriteria": [
|
||||
"Collect CI soak with zero unexplained GC flakes and retain callback-order, overflow-carry and reentrancy assertions."
|
||||
],
|
||||
"demotionRule": "Keep experimental; investigate delivery, coalescing, error-path or pending-reference regressions without weakening the retention or fidelity oracle."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('committed pin', () => {
|
||||
|
||||
it('runs in the static analysis job', () => {
|
||||
const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const commands = workflow.jobs.static_analysis.steps.map((step) => step.run ?? '')
|
||||
const commands = workflow.jobs.preflight.steps.map((step) => step.run ?? '')
|
||||
expect(commands).toContain('pnpm run check:node-runtime-pin')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -156,8 +156,7 @@ describe('README local link check', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Why the ungated job: static_analysis is skipped for docs-only diffs, which is
|
||||
// exactly the kind of PR that deletes a docs-site GIF the README embeds.
|
||||
// Docs-only diffs skip preflight, so the detector must check README links.
|
||||
it('runs on every PR through the ungated detector and in the lint script', () => {
|
||||
const { scripts } = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8'))
|
||||
const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
|
||||
@@ -20,8 +20,7 @@ function assertJoinedBefore(steps, id, consumer) {
|
||||
describe('CI background step barriers', () => {
|
||||
it('joins every background check without suppressing failures', () => {
|
||||
for (const job of [
|
||||
pr.jobs.static_analysis,
|
||||
pr.jobs.typecheck,
|
||||
pr.jobs.preflight,
|
||||
pr.jobs.mobile_web_app,
|
||||
pr.jobs.package,
|
||||
pr.jobs.shell_contracts,
|
||||
@@ -52,7 +51,7 @@ describe('CI background step barriers', () => {
|
||||
|
||||
it('joins planning before publishing the unit artifact', () => {
|
||||
assertJoinedBefore(
|
||||
pr.jobs.typecheck.steps,
|
||||
pr.jobs.preflight.steps,
|
||||
'unit-plan',
|
||||
(step) => step.uses === 'actions/upload-artifact@v7'
|
||||
)
|
||||
@@ -88,7 +87,7 @@ describe('CI background step barriers', () => {
|
||||
})
|
||||
|
||||
it('finishes native import-cycle analysis before mobile installation changes resolution', () => {
|
||||
const steps = pr.jobs.static_analysis.steps
|
||||
const steps = pr.jobs.preflight.steps
|
||||
assertJoinedBefore(steps, 'native-code-quality', (step) =>
|
||||
step.uses?.endsWith('/install-mobile-dependencies')
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ it('warms the same Linux Node runtime the PR shards restore', () => {
|
||||
const install = arm.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const primer = readWorkflow('pr').jobs.static_analysis
|
||||
const primer = readWorkflow('pr').jobs.preflight
|
||||
expect(arm['runs-on']).toBe(primer['runs-on'])
|
||||
expect(arm.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only')
|
||||
expect(install.with).toMatchObject(primer.steps.find((step) => step.uses === install.uses).with)
|
||||
@@ -51,7 +51,7 @@ it('populates shared Electron archives on both Linux architectures without chang
|
||||
|
||||
it('publishes incremental state under a key and prefix that new PRs restore', () => {
|
||||
const cache = steps.find((step) => step.id === 'typecheck-cache')
|
||||
const prCache = readWorkflow('pr').jobs.typecheck.steps.find((step) => step.name === cache.name)
|
||||
const prCache = readWorkflow('pr').jobs.preflight.steps.find((step) => step.name === cache.name)
|
||||
expect(cache.with.path).toBe(prCache.with.path)
|
||||
expect(cache.with['restore-keys']).toBe(prCache.with['restore-keys'])
|
||||
expect(cache.with.key).toBe(
|
||||
|
||||
@@ -23,6 +23,7 @@ export const UNIT_EXCLUDE = [
|
||||
'src/main/pty/omp-shell-wrapper-alias-safety.test.ts',
|
||||
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
|
||||
'src/main/shell-startup-feature-channel.test.ts',
|
||||
'src/main/zsh-deferred-startup-line-init.live-shell.test.ts',
|
||||
'src/main/terminal-history-fish-session.node-pty.test.ts',
|
||||
'src/main/zsh-scoped-histfile.live-shell.test.ts',
|
||||
'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
|
||||
|
||||
@@ -242,6 +242,23 @@ describe('electron-builder config', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Why: serve-sim's addon is a Mach-O, and Windows signing rejects every *.node that is not PE.
|
||||
it('keeps serve-sim out of the Windows and Linux runtime closures', () => {
|
||||
const {
|
||||
PACKAGED_RUNTIME_PACKAGE_ROOTS,
|
||||
createPackagedRuntimeNodeModuleResources
|
||||
} = require('../packaged-runtime-node-modules.cjs')
|
||||
expect(PACKAGED_RUNTIME_PACKAGE_ROOTS).not.toContain('serve-sim')
|
||||
const serveSimTarget = join('node_modules', 'serve-sim')
|
||||
expect(createPackagedRuntimeNodeModuleResources('linux').map((r) => r.to)).not.toContain(
|
||||
serveSimTarget
|
||||
)
|
||||
expect(electronBuilderConfig.linux.extraResources.map((r) => r.to)).not.toContain(
|
||||
serveSimTarget
|
||||
)
|
||||
expect(electronBuilderConfig.win.extraResources.map((r) => r.to)).not.toContain(serveSimTarget)
|
||||
})
|
||||
|
||||
// Why: the Windows CLI shim is delivered only via extraResources to
|
||||
// resources/bin/orca.cmd (beside the native resources/bin/orca.exe). If the
|
||||
// source tree is also packed into app.asar it gets extracted by
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type * as ReactModule from 'react'
|
||||
import type * as GhUtils from '../../src/main/github/gh-utils'
|
||||
import type * as IssueMetadata from '../../src/renderer/src/hooks/useIssueMetadata'
|
||||
import type { GitHubWorkItem } from '../../src/shared/github/work-item-types'
|
||||
import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types'
|
||||
import type { TaskSourceContext } from '../../src/shared/task-source-context'
|
||||
import type { Repo } from '../../src/shared/repo-types'
|
||||
|
||||
const fixture = vi.hoisted(() => {
|
||||
const state: {
|
||||
loads: { key: string | null; load: () => Promise<unknown[]> }[]
|
||||
requests: { args: string[]; host?: string }[]
|
||||
gh: ReturnType<typeof vi.fn>
|
||||
apiUpdate: ReturnType<typeof vi.fn>
|
||||
preference: 'origin' | 'upstream'
|
||||
} = { loads: [], requests: [], gh: vi.fn(), apiUpdate: vi.fn(), preference: 'upstream' }
|
||||
return state
|
||||
})
|
||||
|
||||
vi.mock('react', async (original) => ({
|
||||
...(await original<typeof ReactModule>()),
|
||||
useState: (initial: unknown) => [typeof initial === 'function' ? initial() : initial, vi.fn()],
|
||||
useMemo: <T>(value: () => T) => value(),
|
||||
useCallback: <T>(value: T) => value,
|
||||
useRef: <T>(initial: T) => ({ current: initial }),
|
||||
useEffect: vi.fn()
|
||||
}))
|
||||
vi.mock('zustand/react/shallow', () => ({ useShallow: <T>(value: T) => value }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: unknown) => unknown) =>
|
||||
selector({ patchWorkItem: vi.fn(), patchProjectRowContent: vi.fn() }),
|
||||
{ getState: () => ({ recordFeatureInteraction: vi.fn() }) }
|
||||
)
|
||||
}))
|
||||
vi.mock('@/lib/repo-runtime-owner', () => ({
|
||||
getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null })
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({
|
||||
useGitHubDuplicateIssueCandidates: () => []
|
||||
}))
|
||||
vi.mock('@/components/github/github-work-item-comment-mutations', () => ({
|
||||
notifyWorkItemDetailsMutation: vi.fn()
|
||||
}))
|
||||
vi.mock('@/hooks/useIssueMetadata', async (original) => ({
|
||||
...(await original<typeof IssueMetadata>()),
|
||||
useImmediateMutation: () => ({ isPending: () => false, run: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/hooks/useMetadataListRequest', () => ({
|
||||
useMetadataListRequest: <T>(args: { cacheKey: string | null; load: () => Promise<T[]> }) => {
|
||||
fixture.loads.push({ key: args.cacheKey, load: args.load })
|
||||
return { data: [], loading: false, error: null }
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/github/gh-utils', async (original) => ({
|
||||
...(await original<typeof GhUtils>()),
|
||||
ghExecFileAsync: fixture.gh,
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn(),
|
||||
getOwnerRepoForRemote: async (_path: string, remote: string) => ({
|
||||
owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner',
|
||||
repo: 'widgets'
|
||||
})
|
||||
}))
|
||||
vi.mock('../../src/main/git/remote-name-listing', () => ({
|
||||
shouldProbeGitRemote: async () => true
|
||||
}))
|
||||
|
||||
import { GHEditSection } from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section'
|
||||
import {
|
||||
runGHEditLabelToggle,
|
||||
runGHEditStateChange
|
||||
} from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations'
|
||||
import { findTaskPageDialogWorkItem } from '../../src/renderer/src/components/task-page-cache-selectors'
|
||||
import { getTaskPageRepoSourceContext } from '../../src/renderer/src/components/task-page-source-context'
|
||||
import { workItemsCacheKey } from '../../src/renderer/src/store/github/cache-identity'
|
||||
import { createTestStore } from '../../src/renderer/src/store/slices/github-slice-test-harness'
|
||||
import { getTaskSourceCacheScope } from '../../src/shared/task-source-context'
|
||||
import { listLabels, listAssignableUsers } from '../../src/main/github/issue-field-options'
|
||||
import { useRepoLabels, useRepoAssignees } from '../../src/renderer/src/hooks/useIssueMetadata'
|
||||
import { updateIssue } from '../../src/main/github/issue-update'
|
||||
import { materializeTaskPageItemList } from '../../src/renderer/src/components/task-page-github-work-item-mutations'
|
||||
import {
|
||||
resetTaskPageGitHubMutationRegistryForTests,
|
||||
setTaskPageGitHubMutationQueryKey
|
||||
} from '../../src/renderer/src/components/task-page-github-work-item-mutation-registry'
|
||||
|
||||
function renderEditSection(props: Parameters<typeof GHEditSection>[0]): void {
|
||||
renderToStaticMarkup(createElement(GHEditSection, props))
|
||||
}
|
||||
|
||||
const registeredRepo: Repo = {
|
||||
id: 'repo-1',
|
||||
path: join(tmpdir(), 'orca-opened-issue-repository-fixture'),
|
||||
displayName: 'widgets',
|
||||
badgeColor: 'primary',
|
||||
addedAt: 1,
|
||||
upstream: { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' }
|
||||
}
|
||||
|
||||
function sourceFor(preference: 'origin' | 'upstream'): TaskSourceContext {
|
||||
const source = getTaskPageRepoSourceContext(
|
||||
{ ...registeredRepo, issueSourcePreference: preference },
|
||||
'github'
|
||||
)
|
||||
if (!source) {
|
||||
throw new Error('Registered fixture must produce a source context')
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
const sourceContext = sourceFor('origin')
|
||||
const fork: GitHubWorkItem = {
|
||||
id: 'issue:5',
|
||||
type: 'issue',
|
||||
number: 5,
|
||||
title: 'FORK title',
|
||||
state: 'open',
|
||||
url: 'https://github.com/fork-owner/widgets/issues/5',
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null,
|
||||
repoId: 'repo-1'
|
||||
}
|
||||
const upstream: GitHubWorkItem = {
|
||||
...fork,
|
||||
title: 'UPSTREAM title',
|
||||
url: 'https://github.com/upstream-owner/widgets/issues/5'
|
||||
}
|
||||
const issueRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
|
||||
|
||||
type MetadataArgs = { repoPath: string; ownerRepo?: GitHubOwnerRepo }
|
||||
|
||||
beforeEach(() => {
|
||||
fixture.preference = 'upstream'
|
||||
fixture.loads = []
|
||||
fixture.requests = []
|
||||
resetTaskPageGitHubMutationRegistryForTests()
|
||||
setTaskPageGitHubMutationQueryKey('current-upstream-list')
|
||||
fixture.gh.mockReset()
|
||||
fixture.gh.mockImplementation(async (args: string[], options: { host?: string }) => {
|
||||
fixture.requests.push({ args, host: options.host })
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
fixture.apiUpdate = vi.fn((args: Parameters<typeof window.api.gh.updateIssue>[0]) =>
|
||||
updateIssue(
|
||||
args.repoPath,
|
||||
args.number,
|
||||
args.updates,
|
||||
null,
|
||||
{},
|
||||
fixture.preference,
|
||||
args.ownerRepo
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
gh: {
|
||||
updateIssue: fixture.apiUpdate,
|
||||
listLabels: (args: MetadataArgs) =>
|
||||
listLabels(args.repoPath, fixture.preference, null, {}, args.ownerRepo),
|
||||
listAssignableUsers: (args: MetadataArgs) =>
|
||||
listAssignableUsers(args.repoPath, fixture.preference, null, {}, args.ownerRepo)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetTaskPageGitHubMutationRegistryForTests()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ openedItem: fork, listItem: upstream, preference: 'upstream' },
|
||||
{ openedItem: upstream, listItem: fork, preference: 'origin' },
|
||||
{ openedItem: fork, listItem: fork, preference: 'origin' }
|
||||
] as const)(
|
||||
'scopes $openedItem.title labels under $preference to its canonical list row',
|
||||
async ({ openedItem, listItem, preference }) => {
|
||||
fixture.preference = preference
|
||||
expect(sourceFor('upstream')).toEqual(sourceContext)
|
||||
const store = createTestStore()
|
||||
const key = workItemsCacheKey(
|
||||
registeredRepo.id,
|
||||
36,
|
||||
'',
|
||||
getTaskSourceCacheScope(sourceFor('upstream'))
|
||||
)
|
||||
store.setState({
|
||||
workItemsCache: { [key]: { data: [listItem], fetchedAt: Date.now() } }
|
||||
})
|
||||
const opened =
|
||||
findTaskPageDialogWorkItem(store.getState().workItemsCache, {
|
||||
id: openedItem.id,
|
||||
repoId: openedItem.repoId,
|
||||
url: openedItem.url
|
||||
}) ?? openedItem
|
||||
expect(opened.url).toBe(openedItem.url)
|
||||
const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' }
|
||||
let mutation: Promise<unknown> = Promise.resolve()
|
||||
runGHEditLabelToggle({
|
||||
itemId: opened.id,
|
||||
itemNumber: opened.number,
|
||||
itemRepoId: opened.repoId,
|
||||
repoPath: registeredRepo.path,
|
||||
sourceContext,
|
||||
projectOrigin: undefined,
|
||||
issueRepo: target,
|
||||
label: 'fork-only-label',
|
||||
localLabels: [],
|
||||
run: async (_key, options) => {
|
||||
options.onOptimistic?.()
|
||||
mutation = options.mutate()
|
||||
await mutation
|
||||
options.onSuccess?.()
|
||||
return true
|
||||
},
|
||||
onLabelsChange: vi.fn(),
|
||||
patchWorkItem: store.getState().patchWorkItem,
|
||||
patchProjectRowIfNeeded: vi.fn(),
|
||||
onMutated: vi.fn()
|
||||
})
|
||||
await mutation
|
||||
expect(fixture.requests[0].args).toContain(`${target.owner}/widgets`)
|
||||
expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target }))
|
||||
expect(store.getState().workItemsCache[key]?.data?.[0].labels).toEqual(
|
||||
listItem.url === openedItem.url ? ['fork-only-label'] : []
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ openedItem: fork, owner: 'fork-owner', preference: 'origin' },
|
||||
{ openedItem: fork, owner: 'fork-owner', preference: 'upstream' },
|
||||
{ openedItem: upstream, owner: 'upstream-owner', preference: 'origin' },
|
||||
{ openedItem: upstream, owner: 'upstream-owner', preference: 'upstream' }
|
||||
] as const)(
|
||||
'loads $owner picker candidates while preference=$preference',
|
||||
async ({ preference, openedItem, owner }) => {
|
||||
fixture.preference = preference
|
||||
renderEditSection({
|
||||
item: openedItem,
|
||||
repoPath: registeredRepo.path,
|
||||
repoId: fork.repoId,
|
||||
sourceContext,
|
||||
projectOrigin: undefined,
|
||||
localState: 'open',
|
||||
localLabels: [],
|
||||
assignees: [],
|
||||
onStateChange: vi.fn(),
|
||||
onLabelsChange: vi.fn(),
|
||||
onMutated: vi.fn(),
|
||||
onUse: vi.fn()
|
||||
})
|
||||
for (const request of fixture.loads.filter((load) => load.key !== null)) {
|
||||
await request.load()
|
||||
}
|
||||
expect(
|
||||
fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/')))
|
||||
).toEqual([`repos/${owner}/widgets/labels`, `repos/${owner}/widgets/assignees?per_page=100`])
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ openedItem: fork, listItem: fork },
|
||||
{ openedItem: fork, listItem: upstream },
|
||||
{ openedItem: upstream, listItem: fork },
|
||||
{ openedItem: upstream, listItem: upstream }
|
||||
])(
|
||||
'a $openedItem.title close only controls its own row while search lags (list=$listItem.title)',
|
||||
async ({ openedItem, listItem }) => {
|
||||
const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' }
|
||||
let pending = Promise.resolve()
|
||||
runGHEditStateChange({
|
||||
newState: 'closed',
|
||||
localState: 'open',
|
||||
itemId: fork.id,
|
||||
itemNumber: fork.number,
|
||||
itemRepoId: fork.repoId,
|
||||
repoPath: registeredRepo.path,
|
||||
sourceContext,
|
||||
projectOrigin: undefined,
|
||||
issueRepo: target,
|
||||
run: (_key, options) => {
|
||||
pending = (async () => {
|
||||
options.onOptimistic?.()
|
||||
await options.mutate()
|
||||
options.onSuccess?.()
|
||||
})()
|
||||
return pending
|
||||
},
|
||||
onStateChange: vi.fn(),
|
||||
patchWorkItem: vi.fn(),
|
||||
patchProjectRowIfNeeded: vi.fn(),
|
||||
onMutated: vi.fn()
|
||||
})
|
||||
await pending
|
||||
expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target }))
|
||||
const displayed = materializeTaskPageItemList({
|
||||
networkItems: [listItem],
|
||||
previousItems: [listItem],
|
||||
queryKey: 'current-upstream-list'
|
||||
})
|
||||
expect(displayed[0]?.state).toBe(listItem.url === openedItem.url ? 'closed' : 'open')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps ordinary metadata caches distinct by canonical repository and host', () => {
|
||||
const identities = [
|
||||
issueRepo,
|
||||
{ ...issueRepo, owner: 'upstream-owner' },
|
||||
{ ...issueRepo, host: 'ghe.example:8443' }
|
||||
]
|
||||
for (const ownerRepo of identities) {
|
||||
useRepoLabels(registeredRepo.path, registeredRepo.id, { ownerRepo })
|
||||
useRepoAssignees(registeredRepo.path, registeredRepo.id, { ownerRepo })
|
||||
}
|
||||
expect(new Set(fixture.loads.filter((_, i) => i % 2 === 0).map((load) => load.key)).size).toBe(3)
|
||||
expect(new Set(fixture.loads.filter((_, i) => i % 2 === 1).map((load) => load.key)).size).toBe(3)
|
||||
})
|
||||
|
||||
it('keeps metadata requests without an explicit target compatible', async () => {
|
||||
useRepoLabels(registeredRepo.path, registeredRepo.id)
|
||||
useRepoAssignees(registeredRepo.path, registeredRepo.id)
|
||||
for (const request of fixture.loads) {
|
||||
await request.load()
|
||||
}
|
||||
expect(fixture.loads.map((load) => load.key)).toEqual([registeredRepo.id, registeredRepo.id])
|
||||
expect(
|
||||
fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/')))
|
||||
).toEqual([
|
||||
'repos/upstream-owner/widgets/labels',
|
||||
'repos/upstream-owner/widgets/assignees?per_page=100'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps Project row metadata on the existing slug route', async () => {
|
||||
const labels = vi.fn().mockResolvedValue({ ok: true, labels: [] })
|
||||
const users = vi.fn().mockResolvedValue({ ok: true, users: [] })
|
||||
vi.stubGlobal('window', {
|
||||
api: { gh: { listLabelsBySlug: labels, listAssignableUsersBySlug: users } }
|
||||
})
|
||||
renderEditSection({
|
||||
item: fork,
|
||||
repoPath: registeredRepo.path,
|
||||
repoId: fork.repoId,
|
||||
sourceContext,
|
||||
projectOrigin: {
|
||||
owner: 'project-owner',
|
||||
repo: 'outside',
|
||||
host: 'ghe.example',
|
||||
number: fork.number,
|
||||
type: 'issue',
|
||||
projectId: 'project-1',
|
||||
projectItemId: 'row-1',
|
||||
cacheKey: 'project-key'
|
||||
},
|
||||
localState: 'open',
|
||||
localLabels: [],
|
||||
assignees: [],
|
||||
onStateChange: vi.fn(),
|
||||
onLabelsChange: vi.fn(),
|
||||
onMutated: vi.fn(),
|
||||
onUse: vi.fn()
|
||||
})
|
||||
for (const request of fixture.loads.filter((load) => load.key !== null)) {
|
||||
await request.load()
|
||||
}
|
||||
expect(labels).toHaveBeenCalledWith({
|
||||
owner: 'project-owner',
|
||||
repo: 'outside',
|
||||
host: 'ghe.example'
|
||||
})
|
||||
expect(users).toHaveBeenCalledWith({
|
||||
owner: 'project-owner',
|
||||
repo: 'outside',
|
||||
host: 'ghe.example'
|
||||
})
|
||||
expect(fixture.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls back a rejected fork label without changing the upstream row', async () => {
|
||||
const store = createTestStore()
|
||||
const key = workItemsCacheKey(registeredRepo.id, 36, '', getTaskSourceCacheScope(sourceContext))
|
||||
store.setState({ workItemsCache: { [key]: { data: [upstream, fork], fetchedAt: 1 } } })
|
||||
fixture.gh.mockRejectedValueOnce(new Error('Fixture rejects label'))
|
||||
let pending = Promise.resolve(false)
|
||||
const observed: string[][][] = []
|
||||
runGHEditLabelToggle({
|
||||
itemId: fork.id,
|
||||
itemNumber: fork.number,
|
||||
itemRepoId: fork.repoId,
|
||||
repoPath: registeredRepo.path,
|
||||
sourceContext,
|
||||
projectOrigin: undefined,
|
||||
issueRepo,
|
||||
label: 'fork-only-label',
|
||||
localLabels: [],
|
||||
run: (_key, options) => {
|
||||
pending = (async () => {
|
||||
options.onOptimistic?.()
|
||||
observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? [])
|
||||
try {
|
||||
await options.mutate()
|
||||
return true
|
||||
} catch {
|
||||
options.onRevert?.()
|
||||
observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? [])
|
||||
return false
|
||||
}
|
||||
})()
|
||||
return pending
|
||||
},
|
||||
onLabelsChange: vi.fn(),
|
||||
patchWorkItem: store.getState().patchWorkItem,
|
||||
patchProjectRowIfNeeded: vi.fn(),
|
||||
onMutated: vi.fn()
|
||||
})
|
||||
expect(await pending).toBe(false)
|
||||
expect(observed).toEqual([
|
||||
[[], ['fork-only-label']],
|
||||
[[], []]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not fall back to upstream metadata for an invalid explicit repository', async () => {
|
||||
const invalid = { owner: '../escape', repo: 'widgets', host: 'github.com' }
|
||||
await expect(listLabels(registeredRepo.path, 'upstream', null, {}, invalid)).resolves.toEqual([])
|
||||
await expect(
|
||||
listAssignableUsers(registeredRepo.path, 'upstream', null, {}, invalid)
|
||||
).resolves.toEqual([])
|
||||
expect(fixture.requests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as GhUtils from '../../src/main/github/gh-utils'
|
||||
import type * as ReactModule from 'react'
|
||||
import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem } from '../../src/shared/github/work-item-types'
|
||||
import type { GitHubIssueUpdate } from '../../src/shared/issue-mutation-types'
|
||||
|
||||
const fixture = vi.hoisted(() => {
|
||||
const state: {
|
||||
callbacks: unknown[]
|
||||
mutation: Promise<unknown> | null
|
||||
gh: ReturnType<typeof vi.fn>
|
||||
apiUpdate: ReturnType<typeof vi.fn>
|
||||
patch: ReturnType<typeof vi.fn>
|
||||
preference: 'origin' | 'upstream'
|
||||
localGitOptions: { wslDistro?: string }
|
||||
requests: { args: string[]; host?: string; cwd?: string; wslDistro?: string }[]
|
||||
} = {
|
||||
callbacks: [],
|
||||
mutation: null,
|
||||
gh: vi.fn(),
|
||||
apiUpdate: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
preference: 'origin',
|
||||
localGitOptions: {},
|
||||
requests: []
|
||||
}
|
||||
return state
|
||||
})
|
||||
|
||||
vi.mock('react', async (original) => ({
|
||||
...(await original<typeof ReactModule>()),
|
||||
useState: (value: unknown) => [typeof value === 'function' ? value() : value, vi.fn()],
|
||||
useMemo: <T>(getValue: () => T) => getValue(),
|
||||
useCallback: <T>(callback: T) => {
|
||||
fixture.callbacks.push(callback)
|
||||
return callback
|
||||
}
|
||||
}))
|
||||
vi.mock('zustand/react/shallow', () => ({ useShallow: <T>(selector: T) => selector }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
patchWorkItem: fixture.patch,
|
||||
patchProjectRowContent: fixture.patch,
|
||||
repos: [],
|
||||
settings: {}
|
||||
}),
|
||||
{ getState: () => ({ recordFeatureInteraction: vi.fn() }) }
|
||||
)
|
||||
}))
|
||||
vi.mock('@/lib/repo-runtime-owner', () => ({
|
||||
getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null })
|
||||
}))
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: vi.fn(),
|
||||
PopoverContent: vi.fn(),
|
||||
PopoverTrigger: vi.fn()
|
||||
}))
|
||||
vi.mock('@/hooks/useIssueMetadata', () => ({
|
||||
useRepoAssignees: () => ({ data: [], loading: false, error: null }),
|
||||
useImmediateMutation: () => ({
|
||||
isPending: () => false,
|
||||
run: (_key: string, spec: { mutate: () => Promise<unknown> }) => {
|
||||
fixture.mutation = spec.mutate()
|
||||
}
|
||||
})
|
||||
}))
|
||||
vi.mock('@/hooks/useGitHubSlugMetadata', () => ({
|
||||
useRepoAssigneesBySlug: () => ({
|
||||
data: [{ login: 'octo', name: null, avatarUrl: '' }],
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('@/components/github/work-item-state-presentation', () => ({ ReviewerAvatar: vi.fn() }))
|
||||
vi.mock('../../src/main/github/gh-utils', async (original) => ({
|
||||
...(await original<typeof GhUtils>()),
|
||||
ghExecFileAsync: fixture.gh,
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn(),
|
||||
getOwnerRepoForRemote: async (_path: string, remote: string) => ({
|
||||
owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner',
|
||||
repo: 'widgets'
|
||||
})
|
||||
}))
|
||||
vi.mock('../../src/main/git/remote-name-listing', () => ({
|
||||
shouldProbeGitRemote: async () => true
|
||||
}))
|
||||
|
||||
import { PRAssigneesPanel } from '../../src/renderer/src/components/github/PRAssigneesPanel'
|
||||
import { updateIssue } from '../../src/main/github/issue-update'
|
||||
import { _resetOriginGitHubApiRepositoryCache } from '../../src/main/github/github-api-repository'
|
||||
|
||||
const repoPath = join(tmpdir(), 'orca-pr-assignee-repository-fixture')
|
||||
|
||||
beforeEach(() => {
|
||||
fixture.callbacks = []
|
||||
fixture.mutation = null
|
||||
fixture.requests = []
|
||||
fixture.localGitOptions = {}
|
||||
fixture.gh.mockReset()
|
||||
fixture.gh.mockImplementation(
|
||||
async (
|
||||
args: string[],
|
||||
options: {
|
||||
host?: string
|
||||
cwd?: string
|
||||
wslDistro?: string
|
||||
}
|
||||
) => {
|
||||
fixture.requests.push({
|
||||
args,
|
||||
host: options.host,
|
||||
cwd: options.cwd,
|
||||
wslDistro: options.wslDistro
|
||||
})
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
)
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
fixture.apiUpdate = vi.fn(
|
||||
(args: {
|
||||
repoPath: string
|
||||
number: number
|
||||
updates: GitHubIssueUpdate
|
||||
ownerRepo?: GitHubOwnerRepo
|
||||
}) =>
|
||||
updateIssue(
|
||||
args.repoPath,
|
||||
args.number,
|
||||
args.updates,
|
||||
null,
|
||||
fixture.localGitOptions,
|
||||
fixture.preference,
|
||||
args.ownerRepo
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('window', { api: { gh: { updateIssue: fixture.apiUpdate } } })
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it.each([
|
||||
{ owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: false },
|
||||
{ owner: 'upstream-owner', preference: 'origin', assigned: true, legacy: false },
|
||||
{ owner: 'fork-owner', preference: 'upstream', assigned: false, legacy: false },
|
||||
{ owner: 'fork-owner', preference: 'upstream', assigned: true, legacy: false },
|
||||
{ owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: true }
|
||||
] as const)(
|
||||
'keeps $owner PR assignees under $preference (remove=$assigned, legacy=$legacy)',
|
||||
async ({ owner, preference, assigned, legacy }) => {
|
||||
fixture.preference = preference
|
||||
fixture.localGitOptions = owner === 'fork-owner' ? { wslDistro: 'Ubuntu' } : {}
|
||||
const item: GitHubWorkItem = {
|
||||
id: 'pr:5',
|
||||
type: 'pr',
|
||||
number: 5,
|
||||
title: 'Opened PR',
|
||||
state: 'open',
|
||||
url: `https://github.com/${owner}/widgets/pull/5`,
|
||||
prRepo: legacy ? undefined : { owner, repo: 'widgets', host: 'github.com' },
|
||||
labels: [],
|
||||
updatedAt: '',
|
||||
author: null,
|
||||
repoId: 'repo-1',
|
||||
assignees: assigned ? [{ login: 'octo', name: null, avatarUrl: '' }] : []
|
||||
}
|
||||
PRAssigneesPanel({ item, repoPath, projectOrigin: undefined, onMutated: vi.fn() })
|
||||
const toggleAssignee = fixture.callbacks.at(-1)
|
||||
if (typeof toggleAssignee !== 'function') {
|
||||
throw new Error('PR panel did not create an assignee handler')
|
||||
}
|
||||
toggleAssignee('octo')
|
||||
await fixture.mutation
|
||||
expect(fixture.requests).toEqual([
|
||||
{
|
||||
args: [
|
||||
'issue',
|
||||
'edit',
|
||||
'5',
|
||||
'--repo',
|
||||
`${owner}/widgets`,
|
||||
assigned ? '--remove-assignee' : '--add-assignee',
|
||||
'octo'
|
||||
],
|
||||
host: 'github.com',
|
||||
cwd: repoPath,
|
||||
wslDistro: fixture.localGitOptions.wslDistro
|
||||
}
|
||||
])
|
||||
expect(fixture.apiUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoPath,
|
||||
repoId: item.repoId,
|
||||
ownerRepo: { owner, repo: 'widgets', host: 'github.com' }
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export const JSON_PARSER_CASES = [
|
||||
{ name: 'rg-10k', kind: 'rg', count: 10_000, cap: 2000 },
|
||||
{ name: 'rg-100k', kind: 'rg', count: 100_000, cap: 2000 },
|
||||
{ name: 'rg-100k-cap1', kind: 'rg', count: 100_000, cap: 1 },
|
||||
{ name: 'rg-unicode', kind: 'rg', count: 10_000, cap: 2000, unicode: true },
|
||||
{ name: 'rg-large-dense', kind: 'rg', count: 900_000, cap: 2000, large: true },
|
||||
{ name: 'rg-fast-path', kind: 'rg', count: 1, cap: 2000, large: true },
|
||||
{ name: 'session-messages', kind: 'session' },
|
||||
{ name: 'session-skipped-objects', kind: 'session' },
|
||||
{ name: 'session-skipped-string', kind: 'session' },
|
||||
{ name: 'session-selected-string', kind: 'session' }
|
||||
]
|
||||
|
||||
export function writeJsonParserFixtures(directory) {
|
||||
for (const fixture of JSON_PARSER_CASES) {
|
||||
let value
|
||||
if (fixture.kind === 'rg') {
|
||||
const text = fixture.unicode
|
||||
? '\ufeff日本語😀x'
|
||||
: fixture.large && fixture.count > 1
|
||||
? 'xxxx'
|
||||
: 'x'
|
||||
const matchBytes = Buffer.byteLength(text)
|
||||
value = {
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: `${text}.ts` },
|
||||
lines: {
|
||||
text: text.repeat(fixture.count === 1 ? 4 * 1024 * 1024 : fixture.count)
|
||||
},
|
||||
line_number: 1,
|
||||
submatches: Array.from({ length: fixture.count }, (_, index) => ({
|
||||
match: { text },
|
||||
start: index * matchBytes,
|
||||
end: (index + 1) * matchBytes
|
||||
}))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value = { id: 'synthetic', messages: [{ text: 'one' }] }
|
||||
if (fixture.name === 'session-messages') {
|
||||
value.agent = { model: 'model', other: 'ignored' }
|
||||
value.messages = Array.from({ length: 20_000 }, (_, index) => ({
|
||||
role: index % 2 ? 'assistant' : 'user',
|
||||
text: '\ufeff日本語😀 hello world '.repeat(16),
|
||||
timestamp: index,
|
||||
metadata: { model: 'synthetic', tokens: 512 }
|
||||
}))
|
||||
} else if (fixture.name === 'session-skipped-objects') {
|
||||
value.ignored = Array.from({ length: 200_000 }, (_, index) => ({
|
||||
id: index,
|
||||
data: { text: 'x'.repeat(64), values: [1, 2, 3] }
|
||||
}))
|
||||
} else if (fixture.name === 'session-skipped-string') {
|
||||
value.ignored = '日本語😀x'.repeat(1_500_000)
|
||||
} else {
|
||||
value.messages = [{ text: '日本語😀x'.repeat(1_500_000) }]
|
||||
}
|
||||
}
|
||||
writeFileSync(join(directory, `${fixture.name}.json`), JSON.stringify(value))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createReadStream, readFileSync, statSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
|
||||
import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs'
|
||||
import { JSON_PARSER_CASES, writeJsonParserFixtures } from './json-parser-benchmark-fixtures.mjs'
|
||||
|
||||
// Bundle each revision's two consumers as {baseline,candidate}-{rg,session}.mjs first.
|
||||
const [bundleDirectory, workerFixture, workerArm] = process.argv.slice(2)
|
||||
if (!bundleDirectory || !global.gc) {
|
||||
throw new Error('Usage: node --expose-gc json-parser-migration-benchmark.mjs BUNDLE_DIRECTORY')
|
||||
}
|
||||
|
||||
async function load(arm, kind) {
|
||||
return import(pathToFileURL(resolve(bundleDirectory, `${arm}-${kind}.mjs`)).href)
|
||||
}
|
||||
|
||||
function prepareRun(module, fixture, file) {
|
||||
if (fixture.kind === 'rg') {
|
||||
const text = readFileSync(file, 'utf8')
|
||||
return () =>
|
||||
module.parseRipgrepMatchJson(text, fixture.cap, {
|
||||
structuralTokens: 32 * 1024,
|
||||
nestingDepth: 16
|
||||
})
|
||||
}
|
||||
return () =>
|
||||
module.readStreamedSessionDocument({
|
||||
bytes: createReadStream(file, { highWaterMark: 64 * 1024 }),
|
||||
arrayKey: 'messages',
|
||||
fields: ['id'],
|
||||
objectFields: { agent: ['model'] },
|
||||
create: () => ({ count: 0, textLength: 0 }),
|
||||
consume(state, value) {
|
||||
state.count++
|
||||
state.textLength += typeof value?.text === 'string' ? value.text.length : 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||
}
|
||||
|
||||
async function consumedContentDigest(module, file) {
|
||||
const result = await module.readStreamedSessionDocument({
|
||||
bytes: createReadStream(file, { highWaterMark: 64 * 1024 }),
|
||||
arrayKey: 'messages',
|
||||
fields: ['id'],
|
||||
objectFields: { agent: ['model'] },
|
||||
create: () => createHash('sha256'),
|
||||
consume(hash, value) {
|
||||
hash.update(JSON.stringify(value)).update('\n')
|
||||
}
|
||||
})
|
||||
return { record: result.record, consumedSha256: result.state.digest('hex') }
|
||||
}
|
||||
|
||||
if (workerFixture) {
|
||||
const fixture = JSON_PARSER_CASES.find((item) => workerFixture.endsWith(`${item.name}.json`))
|
||||
assert(fixture)
|
||||
const module = await load(workerArm, fixture.kind)
|
||||
const run = prepareRun(module, fixture, workerFixture)
|
||||
global.gc()
|
||||
const before = process.memoryUsage()
|
||||
let running = true
|
||||
let maxLoopGapMs = 0
|
||||
let previous = performance.now()
|
||||
const observe = () => {
|
||||
const now = performance.now()
|
||||
maxLoopGapMs = Math.max(maxLoopGapMs, now - previous)
|
||||
previous = now
|
||||
if (running) {
|
||||
setImmediate(observe)
|
||||
}
|
||||
}
|
||||
setImmediate(observe)
|
||||
const started = performance.now()
|
||||
const result = await run()
|
||||
const elapsedMs = performance.now() - started
|
||||
await new Promise((done) => setImmediate(done))
|
||||
running = false
|
||||
const peakRssMiB = process.resourceUsage().maxRSS / 1024
|
||||
global.gc()
|
||||
const retainedHeapDeltaMiB = (process.memoryUsage().heapUsed - before.heapUsed) / 1024 ** 2
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
elapsedMs,
|
||||
peakRssMiB,
|
||||
retainedHeapDeltaMiB,
|
||||
maxLoopGapMs,
|
||||
digest: digest(result)
|
||||
})
|
||||
)
|
||||
} else {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-json-parser-benchmark-'))
|
||||
try {
|
||||
writeJsonParserFixtures(directory)
|
||||
global.gc()
|
||||
const results = []
|
||||
for (const fixture of JSON_PARSER_CASES) {
|
||||
const file = join(directory, `${fixture.name}.json`)
|
||||
const runs = {}
|
||||
const contents = {}
|
||||
for (const arm of ['baseline', 'candidate']) {
|
||||
const module = await load(arm, fixture.kind)
|
||||
runs[arm] = prepareRun(module, fixture, file)
|
||||
if (fixture.kind === 'session') {
|
||||
contents[arm] = await consumedContentDigest(module, file)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(contents.candidate, contents.baseline)
|
||||
for (let warmup = 0; warmup < 3; warmup++) {
|
||||
assert.deepEqual(await runs.candidate(), await runs.baseline())
|
||||
}
|
||||
const samples = { baseline: [], candidate: [] }
|
||||
for (const pair of buildCounterbalancedSchedule(12, 'baseline', 'candidate')) {
|
||||
for (const arm of pair) {
|
||||
const started = performance.now()
|
||||
await runs[arm]()
|
||||
samples[arm].push(performance.now() - started)
|
||||
}
|
||||
}
|
||||
const memory = { baseline: [], candidate: [] }
|
||||
for (const pair of buildCounterbalancedSchedule(2, 'baseline', 'candidate')) {
|
||||
for (const arm of pair) {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
['--expose-gc', import.meta.filename, bundleDirectory, file, arm],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
assert.equal(child.status, 0, child.stderr)
|
||||
memory[arm].push(JSON.parse(child.stdout))
|
||||
}
|
||||
}
|
||||
for (const sample of [...memory.baseline, ...memory.candidate]) {
|
||||
assert.equal(sample.digest, memory.baseline[0].digest)
|
||||
}
|
||||
results.push({
|
||||
name: fixture.name,
|
||||
bytes: statSync(file).size,
|
||||
baseline: summarizeBenchmarkSamples(samples.baseline),
|
||||
candidate: summarizeBenchmarkSamples(samples.candidate),
|
||||
samples,
|
||||
memory
|
||||
})
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ node: process.version, platform: process.platform, arch: process.arch, results },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,10 @@ it('preserves deleted and renamed inputs and falls back to extraction on detecti
|
||||
const workflow = parse(
|
||||
readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8')
|
||||
)
|
||||
const step = workflow.jobs.static_analysis.steps.find(
|
||||
const step = workflow.jobs.preflight.steps.find(
|
||||
(candidate) => candidate.name === 'Verify localization extraction'
|
||||
)
|
||||
expect(step.env).toEqual({
|
||||
expect(step.env).toMatchObject({
|
||||
BASE_SHA: '${{ github.event.pull_request.base.sha }}'
|
||||
})
|
||||
// The base side comes from the merge ref's first parent, so the gate needs no merge base and
|
||||
|
||||
@@ -310,6 +310,10 @@ it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', (
|
||||
expect(build.if).toBeUndefined()
|
||||
expect(build['continue-on-error']).toBeUndefined()
|
||||
expect(build.run).toMatch(/^if \[ "\$RUNNER_OS" != Linux \]; then exit 0; fi\n/)
|
||||
expect(build.run).toContain(
|
||||
'pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts'
|
||||
)
|
||||
expect(build.run).not.toContain('"$GITHUB_WORKSPACE/node_modules"')
|
||||
expect(build.run).toContain('echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"')
|
||||
expect(build.run).toContain('echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"')
|
||||
expect(build.run).not.toContain('GITHUB_ENV')
|
||||
|
||||
@@ -92,7 +92,7 @@ it('only skips the unchanged smoke after a successful diff and dependency analys
|
||||
const workflow = parse(
|
||||
readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8')
|
||||
)
|
||||
const step = workflow.jobs.static_analysis.steps.find(
|
||||
const step = workflow.jobs.preflight.steps.find(
|
||||
(candidate) => candidate.name === 'Boot orcad and round-trip a terminal'
|
||||
)
|
||||
expect(step.env).toEqual({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CLI_MAIN_ENTRY_NAMES,
|
||||
createPlainNodeEntryGuardPlugin,
|
||||
@@ -160,14 +160,20 @@ describe('guarded entry names', () => {
|
||||
// hand-written "must stay electron-free" comments, and the port-scan worker sits
|
||||
// one import away from a client that deliberately does require electron.
|
||||
describe('CLI and worker thread entry guard', () => {
|
||||
function runEntryWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
|
||||
function runEntryWriteBundle(
|
||||
plugin: Plugin,
|
||||
bundle: Rollup.OutputBundle,
|
||||
watchMode = false
|
||||
): void {
|
||||
const hook = plugin.writeBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected writeBundle hook')
|
||||
}
|
||||
hook.call(
|
||||
{ meta: { watchMode: false } } as never,
|
||||
{ dir: createOutputDir() } as Rollup.NormalizedOutputOptions,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook reads only meta.watchMode from its context.
|
||||
{ meta: { watchMode } } as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook reads only dir from the output options.
|
||||
{ dir: outputDir ?? createOutputDir() } as Rollup.NormalizedOutputOptions,
|
||||
bundle
|
||||
)
|
||||
}
|
||||
@@ -184,6 +190,93 @@ describe('CLI and worker thread entry guard', () => {
|
||||
} as Rollup.OutputChunk
|
||||
}
|
||||
|
||||
function countElectronRequireScans(run: () => void): number {
|
||||
const pattern = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/
|
||||
const originalTest = RegExp.prototype.test
|
||||
let scans = 0
|
||||
const spy = vi.spyOn(RegExp.prototype, 'test').mockImplementation(function (
|
||||
this: RegExp,
|
||||
value: string
|
||||
) {
|
||||
if (this.source === pattern.source) {
|
||||
scans += 1
|
||||
}
|
||||
return originalTest.call(this, value)
|
||||
})
|
||||
try {
|
||||
run()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
return scans
|
||||
}
|
||||
|
||||
it('scans shared code once across every guarded entry', () => {
|
||||
const shared = entryChunk('shared', 'require("node:fs")')
|
||||
shared.isEntry = false
|
||||
const bundle: Rollup.OutputBundle = { [shared.fileName]: shared }
|
||||
for (const name of GUARDED_ENTRY_NAMES) {
|
||||
const entry = entryChunk(name, `require("./shared.js"); // ${name}`, [shared.fileName])
|
||||
bundle[entry.fileName] = entry
|
||||
}
|
||||
const snapshot = structuredClone(bundle)
|
||||
|
||||
const scans = countElectronRequireScans(() => {
|
||||
runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle)
|
||||
})
|
||||
|
||||
expect(bundle).toEqual(snapshot)
|
||||
expect(scans).toBe(GUARDED_ENTRY_NAMES.length + 1)
|
||||
})
|
||||
|
||||
it('does not retain a successful scan across output bundles', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const entry = entryChunk('stt-worker', 'require("node:fs")')
|
||||
const bundle: Rollup.OutputBundle = { [entry.fileName]: entry }
|
||||
const scans = countElectronRequireScans(() => {
|
||||
runEntryWriteBundle(plugin, bundle)
|
||||
runEntryWriteBundle(plugin, bundle)
|
||||
})
|
||||
expect(scans).toBe(2)
|
||||
|
||||
entry.code = 'require("electron/main")'
|
||||
expect(() => runEntryWriteBundle(plugin, bundle)).toThrow(
|
||||
'[plain-node-entry-guard] "stt-worker" reaches chunk "stt-worker.js" that requires electron. '
|
||||
)
|
||||
})
|
||||
|
||||
it('still reads changed code on the same chunk within one bundle scan', () => {
|
||||
const first = entryChunk('stt-worker', 'require("node:fs")')
|
||||
const second = entryChunk('warp-theme-parser-worker', 'require("node:fs")')
|
||||
const shared = entryChunk('shared', '')
|
||||
shared.isEntry = false
|
||||
let reads = 0
|
||||
Object.defineProperty(shared, 'code', {
|
||||
get: () => (++reads === 1 ? 'require("node:fs")' : 'require("electron")')
|
||||
})
|
||||
first.imports = [shared.fileName]
|
||||
second.dynamicImports = [shared.fileName]
|
||||
const bundle: Rollup.OutputBundle = {
|
||||
[first.fileName]: first,
|
||||
[second.fileName]: second,
|
||||
[shared.fileName]: shared
|
||||
}
|
||||
|
||||
expect(() => runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle)).toThrow(
|
||||
'[plain-node-entry-guard] "warp-theme-parser-worker" reaches chunk "shared.js"'
|
||||
)
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps watch mode free of entry scanning', () => {
|
||||
const entry = entryChunk('stt-worker', 'require("electron")')
|
||||
const bundle: Rollup.OutputBundle = { [entry.fileName]: entry }
|
||||
const scans = countElectronRequireScans(() => {
|
||||
runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle, true)
|
||||
})
|
||||
expect(scans).toBe(0)
|
||||
})
|
||||
|
||||
it.each(CLI_MAIN_ENTRY_NAMES)('rejects direct and transitive Electron imports in %s', (name) => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const entry = entryChunk(name, 'require("electron")')
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyPrJobs } from './pr-code-change-scope.mjs'
|
||||
|
||||
// #24901 changed the send builders and orchestration code, and this job skipped. It runs only for
|
||||
// code a suite executes: loading a module the dispatcher registers is not coverage.
|
||||
describe('cross-version wire routing for the send path', () => {
|
||||
it.each([
|
||||
'src/shared/agent-session-wire-refusals.ts',
|
||||
'src/shared/structured-agent-session-mutation.ts',
|
||||
'src/shared/structured-agent-session-send-mutation.ts',
|
||||
'src/shared/structured-agent-session-outbox.ts',
|
||||
'src/main/runtime/rpc/core.ts',
|
||||
'src/main/runtime/rpc/errors.ts',
|
||||
'src/main/runtime/rpc/rpc-streaming-dispatcher.ts',
|
||||
'src/main/runtime/rpc/orchestration-contract-fence.ts',
|
||||
'src/main/runtime/rpc/orchestration-session-caller.ts',
|
||||
'src/main/runtime/rpc/orchestration-legacy-compatibility.ts',
|
||||
'src/main/runtime/rpc/orchestration-mutation-executor.ts',
|
||||
'src/shared/orchestration-rpc-contract.ts',
|
||||
'src/main/runtime/orchestration/db/schema/migrate.ts'
|
||||
])('runs the cross-version suites when %s changes', (file) => {
|
||||
expect(classifyPrJobs([file])).toMatchObject({ should_run: true, 'cross-version-wire': true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'src/shared/structured-agent-session-outbox-admission.ts',
|
||||
'src/shared/structured-agent-session-outbox-delivery.ts',
|
||||
'src/shared/structured-agent-session-outbox-stop-withdrawal.ts',
|
||||
'src/shared/structured-agent-session-composer.ts',
|
||||
'src/shared/structured-agent-session-reducer.ts',
|
||||
'src/main/runtime/orchestration/send-agent-turn.ts',
|
||||
'src/main/runtime/orchestration/orchestration-caller-identity.ts',
|
||||
'src/main/runtime/rpc/orchestration-legacy-mail.ts',
|
||||
'src/main/runtime/rpc/methods/orchestration.ts',
|
||||
'src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts'
|
||||
])('leaves them off for %s, which no cross-version suite executes', (file) => {
|
||||
expect(classifyPrJobs([file])).toMatchObject({ should_run: true, 'cross-version-wire': false })
|
||||
})
|
||||
})
|
||||
@@ -165,6 +165,11 @@ const CROSS_VERSION_WIRE_PREFIXES = [
|
||||
'src/shared/rpc-contract/agent-launch-params',
|
||||
'src/shared/agent-session-wire',
|
||||
'src/shared/agent-session-mutation-envelope',
|
||||
// The send a client builds (the agent-session suite sends it to the release host) and the
|
||||
// fingerprint the host's ledger and journal re-derive.
|
||||
'src/shared/structured-agent-session-mutation.ts',
|
||||
'src/shared/structured-agent-session-send-mutation.ts',
|
||||
'src/shared/structured-agent-session-outbox.ts',
|
||||
'src/shared/agent-session-record',
|
||||
'src/shared/agent-session-journal-',
|
||||
'src/main/ai-vault/structured-session-ownership.ts',
|
||||
@@ -174,6 +179,15 @@ const CROSS_VERSION_WIRE_PREFIXES = [
|
||||
'src/main/runtime/agent-session-recovery-capsule',
|
||||
'src/shared/agent-session-resume-marker',
|
||||
'src/main/runtime/rpc/dispatcher',
|
||||
// Run on every request the suites dispatch, whatever its method.
|
||||
'src/main/runtime/rpc/core.ts',
|
||||
'src/main/runtime/rpc/errors.ts',
|
||||
'src/main/runtime/rpc/rpc-streaming-dispatcher.ts',
|
||||
'src/main/runtime/rpc/orchestration-contract-fence.ts',
|
||||
'src/main/runtime/rpc/orchestration-session-caller.ts',
|
||||
'src/main/runtime/rpc/orchestration-legacy-compatibility.ts',
|
||||
'src/main/runtime/rpc/orchestration-mutation-executor.ts',
|
||||
'src/shared/orchestration-rpc-contract.ts',
|
||||
'src/main/runtime/rpc/methods/agent-launch',
|
||||
'src/main/runtime/rpc/methods/ai-vault.ts',
|
||||
'src/main/runtime/rpc/methods/browser-tab-create-schema',
|
||||
@@ -357,6 +371,8 @@ const WINDOWS_PACKAGE_TESTS = [
|
||||
'src/main/runtime/unreadable-secret-store-preservation.win32.test.ts',
|
||||
'src/main/ipc/pty-codex-account-attribution.test.ts',
|
||||
'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts',
|
||||
'src/main/ipc/preflight-provider-command-selection.test.ts',
|
||||
'src/main/ipc/preflight-runnable-local-cli.test.ts',
|
||||
'src/relay/windows-port-scan.win32.test.ts',
|
||||
'src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts',
|
||||
'src/main/ssh/remote-node-runtime-store-windows.test.ts'
|
||||
|
||||
@@ -587,14 +587,16 @@ describe('PR Checks skip wiring', () => {
|
||||
expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe(
|
||||
'${{ steps.filter.outputs.mobile_dependencies }}'
|
||||
)
|
||||
const steps = prWorkflow.jobs.static_analysis.steps
|
||||
const steps = prWorkflow.jobs.preflight.steps
|
||||
const install = steps.findIndex(
|
||||
(step) => step.uses === './.github/actions/install-mobile-dependencies'
|
||||
)
|
||||
const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality')
|
||||
expect(install).toBeGreaterThan(-1)
|
||||
expect(install).toBeLessThan(gate)
|
||||
expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'")
|
||||
expect(steps[install].if).toBe(
|
||||
"needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'"
|
||||
)
|
||||
// The install itself moved into the action the packaging jobs share; assert it there so
|
||||
// this job cannot keep the step while the action stops installing anything.
|
||||
const action = parse(
|
||||
@@ -621,21 +623,21 @@ describe('PR Checks skip wiring', () => {
|
||||
})
|
||||
|
||||
it('gates each expensive job on its classifier and cache prerequisite', () => {
|
||||
for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) {
|
||||
for (const jobName of expensiveJobs.filter(
|
||||
(jobName) => !['test', 'static_analysis', 'typecheck'].includes(jobName)
|
||||
)) {
|
||||
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(
|
||||
['package', 'package_windows'].includes(jobName)
|
||||
? ['code_paths', 'static_analysis', 'typecheck']
|
||||
? ['code_paths', 'preflight']
|
||||
: ['code_paths']
|
||||
)
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
|
||||
`needs.code_paths.outputs.${jobName} == 'true'`
|
||||
)
|
||||
}
|
||||
expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'static_analysis', 'typecheck'])
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.typecheck.result == 'success'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.preflight.result == 'success'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
|
||||
expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}')
|
||||
expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}')
|
||||
expect(prWorkflow.jobs.unit_plan).toBeUndefined()
|
||||
expect(prWorkflow.jobs.test_native_cache).toBeUndefined()
|
||||
})
|
||||
@@ -659,7 +661,7 @@ describe('PR Checks skip wiring', () => {
|
||||
expect(verifyStep.run).toContain('expected skipped')
|
||||
expect(verifyStep.run).toContain('expected success')
|
||||
for (const job of prWorkflow.jobs.verify.needs) {
|
||||
if (job === 'code_paths') {
|
||||
if (job === 'code_paths' || job === 'preflight') {
|
||||
continue
|
||||
}
|
||||
const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
|
||||
|
||||
@@ -45,7 +45,7 @@ const nativeImeSpec = readFileSync(
|
||||
const filterStep = prWorkflow.jobs.code_paths.steps.find(
|
||||
(step) => step.name === 'Filter changed E2E specs'
|
||||
)
|
||||
const rollbackStep = prWorkflow.jobs.static_analysis.steps.find(
|
||||
const rollbackStep = prWorkflow.jobs.preflight.steps.find(
|
||||
(step) => step.name === 'Check VM runtime rollback compatibility'
|
||||
)
|
||||
const verifyStep = prWorkflow.jobs.verify.steps.find(
|
||||
@@ -133,7 +133,7 @@ describe('PR E2E gate contract', () => {
|
||||
for (const job of prWorkflow.jobs.verify.needs) {
|
||||
const envVar = job.replaceAll('-', '_').toUpperCase()
|
||||
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
|
||||
if (job === 'code_paths') {
|
||||
if (job === 'code_paths' || job === 'preflight') {
|
||||
continue
|
||||
}
|
||||
expect(successLoop).toContain(`"$${envVar}"`)
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { classifyPrJobs } from './pr-code-change-scope.mjs'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const typecheck = workflow.jobs.typecheck
|
||||
const steps = typecheck.steps
|
||||
const preflight = workflow.jobs.preflight
|
||||
const steps = preflight.steps
|
||||
const compiler = steps.find((step) => step.run === 'pnpm run typecheck')
|
||||
const plan = steps.find((step) => step.id === 'unit-plan')
|
||||
|
||||
it('shares planning setup while keeping the heavy checks on separate runners', () => {
|
||||
expect(workflow.jobs.unit_plan).toBeUndefined()
|
||||
expect(workflow.jobs.test_native_cache).toBeUndefined()
|
||||
expect(typecheck.needs).toEqual(['code_paths'])
|
||||
expect(workflow.jobs.static_analysis.needs).toEqual(['code_paths'])
|
||||
expect(
|
||||
steps.filter((step) => step.uses === './.github/actions/install-node-dependencies')
|
||||
).toHaveLength(1)
|
||||
expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2)
|
||||
expect(compiler.background).toBeUndefined()
|
||||
expect(plan.background).toBe(true)
|
||||
expect(plan.run).toBe('node config/scripts/ci-unit-plan.mjs')
|
||||
expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE')
|
||||
expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler))
|
||||
const installs = workflow.jobs.static_analysis.steps.filter(
|
||||
it('shares one setup and runs the unchanged compiler after static checks finish', () => {
|
||||
expect(workflow.jobs.static_analysis).toBeUndefined()
|
||||
expect(workflow.jobs.typecheck).toBeUndefined()
|
||||
expect(preflight.needs).toEqual(['code_paths'])
|
||||
expect(preflight['runs-on']).toBe('ubuntu-24.04-arm')
|
||||
const installs = steps.filter(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(installs).toHaveLength(2)
|
||||
@@ -32,35 +24,152 @@ it('shares planning setup while keeping the heavy checks on separate runners', (
|
||||
expect(install.with['node-version']).toBe('24')
|
||||
expect(install.with['persist-native-cache']).not.toBe('false')
|
||||
}
|
||||
expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2)
|
||||
expect(compiler.background).toBeUndefined()
|
||||
expect(plan.background).toBe(true)
|
||||
expect(plan.run.trim().split('\n')).toEqual([
|
||||
'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi',
|
||||
'node config/scripts/ci-unit-plan.mjs'
|
||||
])
|
||||
expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE')
|
||||
expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler))
|
||||
expect(steps.indexOf(compiler)).toBeGreaterThan(
|
||||
steps.findIndex((step) => step.wait?.includes('localization-extraction'))
|
||||
)
|
||||
})
|
||||
|
||||
it('requires compiler success and joined planning before publishing shards and admitting tests', () => {
|
||||
it('requires physical preflight success before publishing shards and admitting consumers', () => {
|
||||
const join = steps.findIndex((step) => step.wait === 'unit-plan')
|
||||
const upload = steps.findIndex((step) => step.uses === 'actions/upload-artifact@v7')
|
||||
expect(join).toBeGreaterThan(steps.indexOf(compiler))
|
||||
expect(upload).toBeGreaterThan(join)
|
||||
expect(steps[upload]['continue-on-error']).toBeUndefined()
|
||||
expect(steps[upload].with.name).toBe('unit-selection-attempt-${{ github.run_attempt }}')
|
||||
expect(typecheck.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}')
|
||||
expect(workflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}')
|
||||
expect(preflight.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}')
|
||||
expect(workflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}')
|
||||
for (const job of ['test', 'package', 'package_windows']) {
|
||||
expect(workflow.jobs[job].needs).toEqual(['code_paths', 'static_analysis', 'typecheck'])
|
||||
expect(workflow.jobs[job].needs).toEqual(['code_paths', 'preflight'])
|
||||
}
|
||||
expect(workflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'")
|
||||
expect(workflow.jobs.test.if).toContain("needs.typecheck.result == 'success'")
|
||||
expect(workflow.jobs.verify.needs).toContain('static_analysis')
|
||||
expect(workflow.jobs.verify.needs).toContain('typecheck')
|
||||
for (const result of ['success', 'failure', 'cancelled', 'skipped']) {
|
||||
const admitted = runInNewContext(workflow.jobs.test.if, {
|
||||
cancelled: () => false,
|
||||
needs: { code_paths: { outputs: { test: 'true' } }, preflight: { result } }
|
||||
})
|
||||
expect(admitted, result).toBe(result === 'success')
|
||||
}
|
||||
const verify = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
expect(verify.env.PREFLIGHT).toBe('${{ needs.preflight.result }}')
|
||||
expect(verify.env.PREFLIGHT_SHOULD_RUN).toBe(
|
||||
"${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}"
|
||||
)
|
||||
expect(verify.run).toContain('check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"')
|
||||
expect(workflow.jobs.verify.needs).toContain('preflight')
|
||||
expect(workflow.jobs.verify.needs).not.toContain('typecheck')
|
||||
})
|
||||
|
||||
it.each(
|
||||
[['README.md'], ['mobile/src/App.tsx'], ['cloud/package.json'], ['src/main/index.ts'], []].map(
|
||||
(changed) => ({ changed })
|
||||
it('pins every foreground and background step to its selected phase', () => {
|
||||
const staticPhase = "needs.code_paths.outputs.static_analysis == 'true'"
|
||||
const typePhase = "needs.code_paths.outputs.typecheck == 'true'"
|
||||
const foreground = steps.filter(
|
||||
(step) => !step.background && /outputs\.(static_analysis|typecheck)/.test(step.if ?? '')
|
||||
)
|
||||
)('keeps desktop typechecking and planning off unrelated paths: $changed', ({ changed }) => {
|
||||
expect(foreground.map((step) => [step.name ?? step.run ?? step.uses, step.if])).toEqual([
|
||||
['Reject low-evidence patterns', staticPhase],
|
||||
['Enforce type-aware code-quality baseline', staticPhase],
|
||||
[
|
||||
'./.github/actions/install-mobile-dependencies',
|
||||
`${staticPhase} && needs.code_paths.outputs.mobile_dependencies == 'true'`
|
||||
],
|
||||
['Enforce React Doctor on changed lines', staticPhase],
|
||||
['Check Zustand selector fan-out budget', staticPhase],
|
||||
['Check reliability gate manifest', staticPhase],
|
||||
['Enforce dead design-system classes', staticPhase],
|
||||
['Check VM runtime rollback compatibility', staticPhase],
|
||||
['Enforce max-lines ratchet', staticPhase],
|
||||
['Enforce ts-nocheck ratchet', staticPhase],
|
||||
['Enforce runtime Electron-import ratchet', staticPhase],
|
||||
['Check Node runtime pin', staticPhase],
|
||||
['Boot orcad and round-trip a terminal', staticPhase],
|
||||
['Verify the generated RPC params catalog', staticPhase],
|
||||
['Verify bundled skill guides', staticPhase],
|
||||
['Verify skill freshness manifest', staticPhase],
|
||||
['Verify localization coverage', staticPhase],
|
||||
['Guard against project-owned .d.ts in preload/shared', staticPhase],
|
||||
['Check feature wall asset budget', staticPhase],
|
||||
['Verify macOS entitlements', staticPhase],
|
||||
['Cache TypeScript incremental state', typePhase],
|
||||
['pnpm run typecheck', typePhase],
|
||||
['actions/upload-artifact@v7', typePhase]
|
||||
])
|
||||
expect(
|
||||
steps
|
||||
.filter((step) => step.background)
|
||||
.map((step) => [step.id, step.env.PREFLIGHT_PHASE_SELECTED])
|
||||
).toEqual([
|
||||
['root-lint', `\${{ ${staticPhase} }}`],
|
||||
['native-code-quality', `\${{ ${staticPhase} }}`],
|
||||
['changed-code-quality', `\${{ ${staticPhase} }}`],
|
||||
['localization-extraction', `\${{ ${staticPhase} }}`],
|
||||
['localization-catalogs', `\${{ ${staticPhase} }}`],
|
||||
['unit-plan', `\${{ ${typePhase} }}`]
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ changed: ['README.md'], static_analysis: false, typecheck: false, mobile_dependencies: false },
|
||||
{
|
||||
changed: ['mobile/src/App.tsx'],
|
||||
static_analysis: true,
|
||||
typecheck: false,
|
||||
mobile_dependencies: true
|
||||
},
|
||||
{
|
||||
changed: ['cloud/package.json'],
|
||||
static_analysis: false,
|
||||
typecheck: false,
|
||||
mobile_dependencies: false
|
||||
},
|
||||
{
|
||||
changed: ['src/main/index.ts'],
|
||||
static_analysis: true,
|
||||
typecheck: true,
|
||||
mobile_dependencies: false
|
||||
},
|
||||
{
|
||||
changed: ['mobile/src/App.tsx', 'src/main/index.ts'],
|
||||
static_analysis: true,
|
||||
typecheck: true,
|
||||
mobile_dependencies: true
|
||||
},
|
||||
{ changed: [], static_analysis: true, typecheck: true, mobile_dependencies: true }
|
||||
])('preserves exact phase selection for unrelated paths: $changed', ({ changed, ...expected }) => {
|
||||
const scope = classifyPrJobs(changed)
|
||||
expect(typecheck.if).toBe("needs.code_paths.outputs.typecheck == 'true'")
|
||||
expect(scope.test).toBe(scope.typecheck)
|
||||
if (scope.test) {
|
||||
expect(scope.static_analysis).toBe(true)
|
||||
expect({
|
||||
static_analysis: scope.static_analysis,
|
||||
typecheck: scope.typecheck,
|
||||
mobile_dependencies: scope.mobile_dependencies
|
||||
}).toEqual(expected)
|
||||
const needs = {
|
||||
code_paths: {
|
||||
outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)]))
|
||||
}
|
||||
}
|
||||
expect(runInNewContext(preflight.if, { needs })).toBe(
|
||||
expected.static_analysis || expected.typecheck
|
||||
)
|
||||
expect(runInNewContext(compiler.if, { needs })).toBe(expected.typecheck)
|
||||
expect(scope.test).toBe(expected.typecheck)
|
||||
})
|
||||
|
||||
it('registers successful no-op background work when a phase is unselected or already failed', () => {
|
||||
for (const step of steps.filter((step) => step.background)) {
|
||||
expect(step.if).toBe('!cancelled()')
|
||||
expect(step.env.PREFLIGHT_PHASE_SELECTED).toContain('needs.code_paths.outputs.')
|
||||
expect(step.env.PREFLIGHT_PRIOR_SUCCESS).toBe("${{ job.status == 'success' }}")
|
||||
expect(step.run.split('\n')[0]).toBe(
|
||||
'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,10 +7,14 @@ import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs'
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const gate = workflow.jobs.verify.steps.find((step) => step.name === 'Require successful checks')
|
||||
const variable = (job) => job.replaceAll('-', '_').toUpperCase()
|
||||
const requiredJobs = [
|
||||
'preflight',
|
||||
...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck')
|
||||
]
|
||||
|
||||
function requiredResults(shouldRun) {
|
||||
return Object.fromEntries(
|
||||
PR_CHECK_JOBS.flatMap((job) => [
|
||||
requiredJobs.flatMap((job) => [
|
||||
[variable(job), shouldRun ? 'success' : 'skipped'],
|
||||
[`${variable(job)}_SHOULD_RUN`, String(shouldRun)]
|
||||
])
|
||||
@@ -42,7 +46,7 @@ describe.skipIf(process.platform === 'win32')(
|
||||
})
|
||||
|
||||
it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => {
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
for (const job of requiredJobs) {
|
||||
for (const result of ['', 'skipped', 'failure', 'cancelled']) {
|
||||
const verdict = await verify({ ...requiredResults(true), [variable(job)]: result })
|
||||
expect(verdict.code, `${job}: ${result}`).toBe(1)
|
||||
@@ -57,7 +61,7 @@ describe.skipIf(process.platform === 'win32')(
|
||||
})
|
||||
|
||||
it('rejects unexpected downstream execution when the proven plan requires skips', async () => {
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
for (const job of requiredJobs) {
|
||||
const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' })
|
||||
expect(verdict.code, job).toBe(1)
|
||||
}
|
||||
|
||||
@@ -157,7 +157,11 @@ describe('ready-for-review required check reuse', () => {
|
||||
"github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'"
|
||||
)
|
||||
expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}')
|
||||
expect(workflow.jobs.verify.needs).toEqual(['code_paths', ...PR_CHECK_JOBS])
|
||||
expect(workflow.jobs.verify.needs).toEqual([
|
||||
'code_paths',
|
||||
'preflight',
|
||||
...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck')
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['pr-test-loc.yml', 'mobile.yml'])(
|
||||
|
||||
@@ -27,6 +27,7 @@ const shellContractFiles = [
|
||||
'src/main/pty/omp-shell-wrapper-alias-safety.test.ts',
|
||||
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
|
||||
'src/main/shell-startup-feature-channel.test.ts',
|
||||
'src/main/zsh-deferred-startup-line-init.live-shell.test.ts',
|
||||
'src/main/zsh-scoped-histfile.live-shell.test.ts',
|
||||
'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
|
||||
'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts',
|
||||
@@ -54,7 +55,7 @@ const realZshUsage =
|
||||
describe('PR workflow parallelism', () => {
|
||||
it('keeps lightweight orchestration jobs on the free slim runner', () => {
|
||||
expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim')
|
||||
expect(workflow.jobs.typecheck['runs-on']).toBe('ubuntu-24.04-arm')
|
||||
expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm')
|
||||
expect(workflow.jobs.verify['runs-on']).toBe('ubuntu-slim')
|
||||
expect(prTestLocWorkflow.jobs.loc['runs-on']).toBe('ubuntu-slim')
|
||||
expect(releasePolicyWorkflow.jobs.enforce['runs-on']).toBe('ubuntu-slim')
|
||||
@@ -79,7 +80,7 @@ describe('PR workflow parallelism', () => {
|
||||
const installStep = sharedTest.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const staticInstall = workflow.jobs.static_analysis.steps.find(
|
||||
const staticInstall = workflow.jobs.preflight.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find(
|
||||
@@ -91,7 +92,7 @@ describe('PR workflow parallelism', () => {
|
||||
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
||||
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['24', '26'])
|
||||
expect(workflow.jobs.test.with.runner).toBe('ubuntu-24.04-arm')
|
||||
expect(workflow.jobs.static_analysis['runs-on']).toBe('ubuntu-24.04-arm')
|
||||
expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm')
|
||||
expect(sharedTest['runs-on']).toBe('${{ inputs.runner }}')
|
||||
expect(unitTestWorkflow.on.workflow_call.inputs.runner.default).toBe('ubuntu-latest')
|
||||
expect(nodeNextWorkflow.jobs.test.with.runner).toBeUndefined()
|
||||
@@ -121,7 +122,7 @@ describe('PR workflow parallelism', () => {
|
||||
}
|
||||
expect(staticInstall.with['native-runtime']).toBe('node')
|
||||
expect(staticInstall.with['node-version']).toBe('24')
|
||||
expect(workflow.jobs.test.needs).toContain('static_analysis')
|
||||
expect(workflow.jobs.test.needs).toContain('preflight')
|
||||
expect(workflow.jobs.test_native_cache).toBeUndefined()
|
||||
expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node')
|
||||
expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}')
|
||||
@@ -348,11 +349,11 @@ describe('PR workflow parallelism', () => {
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
for (const jobName of ['typecheck', 'git_compatibility']) {
|
||||
for (const jobName of ['git_compatibility']) {
|
||||
expect(installFor(jobName).with, jobName).toBeUndefined()
|
||||
}
|
||||
expect(installFor('xterm_patch_sync')).toBeUndefined()
|
||||
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
|
||||
expect(installFor('preflight').with['native-runtime']).toBe('node')
|
||||
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
|
||||
expect(sharedTestInstall.with['native-runtime']).toBe('node')
|
||||
expect(installFor('package').with['native-runtime']).toBe('electron')
|
||||
@@ -478,7 +479,7 @@ describe('PR workflow parallelism', () => {
|
||||
})
|
||||
|
||||
it('reuses TypeScript incremental state across typecheck runs', () => {
|
||||
const steps = workflow.jobs.typecheck.steps
|
||||
const steps = workflow.jobs.preflight.steps
|
||||
const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state')
|
||||
const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck')
|
||||
|
||||
@@ -543,8 +544,7 @@ describe('PR workflow parallelism', () => {
|
||||
it('keeps verify as the aggregate required check', () => {
|
||||
expect(workflow.jobs.verify.needs).toEqual([
|
||||
'code_paths',
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'preflight',
|
||||
'git_compatibility',
|
||||
'codex_index_heal_contract',
|
||||
'xterm_patch_sync',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectLatestStableReleaseTag } from './stable-release-tags.mjs'
|
||||
|
||||
function expectedLatest(tags) {
|
||||
let latest = null
|
||||
let latestParts = []
|
||||
for (const tag of tags) {
|
||||
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const parts = match.slice(1).map((part) => {
|
||||
const value = Number.parseInt(part, 10)
|
||||
return Number.isFinite(value) ? value : 0
|
||||
})
|
||||
let comparison = 0
|
||||
for (let index = 0; index < 3 && comparison === 0; index++) {
|
||||
comparison = parts[index] - (latestParts[index] ?? 0)
|
||||
}
|
||||
if (latest === null || comparison >= 0) {
|
||||
latest = tag
|
||||
latestParts = parts
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
function measurePartMaps(tags) {
|
||||
const nativeMap = Array.prototype.map
|
||||
let partMaps = 0
|
||||
Array.prototype.map = function (callback, thisArg) {
|
||||
partMaps++
|
||||
return nativeMap.call(this, callback, thisArg)
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = selectLatestStableReleaseTag(tags)
|
||||
} finally {
|
||||
Array.prototype.map = nativeMap
|
||||
}
|
||||
return { result, partMaps }
|
||||
}
|
||||
|
||||
describe('stable release tag selection work', () => {
|
||||
it.each([0, 1, 12, 128, 1000])('bounds numeric parsing for %i Git tag strings', (count) => {
|
||||
const tags = Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `v1.${(index * 37) % 17}.${(index * 101) % (count + 1)}`
|
||||
)
|
||||
const input = [...tags]
|
||||
const expected = expectedLatest(tags)
|
||||
const measured = measurePartMaps(Object.freeze(tags))
|
||||
expect(measured.result).toBe(expected)
|
||||
expect(tags).toEqual(input)
|
||||
// Each unchanged comparator converts the two triples through four maps.
|
||||
expect(measured.partMaps).toBeLessThanOrEqual(4 * Math.max(0, count - 1))
|
||||
})
|
||||
|
||||
it('preserves tie spelling, numeric fallback and invalid-tag admission', () => {
|
||||
const cases = [
|
||||
{ tags: [], expected: null },
|
||||
{ tags: ['nightly', 'mobile-v1.2.3', 'v1.2.3-rc.1'], expected: null },
|
||||
{ tags: ['v0001.4.003', 'v1.04.3'], expected: 'v1.04.3' },
|
||||
{ tags: ['v1.04.3', 'v0001.4.003'], expected: 'v0001.4.003' },
|
||||
{ tags: ['v1.2.3', 'v1.2.3\n'], expected: 'v1.2.3' },
|
||||
{ tags: ['v1.2.3\r\n', 'v0.0.1'], expected: 'v0.0.1' },
|
||||
{ tags: [`v${'9'.repeat(400)}.1.2`, 'v0.1.2'], expected: 'v0.1.2' },
|
||||
{
|
||||
tags: ['v9007199254740993.1.0', 'v9007199254740992.1.0'],
|
||||
expected: 'v9007199254740992.1.0'
|
||||
}
|
||||
]
|
||||
for (const { tags, expected } of cases) {
|
||||
const input = [...tags]
|
||||
expect(selectLatestStableReleaseTag(Object.freeze(tags))).toBe(expected)
|
||||
expect(tags).toEqual(input)
|
||||
}
|
||||
const sparse = []
|
||||
sparse.length = 12
|
||||
sparse[3] = 'v1.2.3'
|
||||
sparse[8] = 'v2.0.0'
|
||||
expect(selectLatestStableReleaseTag(Object.freeze(sparse))).toBe('v2.0.0')
|
||||
})
|
||||
|
||||
it('selects the same latest spelling across ordinary version namespaces and repeated calls', () => {
|
||||
let randomState = 673151
|
||||
const next = () => {
|
||||
randomState = (randomState * 1664525 + 1013904223) >>> 0
|
||||
return randomState
|
||||
}
|
||||
for (let seed = 0; seed < 256; seed++) {
|
||||
const tags = []
|
||||
for (let index = 0, count = next() % 129; index < count; index++) {
|
||||
const triple = [next() % 13, next() % 17, next() % 257]
|
||||
const prefix = next() % 8 === 0 ? 'mobile-v' : 'v'
|
||||
const suffix = next() % 9 === 0 ? '-rc.1' : ''
|
||||
tags.push(`${prefix}${triple.join('.')}${suffix}`)
|
||||
if (index % 11 === 0) {
|
||||
tags.push(tags.at(-1))
|
||||
}
|
||||
}
|
||||
const input = [...tags]
|
||||
const expected = expectedLatest(tags)
|
||||
expect(selectLatestStableReleaseTag(tags)).toBe(expected)
|
||||
expect(tags).toEqual(input)
|
||||
tags.push('v99.99.99')
|
||||
expect(selectLatestStableReleaseTag(tags)).toBe('v99.99.99')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -21,10 +21,14 @@ export function compareReleaseTags(a, b) {
|
||||
|
||||
/** @param {string[]} tags @returns {string | null} */
|
||||
export function selectLatestStableReleaseTag(tags) {
|
||||
return (
|
||||
tags
|
||||
.filter((tag) => STABLE_DESKTOP_RELEASE_TAG.test(tag))
|
||||
.sort(compareReleaseTags)
|
||||
.at(-1) ?? null
|
||||
)
|
||||
let latest = null
|
||||
for (const tag of tags) {
|
||||
if (
|
||||
STABLE_DESKTOP_RELEASE_TAG.test(tag) &&
|
||||
(latest === null || compareReleaseTags(latest, tag) <= 0)
|
||||
) {
|
||||
latest = tag
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir, userInfo } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { takeRealAgentHomeWriteViolations } from './vitest-real-agent-home-write-guard'
|
||||
import {
|
||||
clearInheritedAgentStateEnv,
|
||||
takeRealAgentHomeWriteViolations
|
||||
} from './vitest-real-agent-home-write-guard'
|
||||
|
||||
// Why never-created paths: each sits under a missing folder or is a forced no-op removal, so even
|
||||
// with the guard off (the ablation) nothing lands in the real home.
|
||||
@@ -17,6 +20,29 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('vitest real-agent-home write guard', () => {
|
||||
it.each([undefined, '', '0', 'true', '1'])(
|
||||
'keeps only the explicitly opted-in Claude profile for %s',
|
||||
(value) => {
|
||||
vi.stubEnv('ORCA_REAL_CLAUDE_CLI_TEST', value)
|
||||
vi.stubEnv('CLAUDE_CONFIG_DIR', '/tmp/explicit-claude-profile')
|
||||
vi.stubEnv('CODEX_HOME', '/tmp/inherited-codex-profile')
|
||||
vi.stubEnv('ORCA_USER_DATA_PATH', '/tmp/inherited-orca-state')
|
||||
vi.stubEnv('ORCA_CODEX_LAUNCH_PREFLIGHT', '/tmp/inherited-live-cli')
|
||||
|
||||
clearInheritedAgentStateEnv()
|
||||
|
||||
expect(process.env.CLAUDE_CONFIG_DIR).toBe(
|
||||
value === '1' ? '/tmp/explicit-claude-profile' : undefined
|
||||
)
|
||||
expect(process.env.CODEX_HOME).toBeUndefined()
|
||||
expect(process.env.ORCA_USER_DATA_PATH).toBeUndefined()
|
||||
expect(process.env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined()
|
||||
expect(() => rmSync(missingRealFolder('.claude'), { force: true })).toThrow(
|
||||
/real-agent-home guard/
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses a named-import sync write under the real ~/.codex', () => {
|
||||
const target = join(missingRealFolder('.codex'), 'config.toml')
|
||||
expect(() => writeFileSync(target, '[projects."/tmp/x"]\n')).toThrow(/real-agent-home guard/)
|
||||
|
||||
@@ -188,12 +188,18 @@ declare global {
|
||||
}
|
||||
const state = (globalThis.orcaRealAgentHomeWriteGuard ??= install())
|
||||
// Why after install: the guard keeps the inherited paths as roots; tests that need one set their own.
|
||||
const inheritedEnvToUnset = realAgentSuiteOptedIn()
|
||||
? []
|
||||
: [...INHERITED_STATE_ENV, ...INHERITED_LIVE_CLI_ENV]
|
||||
for (const name of inheritedEnvToUnset) {
|
||||
delete process.env[name]
|
||||
export function clearInheritedAgentStateEnv(): void {
|
||||
const inheritedEnvToUnset = realAgentSuiteOptedIn()
|
||||
? []
|
||||
: [...INHERITED_STATE_ENV, ...INHERITED_LIVE_CLI_ENV]
|
||||
for (const name of inheritedEnvToUnset) {
|
||||
if (name === 'CLAUDE_CONFIG_DIR' && process.env.ORCA_REAL_CLAUDE_CLI_TEST === '1') {
|
||||
continue
|
||||
}
|
||||
delete process.env[name]
|
||||
}
|
||||
}
|
||||
clearInheritedAgentStateEnv()
|
||||
|
||||
/** Drains recorded violations; only the guard's own self-test should need this. */
|
||||
export function takeRealAgentHomeWriteViolations(): string[] {
|
||||
|
||||
@@ -258,8 +258,8 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
// TypeScript 7 removed node10 resolution; Node16 preserves CommonJS emit for this package.
|
||||
"module": "Node16",
|
||||
// The CLI runs on Node 24; Node20 models synchronous ESM imports from CommonJS.
|
||||
"module": "Node20",
|
||||
"moduleResolution": "Node16",
|
||||
"rootDir": "../src",
|
||||
"outDir": "../out"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.cli.json",
|
||||
"compilerOptions": {
|
||||
"module": "node16",
|
||||
"module": "Node20",
|
||||
"moduleResolution": "node16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 90m">
|
||||
<title>downloads: 90m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 91m">
|
||||
<title>downloads: 91m</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">90m</text>
|
||||
<text x="90" y="14">90m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">91m</text>
|
||||
<text x="90" y="14">91m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -231,7 +231,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
|
||||
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -239,7 +239,7 @@ yay -S stably-orca-bin
|
||||
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
|
||||
|
||||
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android :** [Télécharger l'APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk)
|
||||
- **Android :** [Télécharger l'APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -231,7 +231,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -234,7 +234,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 0.0.50 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [APK 0.0.52 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
|
||||
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -234,7 +234,7 @@ yay -S stably-orca-bin
|
||||
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Baixar APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk)
|
||||
- **Android:** [Baixar APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -231,7 +231,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -46,6 +46,112 @@ used 42 aggregate runner-minutes across 11 test jobs. The
|
||||
estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are
|
||||
baseline observations; post-merge savings have not yet been measured.
|
||||
|
||||
## October 4 clock, byte and import test fixtures
|
||||
|
||||
These changes retain production behavior, original case names and platform
|
||||
outcomes. The paired pilots use three alternating one-worker Node 24 invocations
|
||||
on Ubuntu 24 ARM. Every median below is a complete focused test invocation;
|
||||
they do not establish whole-shard savings or queue-delay improvements.
|
||||
|
||||
| Workload | Baseline median | Candidate median | Reduction | Hosted evidence |
|
||||
| ---------------------------------------------------- | --------------- | ---------------- | --------- | ------------------------------------------------------------------------ |
|
||||
| Codex settlement and Claude stop deadlines, 35 cases | 35.914s | 10.142s | 71.8% | [37186232658](https://github.com/stablyai/orca/actions/runs/37186232658) |
|
||||
| Native-chat delivery, 15 cases | 22.321s | 7.376s | 67.0% | [37183731823](https://github.com/stablyai/orca/actions/runs/37183731823) |
|
||||
| Six profile-storage byte suites, 118 cases | 12.629s | 9.978s | 21.0% | [37183141654](https://github.com/stablyai/orca/actions/runs/37183141654) |
|
||||
| Encrypted account storage, six cases | 18.277s | 0.958s | 94.8% | [37184007241](https://github.com/stablyai/orca/actions/runs/37184007241) |
|
||||
| SSH remote commands, 27 cases | 6.840s | 6.078s | 11.1% | [37184454532](https://github.com/stablyai/orca/actions/runs/37184454532) |
|
||||
| OpenCode subscription, 28 cases | 47.347s | 6.284s | 86.7% | [37184858823](https://github.com/stablyai/orca/actions/runs/37184858823) |
|
||||
| Window-service attachment, 31 cases | 11.910s | 1.619s | 86.4% | [37186340840](https://github.com/stablyai/orca/actions/runs/37186340840) |
|
||||
| OpenCode 2 TUI ownership, 41 cases | 16.811s | 2.429s | 85.6% | [37187699312](https://github.com/stablyai/orca/actions/runs/37187699312) |
|
||||
| Title-send authorization, nine cases | 29.545s | 12.995s | 56.0% | [37188423318](https://github.com/stablyai/orca/actions/runs/37188423318) |
|
||||
| Range selection, 15 cases | 9.737s | 7.130s | 26.8% | [37188996198](https://github.com/stablyai/orca/actions/runs/37188996198) |
|
||||
|
||||
Provider tests wait for the real fake-child write/ready barrier and drain the
|
||||
host stream before installing a scoped parent clock. The original 2.5/5-second
|
||||
Codex and 3-second Claude deadlines remain; before/at assertions check their
|
||||
boundaries. Early rejection and refusal resolve without waiting on an unreachable
|
||||
barrier; finally and suite teardown restore clocks and spies even after a native
|
||||
Vitest timeout. Four healthy failure-path controls pass; old-helper hangs and
|
||||
removed teardown are caught. Missing-child failure retains a real ten-second observation window.
|
||||
Six faults for early/late stop deadlines, late queued resend and missing child
|
||||
output fail the intended assertions. Native-chat tests still use the real React
|
||||
outbox hooks. Their scoped clock retains probe, retry, churn and target-switch
|
||||
windows, drains async act work, and unmounts before clock restoration. All eight
|
||||
hook faults fail; two boundary faults pass the old coarse tests and fail the new
|
||||
before/at assertions. Node/web typecheck, lint and formatting pass.
|
||||
|
||||
Native Buffer.equals replaces deep per-byte assertion traversal. It checks the
|
||||
complete original bytes and length; fixtures, SQLite operations, encryption and
|
||||
processes are unchanged. The six profile suites retain 114 passes and four
|
||||
existing Linux case-sensitivity skips; all 118 pass locally on macOS. Seven
|
||||
profile last-byte/length faults and two encrypted-vault last-byte/length faults
|
||||
fail their exact byte assertions. All six encrypted-vault cases still exercise
|
||||
52 accounts near the 4 MiB encrypted cap, refused growth, restart/readback and
|
||||
private permissions.
|
||||
|
||||
The old SSH fixture created 15,197 short stage paths but never exceeded the real
|
||||
1,048,576 UTF-16-character transport tail cap; its two valid entries also left
|
||||
the 64-result assertion vacuous. The replacement uses about 1,300 real excluded
|
||||
stage directories under long Unicode path components, a real shell channel and
|
||||
the production execCommand limiter. Actual find output exceeds that cap, while
|
||||
the generated filter retains both original valid entries. A separate population
|
||||
of 65 valid directories proves the first-64 limit and native find ordering. File,
|
||||
symlink, nested-install and failed-enumeration checks remain. All 27 case outcomes
|
||||
match across treatments (23 passes and four unavailable PowerShell 5.1 skips on
|
||||
the hosted image). Eight cap, ordering, filtering and failure faults are caught.
|
||||
Paths use platform utilities; local PowerShell availability retains its original
|
||||
skip policy. The deadline and SSH fixtures reuse existing process/stream code.
|
||||
OpenCode subscription tests batch only uninterrupted fixture writes between the
|
||||
original observation barriers. Both SQLite schema versions keep every row, rowid,
|
||||
read cap, poll, clock position and case. No durability pragma or production code
|
||||
changes. All 28 cases pass in every pair. Separate captures compare ordered
|
||||
schema and rows, transaction state, pragmas, signals, page requests/results and
|
||||
subscriber callbacks: 124,754,101 payload bytes match, canonical digest
|
||||
`ba0eb6f09281746071d73fae88e2e8eb45332f36892b90998b374b1b8c59b3e3`.
|
||||
Missing rows, collapsed frontier rowids, read-cap overruns, missing commit and
|
||||
missing rollback each fail the intended case in both schemas. Positive rollback
|
||||
controls pass. The unmeasured full-suite timing report ranked this fixture at
|
||||
134.229 seconds; the paired 47.347-second figure above is the relevant focused
|
||||
baseline, and the two figures must not be mixed into a claimed saving.
|
||||
|
||||
Window attachment tests mock five unrelated registrar modules using their actual
|
||||
types. The existing window ownership, reload, media permission, native file drop,
|
||||
hydration barrier and updater scheduling cases remain real. Exact store/runtime/
|
||||
window arguments and daemon registration after the PTY handler are now asserted;
|
||||
nine actual production wiring/order faults fail. The registrar implementations
|
||||
retain separate handler tests. Concrete existing stubs moved to one fixture to
|
||||
stay within the line limit. All 31 cases and statuses match across all pairs. The
|
||||
final source differs from the timed source only by a required type-assertion
|
||||
safety comment.
|
||||
OpenCode 2 TUI tests use a scoped async clock only after the first real native
|
||||
module import. The unchanged generated plugin runs against the existing fake TUI
|
||||
and fetch. Original poll, permission, retry, preview, slow POST and endpoint
|
||||
windows remain. Before/at assertions pin the 100 ms poll, 500 ms permission,
|
||||
120 ms slow POST and 5-second endpoint boundaries. All 41 original case outcomes
|
||||
match. Separate ordered captures preserve 678 TUI/event rows and 362 complete
|
||||
POST start/completion rows; wall timestamps and cross-stream interleaving are
|
||||
excluded from equivalence. Eleven generated-source faults fail their intended
|
||||
identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/
|
||||
disposer rejection and timeout controls confirm restoration of clocks, fetch,
|
||||
argv and environment. Teardown holds its fake clock through bounded native cleanup and pending-timer checks, then restores real clocks. The [teardown qualification](https://github.com/stablyai/orca/actions/runs/37195736834) passes all 41 cases and 12 failure and restoration control invocations, including interval and retry leaks missed by the earlier teardown.
|
||||
Title-send authorization tests retain real terminal creation, graph binding and
|
||||
positive evidence paths. Negative process-evidence probes use scoped clocks
|
||||
after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at
|
||||
6,600 ms, and the send guard retains its 1,050 ms deadline. Before/at assertions
|
||||
check 6,599/6,600 and 1,049/1,050 ms. All nine original cases remain. Actual
|
||||
early/late wrapper and guard deadlines, false spinner identity and unknown-agent
|
||||
authorization faults fail their intended assertions; native clocks and runtime
|
||||
instance spies are restored after each failure.
|
||||
Range-selection tests stub only the saved-note send menu, which their empty
|
||||
comment populations never render. A facade typed from the actual menu props
|
||||
throws if invoked, and an afterEach assertion verifies no call with unconditional
|
||||
mock clearing in finally. The real hook, Monaco constants/model, line and range
|
||||
drag behavior, draft-card lifecycle and open-inline-card chord remain. All 15
|
||||
original test bodies are byte-unchanged. Three actual range/hunk/draft faults
|
||||
fail their original assertions; a real draft-render menu call hits the facade
|
||||
and sentinel, and an outer cleanup check proves mock state cleared after failure.
|
||||
The actual saved-note menu retains its independent component tests.
|
||||
|
||||
## October 2 headless detector compiler cache
|
||||
|
||||
The deferred detector already avoids dependency setup for known build inputs.
|
||||
@@ -601,6 +707,8 @@ All commands passed and plans matched within each comparison. These command
|
||||
timings exclude setup and queues; compiler variation contributes to the cold
|
||||
difference. The retained arrangement showed no cold compiler penalty.
|
||||
|
||||
The sequential gate in [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity) supersedes the earlier rejection below.
|
||||
|
||||
Combining static analysis too was rejected. An
|
||||
[alternating same-runner comparison](https://github.com/stablyai/orca/actions/runs/36835091650)
|
||||
saved runner occupancy, but cold compilation slowed from 61–64 to 83–89 seconds
|
||||
@@ -1739,6 +1847,8 @@ these local body measurements do not establish hosted or whole-PR time savings.
|
||||
|
||||
## Sequential static analysis and typecheck: retain separate jobs
|
||||
|
||||
The earlier recommendation below is superseded by [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity).
|
||||
|
||||
A four-trial hosted screen kept the slim router unchanged and compared the two
|
||||
independent ARM jobs with one ARM job running their unchanged checks sequentially.
|
||||
The [compiler/planner census](https://github.com/stablyai/orca/actions/runs/37069472888)
|
||||
@@ -1858,6 +1968,32 @@ the imported helper has no top-level side effects, and the Linux rebuild branch
|
||||
is unchanged. Focused tests verify its Linux/macOS no-op behavior. Final-head PR
|
||||
checks qualify separately.
|
||||
|
||||
## October 4 reusable cells for terminal context scans
|
||||
|
||||
Terminal cursor-context scans now request one reusable cell per invocation when
|
||||
the adapter offers getNullCell, and pass it through all unchanged text/style
|
||||
scans. Adapters without that optional method keep the existing allocating path.
|
||||
The scratch cell is local and no cell reference escapes into returned context.
|
||||
Browser composer/readiness text, colors, bold flags and wrapping are unchanged.
|
||||
|
||||
Three alternating one-worker ARM pairs in
|
||||
[37182789677](https://github.com/stablyai/orca/actions/runs/37182789677)
|
||||
ran all 19 original cases from readiness census suite 2. Baseline complete
|
||||
invocations were 37.141 / 37.879 / 37.090 seconds; candidate invocations were
|
||||
33.887 / 33.387 / 32.916 seconds. Median 37.141 to 33.387 seconds saves 10.1%.
|
||||
This is a focused workload measurement, not a whole-shard or queue-delay claim.
|
||||
|
||||
Separate baseline/candidate captures retained all 192 cases across six census
|
||||
suites. Every context and visible projection matched: 643,926 of each, with
|
||||
7,465,308,324 complete length-prefixed payload bytes hashed per test/type/order.
|
||||
The canonical capture digest was
|
||||
`f7440c0f1b5bbb57127cd29245530029415c8e9e243c1744359f330b3c7ace19`.
|
||||
These captures run outside the timing samples. All 41 cursor/composer/browser
|
||||
consumer checks passed. Seven faults for lost dim filtering, wide continuation,
|
||||
bold prompt, custom foreground, wrap preservation, adapter fallback and scratch
|
||||
reuse failed their intended assertions. Node and web typecheck, lint and format
|
||||
passed. Two added controls prove per-call scratch lifetime and adapter parity.
|
||||
|
||||
## October 3 producer follow-up: automatic selection for the measured profile
|
||||
|
||||
The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927)
|
||||
@@ -1895,6 +2031,36 @@ automatic selection and cold publication, not a new timing result. Local
|
||||
verification passed eight suites / 184 tests, the changed-code quality gate and
|
||||
compiled-composite actionlint.
|
||||
|
||||
## October 4 shared PR preflight capacity
|
||||
|
||||
Static analysis and the unchanged compiler now share one ARM runner and guarded
|
||||
Node 24 install. Static checks finish and all background work joins before the
|
||||
compiler starts; unit planning still overlaps compilation. Each phase keeps its
|
||||
classifier output. Successful no-op background bodies register every required
|
||||
join when a phase is unselected or an earlier step failed. Unit and package
|
||||
consumers depend on physical job success, including action cleanup.
|
||||
|
||||
Three counterbalanced pairs in
|
||||
[37180613601](https://github.com/stablyai/orca/actions/runs/37180613601)
|
||||
used the same frozen checkout `f199a20c3acd`, Node 24.21.0, pnpm 12.8.1,
|
||||
policy hashes, native cache hits, warm TypeScript cache and 10,787-file unit plan.
|
||||
Both arms used the PR root-only download-store policy. Total active job time was
|
||||
152 / 153 / 151 seconds separately and 138 / 133 / 129 combined. Excluding the
|
||||
extra measurement-only evidence steps gives 151 / 151 / 149 versus
|
||||
136 / 132 / 128 seconds: median 151 to 132, saving 19 seconds (12.6%).
|
||||
Two heavy runner admissions become one. This saves capacity; it does not prove a
|
||||
whole-PR latency or queue gain. The median active dependency barrier increases
|
||||
from 116 to 132 seconds because compilation follows static checks.
|
||||
|
||||
The separate physical-failure run
|
||||
[37180755694](https://github.com/stablyai/orca/actions/runs/37180755694)
|
||||
proved that an included TypeScript error failed the actual compiler, its planner
|
||||
still joined, and unit/package admissions skipped. A registered late action post
|
||||
failure also blocked both consumers after successful foreground checks and
|
||||
published shards. All 12 unselected/prior-failure no-op backgrounds joined, and
|
||||
the downstream audit passed. Local workflow contracts passed 239 tests across
|
||||
12 suites; lint and formatting passed.
|
||||
|
||||
## October 3 retired-cache collection observation
|
||||
|
||||
The same owner-collection assertion failed in unit shard 3 of
|
||||
@@ -1912,6 +2078,47 @@ The source was restored afterward. Extra collection turns therefore preserve the
|
||||
strong-retention oracle. Hosted qualification is still required; these observations
|
||||
do not prove a particular VM-retention cause or quantify avoided retries.
|
||||
|
||||
## October 4 terminal oracle execution
|
||||
|
||||
Three measured test-support changes preserve the original seeds, payloads,
|
||||
chunk boundaries and meaningful assertions. Serializer comparisons reuse cells
|
||||
and format only the first mismatch instead of allocating descriptors for every
|
||||
cell. The terminal parity writer submits every original chunk in FIFO order and
|
||||
awaits the final parser callback. The independent legacy frame oracle memoizes
|
||||
measured code-point widths. Its discarded algebra-only case never called
|
||||
production and still passed when production always threw.
|
||||
|
||||
Three alternating one-worker hosted ARM pairs measured complete invocations:
|
||||
|
||||
| Cohort | Baseline median | Candidate median | Saving |
|
||||
| ---------------------------------------- | --------------- | ---------------- | ------ |
|
||||
| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% |
|
||||
| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% |
|
||||
| Frame equivalence | 18.472s | 13.736s | 25.6% |
|
||||
|
||||
[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143)
|
||||
retained 116 timed serializer passes and three existing/paired-control skips.
|
||||
Separate captures matched all 190,796,645 raw bytes over 1,611 scenarios and
|
||||
8,617 checkpoints (SHA256 `00ab219cfb31456af2ecd5e766d1b82d47abc751f6f2de0d7f795e36a936d3c7`),
|
||||
including complete outputs and diagnostic payloads. Twenty candidate controls
|
||||
passed; formatting/color/blank/clipping fault controls detected regressions.
|
||||
|
||||
[37181073275](https://github.com/stablyai/orca/actions/runs/37181073275)
|
||||
retained all 16 parity cases and default fuzz counts. Captures matched 2,325
|
||||
batches, 28,182 original chunks and 1,698,285 input bytes, with identical
|
||||
terminal state and serialization per terminal/batch. Independent terminal
|
||||
completion order differs, so comparison uses canonical per-terminal ordering
|
||||
(SHA256 `c38ac1dbbefb9f6dc33ecfe7c495d65b707c1664614544622af93cfc1850e421`).
|
||||
All 73 callback/parser/other-consumer controls passed; first-callback, reversed
|
||||
chunks, missing empty boundary and early-completion faults failed.
|
||||
|
||||
The frame candidate passed all 19 retained cases directly against the original
|
||||
uncached legacy oracle, preserving 4,000 short and 800 near-cap seeded trials.
|
||||
Sequence, surrogate width, byte width and span-transform faults failed real
|
||||
assertions. A part-array alternative was rejected after adding time locally.
|
||||
Hosted Node typecheck passed. These are focused workload savings, not measured
|
||||
whole-shard or queue-delay improvements; application behavior is unchanged.
|
||||
|
||||
## October 3 unit-selection evidence: include failed references
|
||||
|
||||
The caller's `needs.test.result == 'success'` condition prevented the advisory
|
||||
@@ -1941,6 +2148,49 @@ remained red. Its review recognized all five shards as a complete reference
|
||||
(10,608 files, 8,965,977 worker-ms). This was again a full fallback with
|
||||
`selectionEvaluated: false`, not evidence for enabling selected tests.
|
||||
|
||||
## October 4 runtime imports and recovery fixtures
|
||||
|
||||
Three helper-only tests now import the existing terminal modules directly rather
|
||||
than initializing the runtime service. Ten copied-loop cases never exercised
|
||||
runtime memoization: they passed with its cache, timestamp update or prune
|
||||
invalidation disabled. Two actual helper checks remain. The existing runtime
|
||||
prune suite now exercises real leaf cache reuse, split prompt timestamps,
|
||||
ordinary output, fresh prompts and detection after retained-history eviction.
|
||||
Each of those three production faults fails a real runtime assertion.
|
||||
|
||||
Recovery tests now seed three exact fixture variants once, after the seed child
|
||||
has closed. Each crash still receives an independent byte-for-byte copy of the
|
||||
entire database/WAL family and remapped paths. Buffer.equals retains exact byte
|
||||
comparison without recursive matcher overhead. All 46 original crash boundaries
|
||||
and retries remain. Four additional copy-isolation/WAL checks run, and teardown
|
||||
requires that all seed bytes remain unchanged after the full suite.
|
||||
|
||||
Three alternating one-worker hosted ARM pairs in
|
||||
[37182181976](https://github.com/stablyai/orca/actions/runs/37182181976)
|
||||
measured these complete invocations:
|
||||
|
||||
| Cohort | Baseline seconds | Candidate seconds | Median saving |
|
||||
| --------------------------------- | ------------------------ | ------------------------ | ------------- |
|
||||
| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% |
|
||||
| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% |
|
||||
| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% |
|
||||
|
||||
The final runtime cohort has seven real cases versus 16 including the copied
|
||||
loops; its new runtime case is included in candidate timing. Recovery has 50
|
||||
passes versus the original 46. Hosted Node typecheck passed. Recovery faults for
|
||||
last-byte database/WAL corruption, shared database paths, missing WAL copies and
|
||||
accepted/unaccepted seed collision failed the intended assertions. These are
|
||||
focused workload savings, not measured whole-shard or queue-delay gains.
|
||||
|
||||
An independent local cache screen left both caches disabled. Across 14 unchanged
|
||||
files and 92 cases, a warm Vitest transform cache reduced median invocation time
|
||||
3.090 to 1.948 seconds, excluding archive costs; its cold arm increased time to
|
||||
3.281 seconds. Node compilation caching showed no gain. Controls reproduced stale
|
||||
transforms after TypeScript configuration or plugin-option changes, so persisted
|
||||
reuse requires a complete transform-input stamp and hosted net-cost evidence.
|
||||
A separate 130,000-pane leaf-collection optimization was restored: its complete
|
||||
migration-file timing stayed within noise. The regression fixture remains.
|
||||
|
||||
## October 3 removal fixture cleanup ordering
|
||||
|
||||
[37105566358](https://github.com/stablyai/orca/actions/runs/37105566358)
|
||||
@@ -1959,3 +2209,177 @@ same ordering assertion. The gate was released, both controls drained the captur
|
||||
job, and the instrumentation was removed. Changed-code quality passed. This proves
|
||||
the teardown ordering mechanism, not a measured avoided-retry saving. Final-head
|
||||
hosted qualification remains required.
|
||||
|
||||
## October 4 store oracle and retention fixtures
|
||||
|
||||
The randomized in-place-store test validated the copying oracle twice after
|
||||
accepted mutations and compared snapshots through the same production parser.
|
||||
Its 5,000-step retention fixture generated enough tombstones to hit the count
|
||||
limit, but never reached the 4,096-revision age boundary.
|
||||
|
||||
The test retains all four seeds and 1,500 mutations per seed, removes the duplicate
|
||||
validation, and projects snapshots directly from the copying oracle's validated
|
||||
maps. Separate fixtures now check the revision before, at and after expiry and
|
||||
count overflow. Production code is unchanged.
|
||||
|
||||
Three alternating one-worker pairs on `ubuntu-24.04-arm` in
|
||||
[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143)
|
||||
measured baseline invocation times 33.551 / 33.304 / 33.529 seconds and candidate
|
||||
13.848 / 13.816 / 13.875 seconds: median 33.529 to 13.848 seconds, saving 19.681
|
||||
seconds (58.7%). Baseline passed seven tests; candidate passed eight. This is a
|
||||
focused test saving, not a measured whole-shard or queue-delay change.
|
||||
|
||||
Hosted Node typecheck passed. Separate fault controls failed the intended
|
||||
assertion for early, late and disabled age expiry, disabled count compaction,
|
||||
and a snapshot that drops child descriptions. The description fault passes with
|
||||
the original parser-sharing oracle and fails with the independent projection.
|
||||
|
||||
## October 4 Git contention and remaining readiness waits
|
||||
|
||||
The full Git admission benchmark compared a disabled arm with no correctness
|
||||
assertions to an enabled arm with structural ledger checks. Its default CI test
|
||||
now saturates the real base and headroom budgets with FIFO-gated child processes,
|
||||
queues older background and newer interactive work, releases base slots, and
|
||||
requires interactive priority, matching outputs and complete permit release.
|
||||
The full original diagnostic remains opt-in through
|
||||
`ORCA_GIT_ADMISSION_STORM_MEASUREMENT=1`; both opt-in tests passed locally.
|
||||
The existing Windows real-Git parity tests remain unchanged; this fixture retains
|
||||
its existing POSIX platform scope.
|
||||
|
||||
Two remaining Antigravity transcript tests used real 5,000ms refusal windows.
|
||||
They now use the existing scoped `waitForTranscriptIdle` timer harness after the
|
||||
emulator drains. All 60 tests, original captured transcripts, deadlines and
|
||||
readiness assertions remain.
|
||||
|
||||
Three alternating one-worker hosted ARM pairs in
|
||||
[37180614492](https://github.com/stablyai/orca/actions/runs/37180614492)
|
||||
measured these complete focused invocations:
|
||||
|
||||
| Suite | Baseline seconds | Candidate seconds | Median saving |
|
||||
| --------------------- | ------------------------ | ------------------------ | --------------- |
|
||||
| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) |
|
||||
| Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) |
|
||||
|
||||
Each candidate passed its original meaningful checks. Hosted Node typecheck
|
||||
passed. Separate scheduler faults for bypassed admission, withheld release and
|
||||
FIFO-only priority failed the queued-contention or interactive-start assertion.
|
||||
Two additional local transcript faults failed the original picker-rejection and
|
||||
repaint-readiness assertions. These are focused suite savings; whole-shard time
|
||||
and queue delay were not measured by this experiment.
|
||||
|
||||
## October 4 aggregate unit-test comparison
|
||||
|
||||
A [counterbalanced hosted comparison](https://github.com/stablyai/orca/actions/runs/37197643399)
|
||||
measured 128.605 seconds less summed test-process time (4.36%) and a 37.694-second
|
||||
reduction in the slowest shard (5.98%). It compares the accepted optimizations
|
||||
with their original file snapshots on the same source, five fixed shard
|
||||
assignments, Node 24, Ubuntu ARM and four workers per process. This is one paired
|
||||
trial, not a population estimate or a measurement of PR queue delay.
|
||||
|
||||
| Test process | Original snapshots | Accepted optimizations |
|
||||
| ----------------------------- | ------------------ | ---------------------- |
|
||||
| Shard 1 | 574.221s | 533.116s |
|
||||
| Shard 2 | 572.553s | 569.593s |
|
||||
| Shard 3 | 630.629s | 592.935s |
|
||||
| Shard 4 | 565.412s | 546.759s |
|
||||
| Shard 5 | 609.410s | 581.218s |
|
||||
| Sum: runner time during tests | 2952.225s | 2823.621s |
|
||||
| Maximum: test critical path | 630.629s | 592.935s |
|
||||
|
||||
Both arms cover exactly 10,838 timed modules on source `9574c8adb253` and tree
|
||||
`4d5487e8b827`. One added eight-case batching qualification passes in a separate
|
||||
0.770-second invocation outside the table, completing the 10,839-module ordinary
|
||||
census. The complete timed case and outcome
|
||||
comparison accounts for eight approved coverage changes: 105,231 original cases
|
||||
versus 105,242 candidate cases. Removed copied simulations and an algebra-only
|
||||
case are accompanied by real runtime, retention, recovery and reusable-cell
|
||||
regressions. No unexpected case or outcome difference is accepted.
|
||||
|
||||
Each arm starts with distinct empty transform and result caches; Node compilation
|
||||
caching is disabled. Cold-cache execution ordering remains Vitest's default and
|
||||
can change with source size. Source snapshots, assignments, raw reports and case outcomes
|
||||
are checked. Only three case-title fields containing random temporary paths or
|
||||
a UUID use stable identities, bound to the exact two test-source hashes; raw
|
||||
titles remain in the artifacts.
|
||||
|
||||
The five dependency setups total 84.284 seconds and are shared by both arms.
|
||||
The actual paired jobs consumed 5,969 seconds and spanned 2,031 seconds from the
|
||||
first start to the last completion. Those job figures include both treatments,
|
||||
setup, uploads and staggered starts; they cannot be assigned to either arm or
|
||||
used as a workflow saving. The table measures test-process wall time, not CPU
|
||||
time or the complete CI workflow. Focused-suite percentages elsewhere in this
|
||||
report are separate measurements and must not be summed into these results.
|
||||
|
||||
The [earlier aggregate trial](https://github.com/stablyai/orca/actions/runs/37193799646)
|
||||
is rejected because a real test failed; its timings do not qualify a gain. Two
|
||||
local invocations sharing one XDG directory reproduced the Muse refresher failure.
|
||||
The fixture now isolates and restores that setting in both arms. The historical
|
||||
serializer case ledger was also independently corrected from the original source
|
||||
before this fresh trial; its seven original cases and twenty candidate cases are
|
||||
an explicit coverage change rather than an assumed equal census.
|
||||
|
||||
## October 4 shard-weight holdouts
|
||||
|
||||
Fresh shard weights were generated with the production importer from a complete
|
||||
successful run of the accepted source. Two subsequent hosted holdouts used those
|
||||
same weights and assignments without retraining. The
|
||||
[first pair](https://github.com/stablyai/orca/actions/runs/37199891967) alternated
|
||||
existing and fresh assignments across the five jobs; the
|
||||
[second pair](https://github.com/stablyai/orca/actions/runs/37201939057) reversed
|
||||
each job's treatment order.
|
||||
|
||||
| Test-process measurement | First: existing | First: fresh | Reversed: existing | Reversed: fresh |
|
||||
| ------------------------ | --------------- | ------------ | ------------------ | --------------- |
|
||||
| Sum across five shards | 2813.595s | 2776.421s | 2924.689s | 2878.481s |
|
||||
| Maximum shard wall | 578.735s | 584.709s | 603.995s | 614.408s |
|
||||
|
||||
Fresh weights reduced summed test-process time by 1.32% and 1.58%, but increased
|
||||
the slowest shard's time by 1.03% and 1.72%. The small capacity saving comes with
|
||||
a repeated critical-path regression, so the existing weights remain. The 3.01%
|
||||
improvement projected from training module durations is not a measured speed
|
||||
gain.
|
||||
|
||||
Every arm covers the same 10,839 modules and 105,250 case outcomes on source
|
||||
`9574c8adb253`, tree `4d5487e8b827`, Node 24.21.0, Ubuntu ARM and four workers.
|
||||
Both trials use cold caches and the same training data, weights, plans and case
|
||||
identity rules. Complete raw reports, assignments, hashes and opposite treatment
|
||||
orders are checked before combining the results. Two pairs supply no statistical
|
||||
confidence or account-wide queue measurement. The second run's jobs started 119
|
||||
seconds apart; that stagger and the paired jobs' setup and upload costs are
|
||||
separate from the treatment timings above.
|
||||
|
||||
## October 4 remaining unit-test opportunities
|
||||
|
||||
The audit retained real child-process, PTY, SSH and crash-boundary tests. It
|
||||
removed copied simulations or algebra-only cases after fault controls showed
|
||||
that they could pass with production behavior broken. The retained or replacement
|
||||
tests exercise production behavior directly. The focused timings in this report
|
||||
use the final qualified checks. They must not be added together to estimate a
|
||||
whole-workflow saving.
|
||||
|
||||
Several further changes did not justify promotion:
|
||||
|
||||
- A synchronous readiness-clock screen retained all 49 shard cases and their
|
||||
outcomes, but complete invocation time changed only from 34.210 to 33.518
|
||||
seconds in one local pair. That 2% result was too small to ship without a
|
||||
stronger result; the original implementation remains.
|
||||
- A larger local transform-cache screen reduced warm test execution. Separately
|
||||
measured medians for warm tests (17.251 seconds), extraction (3.539 seconds) and
|
||||
archive creation (3.092 seconds) sum to 23.882 seconds, versus 23.473 seconds
|
||||
with caching disabled. This component estimate excludes transfer costs; it is not an
|
||||
end-to-end measurement. Unkeyed plugin options, inherited configuration and
|
||||
import priority also produced stale reuse. Persisted test transforms remain
|
||||
disabled.
|
||||
- Two successful full-run shadow references identified about 1.9% of
|
||||
recorded worker time as omittable. That is advisory worker time, not measured
|
||||
runner occupancy. It does not supply the failed-reference evidence or a
|
||||
complete selected-run comparison needed to enable test selection.
|
||||
- Six sampled failed PR runs contained no failed unit job that could trigger
|
||||
unit-matrix fail-fast. Successful unit siblings of failures in other jobs
|
||||
cannot be counted as savings from that policy. The sample is too small to
|
||||
establish a population-wide rate, and the policy remains unchanged.
|
||||
|
||||
These screens reject the examined changes; they do not establish that every
|
||||
future optimization is exhausted. Shorter admitted jobs and one shared preflight
|
||||
reduce demand on the existing runner allowance. They do not increase that
|
||||
allowance or prove lower queue delay under different account traffic.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# DeepSeek Build terminal identity
|
||||
|
||||
DeepSeek Build is the third-party [`innocarpe/deepseek-build`](https://github.com/innocarpe/deepseek-build) product, published as `@innocarpe/deepseek-build`. It is distinct from official DeepSeek Harness (`@deepseek-ai/dsh`), Reasonix, DSH Console and generic DeepSeek TUI wrappers.
|
||||
|
||||
Orca recognizes manually started Build terminals through its existing process and title observations. `TerminalAgent` includes `dsb`; the launchable `TuiAgent` registry does not. No Build launcher, hook, readiness profile, resume command or history reader is registered. Existing generic terminal input remains available.
|
||||
|
||||
The source and actual macOS release were checked at **v6.9.0**, source commit `74df67a56988e9a32845c4565cc62b021ea68c7d`. The darwin-arm64 release tarball SHA-256 is `a57f225a537fc5c027ac4592e3f37f7bdc28cc2d6e27a366934511ea565cb874`.
|
||||
|
||||
- `package.json` publishes `dsb.js` and `deepseek-build.js` npm shims; the native child is `deepseek-build-agent`.
|
||||
- `crates/dsb-cli/src/main.rs` separates the full-screen entry from `run`. Global value options such as `--cwd` can precede `run`; those invocations remain excluded from interactive recognition.
|
||||
- `crates/dsb-cli/src/agent_launch.rs` emits the product OSC 0 title. The vendored pager's `notifications/title.rs` composes spinner, activity and product segments with ` - ` separators.
|
||||
- The committed `dsb-6-9-0-folder` PTY fixture records the released binary's welcome screen and actual title in an isolated home and plain folder. It makes no successful-authentication or completed-model-turn claim. Its runtime test feeds raw chunks through `onPtyData` with foreground inspection unavailable.
|
||||
|
||||
Explicit native owner markers retain their existing precedence. A Claude task merely mentioning Build is not a Build identity. Runtime publication reuses the existing optional `agentIdentity` string; no new RPC, stream opcode or status producer is added. Older hosts can omit identity, while older readers retain their existing unknown-agent handling. Execution-host process/title observations work without a Git repository; local source tests do not establish native Windows, Linux or SSH device coverage.
|
||||
|
||||
For rendered proof, isolate both Electron and the actual PTY. On macOS, `login(1)` can replace the shell's inherited home. Test-only `ORCA_DISABLE_MACOS_LOGIN_SHELL=1` avoids that wrapper; do not change production launch policy for a proof. Require a nonce-bound file written by a helper executed in the spawned PTY, containing its actual `HOME`, `USERPROFILE`, `DEEPSEEK_BUILD_HOME`, `GROK_HOME` and trust-RPC flag, and verify it before agent launch. A terminal-text assertion can match command echo and is not isolation evidence. Explicit provider environment at the final execution boundary protects the test even after shell startup.
|
||||
|
||||
The observation-type propagation and title/process recognition adapt Wooseong Kim's (`innocarpe`) [PR #23485](https://github.com/stablyai/orca/pull/23485), with source-backed corrections for the second npm shim and value options before `run`. Keep that predecessor open until a reviewed successor merges.
|
||||
|
||||
Independent review follow-up: upstream 6.9.0 outer `Commands::Agent` forwards native PagerArgs options. Native `-p`/`--single` (alias `--print`), `--prompt-json` and `--prompt-file` are one-shot forms and are excluded from interactive process/foreground identity, including equals/compact short forms, npm wrappers and preceding value options. Positional interactive prompt text, native option values and the native `--` terminator stay distinct. Actual release native `--help` confirms exposed flags; source alias and forwarding are pinned above.
|
||||
|
||||
Title follow-up confines Gemini identity/normalization and status sniffing before inspecting Build activity text. A verified Build title uses its leading own braille frame for working and leading `⚠ Action Required - ` for permission; embedded Gemini glyphs in activity/session/cwd text do not change its identity or status. Source-backed frame tests cover wrapped and alert variants, plus OSC input through the actual runtime/listing path. Native Gemini and other provider corpus contracts stay covered.
|
||||
|
||||
Actual released outer `dsb agent -- --help` prints the native TUI help (`outer-forwarded-help.txt`), confirming Clap consumes the outer separator before forwarding. The observer distinguishes this from the native `--`: `dsb agent -- --print task` is one-shot, whereas `dsb agent -- -- --print` and direct native `-- --print` retain literal interactive prompt text.
|
||||
|
||||
Native grammar follow-up: only the outer wrapper's `run` subcommand is one-shot. Native `deepseek-build-agent run`, forwarded `dsb agent run`, and `--leader-socket run` remain interactive; the last consumes `run` as a path value. Native `-c` is boolean, so Clap accepts `-cp task` and `-cptask` as continue plus single-turn prompt. Attached `-m`/`-r`/`-s`/`-w` values (including after `c`) do not expose a prompt flag. The released binary accepted the five review argument topologies with `--help` under an executed private-child environment assertion (`native-grammar-oracle.json`); this proves parsing/help, not successful model generation.
|
||||
|
||||
Attached prompt values can begin with hyphens: native `-p-` and `-cp--print` consume `-` and `--print` as the single-turn prompt. The observer accepts the entire remainder after `p`, while the attached m/r/s/w value shields stay covered. The released native binary and outer `agent` wrapper accepted both forms with `--help` in a nonce-asserted private child (`attached-p-oracle.json`).
|
||||
@@ -1,163 +0,0 @@
|
||||
# jcode hook events
|
||||
|
||||
What jcode actually emits, and why Orca's status mapping is shaped the way it is.
|
||||
Everything below was captured from jcode **v0.87.1 (944f747e9)** by pointing every
|
||||
`[hooks]` entry in `config.toml` at a script that appends `$JCODE_HOOK_PAYLOAD` to
|
||||
a log, then running real turns. Re-capture before changing the mapping; do not
|
||||
edit it from memory.
|
||||
|
||||
## The six events
|
||||
|
||||
jcode's `[hooks]` table (`crates/jcode-base/src/hooks.rs`) has six lifecycle
|
||||
points. Five are **observers** — detached, fire-and-forget, they can never slow
|
||||
the agent. One, `pre_tool`, is a **gate**: jcode spawns it, writes the tool input
|
||||
to its stdin, and waits for it to exit before the tool runs.
|
||||
|
||||
| Event | When | Orca state | Notable payload fields |
|
||||
| --------------- | -------------------------------------------- | ---------- | --------------------------------------------------------- |
|
||||
| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` |
|
||||
| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` |
|
||||
| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) |
|
||||
| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` |
|
||||
| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` |
|
||||
| `session_end` | session closed | `done` | `source` = `close` |
|
||||
|
||||
`session_start` is identity-only. jcode fires it on an idle TUI open, so mapping
|
||||
it to `working` would spin before the user has typed anything (same reason Devin
|
||||
does not map its `SessionStart`).
|
||||
|
||||
## Captured payloads
|
||||
|
||||
A `jcode run` turn that read one file and wrote another:
|
||||
|
||||
```json
|
||||
{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-haiku-4-5","session_id":"session_pawprint_1790149117838_071c2a106812396c","source":"create"}
|
||||
{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"file_path\":\"sample.txt\",\"intent\":\"Read sample.txt to get its contents\"}","tool_name":"read"}
|
||||
{"cwd":"/private/tmp/jcode-work","duration_ms":"0","event":"post_tool","output_bytes":"12","session_id":"session_pawprint_…","status":"ok","tool_name":"read"}
|
||||
{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"content\":\"HELLO\",\"file_path\":\"out.txt\",\"intent\":\"Write uppercased contents of sample.txt to out.txt\"}","tool_name":"write"}
|
||||
{"cwd":"/private/tmp/jcode-work","duration_ms":"9","event":"post_tool","output_bytes":"137","session_id":"session_pawprint_…","status":"ok","tool_name":"write"}
|
||||
```
|
||||
|
||||
A TUI turn that failed upstream (note `turn_start`, which the `run` path does not emit):
|
||||
|
||||
```json
|
||||
{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-opus-5","session_id":"session_snail_…","source":"create"}
|
||||
{"cwd":"/private/tmp/jcode-work","event":"turn_start","model":"claude-opus-5","session_id":"session_snail_…","source":"chat"}
|
||||
{"cwd":"/private/tmp/jcode-work","duration_ms":"6868","error":"Anthropic API error (503 Service Unavailable): …","event":"turn_end","model":"claude-opus-5","session_id":"session_snail_…","status":"error"}
|
||||
```
|
||||
|
||||
Three consequences the mapping depends on:
|
||||
|
||||
- **`turn_start` only fires on the streaming turn path** (TUI, desktop, swarm
|
||||
workers, headless sessions), not `jcode run`. It is what fills the otherwise
|
||||
blank window between a submitted prompt and the first tool call.
|
||||
- **Only `pre_tool` carries `tool_input`.** `post_tool` reports the name and the
|
||||
outcome, so the tool preview has to be held from the matching `pre_tool`.
|
||||
- **Every jcode tool schema has an `intent` string** the model fills in. It is
|
||||
the preview fallback when no tool-specific key (`file_path`, `command`, …)
|
||||
matches.
|
||||
|
||||
## Why Orca subscribes to the gate
|
||||
|
||||
`pre_tool` is the only event that can report a tool *while it runs*. Without it a
|
||||
three-minute `bash` shows no tool at all until it finishes. Two rules keep the
|
||||
gate from ever costing the agent anything:
|
||||
|
||||
1. **The POST is detached.** jcode calls `child.wait_with_output()`, which waits
|
||||
for the process *and* reads its stderr to EOF — a backgrounded child that
|
||||
inherited stderr would hold the gate open for as long as it ran. The managed
|
||||
script runs the POST as `orca_post_jcode_event >/dev/null 2>&1 &`, so the
|
||||
inherited pipes are closed and the script exits immediately.
|
||||
2. **stdin is drained first.** jcode `write_all`s the full tool input to the
|
||||
hook's stdin. A tool input larger than the pipe buffer (a big `write`) would
|
||||
block that write until the gate timed out if nobody read it, so the script
|
||||
drains stdin before any exit path.
|
||||
|
||||
Orca never blocks a jcode tool call: the script always exits 0.
|
||||
|
||||
## Questions and permissions
|
||||
|
||||
jcode has **no interactive per-tool approval prompt**. Its safety model
|
||||
(`crates/jcode-app-core/src/tool/bash_destructive_gate.rs`) either denies a
|
||||
command outright or asks the model to justify it — both inside the tool, with no
|
||||
human in the loop. There is therefore no hook, and no terminal-title state, for
|
||||
"jcode is waiting on you" during ordinary tool use.
|
||||
|
||||
The one tool a *human* answers is ambient mode's `request_permission`
|
||||
(`crates/jcode-app-core/src/tool/ambient.rs`), resolved out of band with
|
||||
`jcode permissions`. Orca maps a `pre_tool` for it to `waiting` and publishes the
|
||||
tool input as the question card. `post_tool` for the same tool is *not* mapped —
|
||||
by then the human has already answered.
|
||||
|
||||
Matching is by exact tool name. jcode's live tool set is `agentgrep, apply_patch,
|
||||
bash, batch, bg, browser, compile_remote, conversation_search, edit, gmail,
|
||||
integration_tools, ls, macos_computer_use, maintainer_feedback, mcp, memory,
|
||||
multiedit, open, panel, patch, read, schedule, session_search, side_panel,
|
||||
skill_manage, swarm, todo, webfetch, websearch, write` plus the ambient tools;
|
||||
a substring rule over that set would be matching on coincidence.
|
||||
|
||||
## Terminal titles
|
||||
|
||||
jcode paints OSC 0 titles roughly once a second. Captured sequence from one TUI
|
||||
session:
|
||||
|
||||
```
|
||||
jcode → 🐍 jcode Snake → 🐍 jcode/creek Snake → 🌐 jcode Snake · work ~0s → … → 🌐 jcode Snake · last ~6s
|
||||
```
|
||||
|
||||
The format is `<emoji> jcode <session-name>[ · +N -M][ · work|last ~<duration>]`
|
||||
(`crates/jcode-tui/src/tui/app/terminal_title.rs`). Orca uses it for tab-bar
|
||||
identity only — status comes from hooks, never from a parsed title. Note there is
|
||||
no "needs input" title state; that is the same gap as above, not an omission in
|
||||
the parser.
|
||||
|
||||
## Per-pane daemons
|
||||
|
||||
jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire
|
||||
*inside the daemon*. Every TUI client connects the daemon the first pane started,
|
||||
so without isolation a second jcode pane's events carry the first pane's
|
||||
`ORCA_PANE_KEY` and its status lands on the wrong tab.
|
||||
|
||||
jcode does forward a client's terminal identity to hooks
|
||||
(`CLIENT_TERMINAL_ENV_VARS` in `crates/jcode-terminal-launch/src/lib.rs`), but
|
||||
that allowlist covers tmux/zellij/herdr and the terminal emulators — not
|
||||
`ORCA_PANE_KEY`. Until it does, Orca stamps a per-pane `JCODE_RUNTIME_DIR` so
|
||||
each pane gets its own daemon, socket, and lock. The value is a 16-hex hash of
|
||||
the pane key because the socket path is capped at `SUN_LEN` (104 bytes) and a
|
||||
full pane key never fits.
|
||||
|
||||
## Windows hook launcher
|
||||
|
||||
Use Jcode **v0.89.0 or newer** on Windows. Earlier observer hooks launch with
|
||||
`DETACHED_PROCESS`, leaving their children without a console to inherit. A
|
||||
console program such as the managed hook's `curl.exe` can then open a Windows
|
||||
Terminal tab on every event. Jcode's launcher fix uses `CREATE_NO_WINDOW` for
|
||||
observer hooks and the `pre_tool` gate, keeping their descendants invisible.
|
||||
Changing the managed script alone cannot repair an older Jcode launcher.
|
||||
|
||||
The managed Windows hook redirects its payload file into curl directly, avoiding
|
||||
the extra shells that a `type ... | curl` pipeline starts. Existing managed scripts
|
||||
are refreshed on Orca startup without changing the user's hook configuration.
|
||||
|
||||
Report: https://github.com/stablyai/orca/pull/22539#issuecomment-5809618574
|
||||
Launcher fix: https://github.com/1jehuang/jcode/pull/1490
|
||||
|
||||
## Config shape
|
||||
|
||||
`[hooks]` values accept a string or an array of strings (`HookCommands` in
|
||||
`crates/jcode-config-types/src/lib.rs`), and jcode re-reads the config on reload,
|
||||
so hooks can be added without restarting. jcode parses a hook command line
|
||||
shell-style but **executes it directly, not through a shell** — the managed value
|
||||
must be the script path, never an `if [ -f … ]` wrapper.
|
||||
|
||||
That shell-style parse is `parse_hook_command`
|
||||
(`crates/jcode-terminal-launch/src/lib.rs`), and it is why Orca stores the path
|
||||
**shell-quoted**. The tokenizer splits on unquoted whitespace and consumes every
|
||||
unquoted backslash as an escape, so a bare Windows path reaches `exec` as
|
||||
`C:Usersme.orcaagent-hooksjcode-hook.cmd` and no hook fires at all; a POSIX home
|
||||
with a space splits into two arguments. Single quotes pass a path through
|
||||
verbatim — backslashes are literal inside them — so Orca single-quotes by
|
||||
default and falls back to double quotes (escaping `\` and `"`) only for a path
|
||||
that itself contains a single quote. The value is then TOML-quoted on the way
|
||||
into the file, so neither the raw path nor the shell-quoted string appears
|
||||
alone.
|
||||
@@ -111,6 +111,23 @@ provider, it puts `unsupported` on the wire and makes that host refuse its own.
|
||||
reply-schema fallback must never shape a param. Gate on the token instead, where the
|
||||
client decides what it is willing to do with an arm it does not know.
|
||||
|
||||
## Session search agent negotiation
|
||||
|
||||
`aiVault.searchStatus` optionally advertises `supportedAgents`; current search clients
|
||||
send their own `supportedAgents` with `aiVault.searchSessions`. These are string lists,
|
||||
so a future provider name does not make a peer reject the capability reply. The client
|
||||
narrows explicit agent filters to the host's list before calling its request parser.
|
||||
The host narrows retrieval to the client's list before publishing a page.
|
||||
|
||||
A peer without this field uses the frozen v1.4.211 search vocabulary. The existing
|
||||
`supportsQoderHistory` flag proves CodeBuddy, ZCode, and Qoder support;
|
||||
`supportsJcodeHistory` independently proves Jcode support. An explicit list takes
|
||||
precedence over both flags. If only the status method is missing, the client still
|
||||
searches the conservative legacy subset; other status errors propagate. Empty host
|
||||
intersections keep the requested filters and use the existing no-match scope, so
|
||||
consent, readiness, and unknown-scope results retain their normal precedence.
|
||||
Local IPC advertises this build's full list, and every remote leg negotiates separately.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real
|
||||
|
||||
@@ -114,6 +114,8 @@ You can pin a worktree to the top of its project to keep long-running work in vi
|
||||
|
||||
When a worktree has nested child worktrees (for example from orchestration or `worktree create` with a parent), the context menu can also offer **Sleep with Descendants (N)** and **Delete with Descendants…**. Sleep with descendants closes active panels on the selected workspace and every validated nested child in the same project, repo, and host — only workspaces with live terminals or browser tabs are targeted for sleep. Delete with descendants makes the existing cascading delete explicit. Stale lineage links, cycles, and children across host or repo boundaries are excluded.
|
||||
|
||||
A parent worktree shows its nested children under an **N children** chip; click the chip to show or hide them. To do the same from the keyboard, assign **Toggle Child Workspaces** under [Settings → Shortcuts](/docs/settings). It acts on the worktree under the pointer, or on the active worktree when nothing is hovered, and does exactly what that card's chip does. On a card with no chip of its own (it has no children, or a sidebar filter hides all of them), it uses the chip on the card's parent instead, and does nothing if there is none.
|
||||
|
||||
Double-click a worktree title in the sidebar to rename it inline. Double-clicking elsewhere on the card still opens the full edit dialog. In **Edit Worktree Details**, the issue field accepts **GitHub** or **Linear** (chip on the field; paste a URL to auto-detect). One linked issue per workspace — changing provider or clearing the field unlinks the previous one. For SSH workspaces whose host is disconnected, the card title row can show an inline reconnect control (see [SSH worktrees](/docs/ssh)).
|
||||
|
||||
## Resource Manager cleanup
|
||||
|
||||
@@ -133,6 +133,7 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then
|
||||
|
||||
- Full keymap — every binding remappable.
|
||||
- Toggle Sleeping Workspaces ships unbound; assign it here if you want a direct shortcut for the sidebar sleeping-worktree filter.
|
||||
- **Toggle Child Workspaces** ships unbound; assign it here to show or hide a parent worktree's nested children, the same as clicking its **N children** chip. It targets the hovered worktree, or the active one when nothing is hovered.
|
||||
- **Toggle Workspace Board** ships unbound; assign it here to open or close the Workspace Board with one shortcut. Existing bindings for `workspace.openBoard` continue to work.
|
||||
- Close all editor tabs defaults to `Cmd+Option+W` on macOS and `Ctrl+Alt+W` on Windows / Linux.
|
||||
- **Tab navigation defaults (new installs):** next/previous tab **across all types** is `Cmd+Shift+]` / `Cmd+Shift+[` (Ctrl on Linux/Windows). Same-type next/previous is `Cmd+Option+]` / `Cmd+Option+[`. Previous recent tab is `Ctrl+Tab`. Existing installs keep customized overrides under `~/.orca/keybindings.json`.
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import packageJson from './package.json' with { type: 'json' }
|
||||
|
||||
const BUNDLED_MAIN_DEPENDENCIES = new Set([
|
||||
'@streamparser/json',
|
||||
'stream-json',
|
||||
'stream-chain',
|
||||
'@xterm/headless',
|
||||
'@xterm/addon-serialize',
|
||||
'tldts',
|
||||
|
||||
@@ -298,8 +298,8 @@
|
||||
{
|
||||
"id": "inventory-lifecycle.unmount-before-1:settled",
|
||||
"observation": {
|
||||
"sender": ["32c9018052ba", "2f1953cc0315"],
|
||||
"payloads": ["8db0d1234c94", "c9b9d7a9b388"],
|
||||
"sender": ["32c9018052ba"],
|
||||
"payloads": ["8db0d1234c94"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"old": "eb79a9b3682a",
|
||||
@@ -312,8 +312,8 @@
|
||||
{
|
||||
"id": "inventory-lifecycle.unmount-before-1:remounted",
|
||||
"observation": {
|
||||
"sender": ["32c9018052ba", "2f1953cc0315"],
|
||||
"payloads": ["8db0d1234c94", "c9b9d7a9b388"],
|
||||
"sender": ["32c9018052ba"],
|
||||
"payloads": ["8db0d1234c94"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"old": "eb79a9b3682a",
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BRIDGE_MAX_MESSAGE_BYTES } from './bridge/bridge-caps'
|
||||
import { createFakeRpcClient } from './bridge-host-test-fakes'
|
||||
import { harness, ID, subscribeFrame } from './bridge-host-test-harness'
|
||||
import { BRIDGE_MAX_UNACKED_FRAMES, BridgeHostSubscriptions } from './bridge-host-subscriptions'
|
||||
import {
|
||||
BridgeTerminalOutputBacklog,
|
||||
TERMINAL_STREAM_ACK_SILENCE_MS,
|
||||
type TerminalBacklogTimers
|
||||
} from './bridge-terminal-output-backlog'
|
||||
|
||||
const HELD_OUTPUT = { type: 'data', streamId: 1, chunk: 'h'.repeat(48 * 1024) }
|
||||
|
||||
function manualTimers(): TerminalBacklogTimers & {
|
||||
pending: () => number
|
||||
delays: number[]
|
||||
fire: () => void
|
||||
} {
|
||||
const handlers = new Map<unknown, () => void>()
|
||||
const delays: number[] = []
|
||||
let nextHandle = 0
|
||||
return {
|
||||
set: (handler, ms) => {
|
||||
nextHandle += 1
|
||||
handlers.set(nextHandle, handler)
|
||||
delays.push(ms)
|
||||
return nextHandle
|
||||
},
|
||||
clear: (handle) => {
|
||||
handlers.delete(handle)
|
||||
},
|
||||
pending: () => handlers.size,
|
||||
delays,
|
||||
fire: () => {
|
||||
for (const [handle, handler] of handlers) {
|
||||
handlers.delete(handle)
|
||||
handler()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitWindowAndHold(onData: (payload: unknown) => void): void {
|
||||
for (let index = 0; index < BRIDGE_MAX_UNACKED_FRAMES; index += 1) {
|
||||
onData({ type: 'data', streamId: 1, chunk: String(index) })
|
||||
}
|
||||
onData(HELD_OUTPUT)
|
||||
}
|
||||
|
||||
function observeBacklogs(): BridgeTerminalOutputBacklog[] {
|
||||
const backlogs: BridgeTerminalOutputBacklog[] = []
|
||||
const hold = BridgeTerminalOutputBacklog.prototype.hold
|
||||
vi.spyOn(BridgeTerminalOutputBacklog.prototype, 'hold').mockImplementation(function (
|
||||
this: BridgeTerminalOutputBacklog,
|
||||
payload: unknown
|
||||
) {
|
||||
if (!backlogs.includes(this)) {
|
||||
backlogs.push(this)
|
||||
}
|
||||
return hold.call(this, payload)
|
||||
})
|
||||
return backlogs
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('bridge subscription start cleanup', () => {
|
||||
it('rethrows the original immediate failure without keeping a slot or timer', () => {
|
||||
const failure = new Error('subscribe failed')
|
||||
const timers = manualTimers()
|
||||
const subscriptions = new BridgeHostSubscriptions({
|
||||
client: {
|
||||
...createFakeRpcClient(),
|
||||
subscribe: () => {
|
||||
throw failure
|
||||
}
|
||||
},
|
||||
post: vi.fn(),
|
||||
onBinaryFrameDropped: vi.fn(),
|
||||
terminalTimers: timers
|
||||
})
|
||||
|
||||
expect(() => subscriptions.start(ID, 'terminal.subscribe', {})).toThrow(failure)
|
||||
expect(subscriptions.size).toBe(0)
|
||||
expect(timers.pending()).toBe(0)
|
||||
expect(timers.delays).toEqual([])
|
||||
})
|
||||
|
||||
it('releases held output and its silence timer when subscribe emits and then throws', () => {
|
||||
const failure = new Error('subscribe failed after output')
|
||||
const timers = manualTimers()
|
||||
const backlogs = observeBacklogs()
|
||||
const subscriptions = new BridgeHostSubscriptions({
|
||||
client: {
|
||||
...createFakeRpcClient(),
|
||||
subscribe: (_method, _params, onData) => {
|
||||
emitWindowAndHold(onData)
|
||||
expect(backlogs[0]?.pendingBytes).toBe(JSON.stringify(HELD_OUTPUT).length)
|
||||
expect(timers.pending()).toBe(1)
|
||||
throw failure
|
||||
}
|
||||
},
|
||||
post: vi.fn(),
|
||||
onBinaryFrameDropped: vi.fn(),
|
||||
terminalTimers: timers
|
||||
})
|
||||
|
||||
let thrown: unknown
|
||||
try {
|
||||
subscriptions.start(ID, 'terminal.subscribe', {})
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBe(failure)
|
||||
expect(subscriptions.size).toBe(0)
|
||||
expect(backlogs).toHaveLength(1)
|
||||
expect({
|
||||
pendingBytes: backlogs[0]?.pendingBytes,
|
||||
held: backlogs[0]?.held,
|
||||
liveTimers: timers.pending()
|
||||
}).toEqual({ pendingBytes: 0, held: false, liveTimers: 0 })
|
||||
expect(timers.delays).toEqual([TERMINAL_STREAM_ACK_SILENCE_MS])
|
||||
})
|
||||
|
||||
it('answers the original failure and leaves a same-ID replacement alive past the old deadline', () => {
|
||||
const client = createFakeRpcClient()
|
||||
const timers = manualTimers()
|
||||
let starts = 0
|
||||
const bridge = harness({
|
||||
ready: true,
|
||||
terminalTimers: timers,
|
||||
client: {
|
||||
...client,
|
||||
subscribe: (method, params, onData, options) => {
|
||||
starts += 1
|
||||
if (starts === 1) {
|
||||
emitWindowAndHold(onData)
|
||||
throw new Error('failed after output')
|
||||
}
|
||||
return client.subscribe(method, params, onData, options)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
bridge.host.receive(subscribeFrame(ID))
|
||||
expect(bridge.last()).toMatchObject({ type: 'error', id: ID })
|
||||
bridge.host.receive(subscribeFrame(ID))
|
||||
expect(client.streams).toHaveLength(1)
|
||||
timers.fire()
|
||||
expect(client.streams[0]?.unsubscribes).toBe(0)
|
||||
client.streams[0]?.emit({ type: 'data', streamId: 2, chunk: 'replacement still live' })
|
||||
expect(bridge.last()).toEqual({
|
||||
v: 1,
|
||||
type: 'event',
|
||||
id: ID,
|
||||
seq: 1,
|
||||
payload: { type: 'data', streamId: 2, chunk: 'replacement still live' }
|
||||
})
|
||||
expect(bridge.frames().filter((frame) => frame.type === 'end')).toEqual([])
|
||||
bridge.host.dispose()
|
||||
expect(client.streams[0]?.unsubscribes).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps a successful synchronous backlog until ack and unsubscribes once on cancel', () => {
|
||||
const timers = manualTimers()
|
||||
const backlogs = observeBacklogs()
|
||||
const unsubscribe = vi.fn()
|
||||
const subscriptions = new BridgeHostSubscriptions({
|
||||
client: {
|
||||
...createFakeRpcClient(),
|
||||
subscribe: (_method, _params, onData) => {
|
||||
emitWindowAndHold(onData)
|
||||
return unsubscribe
|
||||
}
|
||||
},
|
||||
post: vi.fn(),
|
||||
onBinaryFrameDropped: vi.fn(),
|
||||
terminalTimers: timers
|
||||
})
|
||||
|
||||
subscriptions.start(ID, 'terminal.subscribe', {})
|
||||
expect(subscriptions.has(ID)).toBe(true)
|
||||
expect(backlogs[0]?.pendingBytes).toBe(JSON.stringify(HELD_OUTPUT).length)
|
||||
expect(timers.pending()).toBe(1)
|
||||
subscriptions.ack(ID, BRIDGE_MAX_UNACKED_FRAMES)
|
||||
expect(backlogs[0]?.pendingBytes).toBe(0)
|
||||
expect(timers.pending()).toBe(0)
|
||||
subscriptions.cancel(ID, null)
|
||||
subscriptions.cancel(ID, null)
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves an overflow emitted before the original subscribe failure', () => {
|
||||
const failure = new Error('failure after overflow')
|
||||
const timers = manualTimers()
|
||||
const post = vi.fn()
|
||||
const subscriptions = new BridgeHostSubscriptions({
|
||||
client: {
|
||||
...createFakeRpcClient(),
|
||||
subscribe: (_method, _params, onData) => {
|
||||
onData('z'.repeat(BRIDGE_MAX_MESSAGE_BYTES))
|
||||
throw failure
|
||||
}
|
||||
},
|
||||
post,
|
||||
onBinaryFrameDropped: vi.fn(),
|
||||
terminalTimers: timers
|
||||
})
|
||||
|
||||
expect(() => subscriptions.start(ID, 'terminal.subscribe', {})).toThrow(failure)
|
||||
expect(post.mock.calls).toEqual([
|
||||
[JSON.stringify({ v: 1, type: 'end', id: ID, reason: 'overflow' })]
|
||||
])
|
||||
expect(subscriptions.size).toBe(0)
|
||||
expect(timers.pending()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -143,6 +143,7 @@ export class BridgeHostSubscriptions {
|
||||
)
|
||||
} catch (error) {
|
||||
this.open.delete(id)
|
||||
record.backlog?.dispose()
|
||||
throw error
|
||||
}
|
||||
// A stream that emitted and overflowed inside `subscribe` is already retired, and its
|
||||
|
||||
@@ -33,6 +33,14 @@ function stagedMediaFile(extension: string): FsFile {
|
||||
return new FsFile(Paths.cache, `orca-media-${Date.now()}-${Math.random()}.${extension}`)
|
||||
}
|
||||
|
||||
function discardUnreturnedMediaFile(file: FsFile): void {
|
||||
try {
|
||||
file.delete()
|
||||
} catch {
|
||||
// Best effort; the cache is the OS's to reclaim.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies what a uri names into this shell's cache and answers the copy's uri.
|
||||
*
|
||||
@@ -57,11 +65,7 @@ export function copyPickedMediaIntoCache(uri: string): string {
|
||||
destination.write(new FsFile(uri).bytesSync())
|
||||
} catch (error) {
|
||||
// The empty file this just created is nobody's otherwise: the caller never learns its name.
|
||||
try {
|
||||
destination.delete()
|
||||
} catch {
|
||||
// Best effort; the cache is the OS's to reclaim.
|
||||
}
|
||||
discardUnreturnedMediaFile(destination)
|
||||
throw error
|
||||
}
|
||||
return destination.uri
|
||||
@@ -94,7 +98,12 @@ export function nativeMediaDeviceDeps(registry: MediaHandleRegistry): NativeMedi
|
||||
stageBase64: (base64) => {
|
||||
const file = stagedMediaFile('png')
|
||||
file.create({ overwrite: true })
|
||||
file.write(base64, { encoding: 'base64' })
|
||||
try {
|
||||
file.write(base64, { encoding: 'base64' })
|
||||
} catch (error) {
|
||||
discardUnreturnedMediaFile(file)
|
||||
throw error
|
||||
}
|
||||
return file.uri
|
||||
},
|
||||
openFile: (uri) => new FsFile(uri),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
type FileEvent = {
|
||||
operation: 'create' | 'write' | 'delete'
|
||||
uri: string
|
||||
content?: string | Uint8Array
|
||||
options?: { overwrite?: boolean; encoding?: string }
|
||||
}
|
||||
|
||||
type StorageState = {
|
||||
files: Map<string, Uint8Array>
|
||||
events: FileEvent[]
|
||||
writeFailure: unknown
|
||||
deleteFailure: unknown
|
||||
createFailure: unknown
|
||||
readFailure: unknown
|
||||
partialBytes: number
|
||||
}
|
||||
|
||||
const storage = vi.hoisted<StorageState>(() => ({
|
||||
files: new Map(),
|
||||
events: [],
|
||||
writeFailure: null,
|
||||
deleteFailure: null,
|
||||
createFailure: null,
|
||||
readFailure: null,
|
||||
partialBytes: 0
|
||||
}))
|
||||
|
||||
vi.mock('expo-clipboard', () => ({
|
||||
getImageAsync: () => Promise.resolve({ data: 'data:image/png;base64,AQID' })
|
||||
}))
|
||||
vi.mock('expo-document-picker', () => ({}))
|
||||
vi.mock('expo-image-picker', () => ({}))
|
||||
vi.mock('expo-file-system', () => ({
|
||||
File: class {
|
||||
readonly uri: string
|
||||
|
||||
constructor(...parts: string[]) {
|
||||
this.uri = parts.join('/')
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return storage.files.get(this.uri)?.byteLength ?? 0
|
||||
}
|
||||
|
||||
create(options: { overwrite?: boolean }): void {
|
||||
storage.events.push({ operation: 'create', uri: this.uri, options })
|
||||
storage.files.set(this.uri, new Uint8Array())
|
||||
if (storage.createFailure !== null) {
|
||||
throw storage.createFailure
|
||||
}
|
||||
}
|
||||
|
||||
write(content: string | Uint8Array, options?: { encoding?: string }): void {
|
||||
storage.events.push({ operation: 'write', uri: this.uri, content, options })
|
||||
if (storage.writeFailure !== null) {
|
||||
storage.files.set(this.uri, new Uint8Array(storage.partialBytes))
|
||||
throw storage.writeFailure
|
||||
}
|
||||
storage.files.set(
|
||||
this.uri,
|
||||
typeof content === 'string' ? Uint8Array.from(Buffer.from(content, 'base64')) : content
|
||||
)
|
||||
}
|
||||
|
||||
bytesSync(): Uint8Array {
|
||||
if (storage.readFailure !== null) {
|
||||
throw storage.readFailure
|
||||
}
|
||||
return Uint8Array.from([9, 8])
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
storage.events.push({ operation: 'delete', uri: this.uri })
|
||||
if (storage.deleteFailure !== null) {
|
||||
throw storage.deleteFailure
|
||||
}
|
||||
storage.files.delete(this.uri)
|
||||
}
|
||||
},
|
||||
Paths: { cache: 'file:///cache' }
|
||||
}))
|
||||
|
||||
import { MediaHandleRegistry } from '../mobile-web-shell/media-handle-registry'
|
||||
import { createNativeMediaVerbServer } from './native-media'
|
||||
import { copyPickedMediaIntoCache, nativeMediaDeviceDeps } from './native-media-device'
|
||||
|
||||
beforeEach(() => {
|
||||
storage.files.clear()
|
||||
storage.events.length = 0
|
||||
storage.writeFailure = null
|
||||
storage.deleteFailure = null
|
||||
storage.createFailure = null
|
||||
storage.readFailure = null
|
||||
storage.partialBytes = 0
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1234)
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.25)
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function device() {
|
||||
return nativeMediaDeviceDeps(new MediaHandleRegistry({ now: () => 0, discard: vi.fn() }))
|
||||
}
|
||||
|
||||
function thrownBy(operation: () => unknown): unknown {
|
||||
try {
|
||||
operation()
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
throw new Error('Expected staging to throw')
|
||||
}
|
||||
|
||||
function heldBytes(): number {
|
||||
return [...storage.files.values()].reduce((total, bytes) => total + bytes.byteLength, 0)
|
||||
}
|
||||
|
||||
it('preserves the successful staged URI, base64 options and bytes without deleting it', () => {
|
||||
const uri = device().stageBase64('AQID')
|
||||
expect(uri).toBe('file:///cache/orca-media-1234-0.25.png')
|
||||
expect(storage.events).toEqual([
|
||||
{ operation: 'create', uri, options: { overwrite: true } },
|
||||
{ operation: 'write', uri, content: 'AQID', options: { encoding: 'base64' } }
|
||||
])
|
||||
expect(storage.files.get(uri)).toEqual(Uint8Array.from([1, 2, 3]))
|
||||
})
|
||||
|
||||
it.each([0, 4096])('releases an unreturned file after a write fails with %i bytes', (bytes) => {
|
||||
const original = new Error('partial native write')
|
||||
storage.writeFailure = original
|
||||
storage.partialBytes = bytes
|
||||
expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original)
|
||||
expect(heldBytes()).toBe(0)
|
||||
expect(storage.files.size).toBe(0)
|
||||
expect(storage.events.at(-1)).toEqual({
|
||||
operation: 'delete',
|
||||
uri: 'file:///cache/orca-media-1234-0.25.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the write failure when best-effort deletion also fails', () => {
|
||||
const original = new Error('native write failed')
|
||||
storage.writeFailure = original
|
||||
storage.deleteFailure = new Error('cache cannot be deleted')
|
||||
storage.partialBytes = 4096
|
||||
expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original)
|
||||
expect(storage.events.map((event) => event.operation)).toEqual(['create', 'write', 'delete'])
|
||||
expect(heldBytes()).toBe(4096)
|
||||
})
|
||||
|
||||
it('preserves creation failure and its original operation order', () => {
|
||||
const original = new Error('native create failed')
|
||||
storage.createFailure = original
|
||||
expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original)
|
||||
expect(storage.events.map((event) => event.operation)).toEqual(['create'])
|
||||
})
|
||||
|
||||
it('deletes only the failed file while a prior successful file remains readable', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValueOnce(0.1).mockReturnValueOnce(0.2)
|
||||
const successfulUri = device().stageBase64('AQID')
|
||||
const original = new Error('next write failed')
|
||||
storage.writeFailure = original
|
||||
storage.partialBytes = 4096
|
||||
expect(thrownBy(() => device().stageBase64('BAUG'))).toBe(original)
|
||||
expect(storage.events.filter((event) => event.operation === 'delete')).toEqual([
|
||||
{ operation: 'delete', uri: 'file:///cache/orca-media-1234-0.2.png' }
|
||||
])
|
||||
expect([...storage.files.keys()]).toEqual([successfulUri])
|
||||
expect(storage.files.get(successfulUri)).toEqual(Uint8Array.from([1, 2, 3]))
|
||||
expect(heldBytes()).toBe(3)
|
||||
})
|
||||
|
||||
it('keeps the clipboard verb rejection while leaving no unregistered cache bytes', async () => {
|
||||
const original = new Error('clipboard write failed')
|
||||
storage.writeFailure = original
|
||||
storage.partialBytes = 4096
|
||||
const registryDiscard = vi.fn()
|
||||
const registry = new MediaHandleRegistry({ now: () => 0, discard: registryDiscard })
|
||||
const serve = createNativeMediaVerbServer(nativeMediaDeviceDeps(registry))
|
||||
await expect(serve('native.media.pick', { source: 'clipboard', multiple: false })).rejects.toBe(
|
||||
original
|
||||
)
|
||||
expect(registry.liveCount()).toBe(0)
|
||||
expect(registryDiscard).not.toHaveBeenCalled()
|
||||
expect(heldBytes()).toBe(0)
|
||||
expect(storage.files.size).toBe(0)
|
||||
})
|
||||
|
||||
it('retains the provider-copy cleanup and original read error when deletion fails', () => {
|
||||
const original = new Error('provider read failed')
|
||||
storage.readFailure = original
|
||||
storage.deleteFailure = new Error('cache delete failed')
|
||||
expect(thrownBy(() => copyPickedMediaIntoCache('content://media/1'))).toBe(original)
|
||||
expect(storage.events).toEqual([
|
||||
{
|
||||
operation: 'create',
|
||||
uri: 'file:///cache/orca-media-1234-0.25.bin',
|
||||
options: { overwrite: true }
|
||||
},
|
||||
{ operation: 'delete', uri: 'file:///cache/orca-media-1234-0.25.bin' }
|
||||
])
|
||||
})
|
||||
@@ -3,6 +3,8 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
@@ -60,6 +62,7 @@ describe('MobileNativeChatMessage', () => {
|
||||
function render(
|
||||
message: NativeChatMessage,
|
||||
props: {
|
||||
fontScale?: number
|
||||
toolsExpanded?: boolean
|
||||
structuredActivityUi?: boolean
|
||||
activeTurnIsWorking?: boolean
|
||||
@@ -81,6 +84,38 @@ describe('MobileNativeChatMessage', () => {
|
||||
const textIn = (node: ReactTestInstance): string[] =>
|
||||
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
|
||||
|
||||
it.each(['system', 'user'] as const)(
|
||||
'renders a %s host notice as selectable muted text rather than a markdown answer',
|
||||
(role) => {
|
||||
const tree = render(
|
||||
{
|
||||
id: 'notice',
|
||||
role,
|
||||
timestamp: 1,
|
||||
blocks: [
|
||||
{ type: 'text', text: 'provider fallback', presentation: 'history-item-too-large' }
|
||||
]
|
||||
},
|
||||
{ fontScale: 1.5 }
|
||||
)
|
||||
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
|
||||
const text = tree.root.find((node) => String(node.type) === 'Text')
|
||||
expect(text.props.children).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'])
|
||||
expect(text.props.selectable).toBe(true)
|
||||
expect(Object.assign({}, ...text.props.style)).toMatchObject({
|
||||
color: colors.textMuted,
|
||||
fontSize: 25.5
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => {
|
||||
const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }]))
|
||||
expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe(
|
||||
'provider fallback'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a loadable preview URI as an image thumbnail', () => {
|
||||
const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
|
||||
const image = tree.root.findByType('Image' as never)
|
||||
|
||||
@@ -4,6 +4,10 @@ import { Image, Text as NativeText, View } from 'react-native'
|
||||
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
|
||||
import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity'
|
||||
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
AGENT_SESSION_HOST_STATUS_COPY,
|
||||
isAgentSessionHostStatusPresentation
|
||||
} from '../../../src/shared/agent-session-host-status-rows'
|
||||
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileMarkdown } from '../components/MobileMarkdown'
|
||||
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
|
||||
@@ -24,6 +28,13 @@ function Prose({
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
}): React.JSX.Element | null {
|
||||
if (isTextBlock(block)) {
|
||||
if (isAgentSessionHostStatusPresentation(block.presentation)) {
|
||||
return (
|
||||
<Text selectable style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}>
|
||||
{AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
// Inverted (user) bubbles use a fixed dark-on-light text rather than the
|
||||
// markdown renderer's light-on-dark palette.
|
||||
if (invert) {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { createElement, StrictMode, type ReactNode } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
|
||||
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Image: 'Image',
|
||||
Text: 'Text',
|
||||
TextInput: 'TextInput',
|
||||
View: 'View',
|
||||
Pressable: 'Pressable',
|
||||
Keyboard: { dismiss: vi.fn() },
|
||||
ScrollView: ({ children, ...props }: { children?: ReactNode }) =>
|
||||
React.createElement('ScrollView', props, children),
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
|
||||
}
|
||||
})
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ArrowUp: 'ArrowUp',
|
||||
Check: 'Check',
|
||||
ChevronDown: 'ChevronDown',
|
||||
ChevronLeft: 'ChevronLeft',
|
||||
ChevronRight: 'ChevronRight',
|
||||
ImagePlus: 'ImagePlus',
|
||||
Mic: 'Mic',
|
||||
Square: 'Square',
|
||||
X: 'X'
|
||||
}))
|
||||
vi.mock('../components/BottomDrawer', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
BottomDrawer: ({ visible, children }: { visible: boolean; children?: ReactNode }) =>
|
||||
visible ? React.createElement('BottomDrawer', { visible }, children) : null
|
||||
}
|
||||
})
|
||||
|
||||
type Reply = Awaited<ReturnType<RpcClient['sendRequest']>>
|
||||
const missing: Reply = {
|
||||
id: 'search',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'legacy host' },
|
||||
_meta: { runtimeId: 'legacy' }
|
||||
}
|
||||
const inventory: Reply = {
|
||||
id: 'list',
|
||||
ok: true,
|
||||
result: { files: [{ relativePath: 'src/apple.ts' }] },
|
||||
_meta: { runtimeId: 'legacy' }
|
||||
}
|
||||
const searchCall = [
|
||||
'files.searchPaths',
|
||||
{
|
||||
worktree: 'id:folder:remote',
|
||||
query: 'apple',
|
||||
limit: 16
|
||||
}
|
||||
]
|
||||
function pendingReply() {
|
||||
let resolve: (reply: Reply) => void = () => {
|
||||
throw new Error('uninitialized reply')
|
||||
}
|
||||
const promise = new Promise<Reply>((settle) => {
|
||||
resolve = settle
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
function mount(sendRequest: RpcClient['sendRequest'], strict = false, getGeneration = () => 1) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The actual hook only reads these two RPC members at the fake transport boundary.
|
||||
const client = { sendRequest, getGeneration } as RpcClient
|
||||
let search: ReturnType<typeof useMobileNativeChatFileSearch> | undefined
|
||||
function Route({ showChat }: { showChat: boolean }) {
|
||||
search = useMobileNativeChatFileSearch({ client, worktreeId: 'folder:remote' })
|
||||
return showChat
|
||||
? createElement(MobileNativeChatComposer, {
|
||||
value: '@apple',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: async () => false,
|
||||
sendSurfaceId: 'remote-chat',
|
||||
getSendCompletionGeneration: () => 0,
|
||||
getComposerEditGeneration: () => 0,
|
||||
filePaths: search.nativeChatFilePaths,
|
||||
onNeedFiles: search.loadNativeChatFiles
|
||||
})
|
||||
: null
|
||||
}
|
||||
const element = (showChat: boolean) =>
|
||||
strict
|
||||
? createElement(StrictMode, null, createElement(Route, { showChat }))
|
||||
: createElement(Route, { showChat })
|
||||
let renderer: ReturnType<typeof create> | undefined
|
||||
act(() => {
|
||||
renderer = create(element(false))
|
||||
})
|
||||
if (!renderer || !search) {
|
||||
throw new Error('actual route hook did not mount')
|
||||
}
|
||||
const view = renderer
|
||||
return {
|
||||
query: () =>
|
||||
act(() => {
|
||||
search?.loadNativeChatFiles('apple')
|
||||
}),
|
||||
showChat: () =>
|
||||
act(() => {
|
||||
view.update(element(true))
|
||||
}),
|
||||
triggerComposer: () =>
|
||||
act(() => {
|
||||
view.root
|
||||
.find((node) => typeof node.props.onSelectionChange === 'function')
|
||||
.props.onSelectionChange({ nativeEvent: { selection: { end: 6 } } })
|
||||
}),
|
||||
hideChat: () =>
|
||||
act(() => {
|
||||
view.update(element(false))
|
||||
}),
|
||||
paths: () => search?.nativeChatFilePaths,
|
||||
dispose: () =>
|
||||
act(() => {
|
||||
view.unmount()
|
||||
})
|
||||
}
|
||||
}
|
||||
async function debounce(): Promise<void> {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(120)
|
||||
})
|
||||
}
|
||||
async function refuse(search: ReturnType<typeof pendingReply>): Promise<void> {
|
||||
await act(async () => {
|
||||
search.resolve(missing)
|
||||
await search.promise
|
||||
})
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'a disposed actual composer owner admits no new inventory; StrictMode=%s',
|
||||
async (strict) => {
|
||||
const search = pendingReply()
|
||||
const send = vi.fn<RpcClient['sendRequest']>((method) =>
|
||||
method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory)
|
||||
)
|
||||
const view = mount(send, strict)
|
||||
view.showChat()
|
||||
view.triggerComposer()
|
||||
await debounce()
|
||||
expect(send.mock.calls).toEqual([searchCall])
|
||||
view.dispose()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await refuse(search)
|
||||
expect(send.mock.calls.filter(([method]) => method === 'files.searchPaths')).toHaveLength(1)
|
||||
expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([false, true])(
|
||||
'removing only the composer retains live fallback and cache; StrictMode=%s',
|
||||
async (strict) => {
|
||||
const search = pendingReply()
|
||||
const send = vi.fn<RpcClient['sendRequest']>((method) =>
|
||||
method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory)
|
||||
)
|
||||
const view = mount(send, strict)
|
||||
view.showChat()
|
||||
view.triggerComposer()
|
||||
await debounce()
|
||||
expect(send.mock.calls).toEqual([searchCall])
|
||||
view.hideChat()
|
||||
await refuse(search)
|
||||
expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]])
|
||||
expect(view.paths()).toEqual(['src/apple.ts'])
|
||||
view.showChat()
|
||||
view.triggerComposer()
|
||||
await debounce()
|
||||
expect(view.paths()).toEqual(['src/apple.ts'])
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
view.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('64 retired actual hooks do not start 64 whole-workspace reads', async () => {
|
||||
const pending = Array.from({ length: 64 }, pendingReply)
|
||||
let index = 0
|
||||
const send = vi.fn<RpcClient['sendRequest']>((method) =>
|
||||
method === 'files.searchPaths' ? pending[index++]!.promise : Promise.resolve(inventory)
|
||||
)
|
||||
for (let owner = 0; owner < pending.length; owner++) {
|
||||
const view = mount(send)
|
||||
view.query()
|
||||
await debounce()
|
||||
view.dispose()
|
||||
}
|
||||
expect(send.mock.calls).toEqual(pending.map(() => searchCall))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await act(async () => {
|
||||
pending.forEach((reply) => reply.resolve(missing))
|
||||
await Promise.all(pending.map((reply) => reply.promise))
|
||||
})
|
||||
expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'a same-client successor owns its live fallback; StrictMode=%s',
|
||||
async (strict) => {
|
||||
const retiredSearch = pendingReply()
|
||||
let searches = 0
|
||||
const send = vi.fn<RpcClient['sendRequest']>((method) =>
|
||||
method === 'files.searchPaths'
|
||||
? ++searches === 1
|
||||
? retiredSearch.promise
|
||||
: Promise.resolve(missing)
|
||||
: Promise.resolve(inventory)
|
||||
)
|
||||
const retired = mount(send, strict)
|
||||
retired.query()
|
||||
await debounce()
|
||||
retired.dispose()
|
||||
const successor = mount(send, strict)
|
||||
successor.query()
|
||||
await debounce()
|
||||
expect(successor.paths()).toEqual(['src/apple.ts'])
|
||||
expect(send.mock.calls).toEqual([
|
||||
searchCall,
|
||||
searchCall,
|
||||
['files.list', { worktree: 'id:folder:remote' }]
|
||||
])
|
||||
await refuse(retiredSearch)
|
||||
expect(successor.paths()).toEqual(['src/apple.ts'])
|
||||
expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(1)
|
||||
successor.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('reentrant disposal after fallback entry preserves the admitted inventory', async () => {
|
||||
const search = pendingReply()
|
||||
let disposal: (() => void) | undefined
|
||||
const send = vi.fn<RpcClient['sendRequest']>((method) =>
|
||||
method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory)
|
||||
)
|
||||
const view = mount(send, false, () => {
|
||||
disposal?.()
|
||||
return 1
|
||||
})
|
||||
view.query()
|
||||
await debounce()
|
||||
expect(send.mock.calls).toEqual([searchCall])
|
||||
disposal = view.dispose
|
||||
await refuse(search)
|
||||
expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]])
|
||||
})
|
||||
|
||||
it('already admitted inventory still settles after full owner disposal', async () => {
|
||||
const list = pendingReply()
|
||||
const send = vi.fn<RpcClient['sendRequest']>((method) =>
|
||||
method === 'files.searchPaths' ? Promise.resolve(missing) : list.promise
|
||||
)
|
||||
const view = mount(send)
|
||||
view.query()
|
||||
await debounce()
|
||||
expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]])
|
||||
view.dispose()
|
||||
await act(async () => {
|
||||
list.resolve(inventory)
|
||||
await list.promise
|
||||
})
|
||||
expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -29,6 +29,11 @@ export const styles = StyleSheet.create({
|
||||
lineHeight: TEXT_SIZE + 6,
|
||||
fontWeight: '500'
|
||||
},
|
||||
hostNotice: {
|
||||
color: colors.textMuted,
|
||||
fontSize: TEXT_SIZE,
|
||||
lineHeight: TEXT_SIZE + 6
|
||||
},
|
||||
reasoning: {
|
||||
opacity: 0.7
|
||||
},
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame'
|
||||
|
||||
function row(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage {
|
||||
return { id, role, source: 'transcript', timestamp: 1, blocks: [{ type: 'text', text: id }] }
|
||||
}
|
||||
|
||||
function pair(id: string): NativeChatMessage[] {
|
||||
return [row(`${id}:reasoning`, 'reasoning'), row(id)]
|
||||
}
|
||||
|
||||
describe('mobile complete-pair stream windows', () => {
|
||||
it.each(['appended', 'snapshot'])(
|
||||
'keeps a pair at the %s cutoff without moving the cursor',
|
||||
(type) => {
|
||||
const merger = createNativeChatMerger()
|
||||
const base = [
|
||||
...pair('opencode:oldest'),
|
||||
...Array.from({ length: 38 }, (_, i) => row(`m${i}`))
|
||||
]
|
||||
replaceList(merger, base)
|
||||
const result = applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: {
|
||||
type,
|
||||
messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')],
|
||||
hasMore: true,
|
||||
beforeOffset: 1
|
||||
},
|
||||
limit: 40,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] })
|
||||
expect(merger.indexById.get('opencode:oldest:reasoning')).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['appended', 'snapshot'])(
|
||||
'keeps reasoning, answer and omission from one raw row at the %s cutoff',
|
||||
(type) => {
|
||||
const group = [...pair('opencode:oldest'), row('opencode:oldest:omission', 'system')].map(
|
||||
(message) => ({ ...message, transcriptOffset: 7 })
|
||||
)
|
||||
const base = [...group, ...Array.from({ length: 38 }, (_, i) => row(`m${i}`))]
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, base)
|
||||
const result = applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: {
|
||||
type,
|
||||
messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')],
|
||||
hasMore: true,
|
||||
beforeOffset: 7
|
||||
},
|
||||
limit: 40,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] })
|
||||
expect(merger.list.slice(0, 3)).toEqual(group)
|
||||
expect(merger.list).toHaveLength(42)
|
||||
}
|
||||
)
|
||||
|
||||
it('invalidates the cursor when the oldest complete pair leaves', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
const base = [...pair('opencode:oldest'), ...Array.from({ length: 39 }, (_, i) => row(`m${i}`))]
|
||||
replaceList(merger, base)
|
||||
const result = applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: { type: 'appended', messages: [row('latest')] },
|
||||
limit: 40,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
expect(result).toEqual({
|
||||
kind: 'messages',
|
||||
messages: [...base.slice(2), row('latest')],
|
||||
cursorInvalidated: true
|
||||
})
|
||||
expect(merger.indexById.has('opencode:oldest')).toBe(false)
|
||||
expect(merger.indexById.has('opencode:oldest:reasoning')).toBe(false)
|
||||
})
|
||||
|
||||
it('retains a host page with an extra pair row across a user append followed by a pair append', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
const base = [
|
||||
...pair('opencode:first'),
|
||||
...Array.from({ length: 13 }, (_, i) => [
|
||||
row(`user-${i}`, 'user'),
|
||||
...pair(`opencode:${i}`)
|
||||
]).flat()
|
||||
]
|
||||
applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: { type: 'snapshot', messages: base, hasMore: true, beforeOffset: 123 },
|
||||
limit: 40,
|
||||
replaceSnapshot: true
|
||||
})
|
||||
applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: { type: 'appended', messages: [row('latest-user', 'user')] },
|
||||
limit: 40,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
const result = applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: { type: 'appended', messages: pair('opencode:latest') },
|
||||
limit: 40,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
expect(result).toMatchObject({ kind: 'messages', cursorInvalidated: true })
|
||||
expect(merger.list).toHaveLength(41)
|
||||
expect(merger.list.slice(0, 2)).toEqual(pair('opencode:0'))
|
||||
expect(merger.list.slice(-2)).toEqual(pair('opencode:latest'))
|
||||
})
|
||||
|
||||
it('keeps legacy peers with independent reasoning IDs at the raw row limit', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, [row('claude-thinking', 'reasoning'), row('claude-answer')])
|
||||
const result = applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: { type: 'appended', messages: [row('latest')] },
|
||||
limit: 2,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
expect(result).toEqual({
|
||||
kind: 'messages',
|
||||
messages: [row('claude-answer'), row('latest')],
|
||||
cursorInvalidated: true
|
||||
})
|
||||
expect(merger.list).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,434 @@
|
||||
import { createElement, StrictMode } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
type AnimationStart = {
|
||||
options: { toValue: number; duration: number; useNativeDriver: boolean }
|
||||
complete: (result: { finished: boolean }) => void
|
||||
}
|
||||
const boundary = vi.hoisted(() => {
|
||||
const animationStarts: AnimationStart[] = []
|
||||
return {
|
||||
animationStarts,
|
||||
writeText: vi.fn<(text: string) => Promise<void>>(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
synchronous: false
|
||||
}
|
||||
})
|
||||
vi.mock('react-native', () => ({
|
||||
View: 'View',
|
||||
Platform: { OS: 'ios' },
|
||||
Keyboard: { dismiss: vi.fn() },
|
||||
Animated: {
|
||||
timing: (
|
||||
_value: unknown,
|
||||
options: { toValue: number; duration: number; useNativeDriver: boolean }
|
||||
) => ({
|
||||
start: (complete: (result: { finished: boolean }) => void) => {
|
||||
boundary.animationStarts.push({ options, complete })
|
||||
if (boundary.synchronous) {
|
||||
complete({ finished: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
vi.mock('../platform/clipboard', () => ({
|
||||
useClipboardWriter: () => ({ writeText: boundary.writeText }),
|
||||
useClipboardReader: () => ({ contents: async () => ({ text: false, image: false }) })
|
||||
}))
|
||||
vi.mock('../platform/haptics', () => ({
|
||||
triggerSuccess: boundary.success,
|
||||
triggerError: boundary.error,
|
||||
triggerSelection: vi.fn(),
|
||||
triggerEdgeBump: vi.fn()
|
||||
}))
|
||||
vi.mock('../terminal/terminal-copy-gutter-preference', () => ({
|
||||
useTerminalCopyTrimsGutter: () => ({ current: false })
|
||||
}))
|
||||
vi.mock('./mobile-session-styles', () => ({ styles: { container: {}, kavInner: {} } }))
|
||||
vi.mock('./MobileSessionHeader', () => ({ MobileSessionHeader: () => null }))
|
||||
vi.mock('./MobileSessionContentRow', () => ({ MobileSessionContentRow: () => null }))
|
||||
vi.mock('./MobileSessionSheets', () => ({ MobileSessionSheets: () => null }))
|
||||
import { useMobileSessionFeedbackCapabilities } from './use-mobile-session-feedback-capabilities'
|
||||
import { useMobileSessionAccessorySelection } from './use-mobile-session-accessory-selection'
|
||||
import { MobileSessionSurface } from './MobileSessionSurface'
|
||||
|
||||
type FeedbackScope = Parameters<typeof useMobileSessionFeedbackCapabilities>[0]
|
||||
type AccessoryScope = Parameters<typeof useMobileSessionAccessorySelection>[0]
|
||||
type SurfaceController = Parameters<typeof MobileSessionSurface>[0]['controller']
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
boundary.animationStarts.length = 0
|
||||
boundary.synchronous = false
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
function mounted(strict = false) {
|
||||
const setToastMessage = vi.fn()
|
||||
const clearTerminalCache = vi.fn()
|
||||
const cancelSelect = vi.fn()
|
||||
const scope = {
|
||||
client: null,
|
||||
connState: 'connected',
|
||||
initialCreateWarning: '',
|
||||
sessionTabs: [],
|
||||
sessionTabsRef: { current: [] },
|
||||
activeSessionTabId: null,
|
||||
activeSessionTabIdRef: { current: null },
|
||||
markdownDocs: new Map(),
|
||||
markdownDocsRef: { current: new Map() },
|
||||
createWarningState: { source: '', visible: '' },
|
||||
setCreateWarningState: vi.fn(),
|
||||
setToastMessage,
|
||||
toastOpacityRef: { current: {} },
|
||||
toastHideTimerRef: { current: null },
|
||||
toastSeqRef: { current: 0 },
|
||||
clientRef: { current: null },
|
||||
connStateRef: { current: 'connected' },
|
||||
activeSessionTabTypeRef: { current: 'terminal' },
|
||||
delayedActionTimersRef: { current: new Set() },
|
||||
activeSessionTab: { type: 'terminal' },
|
||||
worktreeId: 'folder:remote',
|
||||
isFloatingWorkspaceRoute: false,
|
||||
setTerminalKeyboardMetrics: vi.fn(),
|
||||
setSelectModeActive: vi.fn(),
|
||||
setCanPaste: vi.fn(),
|
||||
ptyModesRef: { current: new Map() },
|
||||
initialModesSeenRef: { current: new Set() },
|
||||
terminalRefs: { current: new Map([['handle', { cancelSelect }]]) },
|
||||
liveInputFocusTimerRef: { current: null },
|
||||
sessionTabActionSheetRequestSeqRef: { current: 0 },
|
||||
activeHandleRef: { current: 'handle' },
|
||||
clearPendingLiveInputCommit: vi.fn(),
|
||||
clearTerminalCache,
|
||||
handleAccessoryKey: vi.fn(),
|
||||
clearSessionTabActionSheetKeyboardListener: vi.fn()
|
||||
}
|
||||
const read: {
|
||||
value:
|
||||
| (ReturnType<typeof useMobileSessionAccessorySelection> &
|
||||
ReturnType<typeof useMobileSessionFeedbackCapabilities>)
|
||||
| undefined
|
||||
} = { value: undefined }
|
||||
function Probe({
|
||||
surfaceKey = 'initial',
|
||||
visible = true
|
||||
}: {
|
||||
surfaceKey?: string
|
||||
visible?: boolean
|
||||
}) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual feedback hook reads only the enumerated members; its type includes unrelated composed controller hooks.
|
||||
const feedbackScope = scope as unknown as FeedbackScope
|
||||
const feedback = useMobileSessionFeedbackCapabilities(feedbackScope)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual accessory hook reads only enumerated members plus actual feedback capability callbacks.
|
||||
const accessoryScope = { ...scope, ...feedback } as unknown as AccessoryScope
|
||||
const accessory = useMobileSessionAccessorySelection(accessoryScope)
|
||||
const result = { ...feedback, ...accessory }
|
||||
read.value = result
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual surface reads only setMobileSessionRootRef; its child components are isolated presentation stubs.
|
||||
const controller = result as unknown as SurfaceController
|
||||
return visible ? createElement(MobileSessionSurface, { key: surfaceKey, controller }) : null
|
||||
}
|
||||
let root: ReturnType<typeof create> | undefined
|
||||
const element = (surfaceKey = 'initial', visible = true) =>
|
||||
strict
|
||||
? createElement(StrictMode, null, createElement(Probe, { surfaceKey, visible }))
|
||||
: createElement(Probe, { surfaceKey, visible })
|
||||
act(() => {
|
||||
root = create(element(), { createNodeMock: () => ({}) })
|
||||
})
|
||||
if (!root || !read.value) {
|
||||
throw new Error('real composed hooks did not mount')
|
||||
}
|
||||
const renderer = root
|
||||
return {
|
||||
root,
|
||||
get api() {
|
||||
if (!read.value) {
|
||||
throw new Error('real composed hooks did not render')
|
||||
}
|
||||
return read.value
|
||||
},
|
||||
scope,
|
||||
setToastMessage,
|
||||
clearTerminalCache,
|
||||
cancelSelect,
|
||||
rerender: (surfaceKey: string, visible = true) =>
|
||||
act(() => renderer.update(element(surfaceKey, visible)))
|
||||
}
|
||||
}
|
||||
it.each([
|
||||
['success', false],
|
||||
['failure', false],
|
||||
['success', true],
|
||||
['failure', true]
|
||||
] as const)(
|
||||
'keeps admitted %s copy outcome after unmount without new timers, StrictMode=%s',
|
||||
async (outcome, strict) => {
|
||||
vi.useFakeTimers()
|
||||
let resolveWrite: (() => void) | undefined
|
||||
let rejectWrite: ((error: Error) => void) | undefined
|
||||
const clipboard = new Promise<void>((resolve, reject) => {
|
||||
resolveWrite = resolve
|
||||
rejectWrite = reject
|
||||
})
|
||||
boundary.writeText.mockReturnValue(clipboard)
|
||||
const view = mounted(strict)
|
||||
const baselineClear = view.clearTerminalCache.mock.calls.length
|
||||
const baselineSequence = view.scope.toastSeqRef.current
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
let copying: Promise<void> | undefined
|
||||
act(() => {
|
||||
copying = view.api.handleSelectionCopy('handle', 'original text')
|
||||
})
|
||||
expect(boundary.writeText.mock.calls).toEqual([['original text']])
|
||||
expect(boundary.animationStarts).toHaveLength(0)
|
||||
act(() => view.root.unmount())
|
||||
expect(view.clearTerminalCache).toHaveBeenCalledTimes(baselineClear + 1)
|
||||
expect(view.scope.toastSeqRef.current).toBe(baselineSequence + 1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await act(async () => {
|
||||
if (outcome === 'success') {
|
||||
resolveWrite?.()
|
||||
} else {
|
||||
rejectWrite?.(new Error('clipboard refused'))
|
||||
}
|
||||
await copying
|
||||
})
|
||||
expect(boundary.success).toHaveBeenCalledTimes(outcome === 'success' ? 1 : 0)
|
||||
expect(boundary.error).toHaveBeenCalledTimes(outcome === 'failure' ? 1 : 0)
|
||||
expect(view.cancelSelect).toHaveBeenCalledTimes(outcome === 'success' ? 1 : 0)
|
||||
expect(warn.mock.calls).toEqual(
|
||||
outcome === 'failure'
|
||||
? [['[mobile-clip] setString failed', { name: 'Error', message: 'clipboard refused' }]]
|
||||
: []
|
||||
)
|
||||
for (const animation of boundary.animationStarts) {
|
||||
act(() => animation.complete({ finished: true }))
|
||||
}
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(boundary.animationStarts).toHaveLength(0)
|
||||
expect(view.setToastMessage).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
it('proves existing detach sequence already fences an admitted fade-in', () => {
|
||||
vi.useFakeTimers()
|
||||
const view = mounted()
|
||||
act(() => view.api.showToast('already visible'))
|
||||
expect(boundary.animationStarts).toHaveLength(1)
|
||||
act(() => view.root.unmount())
|
||||
act(() => boundary.animationStarts[0].complete({ finished: true }))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(boundary.animationStarts).toHaveLength(1)
|
||||
})
|
||||
it('proves existing detach cleanup clears a live hide timer', () => {
|
||||
vi.useFakeTimers()
|
||||
const view = mounted()
|
||||
act(() => view.api.showToast('already visible'))
|
||||
act(() => boundary.animationStarts[0].complete({ finished: true }))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
act(() => view.root.unmount())
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves late admitted copy after same-hook root replacement, StrictMode=%s',
|
||||
async (strict) => {
|
||||
vi.useFakeTimers()
|
||||
let finish: (() => void) | undefined
|
||||
boundary.writeText.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const view = mounted(strict)
|
||||
const originalShow = view.api.showToast
|
||||
let copying: Promise<void> | undefined
|
||||
act(() => {
|
||||
copying = view.api.handleSelectionCopy('handle', 'original text')
|
||||
})
|
||||
const previousClear = view.clearTerminalCache.mock.calls.length
|
||||
view.rerender('replacement')
|
||||
expect(view.clearTerminalCache.mock.calls.length).toBeGreaterThan(previousClear)
|
||||
expect(view.api.showToast).toBe(originalShow)
|
||||
await act(async () => {
|
||||
finish?.()
|
||||
await copying
|
||||
})
|
||||
expect(boundary.writeText.mock.calls).toEqual([['original text']])
|
||||
expect(boundary.success).toHaveBeenCalledTimes(1)
|
||||
expect(view.cancelSelect).toHaveBeenCalledTimes(1)
|
||||
expect(view.setToastMessage.mock.calls).toEqual([['Copied']])
|
||||
expect(boundary.animationStarts).toHaveLength(1)
|
||||
act(() => boundary.animationStarts[0].complete({ finished: true }))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
act(() => view.root.unmount())
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
}
|
||||
)
|
||||
it('preserves late copy while surface is temporarily detached but hook stays mounted', async () => {
|
||||
vi.useFakeTimers()
|
||||
let finish: (() => void) | undefined
|
||||
boundary.writeText.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const view = mounted()
|
||||
let copying: Promise<void> | undefined
|
||||
act(() => {
|
||||
copying = view.api.handleSelectionCopy('handle', 'original text')
|
||||
})
|
||||
view.rerender('inactive', false)
|
||||
expect(view.clearTerminalCache).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
finish?.()
|
||||
await copying
|
||||
})
|
||||
expect(boundary.success).toHaveBeenCalledTimes(1)
|
||||
expect(view.cancelSelect).toHaveBeenCalledTimes(1)
|
||||
expect(view.setToastMessage.mock.calls).toEqual([['Copied']])
|
||||
act(() => boundary.animationStarts[0].complete({ finished: true }))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
view.rerender('active', true)
|
||||
act(() => view.root.unmount())
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
it.each([1200, 1500])('keeps exact live fade-in/hide/fade-out timing at %s ms', (duration) => {
|
||||
vi.useFakeTimers()
|
||||
const view = mounted()
|
||||
act(() => view.api.showToast('message', duration))
|
||||
expect(view.setToastMessage.mock.calls).toEqual([['message']])
|
||||
expect(boundary.animationStarts[0].options).toEqual({
|
||||
toValue: 1,
|
||||
duration: 150,
|
||||
useNativeDriver: true
|
||||
})
|
||||
act(() => boundary.animationStarts[0].complete({ finished: true }))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(duration - 1)
|
||||
})
|
||||
expect(boundary.animationStarts).toHaveLength(1)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(boundary.animationStarts[1].options).toEqual({
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true
|
||||
})
|
||||
act(() => boundary.animationStarts[1].complete({ finished: true }))
|
||||
expect(view.setToastMessage.mock.calls).toEqual([['message'], [null]])
|
||||
act(() => view.root.unmount())
|
||||
})
|
||||
it('preserves old unfinished/replaced animation fences and latest toast ownership', () => {
|
||||
vi.useFakeTimers()
|
||||
const view = mounted()
|
||||
act(() => view.api.showToast('first'))
|
||||
act(() => view.api.showToast('second', 1500))
|
||||
act(() => boundary.animationStarts[0].complete({ finished: true }))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
act(() => boundary.animationStarts[1].complete({ finished: false }))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
act(() => view.api.showToast('third'))
|
||||
act(() => boundary.animationStarts[2].complete({ finished: true }))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
act(() => view.api.showToast('fourth'))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
act(() => boundary.animationStarts[3].complete({ finished: true }))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1200)
|
||||
})
|
||||
act(() => view.api.showToast('fifth'))
|
||||
act(() => boundary.animationStarts[4].complete({ finished: true }))
|
||||
expect(view.setToastMessage.mock.calls).toEqual([
|
||||
['first'],
|
||||
['second'],
|
||||
['third'],
|
||||
['fourth'],
|
||||
['fifth']
|
||||
])
|
||||
act(() => view.root.unmount())
|
||||
})
|
||||
it('keeps 64 admitted copies after 64 surface/hook unmounts without new timers', async () => {
|
||||
vi.useFakeTimers()
|
||||
const releases: (() => void)[] = []
|
||||
const copying: Promise<void>[] = []
|
||||
const views: ReturnType<typeof mounted>[] = []
|
||||
for (let index = 0; index < 64; index++) {
|
||||
boundary.writeText.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
releases.push(resolve)
|
||||
})
|
||||
)
|
||||
const view = mounted()
|
||||
views.push(view)
|
||||
act(() => {
|
||||
copying.push(view.api.handleSelectionCopy('handle', 'copy' + index))
|
||||
})
|
||||
act(() => view.root.unmount())
|
||||
}
|
||||
expect(boundary.writeText.mock.calls).toEqual(
|
||||
Array.from({ length: 64 }, (_, index) => ['copy' + index])
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await act(async () => {
|
||||
for (const release of releases) {
|
||||
release()
|
||||
}
|
||||
await Promise.all(copying)
|
||||
})
|
||||
expect(boundary.success).toHaveBeenCalledTimes(64)
|
||||
expect(boundary.error).not.toHaveBeenCalled()
|
||||
for (const view of views) {
|
||||
expect(view.cancelSelect).toHaveBeenCalledTimes(1)
|
||||
expect(view.clearTerminalCache).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
for (const animation of boundary.animationStarts) {
|
||||
act(() => animation.complete({ finished: true }))
|
||||
}
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(boundary.animationStarts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('preserves synchronous native completion and natural hide cleanup', () => {
|
||||
vi.useFakeTimers()
|
||||
boundary.synchronous = true
|
||||
const view = mounted()
|
||||
act(() => view.api.showToast('sync'))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1200)
|
||||
})
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(view.setToastMessage.mock.calls).toEqual([['sync'], [null]])
|
||||
expect(boundary.animationStarts.map((start) => start.options)).toEqual([
|
||||
{ toValue: 1, duration: 150, useNativeDriver: true },
|
||||
{ toValue: 0, duration: 200, useNativeDriver: true }
|
||||
])
|
||||
act(() => view.root.unmount())
|
||||
})
|
||||
it('preserves reentrant newer-toast sequence and animation start order', () => {
|
||||
vi.useFakeTimers()
|
||||
const view = mounted()
|
||||
view.setToastMessage.mockImplementationOnce(() => view.api.showToast('inner'))
|
||||
act(() => view.api.showToast('outer'))
|
||||
expect(view.setToastMessage.mock.calls).toEqual([['outer'], ['inner']])
|
||||
expect(boundary.animationStarts).toHaveLength(2)
|
||||
act(() => boundary.animationStarts[0].complete({ finished: true }))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
act(() => boundary.animationStarts[1].complete({ finished: true }))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1200)
|
||||
})
|
||||
act(() => boundary.animationStarts[2].complete({ finished: true }))
|
||||
expect(view.setToastMessage.mock.calls).toEqual([['outer'], ['inner'], [null]])
|
||||
act(() => view.root.unmount())
|
||||
})
|
||||
@@ -34,6 +34,14 @@ export function useMobileNativeChatFileSearch(args: {
|
||||
new GenerationScopedRequestOwner<WorkspaceInventoryParameters, string[]>()
|
||||
).current
|
||||
|
||||
const mountedRef = useRef(true)
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
sequenceRef.current++
|
||||
queryCacheRef.current.clear()
|
||||
@@ -90,6 +98,9 @@ export function useMobileNativeChatFileSearch(args: {
|
||||
setNativeChatFilePaths(paths)
|
||||
}
|
||||
const loadLegacyPaths = async (): Promise<void> => {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
// What retires the inventory: this host, this workspace, this logical authority. A
|
||||
// reconnect to the same host leaves the files on disk alone, so the physical session
|
||||
// epoch is deliberately not in it. Read once, so a cutover between the two calls below
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { Animated } from 'react-native'
|
||||
import { reconcileMobileSessionCreateWarningState } from './mobile-session-create-warning-state'
|
||||
import type { MobileSessionTerminalRuntimeModel } from './use-mobile-session-terminal-runtime'
|
||||
@@ -60,6 +60,14 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina
|
||||
}
|
||||
const createWarning = reconciledCreateWarningState.visible
|
||||
|
||||
const mountedRef = useRef(true)
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearDelayedActionTimers = useCallback(() => {
|
||||
for (const timer of delayedActionTimersRef.current) {
|
||||
clearTimeout(timer)
|
||||
@@ -85,6 +93,9 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, durationMs = 1200) => {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
const seq = toastSeqRef.current + 1
|
||||
toastSeqRef.current = seq
|
||||
clearToastHideTimer()
|
||||
|
||||
@@ -53,6 +53,24 @@ function baseScenario(manifest: readonly RecordingScenario[], id: string): Recor
|
||||
return found
|
||||
}
|
||||
|
||||
function omitRetiredInventoryAdmission(scenario: RecordingScenario): RecordingScenario {
|
||||
if (scenario.id !== 'inventory-lifecycle.unmount-before-1') {
|
||||
return scenario
|
||||
}
|
||||
// A late search refusal cannot start an inventory after this owner's unmount.
|
||||
return {
|
||||
...scenario,
|
||||
steps: scenario.steps.filter(
|
||||
(step) =>
|
||||
!(
|
||||
('bind' in step &&
|
||||
(step.bind === 'old-inventory' || step.bind === 'lifecycle-old-inventory')) ||
|
||||
('complete' in step && step.complete === 'lifecycle-old-inventory')
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The manifest scenario a pilot golden expands from, which its suite also mounts and mutates. */
|
||||
export type PilotGolden = DerivedGolden & { scenario: RecordingScenario }
|
||||
|
||||
@@ -144,7 +162,7 @@ export function familyGoldens(manifest: readonly RecordingScenario[]): DerivedGo
|
||||
actions
|
||||
.flatMap((action) => lifecycleSchedules(base, action))
|
||||
.filter(({ scenario }) => !id.includes('hydration') || !scenario.id.endsWith('-1'))
|
||||
)
|
||||
).map(omitRetiredInventoryAdmission)
|
||||
})
|
||||
}
|
||||
return goldens
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { familyGoldens } from './derived-goldens'
|
||||
import { hoistPreludeCheckpoints } from './prelude-checkpoints'
|
||||
import { readScenarios } from './scenario-input'
|
||||
import { bindCompletions, lifecycleSchedules } from './schedule-driver'
|
||||
|
||||
it('omits only the inventory admission following a refusal to a fully unmounted owner', () => {
|
||||
const manifest = readScenarios(
|
||||
resolve(import.meta.dirname, '../../../rpc-foundation/pilot-scenarios.json')
|
||||
)
|
||||
const base = manifest.scenarios.find((scenario) => scenario.id === 'inventory-lifecycle')
|
||||
if (!base) {
|
||||
throw new Error('missing inventory base scenario')
|
||||
}
|
||||
const golden = familyGoldens(manifest.scenarios).find(
|
||||
(item) => item.id === 'lifecycle-inventory-lifecycle'
|
||||
)
|
||||
if (!golden) {
|
||||
throw new Error('missing inventory lifecycle golden')
|
||||
}
|
||||
const original = hoistPreludeCheckpoints(
|
||||
{ ...base, steps: bindCompletions(base.steps) },
|
||||
(['reset', 'unmount', 'blur'] as const).flatMap((action) => lifecycleSchedules(base, action))
|
||||
)
|
||||
const derived = golden.scenarios()
|
||||
expect(derived.map((scenario) => scenario.id)).toEqual(original.map((scenario) => scenario.id))
|
||||
expect(derived).toHaveLength(12)
|
||||
for (const [index, scenario] of original.entries()) {
|
||||
if (scenario.id !== 'inventory-lifecycle.unmount-before-1') {
|
||||
expect(derived[index]).toEqual(scenario)
|
||||
expect(JSON.stringify(derived[index])).toBe(JSON.stringify(scenario))
|
||||
continue
|
||||
}
|
||||
expect(scenario.steps).toHaveLength(13)
|
||||
expect(scenario.steps.slice(7, 10)).toEqual([
|
||||
{ bind: 'old-inventory', request: 'files.list#1', params: { worktree: 'id:A' } },
|
||||
{ bind: 'lifecycle-old-inventory', request: 'old-inventory', params: { worktree: 'id:A' } },
|
||||
{
|
||||
complete: 'lifecycle-old-inventory',
|
||||
params: { worktree: 'id:A' },
|
||||
reply: { ok: true, result: { files: [{ relativePath: 'old.ts' }] } }
|
||||
}
|
||||
])
|
||||
const expected = {
|
||||
...scenario,
|
||||
steps: [...scenario.steps.slice(0, 7), ...scenario.steps.slice(10)]
|
||||
}
|
||||
expect(derived[index]).toEqual(expected)
|
||||
expect(JSON.stringify(derived[index])).toBe(JSON.stringify(expected))
|
||||
expect(expected.steps.filter((step) => 'checkpoint' in step)).toEqual([
|
||||
{ checkpoint: 'lifecycle-boundary' },
|
||||
{ checkpoint: 'settled' },
|
||||
{ checkpoint: 'remounted' }
|
||||
])
|
||||
expect(expected.steps.filter((step) => 'bind' in step)).toEqual([
|
||||
{
|
||||
bind: 'lifecycle-files.searchPaths#1',
|
||||
request: 'files.searchPaths#1',
|
||||
params: { worktree: 'id:A', query: 'old', limit: 16 }
|
||||
}
|
||||
])
|
||||
}
|
||||
})
|
||||
@@ -130,6 +130,9 @@ export class MobileRelayE2eeLink {
|
||||
// `error` is often delivered just before `close`; wait for close so a
|
||||
// typed relay code is not replaced by a generic transport error.
|
||||
this.socket.onerror = () => {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
this.transportErrorTimer ??= setTimeout(() => {
|
||||
this.transportErrorTimer = null
|
||||
this.fail(new RelayOuterError(1006))
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason'
|
||||
|
||||
vi.mock('./mobile-e2ee-v2-client-session', () => ({
|
||||
MobileE2EEV2ClientSession: { create: () => ({}) }
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
|
||||
MobileE2EEV2PhysicalChannel: class {
|
||||
start = vi.fn()
|
||||
handleMessage = vi.fn(async () => {})
|
||||
sendText = vi.fn(() => true)
|
||||
sendBinary = vi.fn(() => true)
|
||||
dispose = vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link'
|
||||
|
||||
class ErrorTimerSocket {
|
||||
readonly OPEN = 1
|
||||
readyState = 1
|
||||
bufferedAmount = 0
|
||||
onopen: (() => void) | null = null
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onclose: ((event: { code: number; reason: string }) => void) | null = null
|
||||
send = vi.fn((_frame: string) => {})
|
||||
close = vi.fn(() => {})
|
||||
}
|
||||
|
||||
function linkFixture(): {
|
||||
link: MobileRelayE2eeLink
|
||||
socket: ErrorTimerSocket
|
||||
onError: ReturnType<typeof vi.fn>
|
||||
onHostCloseReason: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const socket = new ErrorTimerSocket()
|
||||
const onError = vi.fn()
|
||||
const onHostCloseReason = vi.fn()
|
||||
const link = new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
onHostCloseReason,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The observed socket implements every WebSocket member this link and mocked channel use.
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
return { link, socket, onError, onHostCloseReason }
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('closed relay link transport-error timer ownership', () => {
|
||||
it('starts no fallback for errors queued after explicit close', () => {
|
||||
const { link, socket, onError } = linkFixture()
|
||||
link.close()
|
||||
socket.onerror?.()
|
||||
socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(socket.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('starts no fallback after failure and preserves the original thrown error', () => {
|
||||
const { socket, onError } = linkFixture()
|
||||
const failure = new Error('relay auth write failed')
|
||||
socket.send.mockImplementation(() => {
|
||||
throw failure
|
||||
})
|
||||
socket.onopen?.()
|
||||
socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(onError).toHaveBeenCalledExactlyOnceWith(failure)
|
||||
expect(socket.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['close', 'fail', 'fallback'])(
|
||||
'starts no fallback for a reentrant socket.close error during %s',
|
||||
async (mode) => {
|
||||
const { link, socket, onError } = linkFixture()
|
||||
socket.close.mockImplementation(() => {
|
||||
socket.onerror?.()
|
||||
})
|
||||
if (mode === 'close') {
|
||||
link.close()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
} else if (mode === 'fail') {
|
||||
socket.onclose?.({ code: 4409, reason: '' })
|
||||
expect(onError).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'relay_outer_4409' })
|
||||
)
|
||||
} else {
|
||||
socket.onerror?.()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(onError).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'relay_outer_1006' })
|
||||
)
|
||||
}
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(socket.close).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('starts no fallback after normal close and still reports its host reason', () => {
|
||||
const { socket, onError, onHostCloseReason } = linkFixture()
|
||||
socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT })
|
||||
socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(onError).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'relay_outer_4409' })
|
||||
)
|
||||
expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
|
||||
})
|
||||
|
||||
it('retains one live fallback and the exact missing-close grace deadline', async () => {
|
||||
const { socket, onError } = linkFixture()
|
||||
socket.onerror?.()
|
||||
socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(socket.close).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(onError).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'relay_outer_1006' })
|
||||
)
|
||||
expect(socket.close).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('retains a typed close and host reason during the live error grace', async () => {
|
||||
const { socket, onError, onHostCloseReason } = linkFixture()
|
||||
socket.onerror?.()
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(onError).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'relay_outer_4409' })
|
||||
)
|
||||
expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
|
||||
})
|
||||
|
||||
it('retains late host-reason reporting after a rejected handshake', async () => {
|
||||
const { socket, onError, onHostCloseReason } = linkFixture()
|
||||
socket.onmessage?.({ data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4409 }) })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(onError).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'relay_outer_4409' })
|
||||
)
|
||||
socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT })
|
||||
expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
|
||||
expect(onError).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a replacement link independent and leaves no timer after its close', async () => {
|
||||
const old = linkFixture()
|
||||
old.link.close()
|
||||
const replacement = linkFixture()
|
||||
old.socket.onerror?.()
|
||||
replacement.socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
replacement.socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(old.onError).not.toHaveBeenCalled()
|
||||
expect(replacement.onError).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'relay_outer_4409' })
|
||||
)
|
||||
expect(replacement.onHostCloseReason).toHaveBeenCalledExactlyOnceWith(
|
||||
RELAY_HOST_CLOSE_REASON.SIGNED_OUT
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
type ChannelRecord = {
|
||||
options: { onAuthenticated(): void; onText(value: string): void; onError(error: Error): void }
|
||||
start: ReturnType<typeof vi.fn>
|
||||
sendText: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const observed = vi.hoisted(() => ({ channels: new Array<ChannelRecord>() }))
|
||||
vi.mock('./mobile-e2ee-v2-client-session', () => ({
|
||||
MobileE2EEV2ClientSession: { create: () => ({}) }
|
||||
}))
|
||||
vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
|
||||
MobileE2EEV2PhysicalChannel: class {
|
||||
start = vi.fn()
|
||||
handleMessage = vi.fn(async () => {})
|
||||
sendText = vi.fn((_frame: string) => true)
|
||||
dispose = vi.fn()
|
||||
constructor(readonly options: ChannelRecord['options']) {
|
||||
observed.channels.push(this)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { connectMobileRelayForPairing, RelayOuterError } from './mobile-relay-physical-client'
|
||||
|
||||
class PairingTimerSocket {
|
||||
readonly OPEN = 1
|
||||
readyState = 1
|
||||
bufferedAmount = 0
|
||||
onopen: (() => void) | null = null
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onclose: ((event: { code: number }) => void) | null = null
|
||||
send = vi.fn((_frame: string) => {})
|
||||
close = vi.fn(() => {})
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const socket = new PairingTimerSocket()
|
||||
const logs: ConnectionLogEntry[] = []
|
||||
const client = connectMobileRelayForPairing({
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.onorca.dev',
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
|
||||
inviteExpiresAt: Date.now() + 300_000,
|
||||
e2eeFraming: 2
|
||||
},
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
onLog: (entry) => logs.push(entry),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The observed socket implements every WebSocket member this client and mocked channel use.
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
const channel = observed.channels.at(-1)
|
||||
if (!channel) {
|
||||
throw new Error('missing observed pairing channel')
|
||||
}
|
||||
return { client, socket, logs, channel }
|
||||
}
|
||||
|
||||
async function authenticate(target: ReturnType<typeof fixture>): Promise<void> {
|
||||
target.socket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'relay-hello',
|
||||
ok: true,
|
||||
credentialKind: 'invite',
|
||||
leaseExpiresAt: Date.now() + 60_000
|
||||
})
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(target.channel.start).toHaveBeenCalledOnce()
|
||||
target.channel.options.onAuthenticated()
|
||||
}
|
||||
|
||||
function closedLogs(logs: ConnectionLogEntry[]): ConnectionLogEntry[] {
|
||||
return logs.filter((entry) => entry.message === 'Relay: pairing socket closed')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observed.channels.length = 0
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('closed relay pairing client error timer ownership', () => {
|
||||
it('keeps authentication waiter rejection and intentional-close log exact without a late alarm', async () => {
|
||||
const target = fixture()
|
||||
const first = target.client.sendRequest('first').catch((error: unknown) => error)
|
||||
const second = target.client.sendRequest('second').catch((error: unknown) => error)
|
||||
target.client.close()
|
||||
const failure = await first
|
||||
expect(failure).toEqual(new Error('relay pairing client closed'))
|
||||
expect(await second).toBe(failure)
|
||||
target.socket.onerror?.()
|
||||
target.socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(closedLogs(target.logs)).toEqual([
|
||||
expect.objectContaining({ level: 'info', detail: 'relay-c1.onorca.dev' })
|
||||
])
|
||||
expect(target.channel.dispose).toHaveBeenCalledOnce()
|
||||
expect(target.socket.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the original error for every pending RPC and clears their timers before late errors', async () => {
|
||||
const target = fixture()
|
||||
await authenticate(target)
|
||||
const first = target.client.sendRequest('first').catch((error: unknown) => error)
|
||||
const second = target.client.sendRequest('second').catch((error: unknown) => error)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(vi.getTimerCount()).toBe(2)
|
||||
const failure = new Error('channel failed')
|
||||
target.channel.options.onError(failure)
|
||||
expect(await first).toBe(failure)
|
||||
expect(await second).toBe(failure)
|
||||
target.socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(closedLogs(target.logs)).toEqual([
|
||||
expect.objectContaining({ level: 'warn', detail: 'Error: channel failed' })
|
||||
])
|
||||
expect(target.channel.dispose).toHaveBeenCalledOnce()
|
||||
expect(target.socket.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['intentional', 'channel', 'fallback'])(
|
||||
'starts no timer for reentrant socket.close errors during %s',
|
||||
async (mode) => {
|
||||
const target = fixture()
|
||||
const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error)
|
||||
target.socket.close.mockImplementation(() => target.socket.onerror?.())
|
||||
const failure = new Error('channel failed')
|
||||
if (mode === 'intentional') {
|
||||
target.client.close()
|
||||
expect(await waiting).toEqual(new Error('relay pairing client closed'))
|
||||
} else if (mode === 'channel') {
|
||||
target.channel.options.onError(failure)
|
||||
expect(await waiting).toBe(failure)
|
||||
} else {
|
||||
target.socket.onerror?.()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(await waiting).toEqual(new RelayOuterError(1006))
|
||||
}
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(closedLogs(target.logs)).toHaveLength(1)
|
||||
expect(target.channel.dispose).toHaveBeenCalledOnce()
|
||||
expect(target.socket.close).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a normal typed close rejection and log without a late alarm', async () => {
|
||||
const target = fixture()
|
||||
const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error)
|
||||
target.socket.onclose?.({ code: 4409 })
|
||||
expect(await waiting).toEqual(new RelayOuterError(4409))
|
||||
target.socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(closedLogs(target.logs)).toEqual([
|
||||
expect.objectContaining({ level: 'warn', detail: 'relay close code 4409' })
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a rejected hello error and log without a late alarm', async () => {
|
||||
const target = fixture()
|
||||
const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error)
|
||||
target.socket.onmessage?.({
|
||||
data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 })
|
||||
})
|
||||
expect(await waiting).toEqual(new RelayOuterError(4404))
|
||||
target.socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(closedLogs(target.logs)).toEqual([
|
||||
expect.objectContaining({ level: 'warn', detail: 'relay close code 4404' })
|
||||
])
|
||||
expect(target.channel.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains one live alarm and exactly the 250 ms missing-close grace', async () => {
|
||||
const target = fixture()
|
||||
const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error)
|
||||
target.socket.onerror?.()
|
||||
target.socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(closedLogs(target.logs)).toHaveLength(0)
|
||||
expect(target.channel.dispose).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(await waiting).toEqual(new RelayOuterError(1006))
|
||||
expect(closedLogs(target.logs)).toEqual([
|
||||
expect.objectContaining({ level: 'warn', detail: 'relay close code 1006' })
|
||||
])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('retains a typed close during the live error grace', async () => {
|
||||
const target = fixture()
|
||||
const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error)
|
||||
target.socket.onerror?.()
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
target.socket.onclose?.({ code: 4409 })
|
||||
expect(await waiting).toEqual(new RelayOuterError(4409))
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(closedLogs(target.logs)).toEqual([
|
||||
expect.objectContaining({ level: 'warn', detail: 'relay close code 4409' })
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves an already settled RPC through explicit close and late errors', async () => {
|
||||
const target = fixture()
|
||||
await authenticate(target)
|
||||
const pending = target.client.sendRequest('status.get')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
target.channel.options.onText(JSON.stringify({ id: 'relay-pair-1', ok: true, result: 'done' }))
|
||||
const response = await pending
|
||||
expect(response).toEqual({ id: 'relay-pair-1', ok: true, result: 'done' })
|
||||
target.client.close()
|
||||
target.socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(await pending).toBe(response)
|
||||
expect(closedLogs(target.logs)).toHaveLength(1)
|
||||
expect(target.channel.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets a replacement own its single alarm and leaves none after its close', async () => {
|
||||
const old = fixture()
|
||||
const oldWaiting = old.client.sendRequest('old').catch((error: unknown) => error)
|
||||
old.client.close()
|
||||
expect(await oldWaiting).toEqual(new Error('relay pairing client closed'))
|
||||
const replacement = fixture()
|
||||
const waiting = replacement.client.sendRequest('new').catch((error: unknown) => error)
|
||||
old.socket.onerror?.()
|
||||
replacement.socket.onerror?.()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
replacement.socket.onclose?.({ code: 4409 })
|
||||
expect(await waiting).toEqual(new RelayOuterError(4409))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(closedLogs(old.logs)).toHaveLength(1)
|
||||
expect(closedLogs(replacement.logs)).toHaveLength(1)
|
||||
expect(old.channel.dispose).toHaveBeenCalledOnce()
|
||||
expect(replacement.channel.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -118,6 +118,9 @@ export function connectMobileRelayForPairing(args: {
|
||||
// WebSocket implementations commonly emit `error` immediately before
|
||||
// `close`; the bounded fallback represents an opaque 1006 close.
|
||||
socket.onerror = () => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
transportErrorTimer ??= setTimeout(() => {
|
||||
transportErrorTimer = null
|
||||
fail(new RelayOuterError(1006))
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import {
|
||||
buildAgentRowLineageTree,
|
||||
flattenAgentRowLineage,
|
||||
type AgentRowNode
|
||||
} from './agent-row-lineage'
|
||||
|
||||
function row(paneKey: string, parentPaneKey: string | null): RuntimeWorktreeAgentRow {
|
||||
return {
|
||||
paneKey,
|
||||
parentPaneKey,
|
||||
state: 'working',
|
||||
agentType: 'claude',
|
||||
prompt: '',
|
||||
taskTitle: null,
|
||||
displayName: null,
|
||||
lastAssistantMessage: null,
|
||||
toolName: null,
|
||||
toolInput: null,
|
||||
interrupted: false,
|
||||
stateStartedAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Frozen pre-optimization traversal: duplicate pane keys may be emitted on distinct branches.
|
||||
function legacyFlatten(rows: readonly RuntimeWorktreeAgentRow[]): AgentRowNode[] {
|
||||
const { rootRows, childrenByParentPaneKey } = buildAgentRowLineageTree(rows)
|
||||
const out: AgentRowNode[] = []
|
||||
const seen = new Set<string>()
|
||||
const visit = (agent: RuntimeWorktreeAgentRow, depth: number, ancestors: ReadonlySet<string>) => {
|
||||
if (ancestors.has(agent.paneKey)) {
|
||||
return
|
||||
}
|
||||
seen.add(agent.paneKey)
|
||||
out.push({ row: agent, depth, children: [] })
|
||||
const nextAncestors = new Set(ancestors)
|
||||
nextAncestors.add(agent.paneKey)
|
||||
for (const child of childrenByParentPaneKey.get(agent.paneKey) ?? []) {
|
||||
visit(child, depth + 1, nextAncestors)
|
||||
}
|
||||
}
|
||||
for (const root of rootRows) {
|
||||
visit(root, 0, new Set())
|
||||
}
|
||||
for (const agent of rows) {
|
||||
if (!seen.has(agent.paneKey)) {
|
||||
seen.add(agent.paneKey)
|
||||
out.push({ row: agent, depth: 0, children: [] })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('agent lineage traversal parity', () => {
|
||||
it('matches the previous traversal across cycles, dangling parents, duplicates, and input order', () => {
|
||||
const variants = ['a', 'b', 'c'].flatMap((paneKey) =>
|
||||
[null, 'a', 'b', 'c', 'missing'].map((parentPaneKey) => row(paneKey, parentPaneKey))
|
||||
)
|
||||
expect(flattenAgentRowLineage([])).toEqual(legacyFlatten([]))
|
||||
for (const first of variants) {
|
||||
for (const second of variants) {
|
||||
for (const third of variants) {
|
||||
const rows = [first, second, third]
|
||||
const expected = legacyFlatten(rows)
|
||||
const actual = flattenAgentRowLineage(rows)
|
||||
expect(actual).toEqual(expected)
|
||||
actual.forEach((node, index) => expect(node.row).toBe(expected[index]?.row))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('releases the ancestor path between duplicate roots and sibling branches', () => {
|
||||
const firstRoot = row('root', null)
|
||||
const secondRoot = row('root', null)
|
||||
const firstChild = row('child', 'root')
|
||||
const secondChild = row('child', 'root')
|
||||
const grandchild = row('grandchild', 'child')
|
||||
const rows = [firstRoot, firstChild, secondChild, grandchild, secondRoot]
|
||||
const actual = flattenAgentRowLineage(rows)
|
||||
expect(actual).toEqual(legacyFlatten(rows))
|
||||
expect(actual.map((node) => [rows.indexOf(node.row), node.depth])).toEqual([
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
[3, 2],
|
||||
[2, 1],
|
||||
[3, 2],
|
||||
[4, 0],
|
||||
[1, 1],
|
||||
[3, 2],
|
||||
[2, 1],
|
||||
[3, 2]
|
||||
])
|
||||
})
|
||||
|
||||
it('copies no ancestor members while traversing a long lineage', () => {
|
||||
const rows = Array.from({ length: 512 }, (_, index) =>
|
||||
row(`pane-${index}`, index === 0 ? null : `pane-${index - 1}`)
|
||||
)
|
||||
const NativeSet = globalThis.Set
|
||||
let copiedMembers = 0
|
||||
class ObservedSet<T> extends NativeSet<T> {
|
||||
constructor(values?: Iterable<T> | null) {
|
||||
super()
|
||||
if (values) {
|
||||
for (const value of values) {
|
||||
copiedMembers += 1
|
||||
this.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Set', ObservedSet)
|
||||
let actual: AgentRowNode[]
|
||||
try {
|
||||
actual = flattenAgentRowLineage(rows)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(copiedMembers).toBe(0)
|
||||
expect(actual.map((node) => [node.row.paneKey, node.depth])).toEqual(
|
||||
rows.map((agent, index) => [agent.paneKey, index])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -59,21 +59,22 @@ export function flattenAgentRowLineage(rows: readonly RuntimeWorktreeAgentRow[])
|
||||
const { rootRows, childrenByParentPaneKey } = buildAgentRowLineageTree(rows)
|
||||
const out: AgentRowNode[] = []
|
||||
const seen = new Set<string>()
|
||||
const visit = (row: RuntimeWorktreeAgentRow, depth: number, ancestors: ReadonlySet<string>) => {
|
||||
const ancestors = new Set<string>()
|
||||
const visit = (row: RuntimeWorktreeAgentRow, depth: number) => {
|
||||
if (ancestors.has(row.paneKey)) {
|
||||
return
|
||||
}
|
||||
seen.add(row.paneKey)
|
||||
const node: AgentRowNode = { row, depth, children: [] }
|
||||
out.push(node)
|
||||
const nextAncestors = new Set(ancestors)
|
||||
nextAncestors.add(row.paneKey)
|
||||
ancestors.add(row.paneKey)
|
||||
for (const child of childrenByParentPaneKey.get(row.paneKey) ?? []) {
|
||||
visit(child, depth + 1, nextAncestors)
|
||||
visit(child, depth + 1)
|
||||
}
|
||||
ancestors.delete(row.paneKey)
|
||||
}
|
||||
for (const root of rootRows) {
|
||||
visit(root, 0, new Set())
|
||||
visit(root, 0)
|
||||
}
|
||||
// Why: a cyclic component that coexists with a normal rooted tree has no entry
|
||||
// in rootRows and is unreachable from any root, so it would silently vanish.
|
||||
|
||||
+2
-1
@@ -182,7 +182,6 @@
|
||||
"@floating-ui/dom": "1.8.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",
|
||||
@@ -198,6 +197,8 @@
|
||||
"sherpa-onnx": "1.12.37",
|
||||
"smol-toml": "1.8.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"stream-chain": "4.2.6",
|
||||
"stream-json": "3.7.0",
|
||||
"tldts": "7.4.16",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.22.0",
|
||||
|
||||
Generated
+18
-8
@@ -196,9 +196,6 @@ importers:
|
||||
'@parcel/watcher':
|
||||
specifier: ^2.5.6
|
||||
version: 2.5.6
|
||||
'@streamparser/json':
|
||||
specifier: 0.0.26
|
||||
version: 0.0.26
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.300
|
||||
version: 0.15.0-beta.300(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393))
|
||||
@@ -244,6 +241,12 @@ importers:
|
||||
ssh2:
|
||||
specifier: ^1.17.0
|
||||
version: 1.17.0
|
||||
stream-chain:
|
||||
specifier: 4.2.6
|
||||
version: 4.2.6
|
||||
stream-json:
|
||||
specifier: 3.7.0
|
||||
version: 3.7.0
|
||||
tldts:
|
||||
specifier: 7.4.16
|
||||
version: 7.4.16
|
||||
@@ -3185,9 +3188,6 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@streamparser/json@0.0.26':
|
||||
resolution: {integrity: sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.46':
|
||||
resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -7354,6 +7354,12 @@ packages:
|
||||
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
stream-chain@4.2.6:
|
||||
resolution: {integrity: sha512-1zeJ8CrtJfmiba26ui8jXkq/xLRFvhzkdH02D5QLO9Cnovgeb28IJxg88DdraWnjnkbOol/++uIAybJbJhk7ig==}
|
||||
|
||||
stream-json@3.7.0:
|
||||
resolution: {integrity: sha512-rCSBdcBP/bPk6T8QFcxAj1MSzAuc5i49cYW6IE7sYObNEPccBJIUiL6fU9c9BWt40aJK0siVynLX7lWaW8alXw==}
|
||||
|
||||
strict-event-emitter@0.5.1:
|
||||
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
|
||||
|
||||
@@ -10208,8 +10214,6 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@streamparser/json@0.0.26': {}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.46':
|
||||
optional: true
|
||||
|
||||
@@ -14965,6 +14969,12 @@ snapshots:
|
||||
|
||||
stdin-discarder@0.3.2: {}
|
||||
|
||||
stream-chain@4.2.6: {}
|
||||
|
||||
stream-json@3.7.0:
|
||||
dependencies:
|
||||
stream-chain: 4.2.6
|
||||
|
||||
strict-event-emitter@0.5.1:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -63,7 +63,17 @@ async function runSearch(
|
||||
): Promise<{ call: ReturnType<typeof vi.fn>; output: string }> {
|
||||
const call = options.error
|
||||
? vi.fn().mockRejectedValue(options.error)
|
||||
: vi.fn().mockResolvedValue(envelope(options.result ?? resultsResponse))
|
||||
: vi
|
||||
.fn()
|
||||
.mockImplementation((method: string) =>
|
||||
Promise.resolve(
|
||||
envelope(
|
||||
method === 'aiVault.searchStatus'
|
||||
? statusResponse
|
||||
: (options.result ?? resultsResponse)
|
||||
)
|
||||
)
|
||||
)
|
||||
const lines: string[] = []
|
||||
vi.spyOn(console, 'log').mockImplementation((value: unknown) => {
|
||||
lines.push(String(value))
|
||||
@@ -161,9 +171,13 @@ describe('orca search over the runtime RPC', () => {
|
||||
]
|
||||
]
|
||||
|
||||
it.each(flagCases)('sends %s', async (_name, flags, params) => {
|
||||
it.each(flagCases)('sends %s', async (name, flags, params) => {
|
||||
const { call } = await runSearch(flags)
|
||||
|
||||
expect(call).toHaveBeenCalledTimes(name === 'filters' ? 2 : 1)
|
||||
if (name === 'filters') {
|
||||
expect(call).toHaveBeenNthCalledWith(1, 'aiVault.searchStatus', {})
|
||||
}
|
||||
expect(call).toHaveBeenCalledWith('aiVault.searchSessions', {
|
||||
...params,
|
||||
supportedAgents: [...AI_VAULT_AGENTS],
|
||||
|
||||
@@ -30,6 +30,12 @@ __orca_restore_agent_teams_path() {
|
||||
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
# Why: user startup files may set the default OpenCode config after Orca's
|
||||
# spawn env; restore the Orca-managed config dir before the first prompt.
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
|
||||
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
|
||||
# which prints a warning above every command under warn_create_global.
|
||||
builtin typeset -g __orca_in_command=1
|
||||
}
|
||||
__orca_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) || return 0
|
||||
local __orca_direct_line_init=0
|
||||
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
|
||||
__orca_deferred_init
|
||||
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
|
||||
zle zle-line-init "$@"
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
|
||||
local __orca_prev_line_init_fn=""
|
||||
__orca_prompt_mark "$@"
|
||||
fi
|
||||
}
|
||||
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
|
||||
__orca_deferred_sched_init() {
|
||||
local __orca_prompt_status=$?
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
|
||||
builtin unset __orca_deferred_sched_armed
|
||||
builtin unfunction __orca_deferred_sched_init
|
||||
return $__orca_prompt_status
|
||||
}
|
||||
__orca_arm_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
|
||||
if (( ${+widgets[zle-line-init]} )); then
|
||||
zle -A zle-line-init __orca_saved_line_init
|
||||
fi
|
||||
zle -N zle-line-init __orca_deferred_line_init
|
||||
fi
|
||||
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
|
||||
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
|
||||
fi
|
||||
}
|
||||
__orca_deferred_init() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
@@ -54,12 +88,27 @@ __orca_deferred_init() {
|
||||
(( $+_orca_deferred_init_done )) && return 0
|
||||
builtin typeset -g _orca_deferred_init_done=1
|
||||
builtin typeset -g precmd_functions
|
||||
if (( ${+widgets[__orca_saved_line_init]} )); then
|
||||
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -A __orca_saved_line_init zle-line-init
|
||||
fi
|
||||
zle -D __orca_saved_line_init
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -D zle-line-init
|
||||
fi
|
||||
if __orca_has_feature markers; then
|
||||
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
|
||||
(( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
|
||||
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
|
||||
else
|
||||
precmd_functions=(${precmd_functions:#__orca_deferred_init})
|
||||
fi
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
|
||||
__orca_restore_agent_teams_path() {
|
||||
@@ -202,13 +251,25 @@ __orca_deferred_init() {
|
||||
# the permanent hook has not run yet and the first prompt would lose its mark.
|
||||
__orca_has_feature markers && __orca_osc133_precmd
|
||||
builtin unset _orca_shell_features _orca_histfile
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature
|
||||
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
|
||||
local __orca_widget __orca_line_init_bound=0
|
||||
for __orca_widget in "${(v)widgets[@]}"; do
|
||||
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
|
||||
__orca_line_init_bound=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
|
||||
}
|
||||
{
|
||||
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
|
||||
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
|
||||
} always {
|
||||
builtin unset _orca_user_zshenv
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
if (( ! $+_orca_deferred_init_done )); then
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
__orca_arm_deferred_line_init
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ __orca_restore_agent_teams_path() {
|
||||
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then
|
||||
if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then
|
||||
export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}"
|
||||
|
||||
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
|
||||
# which prints a warning above every command under warn_create_global.
|
||||
builtin typeset -g __orca_in_command=1
|
||||
}
|
||||
__orca_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) || return 0
|
||||
local __orca_direct_line_init=0
|
||||
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
|
||||
__orca_deferred_init
|
||||
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
|
||||
zle zle-line-init "$@"
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
|
||||
local __orca_prev_line_init_fn=""
|
||||
__orca_prompt_mark "$@"
|
||||
fi
|
||||
}
|
||||
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
|
||||
__orca_deferred_sched_init() {
|
||||
local __orca_prompt_status=$?
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
|
||||
builtin unset __orca_deferred_sched_armed
|
||||
builtin unfunction __orca_deferred_sched_init
|
||||
return $__orca_prompt_status
|
||||
}
|
||||
__orca_arm_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
|
||||
if (( ${+widgets[zle-line-init]} )); then
|
||||
zle -A zle-line-init __orca_saved_line_init
|
||||
fi
|
||||
zle -N zle-line-init __orca_deferred_line_init
|
||||
fi
|
||||
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
|
||||
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
|
||||
fi
|
||||
}
|
||||
__orca_deferred_init() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
@@ -54,8 +88,17 @@ __orca_deferred_init() {
|
||||
(( $+_orca_deferred_init_done )) && return 0
|
||||
builtin typeset -g _orca_deferred_init_done=1
|
||||
builtin typeset -g precmd_functions
|
||||
if (( ${+widgets[__orca_saved_line_init]} )); then
|
||||
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -A __orca_saved_line_init zle-line-init
|
||||
fi
|
||||
zle -D __orca_saved_line_init
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -D zle-line-init
|
||||
fi
|
||||
if __orca_has_feature markers; then
|
||||
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
|
||||
(( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
|
||||
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
|
||||
else
|
||||
precmd_functions=(${precmd_functions:#__orca_deferred_init})
|
||||
@@ -67,6 +110,12 @@ __orca_deferred_init() {
|
||||
printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2
|
||||
fi
|
||||
fi
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
|
||||
__orca_restore_agent_teams_path() {
|
||||
@@ -219,13 +268,25 @@ __orca_deferred_init() {
|
||||
# the permanent hook has not run yet and the first prompt would lose its mark.
|
||||
__orca_has_feature markers && __orca_osc133_precmd
|
||||
builtin unset _orca_shell_features _orca_histfile
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature
|
||||
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
|
||||
local __orca_widget __orca_line_init_bound=0
|
||||
for __orca_widget in "${(v)widgets[@]}"; do
|
||||
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
|
||||
__orca_line_init_bound=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
|
||||
}
|
||||
{
|
||||
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
|
||||
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
|
||||
} always {
|
||||
builtin unset _orca_user_zshenv
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
if (( ! $+_orca_deferred_init_done )); then
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
__orca_arm_deferred_line_init
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ fi
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
|
||||
[[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
# Why: OMP does not auto-load Orca's managed status extension; wrap only
|
||||
# interactive launch invocations so subcommands such as `omp config` keep
|
||||
# their normal argv shape.
|
||||
|
||||
@@ -31,6 +31,40 @@ builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}"
|
||||
builtin unset ORCA_HISTFILE
|
||||
__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) }
|
||||
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
|
||||
__orca_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) || return 0
|
||||
local __orca_direct_line_init=0
|
||||
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
|
||||
__orca_deferred_init
|
||||
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
|
||||
zle zle-line-init "$@"
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
|
||||
local __orca_prev_line_init_fn=""
|
||||
__orca_prompt_mark "$@"
|
||||
fi
|
||||
}
|
||||
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
|
||||
__orca_deferred_sched_init() {
|
||||
local __orca_prompt_status=$?
|
||||
builtin emulate -L zsh
|
||||
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
|
||||
builtin unset __orca_deferred_sched_armed
|
||||
builtin unfunction __orca_deferred_sched_init
|
||||
return $__orca_prompt_status
|
||||
}
|
||||
__orca_arm_deferred_line_init() {
|
||||
builtin emulate -L zsh
|
||||
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
|
||||
if (( ${+widgets[zle-line-init]} )); then
|
||||
zle -A zle-line-init __orca_saved_line_init
|
||||
fi
|
||||
zle -N zle-line-init __orca_deferred_line_init
|
||||
fi
|
||||
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
|
||||
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
|
||||
fi
|
||||
}
|
||||
__orca_deferred_init() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
@@ -40,7 +74,21 @@ __orca_deferred_init() {
|
||||
(( $+_orca_deferred_init_done )) && return 0
|
||||
builtin typeset -g _orca_deferred_init_done=1
|
||||
builtin typeset -g precmd_functions
|
||||
if (( ${+widgets[__orca_saved_line_init]} )); then
|
||||
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -A __orca_saved_line_init zle-line-init
|
||||
fi
|
||||
zle -D __orca_saved_line_init
|
||||
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
|
||||
zle -D zle-line-init
|
||||
fi
|
||||
precmd_functions=(${precmd_functions:#__orca_deferred_init})
|
||||
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
|
||||
case "${PATH:-}" in
|
||||
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
|
||||
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
|
||||
esac
|
||||
fi
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: remote startup files can re-export user defaults after relay spawn.
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
@@ -170,13 +218,25 @@ __orca_deferred_init() {
|
||||
zle -N zle-line-init __orca_prompt_mark
|
||||
fi
|
||||
builtin unset _orca_shell_features _orca_histfile
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature
|
||||
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
|
||||
local __orca_widget __orca_line_init_bound=0
|
||||
for __orca_widget in "${(v)widgets[@]}"; do
|
||||
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
|
||||
__orca_line_init_bound=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
|
||||
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
|
||||
}
|
||||
{
|
||||
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
|
||||
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
|
||||
} always {
|
||||
builtin unset _orca_user_zshenv
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
if (( ! $+_orca_deferred_init_done )); then
|
||||
builtin typeset -ag precmd_functions
|
||||
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
|
||||
__orca_arm_deferred_line_init
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -147,8 +147,10 @@ describe('managed hook script refresh', () => {
|
||||
homedirMock.mockReturnValue(home)
|
||||
const previousGrokHome = process.env.GROK_HOME
|
||||
const previousKimiHome = process.env.KIMI_CODE_HOME
|
||||
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME
|
||||
delete process.env.GROK_HOME
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
try {
|
||||
await withPlatform('win32', () => {
|
||||
for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
|
||||
@@ -187,6 +189,11 @@ describe('managed hook script refresh', () => {
|
||||
} else {
|
||||
process.env.KIMI_CODE_HOME = previousKimiHome
|
||||
}
|
||||
if (previousXdgConfigHome === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = previousXdgConfigHome
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer } from './server'
|
||||
import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file'
|
||||
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
|
||||
import { PANE } from './server.test-fixtures'
|
||||
|
||||
describe('startup prompt control with status hooks disabled', () => {
|
||||
it.each([false, true])(
|
||||
'persists a pending terminal status at shutdown after starting with hooks %s',
|
||||
async (statusHooksEnabled) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-terminal-persist-'))
|
||||
const server = new AgentHookServer()
|
||||
try {
|
||||
await server.start({ userDataPath: dir, statusHooksEnabled })
|
||||
server.setStatusHooksEnabled(false)
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'folder-1',
|
||||
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
|
||||
})
|
||||
expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done')
|
||||
const statusPath = server.lastStatusPath
|
||||
if (!statusPath) {
|
||||
throw new Error('missing status persistence path')
|
||||
}
|
||||
server.stop()
|
||||
expect(existsSync(statusPath)).toBe(true)
|
||||
expect(JSON.parse(readFileSync(statusPath, 'utf8')).entries[PANE]).toMatchObject({
|
||||
paneKey: PANE,
|
||||
worktreeId: 'folder-1',
|
||||
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
|
||||
})
|
||||
} finally {
|
||||
server.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('enables and disables status without restarting the control listener', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-'))
|
||||
class IsolatedHookServer extends AgentHookServer {
|
||||
constructor() {
|
||||
super()
|
||||
this._setOpenCodeBinderDepsForTests({
|
||||
dbPath: () => join(dir, 'no-user-db'),
|
||||
listSessions: async () => [],
|
||||
listPanes: () => [],
|
||||
sweep: async () => []
|
||||
})
|
||||
}
|
||||
}
|
||||
const server = new IsolatedHookServer()
|
||||
try {
|
||||
await server.start({ userDataPath: dir, statusHooksEnabled: false })
|
||||
const endpoint = server.endpointFilePath
|
||||
if (!endpoint) {
|
||||
throw new Error('missing control endpoint')
|
||||
}
|
||||
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
|
||||
const post = (path: string) =>
|
||||
fetch(`http://127.0.0.1:${coords.port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-orca-agent-hook-token': coords.token },
|
||||
body: '{}'
|
||||
})
|
||||
expect((await post('/hook/opencode')).status).toBe(404)
|
||||
await server.start({ statusHooksEnabled: true })
|
||||
expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port)
|
||||
expect((await post('/hook/opencode')).status).toBe(204)
|
||||
server.setStatusHooksEnabled(false)
|
||||
expect(server.buildPtyEnv()).toEqual({})
|
||||
expect((await post('/hook/opencode')).status).toBe(404)
|
||||
await server.start()
|
||||
expect((await post('/hook/opencode')).status).toBe(404)
|
||||
server.setStartupPromptClaimListener(
|
||||
() => 'pending',
|
||||
() => {}
|
||||
)
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({
|
||||
allowed: false,
|
||||
pending: true
|
||||
})
|
||||
server.setStatusHooksEnabled(true)
|
||||
expect((await post('/hook/opencode')).status).toBe(204)
|
||||
expect(server.endpointFilePath).toBe(endpoint)
|
||||
expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords)
|
||||
} finally {
|
||||
server.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-'))
|
||||
const server = new AgentHookServer()
|
||||
try {
|
||||
await server.start({ userDataPath: dir, statusHooksEnabled: false })
|
||||
expect(server.buildPtyEnv()).toEqual({})
|
||||
const statusPath = server.lastStatusPath
|
||||
if (!statusPath) {
|
||||
throw new Error('missing status persistence path')
|
||||
}
|
||||
writeFileSync(statusPath, 'existing status must survive control-only shutdown')
|
||||
const endpoint = server.endpointFilePath
|
||||
if (!endpoint) {
|
||||
throw new Error('missing control endpoint')
|
||||
}
|
||||
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
|
||||
const post = (path: string, body: string, token = coords.token) =>
|
||||
fetch(`http://127.0.0.1:${coords.port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token },
|
||||
body
|
||||
})
|
||||
expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403)
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
|
||||
allowed: false
|
||||
})
|
||||
const clear = vi.fn()
|
||||
const claim = vi.fn(() => true)
|
||||
server.setStartupPromptClaimListener(claim, clear)
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
|
||||
allowed: true
|
||||
})
|
||||
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({
|
||||
allowed: false
|
||||
})
|
||||
expect(claim).toHaveBeenCalledTimes(1)
|
||||
expect((await post('/hook/opencode', '{}')).status).toBe(404)
|
||||
expect((await post('/statusline/claude', '{}')).status).toBe(404)
|
||||
server.stop()
|
||||
expect(clear).toHaveBeenCalledTimes(1)
|
||||
expect(readFileSync(statusPath, 'utf8')).toBe(
|
||||
'existing status must survive control-only shutdown'
|
||||
)
|
||||
} finally {
|
||||
server.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,11 @@
|
||||
// short of its own Content-Length. The listener fails open on every request error, so the only
|
||||
// way this stays diagnosable is if the truncation is classified before it is swallowed.
|
||||
import { connect } from 'node:net'
|
||||
import { ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference'
|
||||
import { AgentHookServer } from './server'
|
||||
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
|
||||
|
||||
async function postTruncatedHook(
|
||||
port: number,
|
||||
@@ -40,11 +42,15 @@ async function postTruncatedHook(
|
||||
}
|
||||
|
||||
/** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */
|
||||
async function postStalledHook(port: number, token: string): Promise<void> {
|
||||
async function postStalledHook(
|
||||
port: number,
|
||||
token: string,
|
||||
pathname = '/hook/claude'
|
||||
): Promise<void> {
|
||||
const socket = connect({ port, host: '127.0.0.1' })
|
||||
await new Promise<void>((resolve) => socket.on('connect', () => resolve()))
|
||||
socket.write(
|
||||
`POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
|
||||
`POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
|
||||
)
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.on('close', () => resolve())
|
||||
@@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => {
|
||||
expect(reports).toHaveLength(1)
|
||||
}, 20_000)
|
||||
|
||||
it('classifies an interrupted startup claim as retryable without consuming it', async () => {
|
||||
const { server, port, token, reports } = await startServer()
|
||||
const claim = vi.fn(() => true)
|
||||
server.setStartupPromptClaimListener(claim, () => {})
|
||||
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
|
||||
try {
|
||||
await postTruncatedHook(port, token, {
|
||||
pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH,
|
||||
sentBytes: '{"nonce":',
|
||||
announcedLength: 1000
|
||||
})
|
||||
// The reset peer cannot receive this response; observe the real handler's classification.
|
||||
expect(writeHead.mock.calls).toEqual([[503]])
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(reports).toEqual([])
|
||||
} finally {
|
||||
writeHead.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => {
|
||||
const { server, port, token, reports } = await startServer()
|
||||
const claim = vi.fn(() => true)
|
||||
server.setStartupPromptClaimListener(claim, () => {})
|
||||
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
|
||||
try {
|
||||
await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH)
|
||||
expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]])
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(reports).toEqual([])
|
||||
} finally {
|
||||
writeHead.mockRestore()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('never reports for POSTs that deliver their whole body', async () => {
|
||||
const { port, token, reports } = await startServer()
|
||||
|
||||
|
||||
@@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod
|
||||
import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing'
|
||||
import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits'
|
||||
import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference'
|
||||
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
|
||||
import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state'
|
||||
import { trackEmptyPaneKeyHook } from './server-transport-rules'
|
||||
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
|
||||
import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle'
|
||||
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt'
|
||||
|
||||
export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv {
|
||||
export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle {
|
||||
/** Start the loopback listener after hydration and spool replay have settled. */
|
||||
async start(options?: {
|
||||
env?: string
|
||||
userDataPath?: string
|
||||
endpointNamespace?: string
|
||||
statusHooksEnabled?: boolean
|
||||
}): Promise<void> {
|
||||
if (this.server) {
|
||||
if (options?.statusHooksEnabled !== undefined) {
|
||||
this.setStatusHooksEnabled(options.statusHooksEnabled)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.statusHooksEnabled = options?.statusHooksEnabled !== false
|
||||
|
||||
if (options?.env) {
|
||||
this.env = options.env
|
||||
@@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
this.token = randomUUID()
|
||||
this.endpointFileWritten = false
|
||||
this.lastWrittenJson = null
|
||||
if (!this.ownerStateInitialized) {
|
||||
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
|
||||
if (this.lastStatusFilePath) {
|
||||
this.hydrateLastStatusFromDisk()
|
||||
}
|
||||
this.captureHydratedAuthorityCommitments()
|
||||
// Drain before binding the listener so replay cannot race a live hook during startup.
|
||||
if (this.endpointDir) {
|
||||
const replayedPaneKeys = new Set<string>()
|
||||
drainAgentHookSpool({
|
||||
endpointDir: this.endpointDir,
|
||||
getPersistedLaunchTokenHash: (paneKey) =>
|
||||
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
|
||||
ingest: (record: SpoolRecord) => {
|
||||
this.ingestSpoolRecord(record)
|
||||
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
|
||||
}
|
||||
})
|
||||
// Why: the owner may have died while Orca was down; check each replayed pane once.
|
||||
for (const paneKey of replayedPaneKeys) {
|
||||
void this.checkAgentPresence(paneKey)
|
||||
}
|
||||
}
|
||||
this.ownerStateInitialized = true
|
||||
if (this.statusHooksEnabled) {
|
||||
this.initializeStatusHookOwner()
|
||||
}
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
if (req.method !== 'POST') {
|
||||
@@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
||||
try {
|
||||
const body = await readRequestBody(req)
|
||||
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
const claim = this.onStartupPromptClaim?.(body)
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
allowed: claim === true,
|
||||
...(claim === 'pending' ? { pending: true } : {})
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.statusHooksEnabled) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
|
||||
const statusLineEvent = parseClaudeStatusLineBody(body)
|
||||
if (statusLineEvent) {
|
||||
@@ -152,6 +151,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} catch (error) {
|
||||
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
|
||||
if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) {
|
||||
res.writeHead(503)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end('{"allowed":false}')
|
||||
return
|
||||
}
|
||||
// Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut
|
||||
// by something on the loopback path, not by a bad payload. Fail open as before, but count it —
|
||||
// this is the one failure mode that silently stops status for every runtime at once.
|
||||
@@ -192,7 +201,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
this.rollbackTransportStart()
|
||||
throw error
|
||||
}
|
||||
this.startOpenCodeBinderLoop()
|
||||
if (this.statusHooksEnabled) {
|
||||
this.startOpenCodeBinderLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private rollbackTransportStart(): void {
|
||||
@@ -204,14 +215,19 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
|
||||
this.flushStatusPersistSync()
|
||||
// Terminal status may still have a pending write while hook ingress is disabled.
|
||||
if (this.statusHooksEnabled || this.statusPersistTimer) {
|
||||
this.flushStatusPersistSync()
|
||||
}
|
||||
this.stopOpenCodeBinderLoop()
|
||||
this.stopTmuxStatus()
|
||||
this.rollbackTransportStart()
|
||||
this.env = 'production'
|
||||
this.onAgentStatus = null
|
||||
this.onClaudeStatusLine = null
|
||||
this.clearStartupPromptClaims?.()
|
||||
this.clearStartupPromptClaims = null
|
||||
this.onStartupPromptClaim = null
|
||||
this.onPaneStatusCleared = null
|
||||
this.onTransportInterference = null
|
||||
this.transportInterference.reset()
|
||||
|
||||
@@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row'
|
||||
const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER
|
||||
|
||||
export abstract class AgentHookServerListeners extends AgentHookServerState {
|
||||
setStartupPromptClaimListener(
|
||||
listener: (body: unknown) => boolean | 'pending',
|
||||
clear: () => void
|
||||
): void {
|
||||
this.onStartupPromptClaim = listener
|
||||
this.clearStartupPromptClaims = clear
|
||||
}
|
||||
|
||||
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
|
||||
this.onAgentStatus?.(enriched)
|
||||
for (const listener of this.enrichedStatusListeners) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote'
|
||||
|
||||
export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote {
|
||||
buildPtyEnv(): Record<string, string> {
|
||||
if (this.port <= 0 || !this.token) {
|
||||
if (!this.statusHooksEnabled || this.port <= 0 || !this.token) {
|
||||
return {}
|
||||
}
|
||||
const env: Record<string, string> = {
|
||||
|
||||
@@ -89,6 +89,9 @@ export abstract class AgentHookServerState {
|
||||
protected env = 'production'
|
||||
protected onAgentStatus: ServerAgentStatusListener = null
|
||||
protected onClaudeStatusLine: ServerStatusLineListener = null
|
||||
protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null
|
||||
protected clearStartupPromptClaims: (() => void) | null = null
|
||||
protected statusHooksEnabled = true
|
||||
protected onPaneStatusCleared: PaneStatusClearListener | null = null
|
||||
protected paneStatusClearListeners = new Set<PaneStatusClearListener>()
|
||||
protected statusDropListeners = new Set<StatusDropListener>()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
|
||||
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
|
||||
|
||||
export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv {
|
||||
setStatusHooksEnabled(enabled: boolean): void {
|
||||
if (enabled === this.statusHooksEnabled) {
|
||||
return
|
||||
}
|
||||
if (!enabled) {
|
||||
this.flushStatusPersistSync()
|
||||
this.stopOpenCodeBinderLoop()
|
||||
for (const timer of this.assistantMessageRetryTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.assistantMessageRetryTimers.clear()
|
||||
this.clearAllTranscriptPolls()
|
||||
}
|
||||
this.statusHooksEnabled = enabled
|
||||
if (enabled && this.server) {
|
||||
this.initializeStatusHookOwner()
|
||||
this.startOpenCodeBinderLoop()
|
||||
}
|
||||
}
|
||||
|
||||
protected initializeStatusHookOwner(): void {
|
||||
if (!this.ownerStateInitialized) {
|
||||
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
|
||||
if (this.lastStatusFilePath) {
|
||||
this.hydrateLastStatusFromDisk()
|
||||
}
|
||||
this.captureHydratedAuthorityCommitments()
|
||||
// Drain before binding the listener so replay cannot race a live hook during startup.
|
||||
if (this.endpointDir) {
|
||||
const replayedPaneKeys = new Set<string>()
|
||||
drainAgentHookSpool({
|
||||
endpointDir: this.endpointDir,
|
||||
getPersistedLaunchTokenHash: (paneKey) =>
|
||||
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
|
||||
ingest: (record: SpoolRecord) => {
|
||||
this.ingestSpoolRecord(record)
|
||||
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
|
||||
}
|
||||
})
|
||||
// Why: the owner may have died while Orca was down; check each replayed pane once.
|
||||
for (const paneKey of replayedPaneKeys) {
|
||||
void this.checkAgentPresence(paneKey)
|
||||
}
|
||||
}
|
||||
this.ownerStateInitialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -122,6 +123,44 @@ describe('markCopilotFolderTrusted', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a token-bearing config.json owner-only', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
|
||||
const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
|
||||
// Why: a restrictive runner umask would mask a dropped mode.
|
||||
const originalUmask = process.umask(0o022)
|
||||
try {
|
||||
mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true })
|
||||
writeFileSync(configPath, JSON.stringify({ copilotTokens: { a: 'secret' } }), {
|
||||
mode: 0o600
|
||||
})
|
||||
markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
|
||||
expect(statSync(configPath).mode & 0o777).toBe(0o600)
|
||||
expect(JSON.parse(readFileSync(configPath, 'utf-8')).copilotTokens).toEqual({ a: 'secret' })
|
||||
} finally {
|
||||
process.umask(originalUmask)
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a missing config.json owner-only', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
|
||||
const originalUmask = process.umask(0o022)
|
||||
try {
|
||||
markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
|
||||
const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
|
||||
expect(statSync(configPath).mode & 0o777).toBe(0o600)
|
||||
} finally {
|
||||
process.umask(originalUmask)
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves existing config keys and dedups already-trusted folders', () => {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
|
||||
const realpath = realpathSync(workspace)
|
||||
|
||||
@@ -97,7 +97,8 @@ export function markCopilotFolderTrusted(workspacePath: string, home: string): v
|
||||
if (!existsSync(configDir)) {
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
}
|
||||
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
// Why: config.json can hold copilotTokens, so it must stay owner-only (also on shared SSH hosts).
|
||||
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,9 +53,12 @@ test.each(['runtime', 'relay'] as const)(
|
||||
},
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(service.search).not.toHaveBeenCalled()
|
||||
expect(service.reconcile).not.toHaveBeenCalled()
|
||||
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
|
||||
expect(service.search).toHaveBeenCalledExactlyOnceWith(
|
||||
{ query: 'proof', limit: 20, freshness: 'wait-until-current' },
|
||||
{ kind: 'resolved', paths: [''] }
|
||||
)
|
||||
expect(service.reconcile).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -84,7 +87,7 @@ test.each(['qoder', 'jcode'] as const)(
|
||||
)
|
||||
|
||||
test.each(['runtime', 'relay'] as const)(
|
||||
'does not query or reconcile an unsupported-only filter on %s',
|
||||
'keeps an unsupported-only filter narrow through normal service checks on %s',
|
||||
async (transport) => {
|
||||
const request = {
|
||||
query: 'proof',
|
||||
@@ -105,14 +108,25 @@ test.each(['runtime', 'relay'] as const)(
|
||||
{ ...request, filters: { agents: [...request.filters.agents] } },
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(service.search).not.toHaveBeenCalled()
|
||||
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
|
||||
expect(service.search).toHaveBeenCalledExactlyOnceWith(
|
||||
{
|
||||
query: 'proof',
|
||||
limit: 20,
|
||||
filters: { agents: ['jcode'] },
|
||||
freshness: 'wait-until-current'
|
||||
},
|
||||
{ kind: 'resolved', paths: [''] }
|
||||
)
|
||||
expect(service.status).not.toHaveBeenCalled()
|
||||
expect(service.reconcile).not.toHaveBeenCalled()
|
||||
expect(service.reconcile).toHaveBeenCalledTimes(1)
|
||||
for (const reason of ['disabled', 'not-ready'] as const) {
|
||||
service.search.mockResolvedValue({ kind: 'unavailable', reason })
|
||||
expect(
|
||||
await searchSessionService({ query: 'proof', filters: { agents: ['codex'] } }, transport)
|
||||
await searchSessionService(
|
||||
{ query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } },
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@ describe('session search service registry', () => {
|
||||
{ query: 'proof', supportedAgents: ['claude'], filters: { agents: [agent] } },
|
||||
transport
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
|
||||
expect(service.search).toHaveBeenCalledTimes(1)
|
||||
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
|
||||
expect(service.search).toHaveBeenCalledTimes(2)
|
||||
expect(service.reconcile).not.toHaveBeenCalled()
|
||||
const client = createSessionSearchClient(
|
||||
(method, request) =>
|
||||
@@ -114,6 +114,24 @@ describe('session search service registry', () => {
|
||||
)
|
||||
expect((await sessionSearchServiceStatus({}, 'ipc')).supportedAgents).toEqual(AI_VAULT_AGENTS)
|
||||
})
|
||||
it.each(['disabled', 'not-ready'] as const)(
|
||||
'preserves %s precedence for empty negotiated host intersections',
|
||||
async (reason) => {
|
||||
const service = fakeSearchService()
|
||||
service.search.mockResolvedValue({ kind: 'unavailable', reason })
|
||||
setSessionSearchService(service)
|
||||
expect(
|
||||
await searchSessionService(
|
||||
{ query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } },
|
||||
'runtime'
|
||||
)
|
||||
).toEqual({ kind: 'unavailable', reason })
|
||||
expect(service.search).toHaveBeenCalledExactlyOnceWith(
|
||||
{ query: 'proof', limit: 20, filters: { agents: ['jcode'] } },
|
||||
{ kind: 'resolved', paths: [''] }
|
||||
)
|
||||
}
|
||||
)
|
||||
it('waits for reconcile before search, and clears its timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const service = fakeSearchService()
|
||||
|
||||
@@ -52,11 +52,8 @@ export async function searchSessionService(
|
||||
supportsJcodeHistory
|
||||
}
|
||||
)
|
||||
if (compatibleAgents.length === 0) {
|
||||
return { kind: 'unavailable', reason: 'unsupported-agent' }
|
||||
}
|
||||
const compatibleRequest =
|
||||
compatibleAgents.length === agents.length
|
||||
compatibleAgents.length === 0 || compatibleAgents.length === agents.length
|
||||
? request
|
||||
: {
|
||||
...request,
|
||||
@@ -68,12 +65,16 @@ export async function searchSessionService(
|
||||
const hostScope = within
|
||||
? resolveSessionSearchScope(within, sessionSearchScopeCatalog())
|
||||
: undefined
|
||||
const retrievalScope =
|
||||
compatibleAgents.length === 0 && hostScope?.kind !== 'unknown'
|
||||
? { kind: 'resolved' as const, paths: [''] }
|
||||
: hostScope
|
||||
const freshness =
|
||||
request.freshness === 'wait-until-current'
|
||||
? await reconcileWithin(current, freshnessTimeoutMs)
|
||||
: false
|
||||
const result = AiVaultSearchResponseSchema.parse(
|
||||
await current.search(compatibleRequest, hostScope)
|
||||
await current.search(compatibleRequest, retrievalScope)
|
||||
)
|
||||
if (result.kind !== 'results') {
|
||||
return result
|
||||
@@ -81,6 +82,7 @@ export async function searchSessionService(
|
||||
const { debug, ...fields } = result
|
||||
return {
|
||||
...fields,
|
||||
...(compatibleAgents.length === 0 ? { page: { cursor: null, hasMore: false } } : {}),
|
||||
hits: result.hits
|
||||
.filter((hit) => compatibleAgents.includes(hit.agent))
|
||||
.map((hit) => redactForTransport(hit, transport)),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readStreamedSessionDocument } from './session-document-stream'
|
||||
|
||||
async function* bytes(content: string, chunkSize = 7): AsyncGenerator<Buffer> {
|
||||
const buffer = Buffer.from(content)
|
||||
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
|
||||
yield buffer.subarray(offset, offset + chunkSize)
|
||||
}
|
||||
}
|
||||
|
||||
function read(
|
||||
content: string,
|
||||
overrides: Partial<Parameters<typeof readStreamedSessionDocument<unknown[]>>[0]> = {}
|
||||
) {
|
||||
return readStreamedSessionDocument({
|
||||
bytes: bytes(content),
|
||||
arrayKey: 'messages',
|
||||
fields: ['id'],
|
||||
objectFields: { agent: ['model'] },
|
||||
create: (): unknown[] => [],
|
||||
consume: (state, value) => {
|
||||
state.push(value)
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('streamed session document contracts', () => {
|
||||
it.each(['[]', 'null', 'false', '0', '"not an array"', '{}'])(
|
||||
'a later messages value %s clears an earlier fold',
|
||||
async (last) => {
|
||||
expect(await read(`{"messages":[1,2],"messages":${last}}`)).toEqual({ record: {}, state: [] })
|
||||
}
|
||||
)
|
||||
|
||||
it('replaces a failed earlier array and continues with its successor', async () => {
|
||||
const consume = (state: unknown[], value: unknown): void => {
|
||||
if (value === 'bad') {
|
||||
throw new Error('discarded failure')
|
||||
}
|
||||
state.push(value)
|
||||
}
|
||||
expect(await read('{"messages":["bad",1],"messages":[2,3]}', { consume })).toEqual({
|
||||
record: {},
|
||||
state: [2, 3]
|
||||
})
|
||||
expect(await read('{"messages":["bad"],"messages":[]}', { consume })).toEqual({
|
||||
record: {},
|
||||
state: []
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the first consumer failure unless a later array replaces it', async () => {
|
||||
const failure = new Error('consumer failed')
|
||||
let calls = 0
|
||||
await expect(
|
||||
read('{"messages":[1,2]}', {
|
||||
consume: () => {
|
||||
calls++
|
||||
throw failure
|
||||
}
|
||||
})
|
||||
).rejects.toBe(failure)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('gives malformed trailing JSON precedence over a consumer failure', async () => {
|
||||
await expect(
|
||||
read('{"messages":[1]} trailing', {
|
||||
consume: () => {
|
||||
throw new Error('consumer')
|
||||
}
|
||||
})
|
||||
).rejects.toBeInstanceOf(SyntaxError)
|
||||
})
|
||||
|
||||
it('keeps projected object resets and full-field overlap precedence', async () => {
|
||||
expect(await read('{"agent":{"model":"old"},"agent":null}')).toEqual({
|
||||
record: { agent: {} },
|
||||
state: []
|
||||
})
|
||||
expect(await read('{"agent":{"model":"m","extra":[1,2]}}', { fields: ['agent'] })).toEqual({
|
||||
record: { agent: { model: 'm', extra: [1, 2] } },
|
||||
state: []
|
||||
})
|
||||
expect(await read('{"messages":[1,2]}', { objectFields: { messages: ['model'] } })).toEqual({
|
||||
record: { messages: {} },
|
||||
state: [1, 2]
|
||||
})
|
||||
expect(
|
||||
await read('{"messages":{"model":"m","ignored":1}}', {
|
||||
objectFields: { messages: ['model'] }
|
||||
})
|
||||
).toEqual({ record: { messages: { model: 'm' } }, state: [] })
|
||||
})
|
||||
|
||||
it('preserves selected prototype keys as own properties', async () => {
|
||||
const content =
|
||||
'{"id":{"__proto__":{"polluted":true}},"agent":{"model":{"constructor":"value","__proto__":{"x":1}}}}'
|
||||
const parsed = await read(content)
|
||||
expect(parsed?.record).toEqual(JSON.parse(content))
|
||||
expect(Object.getPrototypeOf(parsed?.record)).toBeNull()
|
||||
expect(Object.prototype).not.toHaveProperty('polluted')
|
||||
})
|
||||
|
||||
it.each(['', ' ', '{', '{"messages":[1,]}', '{"messages":[]}{"messages":[]}'])(
|
||||
'rejects malformed input %j',
|
||||
async (content) => {
|
||||
await expect(read(content)).rejects.toBeInstanceOf(SyntaxError)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['null', '[]', '123', '"text"'])(
|
||||
'does not publish a nonobject document %s',
|
||||
async (content) => {
|
||||
expect(await read(content)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('validates discarded subtrees and closes their source on malformed input', async () => {
|
||||
let closed = false
|
||||
async function* malformed() {
|
||||
try {
|
||||
yield Buffer.from('{"ignored":[{"deep":1},]}')
|
||||
throw new Error('must not request another chunk')
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
await expect(read('', { bytes: malformed() })).rejects.toBeInstanceOf(SyntaxError)
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves source failure and closes the source even after a consumer failure', async () => {
|
||||
const failure = new Error('disk failure')
|
||||
let closed = false
|
||||
async function* failing() {
|
||||
try {
|
||||
yield Buffer.from('{"messages":[1]')
|
||||
throw failure
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
await expect(
|
||||
read('', {
|
||||
bytes: failing(),
|
||||
consume: () => {
|
||||
throw new Error('consumer')
|
||||
}
|
||||
})
|
||||
).rejects.toBe(failure)
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves escaped astral text across large and byte-sized chunks', async () => {
|
||||
const value = `${'x'.repeat(65530)}日本語😀\\literal`
|
||||
const content = JSON.stringify({ messages: [value, '\ud800', '\udc00'] })
|
||||
for (const size of [1, 65536, content.length * 4]) {
|
||||
expect(await read(content, { bytes: bytes(content, size) })).toEqual({
|
||||
record: {},
|
||||
state: [value, '\ud800', '\udc00']
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json'
|
||||
import { FlexAssembler, arrayRule, objectRule } from 'stream-json/core/utils/flex-assembler.js'
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { createJsonTokenReader } from '../../shared/json-token-reader'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
|
||||
/** Fold one root array while retaining only the root fields the agent parser uses. */
|
||||
@@ -13,92 +14,108 @@ export async function readStreamedSessionDocument<T>(args: {
|
||||
consume: (state: T, value: unknown) => void
|
||||
signal?: AbortSignal
|
||||
}): Promise<{ record: Record<string, unknown>; state: T } | null> {
|
||||
const parser = new JSONParser({
|
||||
paths: [
|
||||
...args.fields.map((field) => `$.${field}`),
|
||||
...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) =>
|
||||
fields.map((field) => `$.${root}.${field}`)
|
||||
),
|
||||
...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : [])
|
||||
],
|
||||
keepStack: false,
|
||||
stringBufferSize: 64 * 1024
|
||||
})
|
||||
const record: Record<string, unknown> = Object.create(null)
|
||||
const fields = new Set(args.fields)
|
||||
let depth = 0
|
||||
let expectingRootKey = false
|
||||
parser.onToken = ({ token, value }) => {
|
||||
if (depth === 1 && expectingRootKey && token === TokenType.STRING) {
|
||||
if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) {
|
||||
record[value] = Object.create(null)
|
||||
}
|
||||
expectingRootKey = false
|
||||
}
|
||||
if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) {
|
||||
if (depth === 0 && token === TokenType.LEFT_BRACE) {
|
||||
expectingRootKey = true
|
||||
}
|
||||
depth++
|
||||
} else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) {
|
||||
depth--
|
||||
} else if (token === TokenType.COMMA && depth === 1) {
|
||||
expectingRootKey = true
|
||||
}
|
||||
}
|
||||
const objectFields = new Map(
|
||||
Object.entries(args.objectFields ?? {}).map(([root, keys]) => [root, new Set(keys)])
|
||||
)
|
||||
const foldedArray = Symbol('folded session array')
|
||||
let state = args.create()
|
||||
let currentArray: unknown = null
|
||||
let consumeFailure: { error: unknown } | undefined
|
||||
let inFoldedArray = false
|
||||
const reset = (): void => {
|
||||
state = args.create()
|
||||
consumeFailure = undefined
|
||||
}
|
||||
const retain = (path: (string | number)[]): boolean => {
|
||||
const [root, child] = path
|
||||
return (
|
||||
typeof root === 'string' &&
|
||||
((root !== args.arrayKey && fields.has(root)) ||
|
||||
(inFoldedArray && root === args.arrayKey && path.length >= 2) ||
|
||||
(typeof child === 'string' && objectFields.get(root)?.has(child) === true))
|
||||
)
|
||||
}
|
||||
// Every discarded container needs a rule; dropping only its parent still builds large children.
|
||||
const discard = {
|
||||
filter: (path: (string | number)[]) => !retain(path),
|
||||
create: () => null,
|
||||
add: () => {}
|
||||
}
|
||||
const assembler = new FlexAssembler({
|
||||
maxDepth: Infinity,
|
||||
objectRules: [
|
||||
objectRule<Record<string, unknown>>({
|
||||
filter: (path) => path.length === 0,
|
||||
create: () => record,
|
||||
add: (target, key, value) => {
|
||||
if (key === args.arrayKey) {
|
||||
if (value !== foldedArray) {
|
||||
reset()
|
||||
}
|
||||
} else if (fields.has(key)) {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}),
|
||||
objectRule<Record<string, unknown>>({
|
||||
filter: (path) =>
|
||||
path.length === 1 &&
|
||||
typeof path[0] === 'string' &&
|
||||
objectFields.has(path[0]) &&
|
||||
(!fields.has(path[0]) || path[0] === args.arrayKey),
|
||||
create: (path) => {
|
||||
const projected: Record<string, unknown> = Object.create(null)
|
||||
record[String(path[0])] = projected
|
||||
return projected
|
||||
},
|
||||
add: (target, key, value) => {
|
||||
const root = assembler.path[0]
|
||||
if (typeof root === 'string' && objectFields.get(root)?.has(key)) {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}),
|
||||
discard
|
||||
],
|
||||
arrayRules: [
|
||||
arrayRule<null>({
|
||||
filter: (path) => Boolean(args.arrayKey) && path.length === 1 && path[0] === args.arrayKey,
|
||||
create: () => {
|
||||
reset()
|
||||
inFoldedArray = true
|
||||
return null
|
||||
},
|
||||
add: (_target, value) => {
|
||||
if (!consumeFailure) {
|
||||
try {
|
||||
args.consume(state, value)
|
||||
} catch (error) {
|
||||
consumeFailure = { error }
|
||||
}
|
||||
}
|
||||
},
|
||||
finalize: () => {
|
||||
inFoldedArray = false
|
||||
return foldedArray
|
||||
}
|
||||
}),
|
||||
discard
|
||||
]
|
||||
})
|
||||
const parser = createJsonTokenReader((token) => {
|
||||
if (
|
||||
token.name === 'keyValue' &&
|
||||
assembler.depth === 1 &&
|
||||
!assembler.isArray &&
|
||||
objectFields.has(token.value)
|
||||
) {
|
||||
record[token.value] = Object.create(null)
|
||||
}
|
||||
assembler.consume(token)
|
||||
})
|
||||
const decoder = new StringDecoder('utf8')
|
||||
let objectRoot: boolean | undefined
|
||||
parser.onValue = ({ key, value, parent, stack }) => {
|
||||
if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) {
|
||||
if (parent !== currentArray) {
|
||||
state = args.create()
|
||||
consumeFailure = undefined
|
||||
currentArray = parent
|
||||
}
|
||||
if (!consumeFailure) {
|
||||
try {
|
||||
args.consume(state, value)
|
||||
} catch (error) {
|
||||
consumeFailure = { error }
|
||||
}
|
||||
}
|
||||
// The parser's array cursor is independent of retained array slots.
|
||||
parent.pop()
|
||||
} else if (
|
||||
stack.length === 2 &&
|
||||
typeof stack[1].key === 'string' &&
|
||||
typeof key === 'string' &&
|
||||
parent &&
|
||||
!Array.isArray(parent)
|
||||
) {
|
||||
const root = stack[1].key
|
||||
const projected = record[root]
|
||||
if (
|
||||
Object.hasOwn(args.objectFields ?? {}, root) &&
|
||||
args.objectFields?.[root]?.includes(key) &&
|
||||
projected &&
|
||||
typeof projected === 'object'
|
||||
) {
|
||||
Reflect.set(projected, key, value)
|
||||
}
|
||||
} else if (stack.length === 1 && typeof key === 'string') {
|
||||
if (key === args.arrayKey) {
|
||||
if (value !== currentArray || !Array.isArray(value)) {
|
||||
state = args.create()
|
||||
consumeFailure = undefined
|
||||
}
|
||||
currentArray = null
|
||||
} else if (fields.has(key)) {
|
||||
record[key] = value
|
||||
}
|
||||
if (parent && typeof parent === 'object') {
|
||||
Reflect.deleteProperty(parent, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
for await (const chunk of args.bytes) {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
if (objectRoot === undefined) {
|
||||
@@ -107,37 +124,17 @@ export async function readStreamedSessionDocument<T>(args: {
|
||||
objectRoot = first === 123
|
||||
}
|
||||
}
|
||||
parseJson(() => parser.write(decoder.write(chunk)))
|
||||
parser.write(decoder.write(chunk))
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
const tail = decoder.end()
|
||||
if (tail) {
|
||||
parseJson(() => parser.write(tail))
|
||||
}
|
||||
if (objectRoot === undefined) {
|
||||
throw new SyntaxError('Unexpected end of JSON input')
|
||||
}
|
||||
if (!parser.isEnded) {
|
||||
parseJson(() => parser.end(), true)
|
||||
parser.write(tail)
|
||||
}
|
||||
parser.end()
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
if (consumeFailure) {
|
||||
throw consumeFailure.error
|
||||
}
|
||||
return objectRoot ? { record, state } : null
|
||||
}
|
||||
|
||||
function parseJson(run: () => void, ending = false): void {
|
||||
try {
|
||||
run()
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof TokenizerError ||
|
||||
error instanceof TokenParserError ||
|
||||
(ending && error instanceof Error)
|
||||
) {
|
||||
throw new SyntaxError(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +73,13 @@ describe('OpenCode reads through the production shared worker', () => {
|
||||
throw new Error('Expected the bounded native page')
|
||||
}
|
||||
expect(page.items.map((item) => item.rowid)).toEqual([2, 3])
|
||||
expect(page.items[0]?.message.role).toBe('system')
|
||||
expect(page.items[0]?.message.blocks).toEqual([
|
||||
{ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }
|
||||
{
|
||||
type: 'text',
|
||||
text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'],
|
||||
presentation: 'history-item-too-large'
|
||||
}
|
||||
])
|
||||
const repeat = await client.readNativeChat(args)
|
||||
expect(repeat).toEqual(page)
|
||||
|
||||
@@ -67,11 +67,11 @@ describe('protected Antigravity account snapshots', () => {
|
||||
const service = new AntigravityAccountService(store, h.backend)
|
||||
h.setNative(paddedCredential('new-account', 60000))
|
||||
await expect(service.addCurrentAccount()).rejects.toThrow('could not be saved')
|
||||
expect(readFileSync(path)).toEqual(before)
|
||||
expect(readFileSync(path).equals(before)).toBe(true)
|
||||
expect(store.read().accounts).toHaveLength(52)
|
||||
h.setNative(paddedCredential('large-51', 65000))
|
||||
await expect(service.listAccounts()).rejects.toThrow('could not be saved')
|
||||
expect(readFileSync(path)).toEqual(before)
|
||||
expect(readFileSync(path).equals(before)).toBe(true)
|
||||
expect(store.read().accounts).toHaveLength(52)
|
||||
})
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('protected Antigravity account snapshots', () => {
|
||||
'Protected secret storage'
|
||||
)
|
||||
expect(() => store.read()).toThrow('Protected secret storage')
|
||||
expect(readFileSync(path)).toEqual(before)
|
||||
expect(readFileSync(path).equals(before)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -51,6 +51,28 @@ const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
|
||||
token: 'annotationviewporttoken'
|
||||
}
|
||||
|
||||
const MARKER_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
|
||||
...BRIDGE_OPTIONS,
|
||||
markers: [
|
||||
{
|
||||
id: 'historical-document-marker',
|
||||
index: 2,
|
||||
isFixed: false,
|
||||
rectPage: { x: 80, y: 120, width: 100, height: 40 },
|
||||
rectViewport: { x: 80, y: 120, width: 100, height: 40 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function deferGuestInjection(guest: Record<string, unknown>): () => void {
|
||||
let release = (): void => {}
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
guest.executeJavaScriptInIsolatedWorld = vi.fn(() => gate)
|
||||
return release
|
||||
}
|
||||
|
||||
function registerPage(pageId: string, guest: Record<string, unknown>): void {
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
@@ -141,6 +163,161 @@ describe('browserManager.setAnnotationViewportBridge', () => {
|
||||
expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'skips retired queued markers after invalidation (replacement guest: %s)',
|
||||
async (replaceGuest) => {
|
||||
const { guest: firstGuest } = makeGuest(5201)
|
||||
const release = deferGuestInjection(firstGuest)
|
||||
registerPage('tab-retired-markers', firstGuest)
|
||||
const resolveGuest = resolveFromRegistry('tab-retired-markers')
|
||||
const inFlight = browserManager.setAnnotationViewportBridge(
|
||||
'tab-retired-markers',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
await flushViewportOps()
|
||||
const retired = browserManager.setAnnotationViewportBridge(
|
||||
'tab-retired-markers',
|
||||
MARKER_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
const { guest: replacementGuest } = makeGuest(5202)
|
||||
if (replaceGuest) {
|
||||
registerPage('tab-retired-markers', replacementGuest)
|
||||
}
|
||||
const cleared = browserManager.setAnnotationViewportBridge(
|
||||
'tab-retired-markers',
|
||||
{ ...BRIDGE_OPTIONS, enabled: false },
|
||||
resolveGuest
|
||||
)
|
||||
|
||||
release()
|
||||
await expect(inFlight).resolves.toBe(true)
|
||||
await expect(retired).resolves.toBe(false)
|
||||
await expect(cleared).resolves.toBe(true)
|
||||
const currentGuest = replaceGuest ? replacementGuest : firstGuest
|
||||
expect(currentGuest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
[expect.objectContaining({ code: expect.stringContaining('historical-document-marker') })],
|
||||
false
|
||||
)
|
||||
expect(currentGuest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
|
||||
expect.any(Number),
|
||||
[expect.objectContaining({ code: expect.stringContaining('const enabled = false;') })],
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an in-flight injection and applies the latest fresh markers and token', async () => {
|
||||
const { guest } = makeGuest(5203)
|
||||
const release = deferGuestInjection(guest)
|
||||
const resolveGuest = resolveFromRegistry('tab-fresh-markers')
|
||||
registerPage('tab-fresh-markers', guest)
|
||||
const inFlight = browserManager.setAnnotationViewportBridge(
|
||||
'tab-fresh-markers',
|
||||
MARKER_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
await flushViewportOps()
|
||||
const cleared = browserManager.setAnnotationViewportBridge(
|
||||
'tab-fresh-markers',
|
||||
{ ...BRIDGE_OPTIONS, enabled: false },
|
||||
resolveGuest
|
||||
)
|
||||
const fresh = browserManager.setAnnotationViewportBridge(
|
||||
'tab-fresh-markers',
|
||||
{
|
||||
...MARKER_OPTIONS,
|
||||
token: 'remintedpreviewtoken',
|
||||
markers: MARKER_OPTIONS.markers.map((marker) => ({ ...marker, id: 'fresh-preview-marker' }))
|
||||
},
|
||||
resolveGuest
|
||||
)
|
||||
|
||||
release()
|
||||
await expect(inFlight).resolves.toBe(true)
|
||||
await expect(cleared).resolves.toBe(false)
|
||||
await expect(fresh).resolves.toBe(true)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
|
||||
expect.any(Number),
|
||||
[
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('fresh-preview-marker')
|
||||
})
|
||||
],
|
||||
false
|
||||
)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
|
||||
expect.any(Number),
|
||||
[expect.objectContaining({ code: expect.stringContaining('remintedpreviewtoken') })],
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('continues the queue after an in-flight injection fails', async () => {
|
||||
const { guest } = makeGuest(5204)
|
||||
let rejectInjection = (): void => {}
|
||||
const gate = new Promise<void>((_resolve, reject) => {
|
||||
rejectInjection = () => reject(new Error('retired document'))
|
||||
})
|
||||
guest.executeJavaScriptInIsolatedWorld = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(gate)
|
||||
.mockResolvedValue(true)
|
||||
registerPage('tab-failed-injection', guest)
|
||||
const resolveGuest = resolveFromRegistry('tab-failed-injection')
|
||||
const inFlight = browserManager.setAnnotationViewportBridge(
|
||||
'tab-failed-injection',
|
||||
MARKER_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
await flushViewportOps()
|
||||
const cleared = browserManager.setAnnotationViewportBridge(
|
||||
'tab-failed-injection',
|
||||
{ ...BRIDGE_OPTIONS, enabled: false },
|
||||
resolveGuest
|
||||
)
|
||||
|
||||
rejectInjection()
|
||||
await expect(inFlight).resolves.toBe(false)
|
||||
await expect(cleared).resolves.toBe(true)
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge(
|
||||
'tab-failed-injection',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('does not run a queued marker request after page teardown removes its chain', async () => {
|
||||
const { guest } = makeGuest(5205)
|
||||
const release = deferGuestInjection(guest)
|
||||
registerPage('tab-closed-queue', guest)
|
||||
const resolveGuest = resolveFromRegistry('tab-closed-queue')
|
||||
const inFlight = browserManager.setAnnotationViewportBridge(
|
||||
'tab-closed-queue',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
await flushViewportOps()
|
||||
const retired = browserManager.setAnnotationViewportBridge(
|
||||
'tab-closed-queue',
|
||||
MARKER_OPTIONS,
|
||||
resolveGuest
|
||||
)
|
||||
browserManager.unregisterGuest('tab-closed-queue')
|
||||
|
||||
release()
|
||||
await expect(inFlight).resolves.toBe(true)
|
||||
await expect(retired).resolves.toBe(false)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
|
||||
expect(browserManager.getGuestWebContentsId('tab-closed-queue')).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
|
||||
// is the one that can see the guest is gone. The bridge only reports the refusal.
|
||||
it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
|
||||
|
||||
@@ -77,7 +77,13 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec
|
||||
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.catch(() => {})
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
|
||||
.then(() => {
|
||||
// A newer document invalidation retires geometry still waiting in the queue.
|
||||
if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) !== next) {
|
||||
return false
|
||||
}
|
||||
return this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)
|
||||
})
|
||||
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
|
||||
try {
|
||||
return await next
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user