mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Merge branch 'main' into brennanb2025/16038-git-admission-impl
This commit is contained in:
@@ -112,7 +112,6 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable
|
||||
|
||||
The scheduled 2x/day RC cron in [`release-rc.yml`](../../actions/workflows/release-rc.yml) is independent and continues to run automatically from `main`.
|
||||
|
||||
|
||||
## Release Channels
|
||||
|
||||
The public Homebrew cask tracks stable desktop releases:
|
||||
|
||||
@@ -30,6 +30,7 @@ runs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -86,7 +87,9 @@ runs:
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
# Job containers can run composite steps from a source mirror without .git.
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
|
||||
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
|
||||
fi
|
||||
|
||||
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
|
||||
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
|
||||
@@ -117,7 +120,7 @@ runs:
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Restore compiled native modules without saving
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
|
||||
@@ -127,7 +130,7 @@ runs:
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Prepare native runtime
|
||||
if: inputs.native-runtime != 'none'
|
||||
|
||||
@@ -308,7 +308,6 @@ jobs:
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Publish daily macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -17,6 +17,10 @@ on:
|
||||
description: JSON array of changed specs; empty runs the full suite
|
||||
required: false
|
||||
type: string
|
||||
ssh_source_changed:
|
||||
description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane'
|
||||
required: false
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -40,9 +44,10 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why no native-runtime: this job only produces JS bundles. Native modules
|
||||
# are prepared in the consumer shards that actually launch Electron.
|
||||
# Why: the build's plain-Node daemon smoke load resolves node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
@@ -220,14 +225,21 @@ jobs:
|
||||
ssh-docker-watcher-isolation:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
# Why ssh_source_changed first: this lane used to trigger on SSH source only as a side
|
||||
# effect of one route listing a startup-readiness spec — pruning that spec would have
|
||||
# silently retired the whole lane. The signal is now derived from the SSH routes directly.
|
||||
# The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this
|
||||
# lane, so editing one must still run it here.
|
||||
if: >-
|
||||
inputs.test_files == '' ||
|
||||
inputs.ssh_source_changed == 'true' ||
|
||||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
|
||||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
|
||||
runs-on: ubuntu-latest
|
||||
# Why 35: the parking, retention, startup-exec, and paired parity specs run
|
||||
# serially on isolated Electron/SSH fixtures after watcher isolation.
|
||||
timeout-minutes: 35
|
||||
# Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average
|
||||
# ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35
|
||||
# — and the sharded lanes already show that a lane which times out is a lane nobody trusts.
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -259,6 +271,14 @@ jobs:
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
|
||||
|
||||
# Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every
|
||||
# spec below skipped itself while the shard still reported green. Running them on this one
|
||||
# VM pays the fixture image build once instead of ten times, and keeps an SSH regression
|
||||
# legible as an SSH-named failure.
|
||||
- name: Run remaining Docker SSH E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
|
||||
|
||||
- name: Upload watcher isolation traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -290,7 +290,6 @@ jobs:
|
||||
including back to Stable, works in-app from there."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Publish hourly macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Node next compatibility
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Full future-runtime coverage is useful, but not worth doubling every PR matrix.
|
||||
- cron: '0 10 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: node-next-compat
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["26"]'
|
||||
+25
-36
@@ -27,6 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
|
||||
static_analysis: ${{ steps.filter.outputs.static_analysis }}
|
||||
typecheck: ${{ steps.filter.outputs.typecheck }}
|
||||
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
|
||||
@@ -442,17 +443,13 @@ jobs:
|
||||
src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
# Cache-key input changes would otherwise make every shard compile the same
|
||||
# native addon concurrently. Prime the supported Node ABI before the matrix fans out.
|
||||
test_native_cache:
|
||||
name: prepare test native cache node 24
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.test == 'true'
|
||||
if: needs.code_paths.outputs.native_cache_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['24', '26']
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard_total: [8]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -463,32 +460,17 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: '24'
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/shell-startup-feature-channel.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
test:
|
||||
needs: [code_paths, test_native_cache]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.code_paths.outputs.test == 'true' &&
|
||||
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24"]'
|
||||
|
||||
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
|
||||
# would pay for it on every shard to run one file in whichever shard it landed in.
|
||||
@@ -695,7 +677,7 @@ jobs:
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
@@ -748,7 +730,7 @@ jobs:
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Prepare Electron native runtime
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
@@ -777,6 +759,7 @@ jobs:
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
test_files: ${{ steps.filter.outputs.test_files }}
|
||||
ssh_source_changed: ${{ steps.filter.outputs.ssh_source_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -799,6 +782,11 @@ jobs:
|
||||
# authorities, exclusions, and sentinels without evaluating workflow shell.
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
# Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a
|
||||
# spec name surviving in a route's list. Same routes, so the two cannot drift.
|
||||
SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)"
|
||||
echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
echo "SSH source changed: $SSH_SOURCE_CHANGED"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Changed E2E specs: $TEST_FILES_JSON"
|
||||
@@ -817,6 +805,7 @@ jobs:
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
test_files: ${{ needs.e2e-paths.outputs.test_files }}
|
||||
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
|
||||
|
||||
verify:
|
||||
if: always()
|
||||
|
||||
@@ -34,8 +34,7 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
|
||||
@@ -844,6 +844,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore golden test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
tests/e2e/golden-source-control-open-diff.spec.ts \
|
||||
tests/e2e/golden-terminal-file-link.spec.ts
|
||||
|
||||
- name: Install native build tools
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
@@ -946,6 +956,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
@@ -978,13 +998,26 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Install Ubuntu 20.04 prerequisites
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Trust the checked-out workspace in the job container
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Unit tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node_versions:
|
||||
description: JSON array of Node.js major versions to test.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ${{ fromJSON(inputs.node_versions) }}
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard_total: [8]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/shell-startup-feature-channel.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
@@ -7,13 +7,15 @@ All UI work — layout, color, typography, spacing, component selection, UX beha
|
||||
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
|
||||
|
||||
# Style
|
||||
|
||||
## Reuse Before Reimplementing
|
||||
|
||||
Before writing new logic at any scale — a function, component, IPC channel, state store, or whole subsystem/flow — check whether an existing implementation already does the job (or nearly does). Extend or generalize it instead of building a parallel version; only write from scratch when nothing fits. Keep the check proportionate: a quick search for trivial code, a real one before building anything substantial.
|
||||
|
||||
## Concise/Brief Non-obvious Comments ONLY
|
||||
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
* BE CONCISE. 1 LINE if possible
|
||||
|
||||
- DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
- BE CONCISE. 1 LINE if possible
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
@@ -32,6 +34,7 @@ Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuf
|
||||
- **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format`
|
||||
|
||||
# Considerations
|
||||
|
||||
## Worktree Safety
|
||||
|
||||
Always use the primary working directory (the worktree) for all file reads and edits. Never follow absolute paths from subagent results that point to the main repo.
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [Download APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -262,6 +262,7 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
|
||||
</p>
|
||||
|
||||
## Signed Builds
|
||||
|
||||
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
|
||||
|
||||
## License
|
||||
|
||||
@@ -19,7 +19,6 @@ inline src/main/index.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/providers/local-pty-provider.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
@@ -42,7 +41,6 @@ inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/components/GitLabItemDialog.tsx
|
||||
inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
@@ -53,7 +51,6 @@ inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
|
||||
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
|
||||
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
|
||||
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
|
||||
inline src/renderer/src/components/settings/AccountsPane.tsx
|
||||
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
diff --git a/binding.gyp b/binding.gyp
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -3,7 +3,7 @@
|
||||
@@ -3,7 +3,6 @@
|
||||
{
|
||||
"target_name": "windows_process_tree",
|
||||
"dependencies": [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
"conditions": [
|
||||
['OS=="win"', {
|
||||
@@ -16,9 +16,6 @@
|
||||
@@ -15,14 +14,13 @@
|
||||
"src/process_commandline.cc"
|
||||
],
|
||||
"include_dirs": [],
|
||||
- "include_dirs": [],
|
||||
+ "include_dirs": ["deps/node-addon-api"],
|
||||
+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],
|
||||
"libraries": [ 'psapi.lib' ],
|
||||
- "msvs_configuration_attributes": {
|
||||
- "SpectreMitigation": "Spectre"
|
||||
- },
|
||||
"msvs_settings": {
|
||||
"VCCLCompilerTool": {
|
||||
+ "ExceptionHandling": 1,
|
||||
"AdditionalOptions": [
|
||||
"/guard:cf",
|
||||
"/sdl",
|
||||
diff --git a/src/process.cc b/src/process.cc
|
||||
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
|
||||
--- a/src/process.cc
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,13 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
|
||||
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -1,13 +1,10 @@
|
||||
@@ -1,13 +1,18 @@
|
||||
{
|
||||
'target_defaults': {
|
||||
'dependencies': [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
+ # Orca: GCC 9 (Ubuntu 20.04 floor) accepts C++20 as gnu++2a, but rejects
|
||||
+ # the newer gnu++20 spelling emitted by Node 24's gyp flags.
|
||||
+ 'cflags_cc!': [
|
||||
+ '-std=gnu++20'
|
||||
+ ],
|
||||
+ 'cflags_cc': [
|
||||
+ '-std=gnu++2a'
|
||||
+ ],
|
||||
'conditions': [
|
||||
['OS=="win"', {
|
||||
- 'msvs_configuration_attributes': {
|
||||
|
||||
+508
-116
File diff suppressed because one or more lines are too long
@@ -132,12 +132,16 @@ function compileNodePty(dir) {
|
||||
return built
|
||||
}
|
||||
console.log('[orcad-prebuilds] compiling node-pty from patched source ...')
|
||||
const result = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['node-gyp', 'rebuild'], {
|
||||
cwd: dir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true
|
||||
})
|
||||
const result = spawnSync(
|
||||
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
['node-gyp', 'rebuild'],
|
||||
{
|
||||
cwd: dir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
slotName
|
||||
} from './build-orcad-prebuilds.mjs'
|
||||
|
||||
const PATCHED_BINDING_GYP = "'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
|
||||
const PATCHED_BINDING_GYP =
|
||||
"'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
|
||||
const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);'
|
||||
|
||||
const dirs = []
|
||||
@@ -107,8 +108,9 @@ describe('mergeManifest', () => {
|
||||
|
||||
it('does not duplicate a slot rebuilt twice', () => {
|
||||
const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' })
|
||||
expect(mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots)
|
||||
.toEqual(['darwin-arm64'])
|
||||
expect(
|
||||
mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots
|
||||
).toEqual(['darwin-arm64'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -26,9 +26,11 @@ import {
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync
|
||||
readSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..', '..')
|
||||
@@ -67,13 +69,16 @@ function assertPatchApplied() {
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) {
|
||||
if (bindingGyp.includes('node_addon_api.gyp')) {
|
||||
throw new Error(
|
||||
'binding.gyp still uses require("node-addon-api").targets. That path is ' +
|
||||
'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' +
|
||||
'binding.gyp still depends on node_addon_api.gyp. pnpm and node-gyp rewrite that ' +
|
||||
'project path incorrectly on Windows. ' +
|
||||
'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (!bindingGyp.includes('"include_dirs": ["deps/node-addon-api"]')) {
|
||||
throw new Error('binding.gyp does not use the staged node-addon-api headers.')
|
||||
}
|
||||
const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
|
||||
if (processCc.includes('process_count < 1024')) {
|
||||
throw new Error(
|
||||
@@ -83,6 +88,58 @@ function assertPatchApplied() {
|
||||
}
|
||||
}
|
||||
|
||||
// pnpm can materialize this CRLF package without applying its patch. Repair the
|
||||
// load-bearing build settings before node-gyp so the release build stays safe.
|
||||
function applyWindowsProcessTreeBuildFixes() {
|
||||
const bindingPath = join(PACKAGE_DIR, 'binding.gyp')
|
||||
const processPath = join(PACKAGE_DIR, 'src', 'process.cc')
|
||||
const nodeAddonApiDir = dirname(
|
||||
createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')
|
||||
)
|
||||
const stagedHeaderDir = join(PACKAGE_DIR, 'deps', 'node-addon-api')
|
||||
let bindingGyp = readFileSync(bindingPath, 'utf8')
|
||||
let processCc = readFileSync(processPath, 'utf8')
|
||||
const originalBinding = bindingGyp
|
||||
const originalProcess = processCc
|
||||
|
||||
for (const dynamicDependency of [
|
||||
String.raw`<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except`,
|
||||
String.raw`<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except`,
|
||||
'../../node-addon-api/node_addon_api.gyp:node_addon_api_except'
|
||||
]) {
|
||||
bindingGyp = bindingGyp.replace(`"${dynamicDependency}",`, '')
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"include_dirs": []',
|
||||
'"include_dirs": ["deps/node-addon-api"],\n "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"]'
|
||||
)
|
||||
if (!bindingGyp.includes('"ExceptionHandling": 1')) {
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"VCCLCompilerTool": {',
|
||||
'"VCCLCompilerTool": {\n "ExceptionHandling": 1,'
|
||||
)
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
/\r?\n\s*"msvs_configuration_attributes": \{\s*"SpectreMitigation": "Spectre"\s*\},?/s,
|
||||
''
|
||||
)
|
||||
processCc = processCc.replace(/process_count < 1024 && /, '')
|
||||
|
||||
if (bindingGyp !== originalBinding) {
|
||||
writeFileSync(bindingPath, bindingGyp)
|
||||
}
|
||||
if (processCc !== originalProcess) {
|
||||
writeFileSync(processPath, processCc)
|
||||
}
|
||||
mkdirSync(stagedHeaderDir, { recursive: true })
|
||||
for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h']) {
|
||||
copyFileSync(join(nodeAddonApiDir, header), join(stagedHeaderDir, header))
|
||||
}
|
||||
if (bindingGyp !== originalBinding || processCc !== originalProcess) {
|
||||
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the PE machine field, so an arm64 request cannot ship an x64 binary. */
|
||||
function readPeMachine(binaryPath) {
|
||||
const fd = openSync(binaryPath, 'r')
|
||||
@@ -109,6 +166,7 @@ function main() {
|
||||
if (!existsSync(PACKAGE_DIR)) {
|
||||
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
|
||||
}
|
||||
applyWindowsProcessTreeBuildFixes()
|
||||
assertPatchApplied()
|
||||
|
||||
console.log(`[windows-process-tree] building ${arch} from ${PACKAGE_DIR}`)
|
||||
|
||||
@@ -378,11 +378,15 @@ function rebuildNodeRuntimeModules(moduleNames) {
|
||||
|
||||
function runPnpm(args, { cwd = projectDir } = {}) {
|
||||
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env =
|
||||
process.platform === 'linux' && args.includes('node-gyp')
|
||||
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
|
||||
: process.env
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
env: process.env
|
||||
env
|
||||
})
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('ensure-native-runtime', () => {
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeFakeNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
@@ -44,6 +45,9 @@ describe('ensure-native-runtime', () => {
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
if (process.platform === 'linux') {
|
||||
expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
|
||||
}
|
||||
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
|
||||
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
|
||||
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
|
||||
@@ -239,6 +243,14 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
if (!markerExists) {
|
||||
throw new Error('ABI mismatch sentinel')
|
||||
}
|
||||
return {
|
||||
dir: '../build/Release/',
|
||||
module: {
|
||||
listJobProcessIds() {},
|
||||
terminateJob() {},
|
||||
assignCurrentProcessToJob() {}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -337,6 +349,10 @@ appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
|
||||
)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`
|
||||
)
|
||||
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
|
||||
`
|
||||
)
|
||||
|
||||
@@ -47,6 +47,33 @@ function executeInstallScript(fixture) {
|
||||
}
|
||||
|
||||
describe('install-node-dependencies action', () => {
|
||||
it('skips the lockfile diff when a job container has no Git metadata', () => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
|
||||
|
||||
const result = executeInstallScript(fixture)
|
||||
expect(result.status, result.stderr || result.stdout).toBe(0)
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips the lockfile diff for a bare repository', () => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
|
||||
expect(run('git', ['init', '--bare', '-q'], { cwd: fixture.workspace }).status).toBe(0)
|
||||
|
||||
const result = executeInstallScript(fixture)
|
||||
expect(result.status, result.stderr || result.stdout).toBe(0)
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['package.json', '{"name":"changed"}\n'],
|
||||
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const NODE_PTY_JOB_EXPORTS = [
|
||||
'listJobProcessIds',
|
||||
'terminateJob',
|
||||
'assignCurrentProcessToJob'
|
||||
]
|
||||
const NODE_PTY_JOB_EXPORTS = ['listJobProcessIds', 'terminateJob', 'assignCurrentProcessToJob']
|
||||
|
||||
function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
|
||||
if (platform !== 'win32' || nativeName !== 'conpty') {
|
||||
|
||||
@@ -17,11 +17,11 @@ describe('Electron runtime package contract', () => {
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
|
||||
expect(patch).toContain('readonly clearModelGeneration: number')
|
||||
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
|
||||
expect(patch).toContain('this.clearModelGeneration++')
|
||||
expect(patch).toContain('this._atlas._clearModelGeneration||0')
|
||||
expect(patch.match(/\^\(\?:\[1-8\]\\d\{2\}\|900\)\$/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps root postinstall as the single Electron binary install owner', () => {
|
||||
@@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => {
|
||||
expect(copyStep.run).toContain('git add "$CASK_PATH"')
|
||||
})
|
||||
|
||||
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
|
||||
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
|
||||
const parsedWorkflow = parse(prWorkflow)
|
||||
const installStep = parsedWorkflow.jobs.test.steps.find(
|
||||
it('installs the Electron package binary in the shared unit-test workflow', () => {
|
||||
const unitTestWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8')
|
||||
)
|
||||
const installStep = unitTestWorkflow.jobs.test.steps.find(
|
||||
(step) => step.name === 'Install Electron package binary for tests'
|
||||
)
|
||||
|
||||
|
||||
@@ -116,6 +116,19 @@ const NATIVE_RUNTIME_PREFIXES = [
|
||||
'config/patches/@vscode__windows-process-tree'
|
||||
]
|
||||
|
||||
const NATIVE_CACHE_FILES = new Set([
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'config/scripts/ensure-native-runtime.mjs',
|
||||
'config/scripts/rebuild-native-deps.mjs'
|
||||
])
|
||||
|
||||
const NATIVE_CACHE_PREFIXES = [
|
||||
'config/patches/node-pty@',
|
||||
'config/patches/@vscode__windows-process-tree'
|
||||
]
|
||||
|
||||
const SHARED_PACKAGE_PREFIXES = [
|
||||
'electron.vite.config.ts',
|
||||
'config/electron-builder',
|
||||
@@ -222,7 +235,11 @@ export function classifyPrJobs(changedFiles) {
|
||||
shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
|
||||
])
|
||||
)
|
||||
return { should_run: shouldRun, ...jobs }
|
||||
return {
|
||||
should_run: shouldRun,
|
||||
native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
|
||||
...jobs
|
||||
}
|
||||
}
|
||||
|
||||
function jobDetector(job) {
|
||||
@@ -274,6 +291,10 @@ function isDesktopIrrelevantPath(file) {
|
||||
return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
|
||||
}
|
||||
|
||||
function isNativeCacheInputPath(file) {
|
||||
return NATIVE_CACHE_FILES.has(file) || matchesPrefix(file, NATIVE_CACHE_PREFIXES)
|
||||
}
|
||||
|
||||
function isGlobalForcePath(file) {
|
||||
return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
|
||||
}
|
||||
|
||||
@@ -207,6 +207,22 @@ describe('per-job path classification', () => {
|
||||
expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
|
||||
})
|
||||
|
||||
it('primes native caches only when their immutable inputs change', () => {
|
||||
expect(classifyPrJobs([]).native_cache_changed).toBe(true)
|
||||
expect(classifyPrJobs(['README.md']).native_cache_changed).toBe(false)
|
||||
expect(classifyPrJobs(['src/main/index.ts']).native_cache_changed).toBe(false)
|
||||
for (const file of [
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'config/scripts/ensure-native-runtime.mjs',
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
'config/patches/node-pty@1.1.0.patch'
|
||||
]) {
|
||||
expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps unit-test-only diffs out of packaging', () => {
|
||||
expectClassification(['src/main/git/git-status.test.ts'], {
|
||||
git_compatibility: true
|
||||
@@ -238,7 +254,7 @@ describe('PR Checks skip wiring', () => {
|
||||
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
|
||||
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
|
||||
for (const jobName of ['should_run', ...expensiveJobs]) {
|
||||
for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) {
|
||||
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
|
||||
`\${{ steps.filter.outputs.${jobName} }}`
|
||||
)
|
||||
@@ -250,13 +266,26 @@ describe('PR Checks skip wiring', () => {
|
||||
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('gates each expensive job on its own classifier output', () => {
|
||||
for (const jobName of expensiveJobs) {
|
||||
it('gates each expensive job on its classifier and cache prerequisite', () => {
|
||||
for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) {
|
||||
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
|
||||
`needs.code_paths.outputs.${jobName} == 'true'`
|
||||
)
|
||||
}
|
||||
expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache'])
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'")
|
||||
expect(prWorkflow.jobs.test_native_cache.needs).toEqual(['code_paths'])
|
||||
expect(prWorkflow.jobs.test_native_cache.if).toBe(
|
||||
"needs.code_paths.outputs.native_cache_changed == 'true'"
|
||||
)
|
||||
expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined()
|
||||
const primerInstall = prWorkflow.jobs.test_native_cache.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(primerInstall.with['node-version']).toBe('24')
|
||||
})
|
||||
|
||||
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parse as parseJsonc } from 'jsonc-parser'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
|
||||
import {
|
||||
hasSshSourceChange,
|
||||
PR_E2E_SOURCE_ROUTES,
|
||||
selectPrE2eSpecs,
|
||||
SSH_SOURCE_ROUTE_IDS
|
||||
} from './pr-e2e-source-routing.mjs'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
@@ -165,7 +170,7 @@ describe('PR E2E gate contract', () => {
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(installFor('build').with).toBeUndefined()
|
||||
expect(installFor('build').with['native-runtime']).toBe('node')
|
||||
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
|
||||
expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
|
||||
}
|
||||
@@ -209,6 +214,8 @@ describe('PR E2E gate contract', () => {
|
||||
'src/main/providers/ssh-',
|
||||
'src/main/ipc/pty',
|
||||
'src/relay/',
|
||||
'src/shared/ssh-',
|
||||
'src/renderer/src/store/slices/direct-ssh-',
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-'
|
||||
]
|
||||
for (const authority of sshSourceAuthorities) {
|
||||
@@ -217,10 +224,24 @@ describe('PR E2E gate contract', () => {
|
||||
)
|
||||
}
|
||||
|
||||
// Why named files rather than prefixes: these seams are single modules, and a prefix
|
||||
// here would route their unrelated neighbours.
|
||||
for (const file of [
|
||||
'src/main/runtime/public-ssh-state.ts',
|
||||
'src/renderer/src/startup/ssh-startup-reconnect.ts',
|
||||
'src/renderer/src/store/slices/ssh.ts'
|
||||
]) {
|
||||
expect(selectPrE2eSpecs([file]), file).toContain(
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
|
||||
)
|
||||
}
|
||||
|
||||
const mappedSpecs = [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
]
|
||||
@@ -249,6 +270,140 @@ describe('PR E2E gate contract', () => {
|
||||
expect(changedInstall.run).toContain('openssh-client')
|
||||
})
|
||||
|
||||
it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
|
||||
// Why by name: none of these carry "ssh", so the SSH authorities above never reach them
|
||||
// — a closed-tab tombstone and a dropped default-tabs marker both shipped through it.
|
||||
for (const file of [
|
||||
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
|
||||
'src/main/ipc/remote-workspace-snapshot-normalization.ts',
|
||||
'src/renderer/src/lib/worktree-initial-terminal-seeding.ts',
|
||||
'src/renderer/src/lib/worktree-default-terminal-tabs.ts',
|
||||
'src/shared/remote-workspace-session-projection.ts',
|
||||
'src/renderer/src/components/terminal/initial-terminal.ts'
|
||||
]) {
|
||||
const specs = selectPrE2eSpecs([file])
|
||||
expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts')
|
||||
expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts')
|
||||
}
|
||||
|
||||
expect(
|
||||
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => {
|
||||
// The behavioural half of the invariant, and the part that actually matters: an SSH source
|
||||
// edit is recognised as one, through the same routes that select the specs.
|
||||
for (const file of [
|
||||
'src/main/ssh/connection.ts',
|
||||
'src/relay/pty-handler.ts',
|
||||
'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts',
|
||||
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
|
||||
'src/main/ipc/remote-workspace-snapshot-normalization.ts'
|
||||
]) {
|
||||
expect(hasSshSourceChange([file]), file).toBe(true)
|
||||
}
|
||||
for (const file of [
|
||||
'src/main/git/git-status.ts',
|
||||
'src/renderer/src/components/tab-bar/BrowserTab.tsx',
|
||||
'src/main/ssh/connection.test.ts'
|
||||
]) {
|
||||
expect(hasSshSourceChange([file]), file).toBe(false)
|
||||
}
|
||||
|
||||
// Why: the signal must stay derived from the routes. A route id that no longer exists would
|
||||
// silently narrow it to nothing.
|
||||
for (const id of SSH_SOURCE_ROUTE_IDS) {
|
||||
expect(
|
||||
PR_E2E_SOURCE_ROUTES.map((route) => route.id),
|
||||
id
|
||||
).toContain(id)
|
||||
}
|
||||
|
||||
// Why text and not structure: a job `if:` is only ever available as a string. The strongest
|
||||
// available assertion is that the source signal is its own disjunct, so the lane no longer
|
||||
// depends on a spec name surviving in a route's spec list.
|
||||
const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
|
||||
expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||")
|
||||
|
||||
expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string')
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.ssh_source_changed).toBe(
|
||||
'${{ steps.filter.outputs.ssh_source_changed }}'
|
||||
)
|
||||
expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe(
|
||||
'${{ needs.e2e-paths.outputs.ssh_source_changed }}'
|
||||
)
|
||||
expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source')
|
||||
expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED')
|
||||
})
|
||||
|
||||
it('gives every Docker-gated SSH spec a lane that runs it', () => {
|
||||
// Why this shape: the sharded lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec
|
||||
// that no runner names runs nowhere and still reports green — the silent skip this file
|
||||
// exists to prevent. Asserting reachability rather than a literal keeps that true when
|
||||
// the lanes move.
|
||||
// Why these two are exempt: each needs something CI cannot give it, recorded in
|
||||
// run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage.
|
||||
const unreachableSpecs = new Set([
|
||||
'tests/e2e/ssh-docker-relay-perf.spec.ts',
|
||||
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
|
||||
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
|
||||
])
|
||||
// Why comments are stripped: this file's own runner lists the two exempt specs by name in a
|
||||
// prose comment. A substring scan over raw text would count any spec merely *discussed* in a
|
||||
// runner as claimed by it -- the silent skip this assertion exists to catch, re-entering
|
||||
// through the documentation.
|
||||
const stripComments = (text) =>
|
||||
text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
||||
const laneRunners = [
|
||||
'run-ssh-docker-e2e.mjs',
|
||||
'run-ssh-docker-watcher-isolation-e2e.mjs',
|
||||
'run-ssh-docker-terminal-parking-e2e.mjs'
|
||||
].map((file) => stripComments(readFileSync(join(projectDir, 'config/scripts', file), 'utf8')))
|
||||
|
||||
// Why a comparison and not the bare name: preview and demo specs cite the flag in a
|
||||
// "how to run me" comment without gating on it. Why a regex rather than one literal: an
|
||||
// equally-valid spelling (double quotes, or a `!==` guard) would escape a fixed-string scan
|
||||
// and the spec would silently leave the contract.
|
||||
const dockerGateExpression = /ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/
|
||||
const dockerGatedSpecs = readdirSync(join(projectDir, 'tests/e2e'))
|
||||
.filter((file) => file.endsWith('.spec.ts'))
|
||||
.map((file) => `tests/e2e/${file}`)
|
||||
.filter((spec) => dockerGateExpression.test(readFileSync(join(projectDir, spec), 'utf8')))
|
||||
expect(dockerGatedSpecs.length).toBeGreaterThan(0)
|
||||
|
||||
const unclaimed = dockerGatedSpecs.filter(
|
||||
(spec) => !unreachableSpecs.has(spec) && !laneRunners.some((runner) => runner.includes(spec))
|
||||
)
|
||||
expect(
|
||||
unclaimed,
|
||||
`Docker-gated specs claimed by no lane runner: ${unclaimed.join(', ')}`
|
||||
).toEqual([])
|
||||
|
||||
// Why: an exemption that outlives its spec would quietly excuse a real gap.
|
||||
for (const spec of unreachableSpecs) {
|
||||
expect(dockerGatedSpecs, spec).toContain(spec)
|
||||
// Why also assert absence from every runner: `unreachableSpecs` short-circuits the
|
||||
// unclaimed check above, so a spec could be documented as exempt while a runner still
|
||||
// invokes it -- an exemption that reads as coverage removal but changes nothing, and a
|
||||
// lane that stays red for a reason the file says it excluded.
|
||||
for (const runner of laneRunners) {
|
||||
expect(runner.includes(spec), `${spec} is exempt but still invoked by a lane runner`).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const laneStep = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].steps.find(
|
||||
(step) => step.name === 'Run remaining Docker SSH E2E'
|
||||
)
|
||||
expect(laneStep.run).toContain('test:e2e:ssh-docker')
|
||||
// Why: the added serial tests, several budgeting 4-10 minutes each, do not fit the old 35.
|
||||
expect(
|
||||
e2eWorkflow.jobs['ssh-docker-watcher-isolation']['timeout-minutes']
|
||||
).toBeGreaterThanOrEqual(60)
|
||||
})
|
||||
|
||||
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
|
||||
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
|
||||
|
||||
@@ -18,12 +18,32 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
],
|
||||
// Why the store/startup/shared additions: the SSH-named authorities stop at the main
|
||||
// process and the pane component, but the reconnect ledgers and retained-payload
|
||||
// admission that decide whether a pane rebinds live in the renderer store.
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
|
||||
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
// Why a sibling route rather than more paths on ssh-terminal-source: these modules carry
|
||||
// no "ssh" in their names, and only the two restore specs gate them. Folding them in
|
||||
// would run the whole SSH terminal list for a tab-tombstone edit.
|
||||
id: 'ssh-workspace-session-restore',
|
||||
specs: [
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts'
|
||||
],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
@@ -131,6 +151,18 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
|
||||
return [...specs].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */
|
||||
export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore']
|
||||
|
||||
// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to
|
||||
// trigger only because one route happened to list a startup-readiness spec, so pruning that
|
||||
// spec would have silently retired the lane. Two lists that must agree is how that drifted.
|
||||
export function hasSshSourceChange(changedPaths) {
|
||||
return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some(
|
||||
(route) => changedPaths.some(route.matches)
|
||||
)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
let input = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
@@ -138,6 +170,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
input += chunk
|
||||
}
|
||||
const changedPaths = input.split(/\r?\n/).filter(Boolean)
|
||||
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
|
||||
process.stdout.write(`${JSON.stringify(specs)}\n`)
|
||||
if (process.argv.includes('--ssh-source')) {
|
||||
process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
|
||||
} else {
|
||||
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
|
||||
process.stdout.write(`${JSON.stringify(specs)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
|
||||
const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
|
||||
const dependencyAction = parse(
|
||||
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
|
||||
)
|
||||
@@ -47,22 +49,35 @@ describe('PR workflow parallelism', () => {
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
})
|
||||
|
||||
it('shards the general test suite across Node 24 and Node 26', () => {
|
||||
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
|
||||
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => index + 1)
|
||||
it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => {
|
||||
const sharedTest = unitTestWorkflow.jobs.test
|
||||
const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = sharedTest.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([8])
|
||||
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = workflow.jobs.test.steps.find(
|
||||
const primerInstall = workflow.jobs.test_native_cache.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
||||
expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
|
||||
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
||||
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26'])
|
||||
expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
|
||||
expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
|
||||
expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
|
||||
expect(sharedTest.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => index + 1)
|
||||
)
|
||||
expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
|
||||
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(testStep.run).toContain(`--exclude=${testFile}`)
|
||||
}
|
||||
expect(primerInstall.with['native-runtime']).toBe('node')
|
||||
expect(primerInstall.with['node-version']).toBe('24')
|
||||
expect(workflow.jobs.test.needs).toContain('test_native_cache')
|
||||
})
|
||||
|
||||
it('runs real-shell coverage once outside the general shards', () => {
|
||||
@@ -208,6 +223,8 @@ describe('PR workflow parallelism', () => {
|
||||
|
||||
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
||||
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
|
||||
const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
|
||||
expect(steps[pnpmIndex].with.version).toBe(packageManagerVersion)
|
||||
expect(steps[nodeIndex].with.cache).toBe('pnpm')
|
||||
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
|
||||
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
|
||||
@@ -232,13 +249,16 @@ describe('PR workflow parallelism', () => {
|
||||
workflow.jobs[jobName].steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
|
||||
expect(installFor(jobName).with, jobName).toBeUndefined()
|
||||
}
|
||||
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
|
||||
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
|
||||
expect(installFor('test').with['native-runtime']).toBe('node')
|
||||
expect(sharedTestInstall.with['native-runtime']).toBe('node')
|
||||
expect(installFor('package').with['native-runtime']).toBe('electron')
|
||||
expect(installFor('package_windows').with['native-runtime']).toBe('node')
|
||||
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
|
||||
@@ -316,6 +336,9 @@ describe('PR workflow parallelism', () => {
|
||||
expect(cacheStep.with.key).toContain(
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
|
||||
)
|
||||
expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
|
||||
expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
|
||||
expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
|
||||
expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
|
||||
expect(cacheStep.with.path).toContain('windows-native-registry@')
|
||||
expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
|
||||
|
||||
@@ -434,6 +434,20 @@ describe('check-mode reporting', () => {
|
||||
// These run without network or a build, so ordinary `pnpm test` catches the two
|
||||
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
|
||||
describe('committed xterm patch artifacts', () => {
|
||||
it('normalizes Chromium font-weight serialization in every WebGL runtime copy', async () => {
|
||||
const patch = await readFile(
|
||||
path.join(REPO_ROOT, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
|
||||
'utf8'
|
||||
)
|
||||
expect(patch).not.toMatch(/desiredWeight !== '400'|orcaProbeWeight!=="400"/)
|
||||
expect(
|
||||
patch.match(/(?:actualWeightToken === 'normal'|orcaActualWeightToken==="normal")/g)
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
patch.match(/(?:actualWeight !== desiredWeight|orcaActualWeight!==orcaProbeWeight)/g)
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('records the lockfile hash pnpm derives from the patch file', async () => {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
|
||||
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
|
||||
|
||||
@@ -10,6 +10,17 @@ const releaseWorkflow = parse(
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
|
||||
describe('release E2E dispatch contract', () => {
|
||||
it('validates immutable tags with the current golden test harness', () => {
|
||||
const restoreStep = releaseWorkflow.jobs['terminal-rendering-golden'].steps.find(
|
||||
(step) => step.name === 'Restore golden test harness from the workflow ref'
|
||||
)
|
||||
|
||||
expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restoreStep.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restoreStep.run).toContain('golden-source-control-open-diff.spec.ts')
|
||||
expect(restoreStep.run).toContain('golden-terminal-file-link.spec.ts')
|
||||
})
|
||||
|
||||
it('dispatches tag-scoped E2E only after publication', () => {
|
||||
const dispatchJob = releaseWorkflow.jobs['post-release-e2e']
|
||||
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch tag-scoped E2E')
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1',
|
||||
ORCA_E2E_WEB_CLIENT: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane
|
||||
// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading
|
||||
// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion.
|
||||
//
|
||||
// Deliberately absent, and therefore still covered by no CI trigger:
|
||||
// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would
|
||||
// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in
|
||||
// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at
|
||||
// all. Recorded as a real gap, not as coverage living somewhere else.
|
||||
// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
|
||||
// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
|
||||
// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying:
|
||||
// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 /
|
||||
// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate
|
||||
// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above.
|
||||
// (b) it is ROTTED: four call sites are out of date against terminal.ts's current
|
||||
// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane
|
||||
// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
|
||||
// semantic decisions (which ptyId to capture, which split direction) that change
|
||||
// what the repro measures. Tracked in stablyai/orca#16764.
|
||||
//
|
||||
// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
|
||||
// grep-inverts away.
|
||||
//
|
||||
// Known gaps in SSH e2e coverage, recorded here because nothing else names them:
|
||||
// - The job that runs this is still called `ssh-docker-watcher-isolation`, though watcher
|
||||
// isolation is now one spec of many. Renaming it changes the GitHub check name and can
|
||||
// break required-check config, so the name understates the job on purpose.
|
||||
// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on
|
||||
// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment
|
||||
// has the exact wiring to flip it, and the gate contract asserts the current state.
|
||||
// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere
|
||||
// and are not Docker-gated, which puts them outside this file's contract:
|
||||
// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER)
|
||||
// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER)
|
||||
// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH)
|
||||
// ssh-localhost (ORCA_E2E_SSH_LOCALHOST)
|
||||
// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E)
|
||||
// Runner scripts for the first four sit unused in package.json; no workflow calls them.
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-external-image-preview.spec.ts',
|
||||
'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts',
|
||||
'tests/e2e/ssh-pi-compatible-agent-title.spec.ts',
|
||||
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
|
||||
'tests/e2e/ssh-restart-tab-accumulation.spec.ts',
|
||||
'tests/e2e/ssh-skill-installation.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--project',
|
||||
'electron-headful',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -44,6 +44,48 @@ describe('skill-sharing release workflow', () => {
|
||||
expect(command).toContain('src/shared/skill-bundle-install-contract.test.ts')
|
||||
})
|
||||
|
||||
it('installs the archive tool required by Electron on the Linux floor', () => {
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const prerequisites = stepNamed(linux, 'Install Ubuntu 20.04 prerequisites')
|
||||
|
||||
expect(prerequisites.run).toMatch(/apt-get install[^\n]*\bunzip\b/)
|
||||
})
|
||||
|
||||
it('trusts only the checked-out workspace before container git operations', () => {
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const trustWorkspace = stepNamed(linux, 'Trust the checked-out workspace in the job container')
|
||||
const restoreHarness = stepNamed(
|
||||
linux,
|
||||
'Restore skill-sharing test harness from the workflow ref'
|
||||
)
|
||||
const safeDirectoryCommands = linux.steps
|
||||
.filter((step) => typeof step.run === 'string' && step.run.includes('safe.directory'))
|
||||
.map((step) => step.run)
|
||||
|
||||
expect(trustWorkspace.run).toBe('git config --global --add safe.directory "$GITHUB_WORKSPACE"')
|
||||
expect(linux.steps.indexOf(trustWorkspace)).toBeLessThan(linux.steps.indexOf(restoreHarness))
|
||||
expect(safeDirectoryCommands).toEqual([
|
||||
'git config --global --add safe.directory "$GITHUB_WORKSPACE"'
|
||||
])
|
||||
})
|
||||
|
||||
it('validates immutable tags with the current skill-sharing test harness', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
'skill-sharing-linux-floor-release-gate'
|
||||
]) {
|
||||
const restore = stepNamed(
|
||||
workflow.jobs[jobName],
|
||||
'Restore skill-sharing test harness from the workflow ref'
|
||||
)
|
||||
|
||||
expect(restore.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restore.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restore.run).toContain('skill-freshness-inventory.test.ts')
|
||||
expect(restore.run).toContain('skill-provider-runtime-roots.test.ts')
|
||||
}
|
||||
})
|
||||
|
||||
it('archives bounded machine-readable evidence from every platform', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
|
||||
@@ -9,26 +9,30 @@ const PATCH = readFileSync(
|
||||
'utf8'
|
||||
)
|
||||
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
|
||||
const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
|
||||
|
||||
describe('windows-process-tree node-addon-api gyp path', () => {
|
||||
it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => {
|
||||
expect(PATCH).toContain(
|
||||
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
|
||||
it('stages headers without a pnpm-sensitive gyp dependency', () => {
|
||||
expect(PATCH).not.toContain('+ "../../node-addon-api')
|
||||
expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],')
|
||||
expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],')
|
||||
const buildScript = readFileSync(
|
||||
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
|
||||
expect(bindingGyp).toContain(ABSOLUTE_GYP)
|
||||
expect(bindingGyp).not.toContain("require('node-addon-api').targets")
|
||||
expect(
|
||||
readFileSync(
|
||||
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
).toContain(ABSOLUTE_GYP)
|
||||
expect(buildScript).toContain(
|
||||
"for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h'])"
|
||||
)
|
||||
expect(buildScript).toContain("import { createRequire } from 'node:module'")
|
||||
expect(buildScript).toContain("import { dirname, join, resolve } from 'node:path'")
|
||||
expect(buildScript).toContain(
|
||||
"createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')"
|
||||
)
|
||||
expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.')
|
||||
})
|
||||
|
||||
it('resolves node_addon_api.gyp to a real file from the package directory', () => {
|
||||
const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], {
|
||||
const resolved = execFileSync(process.execPath, ['-p', RESOLVED_GYP], {
|
||||
cwd: PACKAGE_DIR,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
|
||||
@@ -50,6 +50,11 @@ describe('packaged Windows PTY native capability routing', () => {
|
||||
expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
|
||||
expect(nodeCacheSave.with.key).toContain('-node-node')
|
||||
expect(electronCache.with.key).toContain('-electron-node')
|
||||
for (const cache of [nodeCacheSave, electronCache]) {
|
||||
expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
|
||||
expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
|
||||
expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
|
||||
}
|
||||
expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']")
|
||||
expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')")
|
||||
expect(build.run).toBe('pnpm run build:release:parallel')
|
||||
|
||||
+14
-14
@@ -137,20 +137,20 @@ Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI
|
||||
|
||||
When a control has multiple plausible primitives, use this fork:
|
||||
|
||||
| You want… | Reach for | Don't use |
|
||||
| ------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------- |
|
||||
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
|
||||
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
|
||||
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
|
||||
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
|
||||
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
|
||||
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
| You want… | Reach for | Don't use |
|
||||
| ------------------------------------------------------------ | ------------------------------------------ | ------------------------------------- |
|
||||
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
|
||||
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
|
||||
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
|
||||
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
|
||||
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
|
||||
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
|
||||
If you find yourself styling around a primitive (`<Popover>` to act like a `<Dialog>`, or vice versa), stop and reconsider — the focus-management semantics differ and a future contributor will be misled by the mismatch.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 31m">
|
||||
<title>downloads: 31m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 32m">
|
||||
<title>downloads: 32m</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">31m</text>
|
||||
<text x="90" y="14">31m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">32m</text>
|
||||
<text x="90" y="14">32m</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.44/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.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,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.44/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/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) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -235,7 +235,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) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android :** [Télécharger l'APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
|
||||
- **Android :** [Télécharger l'APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/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.44/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.46/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,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.44/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [APK 0.0.44 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [APK 0.0.46 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/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) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,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) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Baixar APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
|
||||
- **Android:** [Baixar APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/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.44/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.46/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,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.44/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,23 +17,59 @@ differ on other Debian-derived releases.
|
||||
|
||||
## Ubuntu and Debian prerequisites
|
||||
|
||||
Install the AppImage runtime dependency and Xvfb:
|
||||
Install the CLI tools, Xvfb, and the shared libraries Electron links against.
|
||||
A minimal server or container image ships none of the Electron libraries, and
|
||||
`orca serve` then fails before Electron starts:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl file jq xvfb zlib1g-dev
|
||||
sudo apt-get install -y \
|
||||
curl file jq xvfb zlib1g-dev ca-certificates git \
|
||||
libgtk-3-0t64 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libgbm1 libasound2t64 \
|
||||
libxtst6 libcups2t64 libdrm2 libxkbcommon0 libpango-1.0-0 libcairo2 libatspi2.0-0t64 \
|
||||
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxrender1 libx11-xcb1 \
|
||||
libxcb-dri3-0 libxss1
|
||||
```
|
||||
|
||||
On Ubuntu 22.04, install `libfuse2` to execute the AppImage through FUSE. On
|
||||
Ubuntu 24.04 and Debian, the equivalent package may be `libfuse2t64`. FUSE is
|
||||
That command is for Ubuntu 24.04 and newer and Debian 13 and newer. Those
|
||||
releases carried out the 64-bit `time_t` transition, which renamed six of the
|
||||
packages with a `t64` suffix. On Ubuntu 20.04, Ubuntu 22.04, and Debian 12,
|
||||
substitute the unsuffixed names:
|
||||
|
||||
- `libgtk-3-0t64` becomes `libgtk-3-0`
|
||||
- `libatk1.0-0t64` becomes `libatk1.0-0`
|
||||
- `libatk-bridge2.0-0t64` becomes `libatk-bridge2.0-0`
|
||||
- `libasound2t64` becomes `libasound2`
|
||||
- `libcups2t64` becomes `libcups2`
|
||||
- `libatspi2.0-0t64` becomes `libatspi2.0-0`
|
||||
|
||||
The other names are identical on every supported release. The substitution is not
|
||||
symmetric, so use the list that matches the release. A `t64` name on Ubuntu 20.04,
|
||||
Ubuntu 22.04, or Debian 12 fails immediately with `E: Unable to locate package
|
||||
libgtk-3-0t64`. In the other direction the old names mostly still resolve, because
|
||||
each renamed package declares `Provides:` its unsuffixed name — except `libasound2`
|
||||
on Ubuntu 24.04, where `liboss4-salsa-asound2` in `universe` claims that name too.
|
||||
apt will not choose between two providers and exits with `E: Package 'libasound2'
|
||||
has no installation candidate`, which aborts the entire install line and leaves none
|
||||
of the libraries installed.
|
||||
|
||||
On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through
|
||||
FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain
|
||||
`libfuse2` name also resolves there because nothing else provides it. FUSE is
|
||||
optional: without it, use the AppImage's supported extraction path:
|
||||
|
||||
```bash
|
||||
cd /opt/orca
|
||||
./orca-linux.AppImage --appimage-extract
|
||||
sudo chmod -R a+rX /opt/orca/squashfs-root
|
||||
/opt/orca/squashfs-root/AppRun serve --port 6768
|
||||
```
|
||||
|
||||
The `chmod` is required whenever the extraction runs as a different user than
|
||||
the server: `--appimage-extract` creates `squashfs-root` as `drwx------` owned by
|
||||
the extracting user, so anyone else — including a dedicated service user — cannot
|
||||
even traverse it, and the run fails before Electron starts.
|
||||
|
||||
Docker commonly has no FUSE device. Use `--appimage-extract` once or
|
||||
`--appimage-extract-and-run`; neither requires a privileged container. The
|
||||
extract-and-run wrapper can print extracted paths before Orca starts, so
|
||||
@@ -144,8 +180,15 @@ AppImage, but must not be able to replace it or the rollback artifacts.
|
||||
sudo useradd --system --create-home --shell /usr/sbin/nologin orca
|
||||
sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage
|
||||
sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage
|
||||
# Only if you ran --appimage-extract: extraction leaves squashfs-root root-only.
|
||||
sudo chmod -R a+rX /opt/orca/squashfs-root
|
||||
```
|
||||
|
||||
The last line matters because the two halves of this guide combine badly without
|
||||
it. `--appimage-extract` writes `squashfs-root` as `drwx------ root root`, so the
|
||||
`orca` service user cannot read or traverse the extracted tree and the unit fails
|
||||
at startup. `chmod 755 /opt/orca` alone does not reach into it.
|
||||
|
||||
For most hosts, one `orca serve` service is enough because Orca starts Xvfb on
|
||||
display `:99` when no display exists:
|
||||
|
||||
@@ -834,11 +877,12 @@ refuse to run there and print the command to run on the machine you want.
|
||||
- GPU or DRI warnings on a VPS: keep `LIBGL_ALWAYS_SOFTWARE=1` in the service
|
||||
environment.
|
||||
- Chromium sandbox errors: confirm the service is running as the non-root
|
||||
`orca` user and that `/opt/orca` is readable by that user.
|
||||
`orca` user and that `/opt/orca` is readable by that user, including
|
||||
`/opt/orca/squashfs-root` if you extracted the AppImage.
|
||||
- Clients cannot connect: make sure `--pairing-address` is an address reachable
|
||||
from the client, and make sure firewalls allow the selected `--port`.
|
||||
- Journal shows `Another Orca instance is already running for this userData
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
`RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the
|
||||
owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or
|
||||
keep it and leave the unit down), then run
|
||||
@@ -857,4 +901,7 @@ refuse to run there and print the command to run on the machine you want.
|
||||
`sudo systemctl reset-failed orca-serve.service` first.
|
||||
- Diagnosing other missing libraries: extract the AppImage without launching it
|
||||
with `./orca-linux.AppImage --appimage-extract`, then run
|
||||
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
|
||||
`ldd squashfs-root/orca-ide` to list any shared libraries the host is missing.
|
||||
The Electron binary is `orca-ide`, not `orca`; `ldd` on a path that does not
|
||||
exist prints nothing and exits cleanly, which reads as a clean result in
|
||||
exactly the situation where you are hunting a missing library.
|
||||
|
||||
@@ -25,11 +25,11 @@ broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/99
|
||||
The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved
|
||||
several long-stable functions into libc under brand-new symbol versions:
|
||||
|
||||
| Symbol | New version | node-pty use |
|
||||
| ----------------- | ------------- | ----------------------- |
|
||||
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
|
||||
| `openpty` | `GLIBC_2.34` | allocate the pty |
|
||||
| `forkpty` | `GLIBC_2.34` | fork the shell |
|
||||
| Symbol | New version | node-pty use |
|
||||
| ----------------- | ------------ | ----------------------- |
|
||||
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
|
||||
| `openpty` | `GLIBC_2.34` | allocate the pty |
|
||||
| `forkpty` | `GLIBC_2.34` | fork the shell |
|
||||
|
||||
Electron itself (glibc 2.25) and the other bundled native modules
|
||||
(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under
|
||||
|
||||
@@ -10,12 +10,12 @@ Design background: `docs/design/shipping-orcad.html` §00c and §04.
|
||||
|
||||
A deployment is **orcad** plus **the terminal daemon**.
|
||||
|
||||
| | orcad | terminal daemon |
|
||||
| --- | --- | --- |
|
||||
| Started by | the supervisor | orcad, detached |
|
||||
| Owns | RPC, git, worktrees, persistence | every local PTY |
|
||||
| Lifetime | one supervised run | **outlives orcad** |
|
||||
| Endpoint | `ws://<bind>:<port>` | `<data-root>/daemon/daemon-v<N>.sock` |
|
||||
| | orcad | terminal daemon |
|
||||
| ---------- | -------------------------------- | ------------------------------------- |
|
||||
| Started by | the supervisor | orcad, detached |
|
||||
| Owns | RPC, git, worktrees, persistence | every local PTY |
|
||||
| Lifetime | one supervised run | **outlives orcad** |
|
||||
| Endpoint | `ws://<bind>:<port>` | `<data-root>/daemon/daemon-v<N>.sock` |
|
||||
|
||||
The daemon outliving orcad is the property the whole peer model is recommended for
|
||||
(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime
|
||||
@@ -52,13 +52,13 @@ The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`.
|
||||
Before the profile index or the store is touched, orcad takes `<data-root>/orcad.lock`.
|
||||
It refuses to start when:
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
|
||||
| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
|
||||
| `orcad_instance_lock_held` | another live orcad owns this root |
|
||||
| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
|
||||
| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
|
||||
| Code | Meaning |
|
||||
| -------------------------------------- | ------------------------------------------------------------- |
|
||||
| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
|
||||
| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
|
||||
| `orcad_instance_lock_held` | another live orcad owns this root |
|
||||
| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
|
||||
| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
|
||||
|
||||
A root that is merely too permissive and that we own is tightened to `0700` rather than
|
||||
refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal
|
||||
@@ -91,14 +91,15 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
|
||||
and exits 1, so the failure stays attributable instead of arriving as an unlogged kill.
|
||||
- **Exit codes.**
|
||||
|
||||
| Code | Meaning | Supervisor should |
|
||||
| --- | --- | --- |
|
||||
| 0 | clean shutdown | restart per policy |
|
||||
| 1 | startup or shutdown failure | restart with backoff |
|
||||
| 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
|
||||
| Code | Meaning | Supervisor should |
|
||||
| ---- | ------------------------------------------------------------ | -------------------- |
|
||||
| 0 | clean shutdown | restart per policy |
|
||||
| 1 | startup or shutdown failure | restart with backoff |
|
||||
| 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
|
||||
|
||||
78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data
|
||||
root owned by someone else is a restart-spin, not a recovery.
|
||||
|
||||
- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract
|
||||
to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes
|
||||
its own NDJSON lifecycle log to `<data-root>/logs/daemon.log` (suppressed by
|
||||
@@ -110,7 +111,7 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
|
||||
- **Launch.** Forked detached from `daemon-entry.js` beside `orcad.js`, with its own PID
|
||||
record, token and socket under `<data-root>/daemon`.
|
||||
- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not
|
||||
replaced, unless it is unhealthy, foreign, or built from a superseded bundle *and* owns no
|
||||
replaced, unless it is unhealthy, foreign, or built from a superseded bundle _and_ owns no
|
||||
live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers
|
||||
to live work.
|
||||
- **Restart.** The adapter respawns the daemon on death, transparently to callers.
|
||||
|
||||
@@ -113,7 +113,7 @@ getting that wrong turns a skew into a false "nothing is blocked".
|
||||
unverifiable, the pane was unreadable, or the agent probe did not answer in time.
|
||||
|
||||
A new client against an old host sees the field absent, which is why absence must read as
|
||||
*unknown* and never as *not waiting*. Collapsing absent into `null` at any hop — including a
|
||||
_unknown_ and never as _not waiting_. Collapsing absent into `null` at any hop — including a
|
||||
convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable
|
||||
from a healthy idle worker, which is the exact failure the field exists to remove.
|
||||
|
||||
@@ -180,7 +180,7 @@ has to be there before the first snapshot is interpreted, which is earlier than
|
||||
renderer could wait on. Every other viewer — a second desktop, the web client, which installs no
|
||||
page renderer at all, the dashboard pop-out, which is deliberately left unstamped — keeps tracking
|
||||
the host, which is the only reason a mirrored viewer shows anything but its first snapshot
|
||||
forever. Improving what a *second* client sees still means fixing the publish, not the carve-out;
|
||||
forever. Improving what a _second_ client sees still means fixing the publish, not the carve-out;
|
||||
the carve-out no longer stands in the way of it.
|
||||
|
||||
The two failure fields above are deliberately left on the looser `placement?.kind !== 'client'`
|
||||
|
||||
@@ -28,11 +28,11 @@ Three corrections to the first draft:
|
||||
verified. The ssh2 proxy-spawn at `:697` is effectively unreachable. Good news for migration, and
|
||||
the first draft's motivating example was simply wrong.
|
||||
- **Agent forwarding was overstated.** `agentForward` is gated on the user's `ForwardAgent yes`
|
||||
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent *auth*, whose
|
||||
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent _auth_, whose
|
||||
signatures bind the session id and cannot be replayed onward. The risk applies to users who opted
|
||||
into `ForwardAgent`, not everyone.
|
||||
- **Credential theft was understated, and the relay claim was backwards.** `isAgentFallbackError`
|
||||
treats *any* auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
|
||||
treats _any_ auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
|
||||
publickey walks the user to the password prompt (`ssh-connection.ts:844`) and the private-key
|
||||
**passphrase** prompt (`:834`), and `cachedPassword` is replayed without prompting on every
|
||||
reconnect (`:709`). Meanwhile the relay upload matters less than assumed — the attacker already
|
||||
@@ -65,7 +65,7 @@ Two consequences to own rather than discover:
|
||||
|
||||
There is no `ssh`-only way out of this: `-F /dev/null` does NOT invert the exclusion, it reports
|
||||
built-in defaults, so a probe built on it looks permissive on every machine. Verified against
|
||||
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — *could*
|
||||
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — _could_
|
||||
the site config be restricting host keys — where anything ambiguous (unreadable, an unresolvable
|
||||
`Include`, the directive present at all) keeps the refusal. Only a site config that demonstrably
|
||||
says nothing about host keys clears it, which is what stops the rule punishing every devcontainer,
|
||||
@@ -130,16 +130,16 @@ connect, training them to dismiss the one warning that matters.
|
||||
|
||||
> **Corrected against a live client.** The premise above is wrong about OpenSSH, though the
|
||||
> conclusion survives. `check_key_in_hostkeys` is not type-scoped at all: ANY non-marker entry for
|
||||
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — known_hosts
|
||||
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — `known_hosts`
|
||||
> holding only `ssh-rsa` against an ed25519-only server prints `IDENTIFICATION HAS CHANGED` and
|
||||
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the *situation* via
|
||||
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the _situation_ via
|
||||
> `order_hostkeyalgs`, and hard-fails when the situation arises anyway. Our split into `mismatch`
|
||||
> and `unknown-type-known-host` therefore only chooses the wording — both refuse, which is ssh's
|
||||
> action. What the ordering below buys us is what it buys ssh: the situation mostly never arises.
|
||||
|
||||
**But scoping alone is a downgrade vector, and this is the correction that most changes the design.**
|
||||
OpenSSH is safe here only because `order_hostkeyalgs()` reorders the client's proposed host-key
|
||||
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the *client's* order
|
||||
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the _client's_ order
|
||||
priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first
|
||||
regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a
|
||||
friendly first-contact prompt instead of a hard failure.
|
||||
@@ -147,7 +147,7 @@ friendly first-contact prompt instead of a hard failure.
|
||||
Therefore: **set ssh2's `algorithms.serverHostKey` to lead with the key types already known for that
|
||||
host.** Type scoping without algorithm ordering is not a safe design.
|
||||
|
||||
And when the presented type is unknown *while other types are known for this host*, that is
|
||||
And when the presented type is unknown _while other types are known for this host_, that is
|
||||
`unknown-type-known-host` — never a plain TOFU prompt. It must say we already hold a different key
|
||||
for this host.
|
||||
|
||||
@@ -193,7 +193,7 @@ means nothing is known.
|
||||
|
||||
### D5. Recovery must not live in the failure dialog
|
||||
|
||||
A "forget this host key" button *in* the mismatch dialog is D4's rejected "trust anyway" with one
|
||||
A "forget this host key" button _in_ the mismatch dialog is D4's rejected "trust anyway" with one
|
||||
extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it
|
||||
shows the stored fingerprint so the user is choosing knowingly.
|
||||
|
||||
@@ -201,7 +201,7 @@ Offer it only when **our** store is what disagreed; when `known_hosts` disagrees
|
||||
record cannot unblock the connect. Messages, written to avoid naming internals:
|
||||
|
||||
> **Ours disagreed** — "The host key for `build-01` changed since you last connected from Orca. If you
|
||||
> rebuilt or reprovisioned this machine, this is expected." → *Forget the saved key* / *Cancel*
|
||||
> rebuilt or reprovisioned this machine, this is expected." → _Forget the saved key_ / _Cancel_
|
||||
|
||||
> **`known_hosts` disagreed** — "The host key for `build-01` does not match the entry in
|
||||
> `~/.ssh/known_hosts`. `ssh` and `git` will refuse this host too. Run `ssh-keygen -R build-01`." →
|
||||
@@ -239,7 +239,7 @@ machines, two targets can name one machine, and a re-created target must not los
|
||||
The store is a **dedicated file**, not the main persistence blob (`persistence.ts:7088`): a settings
|
||||
restore or rollback must not silently reset trust. Accept and mismatch events are logged.
|
||||
|
||||
`hostKeyFingerprint` is now security-relevant *and* wire-relevant — it is an isolation namespace sent
|
||||
`hostKeyFingerprint` is now security-relevant _and_ wire-relevant — it is an isolation namespace sent
|
||||
to the host (`ssh-relay-session.ts:1298`, `managed-hook-owner-identity.ts:187`). It is `undefined` on
|
||||
the system transport, so **no trust logic may key off it**, and its format must not change (see
|
||||
Traps).
|
||||
@@ -247,11 +247,11 @@ Traps).
|
||||
## Phasing — ship the defence before the dialog
|
||||
|
||||
Review made the case that the riskiest part of this change is not the security model but the modal.
|
||||
Startup restore fires eager connects for *all* previously-active targets in parallel (`App.tsx:1041`)
|
||||
Startup restore fires eager connects for _all_ previously-active targets in parallel (`App.tsx:1041`)
|
||||
with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs
|
||||
outliving the timeout that already deferred them. Runtime-owned ephemeral VMs
|
||||
(`ephemeral-vm-runtime-ssh.ts:31`) dial a freshly provisioned host with a brand-new key on every
|
||||
launch. Paired-web connects run on the *host desktop* (`runtime/rpc/methods/ssh.ts:32`), so the
|
||||
launch. Paired-web connects run on the _host desktop_ (`runtime/rpc/methods/ssh.ts:32`), so the
|
||||
dialog would open on someone else's screen while the web user watches a spinner.
|
||||
|
||||
**Phase 1 — no new modal.** Consult `known_hosts` + our store. `match` connects. `unknown` persists
|
||||
@@ -301,10 +301,11 @@ Each of these makes the fix silently do nothing. All confirmed in our tree.
|
||||
## Scope
|
||||
|
||||
**In scope, corrected:** IPv6 literals and `[host]:port` bracket parsing. Review was right that this
|
||||
is a *parser* requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
|
||||
is a _parser_ requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
|
||||
`unknown`, which is the prompt-training harm D3 exists to avoid.
|
||||
|
||||
**Out of scope, with consequences stated:**
|
||||
|
||||
- **`CheckHostIP`** — OpenSSH defaults it off; we form candidates from the hostname only.
|
||||
- **WSL** — `src/main/ssh/` has no WSL awareness; a distro's `known_hosts` is unreachable, so WSL
|
||||
users get first-contact treatment for hosts they already verified.
|
||||
@@ -337,7 +338,7 @@ verified by running an OpenSSH 10.2p1 client against a real `sshd` on `127.0.0.1
|
||||
its verdict:
|
||||
|
||||
- **The bare-host fallback pass never reports a change.** With `StrictHostKeyChecking=accept-new`, a
|
||||
bare line holding a *different* key, dialed on a non-default port, made ssh connect and append a
|
||||
bare line holding a _different_ key, dialed on a non-default port, made ssh connect and append a
|
||||
new `[127.0.0.1]:2222` line — first contact, no `IDENTIFICATION HAS CHANGED`. Reporting `mismatch`
|
||||
on that pass would refuse hosts ssh connects to happily, and would have looked like the cautious
|
||||
choice.
|
||||
@@ -355,16 +356,16 @@ subtly wrong.**
|
||||
Fixed after review:
|
||||
|
||||
1. **Our own store was type-downgradable.** The inline lookup filtered by key type first and could
|
||||
only answer match/mismatch/unknown, so a record of a *different* type read as `unknown`. D3's
|
||||
only answer match/mismatch/unknown, so a record of a _different_ type read as `unknown`. D3's
|
||||
downgrade, applied to the records we create ourselves. Stored types now also feed the algorithm
|
||||
ordering — without that the guard is only half present.
|
||||
2. **We keyed on the Orca label.** `ssh -G` echoes its own argument back as `hostname` when no Host
|
||||
block matches, so for a manual target `resolved.hostname` *is* the label — the one name D2
|
||||
block matches, so for a manual target `resolved.hostname` _is_ the label — the one name D2
|
||||
forbids. We consulted no entries at all.
|
||||
3. **A refused key still walked the credential ladder.** ssh2 reports a denial as a generic auth
|
||||
failure, so we went on to prompt for the passphrase and hand it to the host we had just refused.
|
||||
Rejections are now a typed error recognised before any fallback.
|
||||
4. **Fail-closed nearly became fail-always.** "No readable known_hosts" counted a *missing* file the
|
||||
4. **Fail-closed nearly became fail-always.** "No readable `known_hosts`" counted a _missing_ file the
|
||||
same as an unreadable one, so a profile that had never connected — everyone's first run — would
|
||||
have been refused, and the suite passed only because dev machines have a `known_hosts`.
|
||||
5. **Ephemeral runtimes were refused for a policy they cannot satisfy.** The carve-out sat below the
|
||||
@@ -404,7 +405,7 @@ tooltip, and the terminal reconnect overlay never asked for it at all. Still ope
|
||||
`getPublicSshState`, so a paired-web client always sees exactly `SSH connection unavailable`.
|
||||
Pre-existing, but it makes the Phase 2 dialog message unreachable there without a change. Note the
|
||||
redaction is not web-only: any target owned by a paired runtime environment is redacted, so a
|
||||
*desktop* user viewing a remote-Orca-server-owned host gets the same generic string.
|
||||
_desktop_ user viewing a remote-Orca-server-owned host gets the same generic string.
|
||||
- **RPC fail-fast** becomes load-bearing the moment the dialog exists (see Phasing).
|
||||
|
||||
**Known gaps that Phase 1 accepts, listed so they are choices and not surprises:**
|
||||
|
||||
@@ -15,7 +15,10 @@ since removed.
|
||||
table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`.
|
||||
|
||||
```ts
|
||||
import { readWindowsProcessTable, readWindowsProcessTableFresh } from '../windows/windows-process-table'
|
||||
import {
|
||||
readWindowsProcessTable,
|
||||
readWindowsProcessTableFresh
|
||||
} from '../windows/windows-process-table'
|
||||
```
|
||||
|
||||
- `readWindowsProcessTable()` — shared TTL cache. Use for anything periodic.
|
||||
@@ -31,11 +34,11 @@ survived its own teardown (#9045).
|
||||
|
||||
Measured on Windows 11 with 1050 processes (p50 / p95):
|
||||
|
||||
| | p50 | p95 |
|
||||
| --- | --- | --- |
|
||||
| pid + ppid + name | 15.9 ms | 17.5 ms |
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
| | p50 | p95 |
|
||||
| -------------------------------- | ------- | ------- |
|
||||
| pid + ppid + name | 15.9 ms | 17.5 ms |
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
|
||||
Those CIM numbers are from a 1050-process host. The scan scales with process
|
||||
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
|
||||
@@ -62,7 +65,7 @@ Two guards, and they work together:
|
||||
the module refuses every further read until that read's callback fires.
|
||||
|
||||
The wedge used to be a 30 s cooldown that let one probe through per window. That
|
||||
bounded the *rate* of new callbacks but not the total: a permanently wedged
|
||||
bounded the _rate_ of new callbacks but not the total: a permanently wedged
|
||||
reader retained one more closure every 30 s for as long as the app ran, and each
|
||||
probe also blocked its caller for the full 3 s deadline first. Gating on the
|
||||
outstanding read instead bounds retention at exactly one callback, and gives up
|
||||
@@ -90,7 +93,7 @@ checked on a real Windows SSH host with 1486 processes:
|
||||
|
||||
**Installing it normally rebuilds from source, and that build fails.** The
|
||||
tarball carries a `binding.gyp`, so npm runs `node-gyp rebuild` regardless of
|
||||
what is already compiled inside it. On a host that *already had* MSVC Build
|
||||
what is already compiled inside it. On a host that _already had_ MSVC Build
|
||||
Tools 2022 installed, that build still failed:
|
||||
|
||||
```
|
||||
@@ -103,7 +106,7 @@ would then break outright rather than degrade: `installNativeDeps` throws on
|
||||
failure, and the toolchain-skip retry is gated to Linux.
|
||||
|
||||
**Skipping the build and using the shipped binary returns a truncated table.**
|
||||
Contrary to what this file used to claim, the published 0.8.0 tarball *does*
|
||||
Contrary to what this file used to claim, the published 0.8.0 tarball _does_
|
||||
contain `build/Release/windows_process_tree.node` — an MSVC build directory that
|
||||
looks accidentally published (`.obj` and `.tlog` files ship with it). It is
|
||||
N-API, so it loads on any modern Node. But it predates our patch and still has
|
||||
@@ -151,14 +154,14 @@ it as an optional relay artifact.
|
||||
`config/scripts/build-windows-process-tree-relay-addon.mjs` builds it from the
|
||||
source pnpm has already patched, on a Windows runner, and refuses to run if
|
||||
any patch hunk is missing — the Spectre hunk fails loudly, the 1024-process
|
||||
hunk fails *silently*, and the relative gyp path dies at configure on Windows.
|
||||
hunk fails _silently_, and the relative gyp path dies at configure on Windows.
|
||||
The source is checked rather than the install trusted. It also reads the PE
|
||||
machine field of the output, because a cross-build that quietly emitted host
|
||||
arch would ship a binary the target cannot load.
|
||||
|
||||
Windows arm64 cross-compiles from the x64 runner — verified on real hardware,
|
||||
producing `IMAGE_FILE_MACHINE_ARM64` (0xaa64) against x64's 0x8664. It needs the
|
||||
optional *MSVC v143 ARM64 build tools* component; without it node-gyp fails with
|
||||
optional _MSVC v143 ARM64 build tools_ component; without it node-gyp fails with
|
||||
`MSB8020`, which is why the addon build runs before the long packaging step.
|
||||
`ORCA_REQUIRE_RELAY_NATIVE_ADDONS` is a per-arch list so a future arch can be
|
||||
added best-effort before it is promoted to required.
|
||||
@@ -258,7 +261,7 @@ The per-PTY job deliberately does **not** set
|
||||
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Measured on Windows 11: with that flag,
|
||||
releasing the handle when the shell exits also kills whatever the user left
|
||||
running, so typing `exit` in a pane reaped a `start /b` server that used to
|
||||
survive. The job exists to make an *explicit* teardown exact, not to redefine
|
||||
survive. The job exists to make an _explicit_ teardown exact, not to redefine
|
||||
what a clean exit means.
|
||||
|
||||
Reaping a dead daemon's shells (#9195, #10415) is therefore a **second, nested
|
||||
@@ -283,7 +286,7 @@ kill-on-close job on the app, which is exactly what the crash-survival
|
||||
guarantee forbids.
|
||||
|
||||
Once the shell exits, node-pty drops its handle record and closes the job, so a
|
||||
terminated tree reports `null` rather than `[]`. Null means *unverifiable* in
|
||||
terminated tree reports `null` rather than `[]`. Null means _unverifiable_ in
|
||||
the sense of [`ssh-execution-boundary.md`](./ssh-execution-boundary.md) — no job
|
||||
support, not a ConPTY, or no longer tracked. It is never evidence that
|
||||
processes died.
|
||||
|
||||
@@ -25,7 +25,7 @@ scripts:
|
||||
|
||||
## Why the script declares it, not the terminal preference
|
||||
|
||||
`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
|
||||
`terminalWindowsShell` says which shell _interactive terminals_ open in. It says nothing about the
|
||||
language a project's setup script is written in. Deriving the runner from it had two consequences:
|
||||
|
||||
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
|
||||
|
||||
@@ -35,7 +35,7 @@ whichever poller arrives first afterwards pays a full-fleet `git worktree list`
|
||||
fan-out. The many high-frequency callers — `listTerminals` without a selector,
|
||||
`showTerminal`, `getWorktreePs`, `listManagedWorktrees`,
|
||||
`resolveWorktreeSelector`, orchestration authority refresh — only determine
|
||||
*who* pays, not *how often*. Steady-state subprocess volume is therefore
|
||||
_who_ pays, not _how often_. Steady-state subprocess volume is therefore
|
||||
`repos / 30 s`, independent of poll rate, which is exactly the observed
|
||||
~1 sweep / 30.5 s.
|
||||
|
||||
@@ -86,16 +86,16 @@ common directory without a subprocess (read `.git`; if it is a `gitdir:` file,
|
||||
follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a
|
||||
bare gitdir), then records:
|
||||
|
||||
| Input | External change it catches |
|
||||
| --- | --- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| Input | External change it catches |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) |
|
||||
|
||||
"per checkout" covers the main worktree and each linked worktree. Reading the
|
||||
@@ -159,21 +159,21 @@ rescans. Capturing after the scan would let that mutation be masked forever.
|
||||
|
||||
Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint
|
||||
included) and bumps the generation, so every event-driven path still forces a
|
||||
real scan on the next read. The fingerprint only ever *extends* an entry that
|
||||
real scan on the next read. The fingerprint only ever _extends_ an entry that
|
||||
the TTL alone would have refreshed.
|
||||
|
||||
## Freshness budget
|
||||
|
||||
| Change | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
| Change | Before | After |
|
||||
| --------------------------------------------------------------------- | ----------------- | ----------------- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
|
||||
The two regressions are bounded by the reconciliation interval and are both
|
||||
changes Orca does not make itself.
|
||||
@@ -185,10 +185,10 @@ thread" in the naive sense — but they are not equally free there. Measured on
|
||||
macOS with a 1 ms interval sampling event-loop lag while each ran 30 times
|
||||
against a repo with 20 linked worktrees:
|
||||
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| --- | --- | --- | --- |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| ------------------- | ------------- | -------------------------- | ------------------ |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
|
||||
`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency
|
||||
is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout
|
||||
@@ -212,10 +212,10 @@ sparse-checkout probes already do.
|
||||
reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30
|
||||
simulated minutes — and counts `git worktree list` invocations:
|
||||
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| --- | --- | --- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| ------------------------ | ------------------------------ | -------- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
|
||||
A 90 % reduction, with the remainder being the bounded reconciliation. Repos
|
||||
with genuine external activity keep rescanning at the 30 s cadence because the
|
||||
@@ -227,7 +227,7 @@ Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272
|
||||
## Rejected alternatives
|
||||
|
||||
**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess
|
||||
reduction, but it degrades *every* external-change latency to 5 min, including
|
||||
reduction, but it degrades _every_ external-change latency to 5 min, including
|
||||
the common "I ran `git worktree add` in a terminal" case. The fingerprint buys
|
||||
the same reduction without that regression.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ exactly the case a POSIX script uses to mean a literal dollar.
|
||||
Build argv with `buildWslExecArgs()` in `src/shared/wsl-login-shell-command.ts`. A test walks the
|
||||
tree and fails if the `--` form reappears.
|
||||
|
||||
The `--` inside `sh -s -- <path>` is a *shell* argument separator and is unrelated; leave it alone.
|
||||
The `--` inside `sh -s -- <path>` is a _shell_ argument separator and is unrelated; leave it alone.
|
||||
|
||||
## 2. Machine-read output must be fenced
|
||||
|
||||
|
||||
@@ -4,24 +4,24 @@ Three layers of coverage, because each catches what the others structurally cann
|
||||
|
||||
## 1. Unit — runs everywhere, every PR
|
||||
|
||||
| Suite | Pins |
|
||||
|---|---|
|
||||
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
|
||||
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
|
||||
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
|
||||
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
|
||||
| Suite | Pins |
|
||||
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
|
||||
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
|
||||
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
|
||||
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
|
||||
|
||||
## 2. Ratchets — the goalposts, enforced continuously
|
||||
|
||||
| Guard | Measures |
|
||||
|---|---|
|
||||
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
|
||||
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
|
||||
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
|
||||
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
|
||||
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
|
||||
| Guard | Measures |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
|
||||
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
|
||||
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
|
||||
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
|
||||
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
|
||||
|
||||
Each fails on a **new** offender *and* on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
|
||||
Each fails on a **new** offender _and_ on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
|
||||
|
||||
## 3. Real-binary — the assertions nothing else can make
|
||||
|
||||
@@ -48,4 +48,4 @@ Recorded rather than implied, because a guard that looks complete is worse than
|
||||
|
||||
### Verifying a guard change
|
||||
|
||||
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that *reduced* the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
|
||||
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that _reduced_ the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
|
||||
|
||||
@@ -115,7 +115,7 @@ the resulting patch is silently wrong — the failure mode is a `.mjs` that is
|
||||
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
|
||||
to run a build step that names one of those scripts.
|
||||
|
||||
The generator also builds the *unmodified* commit first and asserts that it
|
||||
The generator also builds the _unmodified_ commit first and asserts that it
|
||||
reproduces the published `lib/` byte for byte before it emits anything. A
|
||||
toolchain or build-order problem therefore surfaces as an explicit "did not
|
||||
reproduce the published bundles" error rather than as 7 MB of mystery diff.
|
||||
|
||||
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
|
||||
index: resolve('src/main/index.ts'),
|
||||
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
|
||||
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
|
||||
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
|
||||
+209
-23
@@ -1,20 +1,35 @@
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import Constants from 'expo-constants'
|
||||
import { ChevronLeft, Copy, Check } from 'lucide-react-native'
|
||||
import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { connectionLogStore } from '../src/transport/connection-log-buffer'
|
||||
import { useHostClient } from '../src/transport/client-context'
|
||||
import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
|
||||
import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
useConnectionPathStatus,
|
||||
useReconnectAttempt
|
||||
} from '../src/transport/client-context-connection-metrics'
|
||||
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
|
||||
import {
|
||||
diagnoseConnection,
|
||||
getReportableConnectionIncidentId
|
||||
} from '../src/diagnostics/connection-diagnostics-analysis'
|
||||
import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
|
||||
import {
|
||||
readHydratedConnectionLog,
|
||||
readConnectionDiagnosticsSnapshot,
|
||||
resolveDiagnosticsHostId,
|
||||
getDiagnosticsSubmissionState,
|
||||
updateDiagnosticsSubmissionState,
|
||||
type DiagnosticsSubmissionStates
|
||||
} from '../src/diagnostics/connection-diagnostics-screen-data'
|
||||
import { useHostStatusGates } from '../src/transport/host-status-gates'
|
||||
import { loadHostAppVersion } from '../src/transport/host-app-version-store'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
|
||||
|
||||
// Why: getSnapshot must be referentially stable when there's no data —
|
||||
@@ -25,11 +40,19 @@ const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
|
||||
// screen also *acquires* the host client — opening it kicks a dial and the
|
||||
// log fills live instead of showing a stale tail.
|
||||
export default function ConnectionLogScreen() {
|
||||
const clientContext = useRpcClientContext()
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{ hostId?: string }>()
|
||||
const insets = useSafeAreaInsets()
|
||||
const routeKey = useMemo(() => ({}), [params.hostId])
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [manualSelection, setManualSelection] = useState<{
|
||||
hostId: string
|
||||
requestedHostId: string | undefined
|
||||
routeKey: object
|
||||
} | null>(null)
|
||||
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
|
||||
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
@@ -38,17 +61,28 @@ export default function ConnectionLogScreen() {
|
||||
return
|
||||
}
|
||||
setHosts(loaded)
|
||||
setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
|
||||
const selected = hosts.find((h) => h.id === selectedId) ?? null
|
||||
const { state } = useHostClient(selected?.id)
|
||||
const { client, state } = useHostClient(selected?.id)
|
||||
const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
|
||||
hostId: selected?.id,
|
||||
client,
|
||||
connState: state
|
||||
})
|
||||
const reconnectAttempts = useReconnectAttempt(selected?.id)
|
||||
const lastConnectedAt = useLastConnectedAt(selected?.id)
|
||||
const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) {
|
||||
void readHydratedConnectionLog(connectionLogStore, selectedId)
|
||||
}
|
||||
}, [selectedId])
|
||||
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) =>
|
||||
@@ -60,25 +94,93 @@ export default function ConnectionLogScreen() {
|
||||
[selectedId]
|
||||
)
|
||||
const entries = useSyncExternalStore(subscribe, getSnapshot)
|
||||
const diagnosis = selected
|
||||
? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
|
||||
: null
|
||||
const incidentId = selected
|
||||
? getReportableConnectionIncidentId({
|
||||
endpoint: selected.endpoint,
|
||||
state,
|
||||
activePath,
|
||||
pendingPath,
|
||||
entries
|
||||
})
|
||||
: null
|
||||
const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
|
||||
const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
|
||||
const copied = copiedHostId === selectedId
|
||||
|
||||
const copyDiagnostics = useCallback(async () => {
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
|
||||
const snapshot = await readConnectionDiagnosticsSnapshot(
|
||||
clientContext,
|
||||
connectionLogStore,
|
||||
selected.id
|
||||
)
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: selected.name,
|
||||
endpoint: selected.endpoint,
|
||||
state,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt,
|
||||
state: snapshot.state,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
lastConnectedAt: snapshot.lastConnectedAt,
|
||||
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
|
||||
appVersion: Constants.expoConfig?.version ?? 'unknown',
|
||||
entries
|
||||
desktopAppVersion,
|
||||
entries: snapshot.entries,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath
|
||||
})
|
||||
await Clipboard.setStringAsync(report)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [selected, state, reconnectAttempts, lastConnectedAt, entries])
|
||||
setCopiedHostId(selected.id)
|
||||
setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
|
||||
}, [selected, liveDesktopAppVersion, clientContext])
|
||||
|
||||
const sendDiagnostics = useCallback(async () => {
|
||||
if (!selected || !submissionKey || submissionState === 'sending') {
|
||||
return
|
||||
}
|
||||
const startedKey = submissionKey
|
||||
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
|
||||
const appVersion = Constants.expoConfig?.version ?? 'unknown'
|
||||
const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
|
||||
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
|
||||
const snapshot = await readConnectionDiagnosticsSnapshot(
|
||||
clientContext,
|
||||
connectionLogStore,
|
||||
selected.id
|
||||
)
|
||||
const currentIncidentId = getReportableConnectionIncidentId({
|
||||
endpoint: selected.endpoint,
|
||||
state: snapshot.state,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath,
|
||||
entries: snapshot.entries
|
||||
})
|
||||
if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
|
||||
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
|
||||
return
|
||||
}
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: selected.name,
|
||||
endpoint: selected.endpoint,
|
||||
state: snapshot.state,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
lastConnectedAt: snapshot.lastConnectedAt,
|
||||
platform,
|
||||
appVersion,
|
||||
desktopAppVersion,
|
||||
entries: snapshot.entries,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath
|
||||
})
|
||||
const result = await submitConnectionDiagnostics({ report, appVersion, platform })
|
||||
setSubmissionStates((states) =>
|
||||
updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
|
||||
)
|
||||
}, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
@@ -86,7 +188,7 @@ export default function ConnectionLogScreen() {
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Connection log</Text>
|
||||
<Text style={styles.heading}>Network diagnostics</Text>
|
||||
</View>
|
||||
|
||||
{hosts.length > 1 && (
|
||||
@@ -95,7 +197,9 @@ export default function ConnectionLogScreen() {
|
||||
<Pressable
|
||||
key={host.id}
|
||||
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
|
||||
onPress={() => setSelectedId(host.id)}
|
||||
onPress={() =>
|
||||
setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
|
||||
@@ -121,14 +225,49 @@ export default function ConnectionLogScreen() {
|
||||
) : (
|
||||
<Copy size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy report'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{diagnosis && (
|
||||
<View style={styles.diagnosisCard}>
|
||||
<Text style={styles.diagnosisHeading}>What this suggests</Text>
|
||||
<Text style={styles.diagnosisText}>{diagnosis.likelyCause}</Text>
|
||||
<Text style={styles.diagnosisNext}>{diagnosis.nextStep}</Text>
|
||||
{diagnosis.reportability === 'orca-relay' && (
|
||||
<>
|
||||
<Text style={styles.privacyHint}>
|
||||
Sends a size-limited redacted report including host name, endpoint, versions,
|
||||
connection state, and events—never terminal contents or credentials.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.sendButton}
|
||||
onPress={() => void sendDiagnostics()}
|
||||
disabled={submissionState === 'sending'}
|
||||
>
|
||||
{submissionState === 'sent' ? (
|
||||
<Check size={14} color={colors.statusGreen} />
|
||||
) : (
|
||||
<Send size={14} color={colors.textPrimary} />
|
||||
)}
|
||||
<Text style={styles.sendButtonText}>
|
||||
{submissionState === 'sending'
|
||||
? 'Sending…'
|
||||
: submissionState === 'sent'
|
||||
? 'Diagnostics sent'
|
||||
: submissionState === 'failed'
|
||||
? 'Retry sending'
|
||||
: 'Send diagnostics to Orca'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{entries.length > 0 ? (
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} />
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} fillAvailableHeight />
|
||||
) : (
|
||||
<Text style={styles.emptyText}>
|
||||
No connection events yet this session. Events appear as the app dials this host.
|
||||
No connection events yet. Events appear as the app dials this host.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
@@ -199,6 +338,53 @@ const styles = StyleSheet.create({
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
diagnosisCard: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: 10,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
diagnosisHeading: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
diagnosisText: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: 18
|
||||
},
|
||||
diagnosisNext: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 18,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
privacyHint: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: 11,
|
||||
lineHeight: 15,
|
||||
color: colors.textMuted
|
||||
},
|
||||
sendButton: {
|
||||
marginTop: spacing.md,
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
sendButtonText: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
+15
-309
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
|
||||
import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
import {
|
||||
@@ -63,14 +63,16 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { HostDiagnosticsLink } from '../../../src/components/HostDiagnosticsLink'
|
||||
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
|
||||
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { setCachedRepos } from '../../../src/cache/repo-cache'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { colors, spacing } from '../../../src/theme/mobile-theme'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles'
|
||||
import { leaveHostRoute } from '../../../src/host-route-exit'
|
||||
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
|
||||
import {
|
||||
@@ -1090,6 +1092,17 @@ export function HostScreen({
|
||||
/>
|
||||
)}
|
||||
|
||||
{connState !== 'connected' &&
|
||||
!relayRecovery.pairingRejected &&
|
||||
reconnectAttempts >= 3 &&
|
||||
hostId ? (
|
||||
<HostDiagnosticsLink
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
|
||||
{routeNotice && (
|
||||
<HostRouteNoticeBanner
|
||||
@@ -1410,310 +1423,3 @@ export default function HostWorktreeRoute() {
|
||||
function ListSeparator() {
|
||||
return <View style={styles.separator} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
topChrome: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 34,
|
||||
paddingTop: spacing.xs,
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sidebarCollapseButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
hostIdentity: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
marginRight: spacing.md
|
||||
},
|
||||
hostNameText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
reconnectButton: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
reconnectButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbar: {
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
embeddedFilterChip: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
embeddedModeButton: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
filterChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
filterChipActive: {
|
||||
borderColor: colors.textSecondary,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: colors.textPrimary
|
||||
},
|
||||
modeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
sortLabel: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
toolbarSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
floatingWorkspaceHeaderButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
embeddedToolbarIconButton: {
|
||||
flex: 1,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
toolbarIconDisabled: {
|
||||
opacity: 0.6
|
||||
},
|
||||
searchToggle: {
|
||||
padding: spacing.xs
|
||||
},
|
||||
searchBar: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
list: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs
|
||||
},
|
||||
sectionIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionRepoIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
sectionCount: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginLeft: spacing.lg + 24,
|
||||
marginRight: spacing.lg
|
||||
},
|
||||
filterModalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterModalTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterSectionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
filterGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
gap: spacing.sm
|
||||
},
|
||||
filterRowText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
filterSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
filterRepoDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
},
|
||||
confirmContent: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
confirmTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
confirmMessage: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20
|
||||
},
|
||||
confirmButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
confirmBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
confirmBtnCancel: {
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
confirmBtnDestructive: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
confirmBtnPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
confirmBtnCancelText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
confirmBtnDestructiveText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -117,6 +117,10 @@ import {
|
||||
} from '../../../../src/terminal/terminal-accessory-layout'
|
||||
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
|
||||
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
|
||||
import {
|
||||
createTerminalAccessoryRepeatController,
|
||||
createTerminalAccessoryRepeatSender
|
||||
} from '../../../../src/terminal/terminal-accessory-repeat'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
@@ -2993,16 +2997,22 @@ export default function SessionScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccessoryKey(input: ReturnType<typeof createTerminalLiveAccessoryInput>) {
|
||||
if (!client || !activeHandle || !canSend) {
|
||||
return
|
||||
async function handleAccessoryKey(
|
||||
input: ReturnType<typeof createTerminalLiveAccessoryInput>,
|
||||
targetHandle = activeHandleRef.current,
|
||||
isDeliveryTargetCurrent: () => boolean = () => targetHandle === activeHandleRef.current
|
||||
) {
|
||||
if (!client || !targetHandle || !isDeliveryTargetCurrent() || !canSend) {
|
||||
return false
|
||||
}
|
||||
const targetHandle = activeHandle
|
||||
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
|
||||
if (accessoryCommit.kind !== 'allow-raw') {
|
||||
return
|
||||
if (!isDeliveryTargetCurrent()) {
|
||||
return false
|
||||
}
|
||||
await sendTerminalLiveAccessoryRawBytes({
|
||||
if (accessoryCommit.kind !== 'allow-raw') {
|
||||
return accessoryCommit.kind === 'handled'
|
||||
}
|
||||
return sendTerminalLiveAccessoryRawBytes({
|
||||
client: clientRef.current,
|
||||
targetHandle,
|
||||
activeHandle: activeHandleRef.current,
|
||||
@@ -3399,32 +3409,37 @@ export default function SessionScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive).
|
||||
const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
// Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal.
|
||||
const accessoryRepeatRef = useRef(
|
||||
createTerminalAccessoryRepeatController<ReturnType<typeof createTerminalLiveAccessoryInput>>()
|
||||
)
|
||||
// Why: the current callback observes reconnect state while the sender pins the press to its original terminal.
|
||||
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
|
||||
handleAccessoryKeyRef.current = handleAccessoryKey
|
||||
const stopAccessoryRepeat = useCallback(() => {
|
||||
if (repeatTimeoutRef.current) {
|
||||
clearTimeout(repeatTimeoutRef.current)
|
||||
repeatTimeoutRef.current = null
|
||||
}
|
||||
if (repeatIntervalRef.current) {
|
||||
clearInterval(repeatIntervalRef.current)
|
||||
repeatIntervalRef.current = null
|
||||
}
|
||||
accessoryRepeatRef.current.stop()
|
||||
}, [])
|
||||
const startAccessoryRepeat = useCallback(
|
||||
(input: ReturnType<typeof createTerminalLiveAccessoryInput>) => {
|
||||
stopAccessoryRepeat()
|
||||
repeatTimeoutRef.current = setTimeout(() => {
|
||||
repeatIntervalRef.current = setInterval(() => {
|
||||
void handleAccessoryKeyRef.current(input)
|
||||
}, 45)
|
||||
}, 400)
|
||||
// Why: a held repeat must not resume through a replacement client or connection.
|
||||
const targetClient = clientRef.current
|
||||
const targetConnectedAt = targetClient?.getLastConnectedAt() ?? null
|
||||
const isDeliveryTargetCurrent = (targetHandle: string) =>
|
||||
activeHandleRef.current === targetHandle &&
|
||||
targetClient !== null &&
|
||||
clientRef.current === targetClient &&
|
||||
connStateRef.current === 'connected' &&
|
||||
targetClient.getLastConnectedAt() === targetConnectedAt
|
||||
accessoryRepeatRef.current.start(
|
||||
input,
|
||||
createTerminalAccessoryRepeatSender(
|
||||
activeHandleRef.current,
|
||||
isDeliveryTargetCurrent,
|
||||
(nextInput, targetHandle, isTargetCurrent) =>
|
||||
handleAccessoryKeyRef.current(nextInput, targetHandle, isTargetCurrent)
|
||||
)
|
||||
)
|
||||
},
|
||||
[stopAccessoryRepeat]
|
||||
[]
|
||||
)
|
||||
const setMobileSessionRootRef = useCallback(
|
||||
(node: View | null): void => {
|
||||
@@ -3440,15 +3455,14 @@ export default function SessionScreen() {
|
||||
clearPendingLiveInputCommit()
|
||||
sessionTabActionSheetRequestSeqRef.current += 1
|
||||
clearSessionTabActionSheetKeyboardListener()
|
||||
stopAccessoryRepeat()
|
||||
accessoryRepeatRef.current.cancel()
|
||||
},
|
||||
[
|
||||
clearPendingLiveInputCommit,
|
||||
clearDelayedActionTimers,
|
||||
clearSessionTabActionSheetKeyboardListener,
|
||||
clearTerminalCache,
|
||||
clearToastHideTimer,
|
||||
stopAccessoryRepeat
|
||||
clearToastHideTimer
|
||||
]
|
||||
)
|
||||
|
||||
@@ -4848,7 +4862,6 @@ export default function SessionScreen() {
|
||||
return
|
||||
}
|
||||
const input = createTerminalLiveAccessoryInput(key)
|
||||
void handleAccessoryKey(input)
|
||||
startAccessoryRepeat(input)
|
||||
}}
|
||||
onPressOut={() => {
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function TroubleshootScreen() {
|
||||
onPress={() => router.push('/connection-log')}
|
||||
>
|
||||
<ScrollText size={16} color={colors.textPrimary} />
|
||||
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
|
||||
<Text style={styles.diagnosticButtonLabel}>View network diagnostics</Text>
|
||||
</Pressable>
|
||||
|
||||
{checks.length > 0 && (
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
|
||||
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
|
||||
// can't resolve Node's builtin in a React Native bundle.
|
||||
import { Buffer } from 'buffer'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -10,7 +7,6 @@ import {
|
||||
PanResponder,
|
||||
PixelRatio,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
@@ -24,7 +20,7 @@ import type {
|
||||
BrowserScreencastFrame,
|
||||
BrowserScreencastFrameMetadata
|
||||
} from '../transport/browser-screencast-protocol'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
|
||||
buildMobileBrowserScreencastRequest,
|
||||
@@ -47,11 +43,32 @@ import {
|
||||
computeBrowserTouchClickRadiusCss,
|
||||
mapScreenToBrowserPoint,
|
||||
readLocalTouchPoint,
|
||||
type BrowserFrameGeometry,
|
||||
type BrowserPoint,
|
||||
type BrowserTouchLayout,
|
||||
type BrowserZoomState
|
||||
} from './browser-touch-geometry'
|
||||
import {
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
assertRpcOk,
|
||||
browserErrorMessage,
|
||||
browserFrameMetadataEqual,
|
||||
buttonColor,
|
||||
cacheBrowserFrame,
|
||||
clearCachedBrowserFramesForWorktree,
|
||||
createBrowserFrameDataUri,
|
||||
createPinchGesture,
|
||||
getCachedBrowserFrame,
|
||||
makeBrowserFrameCacheKey,
|
||||
peekCachedBrowserFrame,
|
||||
shouldSurfaceBrowserError,
|
||||
updateBrowserImageSource,
|
||||
updateBrowserLayerVisibility,
|
||||
updatePinchZoom,
|
||||
type FrameLayer,
|
||||
type PinchGesture
|
||||
} from './mobile-browser-frame-state'
|
||||
import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles'
|
||||
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
|
||||
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
|
||||
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
|
||||
@@ -79,15 +96,6 @@ type MobileBrowserPaneProps = {
|
||||
onToast: (message: string, durationMs?: number) => void
|
||||
}
|
||||
|
||||
type FrameLayer = 0 | 1
|
||||
|
||||
type PinchGesture = {
|
||||
distance: number
|
||||
scale: number
|
||||
anchorX: number
|
||||
anchorY: number
|
||||
}
|
||||
|
||||
type PanGesture = {
|
||||
x: number
|
||||
y: number
|
||||
@@ -105,17 +113,7 @@ const SCROLL_START_SLOP = 22
|
||||
const LONG_PRESS_MS = 550
|
||||
const WHEEL_INTERVAL_MS = 70
|
||||
const TOUCH_CLICK_RADIUS_DIP = 14
|
||||
const MIN_ZOOM = 1
|
||||
const MAX_ZOOM = 3.5
|
||||
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
|
||||
const BROWSER_FRAME_CACHE_LIMIT = 4
|
||||
|
||||
type BrowserFrameCacheEntry = {
|
||||
uri: string
|
||||
metadata: BrowserScreencastFrameMetadata
|
||||
}
|
||||
|
||||
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
|
||||
|
||||
type BrowserPageParams = {
|
||||
worktree: string
|
||||
@@ -1268,350 +1266,3 @@ export function MobileBrowserPane({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function buttonColor(enabled: boolean): string {
|
||||
return enabled ? colors.textSecondary : colors.textMuted
|
||||
}
|
||||
|
||||
function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
|
||||
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
|
||||
}
|
||||
|
||||
function makeBrowserFrameCacheKey(
|
||||
worktreeId: string,
|
||||
browserPageId: string | null,
|
||||
viewMode: MobileBrowserViewMode
|
||||
): string | null {
|
||||
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
|
||||
}
|
||||
|
||||
function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
|
||||
const prefix = `${worktreeId}:`
|
||||
for (const key of browserFrameCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
browserFrameCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
if (!cacheKey) {
|
||||
return null
|
||||
}
|
||||
const cached = browserFrameCache.get(cacheKey)
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
|
||||
}
|
||||
|
||||
function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
|
||||
if (!cacheKey) {
|
||||
return
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, entry)
|
||||
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
|
||||
const oldestKey = browserFrameCache.keys().next().value
|
||||
if (typeof oldestKey !== 'string') {
|
||||
break
|
||||
}
|
||||
browserFrameCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
function updateBrowserLayerVisibility(
|
||||
layers: [View | null, View | null],
|
||||
visible: FrameLayer
|
||||
): void {
|
||||
for (const [index, layer] of layers.entries()) {
|
||||
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
|
||||
}
|
||||
}
|
||||
|
||||
function updateBrowserImageSource(image: Image | null, uri: string): void {
|
||||
// Why: browser frames are large strings; mutating only the native Image
|
||||
// source avoids re-rendering the whole tab view for every streamed frame.
|
||||
const source = [{ uri }]
|
||||
image?.setNativeProps({ source, src: source })
|
||||
}
|
||||
|
||||
function assertRpcOk(
|
||||
response: RpcSuccess | RpcFailure,
|
||||
fallbackMessage: string
|
||||
): asserts response is RpcSuccess {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message || fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
function browserFrameMetadataEqual(
|
||||
a: BrowserScreencastFrameMetadata | null,
|
||||
b: BrowserScreencastFrameMetadata
|
||||
): boolean {
|
||||
return (
|
||||
a?.deviceWidth === b.deviceWidth &&
|
||||
a?.deviceHeight === b.deviceHeight &&
|
||||
a?.pageScaleFactor === b.pageScaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
function browserErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
function shouldSurfaceBrowserError(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
// Why: selector_not_found can be emitted by in-flight page automation while
|
||||
// the browser is still usable; replacing the frame with it feels like a crash.
|
||||
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
|
||||
}
|
||||
|
||||
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
|
||||
const touches = event.nativeEvent.touches
|
||||
if (!touches || touches.length < 2) {
|
||||
return null
|
||||
}
|
||||
const a = readLocalTouchPoint(touches[0])
|
||||
const b = readLocalTouchPoint(touches[1])
|
||||
return a && b ? { a, b } : null
|
||||
}
|
||||
|
||||
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
function createPinchGesture(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
zoom: BrowserZoomState
|
||||
): PinchGesture | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const distance = pointDistance(pair.a, pair.b)
|
||||
if (distance < 8) {
|
||||
return null
|
||||
}
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
|
||||
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
|
||||
return {
|
||||
distance,
|
||||
scale: zoom.scale,
|
||||
anchorX: (centerX - frameCenterX) / zoom.scale,
|
||||
anchorY: (centerY - frameCenterY) / zoom.scale
|
||||
}
|
||||
}
|
||||
|
||||
function updatePinchZoom(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
pinch: PinchGesture
|
||||
): BrowserZoomState | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const nextScale = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
|
||||
)
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
|
||||
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
|
||||
return clampBrowserZoomState(
|
||||
{
|
||||
scale: nextScale,
|
||||
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
|
||||
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
|
||||
},
|
||||
geometry,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
toolbar: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
viewport: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
browserImageHost: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImageFill: {
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
browserImageLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserImageLayerHidden: {
|
||||
opacity: 0
|
||||
},
|
||||
browserZoomOffset: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserFrameBox: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImage: {
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.2)'
|
||||
},
|
||||
errorText: {
|
||||
color: colors.textPrimary,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
dialogOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.5)'
|
||||
},
|
||||
dialogCard: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel,
|
||||
padding: spacing.lg
|
||||
},
|
||||
dialogTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogMessage: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
dialogActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
dialogButton: {
|
||||
minHeight: 34,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
dialogButtonPrimary: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
dialogButtonPressed: {
|
||||
opacity: 0.75
|
||||
},
|
||||
dialogButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogButtonPrimaryText: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
keyboardDock: {
|
||||
zIndex: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.xs + 2
|
||||
},
|
||||
keyboardInput: {
|
||||
flex: 1,
|
||||
height: 34,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.35
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Buffer } from 'buffer'
|
||||
import type { GestureResponderEvent, Image, View } from 'react-native'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
import type {
|
||||
BrowserScreencastFrame,
|
||||
BrowserScreencastFrameMetadata
|
||||
} from '../transport/browser-screencast-protocol'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
clampBrowserZoomState,
|
||||
readLocalTouchPoint,
|
||||
type BrowserFrameGeometry,
|
||||
type BrowserPoint,
|
||||
type BrowserZoomState
|
||||
} from './browser-touch-geometry'
|
||||
import type { MobileBrowserViewMode } from './browser-screencast-request'
|
||||
|
||||
export type FrameLayer = 0 | 1
|
||||
export type PinchGesture = {
|
||||
distance: number
|
||||
scale: number
|
||||
anchorX: number
|
||||
anchorY: number
|
||||
}
|
||||
export type BrowserFrameCacheEntry = {
|
||||
uri: string
|
||||
metadata: BrowserScreencastFrameMetadata
|
||||
}
|
||||
export const MIN_ZOOM = 1
|
||||
export const MAX_ZOOM = 3.5
|
||||
const BROWSER_FRAME_CACHE_LIMIT = 4
|
||||
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
|
||||
|
||||
export function buttonColor(enabled: boolean): string {
|
||||
return enabled ? colors.textSecondary : colors.textMuted
|
||||
}
|
||||
|
||||
export function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
|
||||
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
|
||||
}
|
||||
|
||||
export function makeBrowserFrameCacheKey(
|
||||
worktreeId: string,
|
||||
browserPageId: string | null,
|
||||
viewMode: MobileBrowserViewMode
|
||||
): string | null {
|
||||
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
|
||||
}
|
||||
|
||||
export function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
|
||||
const prefix = `${worktreeId}:`
|
||||
for (const key of browserFrameCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
browserFrameCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
if (!cacheKey) {
|
||||
return null
|
||||
}
|
||||
const cached = browserFrameCache.get(cacheKey)
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
export function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
|
||||
}
|
||||
|
||||
export function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
|
||||
if (!cacheKey) {
|
||||
return
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, entry)
|
||||
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
|
||||
const oldestKey = browserFrameCache.keys().next().value
|
||||
if (typeof oldestKey !== 'string') {
|
||||
break
|
||||
}
|
||||
browserFrameCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function updateBrowserLayerVisibility(
|
||||
layers: [View | null, View | null],
|
||||
visible: FrameLayer
|
||||
): void {
|
||||
for (const [index, layer] of layers.entries()) {
|
||||
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
|
||||
}
|
||||
}
|
||||
|
||||
export function updateBrowserImageSource(image: Image | null, uri: string): void {
|
||||
// Why: browser frames are large strings; mutating only the native Image
|
||||
// source avoids re-rendering the whole tab view for every streamed frame.
|
||||
const source = [{ uri }]
|
||||
image?.setNativeProps({ source, src: source })
|
||||
}
|
||||
|
||||
export function assertRpcOk(
|
||||
response: RpcSuccess | RpcFailure,
|
||||
fallbackMessage: string
|
||||
): asserts response is RpcSuccess {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message || fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
export function browserFrameMetadataEqual(
|
||||
a: BrowserScreencastFrameMetadata | null,
|
||||
b: BrowserScreencastFrameMetadata
|
||||
): boolean {
|
||||
return (
|
||||
a?.deviceWidth === b.deviceWidth &&
|
||||
a?.deviceHeight === b.deviceHeight &&
|
||||
a?.pageScaleFactor === b.pageScaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
export function browserErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
export function shouldSurfaceBrowserError(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
// Why: selector_not_found can be emitted by in-flight page automation while
|
||||
// the browser is still usable; replacing the frame with it feels like a crash.
|
||||
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
|
||||
}
|
||||
|
||||
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
|
||||
const touches = event.nativeEvent.touches
|
||||
if (!touches || touches.length < 2) {
|
||||
return null
|
||||
}
|
||||
const a = readLocalTouchPoint(touches[0])
|
||||
const b = readLocalTouchPoint(touches[1])
|
||||
return a && b ? { a, b } : null
|
||||
}
|
||||
|
||||
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
export function createPinchGesture(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
zoom: BrowserZoomState
|
||||
): PinchGesture | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const distance = pointDistance(pair.a, pair.b)
|
||||
if (distance < 8) {
|
||||
return null
|
||||
}
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
|
||||
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
|
||||
return {
|
||||
distance,
|
||||
scale: zoom.scale,
|
||||
anchorX: (centerX - frameCenterX) / zoom.scale,
|
||||
anchorY: (centerY - frameCenterY) / zoom.scale
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePinchZoom(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
pinch: PinchGesture
|
||||
): BrowserZoomState | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const nextScale = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
|
||||
)
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
|
||||
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
|
||||
return clampBrowserZoomState(
|
||||
{
|
||||
scale: nextScale,
|
||||
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
|
||||
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
|
||||
},
|
||||
geometry,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const mobileBrowserPaneStyles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
toolbar: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
viewport: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
browserImageHost: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImageFill: {
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
browserImageLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserImageLayerHidden: {
|
||||
opacity: 0
|
||||
},
|
||||
browserZoomOffset: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserFrameBox: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImage: {
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.2)'
|
||||
},
|
||||
errorText: {
|
||||
color: colors.textPrimary,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
dialogOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.5)'
|
||||
},
|
||||
dialogCard: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel,
|
||||
padding: spacing.lg
|
||||
},
|
||||
dialogTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogMessage: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
dialogActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
dialogButton: {
|
||||
minHeight: 34,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
dialogButtonPrimary: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
dialogButtonPressed: {
|
||||
opacity: 0.75
|
||||
},
|
||||
dialogButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogButtonPrimaryText: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
keyboardDock: {
|
||||
zIndex: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.xs + 2
|
||||
},
|
||||
keyboardInput: {
|
||||
flex: 1,
|
||||
height: 34,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.35
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
import { ConnectionLog } from './ConnectionLog'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
const entries: ConnectionLogEntry[] = [
|
||||
{ id: 'event-1', ts: 1, level: 'info', message: 'Opening WebSocket' }
|
||||
]
|
||||
|
||||
const duplicateEntries: ConnectionLogEntry[] = [
|
||||
{ id: 'relay-1', ts: 1, level: 'info', message: 'Relay recovery started' },
|
||||
{ id: 'relay-1', ts: 2, level: 'warn', message: 'Relay retry scheduled' }
|
||||
]
|
||||
|
||||
describe('ConnectionLog', () => {
|
||||
type RenderedNode = { props: { style?: unknown } }
|
||||
type Renderer = {
|
||||
root: {
|
||||
findAllByType: (type: unknown) => RenderedNode[]
|
||||
findByType: (type: unknown) => RenderedNode
|
||||
}
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
let renderer: Renderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function renderLog(fillAvailableHeight = false): Renderer {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(ConnectionLog, { entries, fillAvailableHeight })
|
||||
) as unknown as Renderer
|
||||
})
|
||||
return renderer as Renderer
|
||||
}
|
||||
|
||||
it('keeps the compact height in pairing flows', () => {
|
||||
const instance = renderLog()
|
||||
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
|
||||
|
||||
expect(containerStyles).toContainEqual({ maxHeight: 240 })
|
||||
})
|
||||
|
||||
it('fills the available diagnostics viewport', () => {
|
||||
const instance = renderLog(true)
|
||||
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
|
||||
const scroll = instance.root.findByType('ScrollView')
|
||||
|
||||
expect(containerStyles).toContainEqual({ flex: 1 })
|
||||
expect(scroll.props.style).toEqual({ flex: 1 })
|
||||
})
|
||||
|
||||
it('does not emit duplicate-key warnings for repeated persisted event IDs', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(ConnectionLog, { entries: duplicateEntries })
|
||||
) as unknown as Renderer
|
||||
})
|
||||
|
||||
expect(
|
||||
consoleError.mock.calls.some(([message]) =>
|
||||
String(message).includes('Encountered two children with the same key')
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
// Tag printed before the first entry so it's clear what's being logged
|
||||
// (e.g. 'Pairing' vs 'Reconnect').
|
||||
title?: string
|
||||
fillAvailableHeight?: boolean
|
||||
}
|
||||
|
||||
const LEVEL_COLOR: Record<ConnectionLogEntry['level'], string> = {
|
||||
@@ -37,42 +38,53 @@ function formatTime(ts: number, baseTs: number): string {
|
||||
return `+${Math.round(elapsed)}s`
|
||||
}
|
||||
|
||||
export function ConnectionLog({ entries, title }: Props) {
|
||||
export function ConnectionLog({ entries, title, fillAvailableHeight = false }: Props) {
|
||||
const scrollRef = useRef<ScrollView | null>(null)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
const baseTs = entries[0]!.ts
|
||||
const keyOccurrences = new Map<string, number>()
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
fillAvailableHeight ? styles.fillContainer : styles.boundedContainer
|
||||
]}
|
||||
>
|
||||
{title && <Text style={styles.title}>{title}</Text>}
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
style={fillAvailableHeight ? styles.fillScroll : styles.boundedScroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<View key={entry.id} style={styles.row}>
|
||||
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
|
||||
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{LEVEL_GLYPH[entry.level]}
|
||||
</Text>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{entry.message}
|
||||
{entries.map((entry) => {
|
||||
const occurrence = keyOccurrences.get(entry.id) ?? 0
|
||||
keyOccurrences.set(entry.id, occurrence + 1)
|
||||
const renderKey = occurrence === 0 ? entry.id : `${entry.id}:${occurrence}`
|
||||
return (
|
||||
<View key={renderKey} style={styles.row}>
|
||||
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
|
||||
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{LEVEL_GLYPH[entry.level]}
|
||||
</Text>
|
||||
{entry.detail && (
|
||||
<Text style={styles.detail} numberOfLines={2}>
|
||||
{entry.detail}
|
||||
<View style={styles.rowText}>
|
||||
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{entry.message}
|
||||
</Text>
|
||||
)}
|
||||
{entry.detail && (
|
||||
<Text style={styles.detail} numberOfLines={2}>
|
||||
{entry.detail}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
@@ -81,7 +93,6 @@ export function ConnectionLog({ entries, title }: Props) {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
maxHeight: 240,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
@@ -89,6 +100,12 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
boundedContainer: {
|
||||
maxHeight: 240
|
||||
},
|
||||
fillContainer: {
|
||||
flex: 1
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily,
|
||||
@@ -97,9 +114,12 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 1,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
scroll: {
|
||||
boundedScroll: {
|
||||
maxHeight: 200
|
||||
},
|
||||
fillScroll: {
|
||||
flex: 1
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 6
|
||||
},
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChevronRight } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export function HostDiagnosticsLink({ onPress }: { onPress: () => void }): React.JSX.Element {
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.link}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="View network diagnostics"
|
||||
>
|
||||
<Text style={styles.text}>View network diagnostics</Text>
|
||||
<ChevronRight size={16} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
link: {
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
text: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
diagnoseConnection,
|
||||
getReportableConnectionIncidentId
|
||||
} from './connection-diagnostics-analysis'
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
|
||||
function event(message: string, detail?: string): ConnectionLogEntry {
|
||||
return { id: message, ts: 1, level: 'error', message, detail }
|
||||
}
|
||||
|
||||
describe('diagnoseConnection', () => {
|
||||
it('reports the current healthy path instead of a historical failure', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'connected',
|
||||
activePath: 'relay',
|
||||
entries: [event('WebSocket connect timeout')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Connection is healthy via Relay.',
|
||||
nextStep: 'No action needed.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('distinguishes an invalid Relay credential from a transient outage', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('Relay: relay dial failed', 'relay director resolve failed (401)')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Relay rejected the saved resume credential.',
|
||||
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies the direct Tailscale timeout while Relay recovery is pending', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'tailscale',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('WebSocket connect timeout')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The saved Tailscale endpoint did not answer before the connection timeout.',
|
||||
nextStep: 'Relay recovery is in progress; keep Orca open while it retries.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies an authenticated Relay liveness failure', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Relay stopped answering authenticated health checks.',
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: 'orca-relay'
|
||||
})
|
||||
})
|
||||
|
||||
it('separates an active Relay close from a failed Relay dial', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay: active relay session failed', 'RelayOuterError: close code 4408'),
|
||||
code: 'relay-session-failed',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The active Relay session closed unexpectedly.',
|
||||
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
|
||||
reportability: 'orca-relay'
|
||||
})
|
||||
})
|
||||
|
||||
it('marks authenticated Relay liveness failures as safe to send', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
}).reportability
|
||||
).toBe('orca-relay')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['direct timeout', 'connect-timeout', 'tailscale'],
|
||||
['handshake timeout', 'handshake-timeout', 'relay'],
|
||||
['invalid credential', 'relay director resolve failed (401)', 'relay'],
|
||||
['ambiguous recovery', 'retry scheduled', 'relay']
|
||||
] as const)('does not offer submission for %s', (_name, message, path) => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [{ ...event(message), path }]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('does not offer submission for a bounded Relay director outage', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('Relay: relay dial failed', 'relay director resolve failed (503)')]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('classifies the newest failure instead of an older persisted 401', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [
|
||||
event('Relay: relay dial failed', 'relay director resolve failed (401)'),
|
||||
{ ...event('WebSocket connect timeout'), id: 'newer', ts: 2, code: 'connect-timeout' }
|
||||
]
|
||||
}).likelyCause
|
||||
).toBe('The saved Tailscale endpoint did not answer before the connection timeout.')
|
||||
})
|
||||
|
||||
it('requires structured Relay evidence before offering submission', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [event('active relay session failed')]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('ignores failures from before the current app resume boundary', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'connecting',
|
||||
activePath: 'lan',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
},
|
||||
{
|
||||
...event('App returned to foreground'),
|
||||
id: 'resume',
|
||||
ts: 2,
|
||||
code: 'app-resumed'
|
||||
},
|
||||
{ ...event('WebSocket closed'), id: 'closed', ts: 3, code: 'socket-closed' }
|
||||
]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('uses a structured direct liveness path ahead of the current active path', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Connection health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'lan'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The connected host stopped answering authenticated health checks.',
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('keys reportability to the current structured incident', () => {
|
||||
const args = {
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting' as const,
|
||||
activePath: 'relay' as const,
|
||||
entries: [
|
||||
{ ...event('Authenticated'), code: 'direct-connected' as const, path: 'lan' as const },
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
id: 'current-relay-failure',
|
||||
ts: 2,
|
||||
code: 'liveness-timeout' as const,
|
||||
path: 'relay' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(getReportableConnectionIncidentId(args)).toBe('current-relay-failure')
|
||||
expect(
|
||||
getReportableConnectionIncidentId({
|
||||
...args,
|
||||
entries: [...args.entries, { ...event('Network changed'), ts: 3, code: 'network-changed' }]
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type {
|
||||
ConnectionLogEntry,
|
||||
ConnectionState,
|
||||
MobileConnectionDiagnosticPath
|
||||
} from '../transport/types'
|
||||
|
||||
export type ConnectionDiagnosis = {
|
||||
likelyCause: string
|
||||
nextStep: string
|
||||
reportability: 'none' | 'orca-relay'
|
||||
}
|
||||
|
||||
type DiagnoseConnectionArgs = {
|
||||
endpoint: string
|
||||
state: ConnectionState
|
||||
activePath?: MobileConnectionDiagnosticPath
|
||||
pendingPath?: MobileConnectionDiagnosticPath | null
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
}
|
||||
|
||||
export function diagnoseConnection(args: DiagnoseConnectionArgs): ConnectionDiagnosis {
|
||||
if (args.state === 'connected') {
|
||||
return {
|
||||
likelyCause: `Connection is healthy${args.activePath ? ` via ${formatPath(args.activePath)}` : ''}.`,
|
||||
nextStep: 'No action needed.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
const failure = findCurrentDiagnosticFailure(args.entries)
|
||||
const evidence = failure ? `${failure.code ?? ''} ${failure.message} ${failure.detail ?? ''}` : ''
|
||||
|
||||
if (/relay director resolve failed \(401\)/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'Relay rejected the saved resume credential.',
|
||||
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/relay director resolve failed \(503\)/i.test(evidence)) {
|
||||
const retryMs = parseRetryDelayMs(evidence)
|
||||
return {
|
||||
likelyCause: `Relay service was temporarily unavailable${retryMs == null ? '.' : ` and asked Orca to retry in ${formatDelay(retryMs)}.`}`,
|
||||
nextStep: 'Keep Orca open; recovery should retry automatically.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/liveness-timeout|liveness timeout|connection health check failed/i.test(evidence)) {
|
||||
const relayLiveness = failure?.code === 'liveness-timeout' && failure.path === 'relay'
|
||||
const structuredDirectLiveness =
|
||||
failure?.code === 'liveness-timeout' &&
|
||||
(failure.path === 'lan' || failure.path === 'tailscale')
|
||||
const path =
|
||||
relayLiveness || (!structuredDirectLiveness && args.activePath === 'relay')
|
||||
? 'Relay'
|
||||
: 'The connected host'
|
||||
return {
|
||||
likelyCause: `${path} stopped answering authenticated health checks.`,
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: relayLiveness ? 'orca-relay' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/relay-session-failed|active relay session failed/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The active Relay session closed unexpectedly.',
|
||||
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
|
||||
reportability:
|
||||
failure?.code === 'relay-session-failed' && failure.path === 'relay' ? 'orca-relay' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/authentication-rejected|unauthorized|pairing may be revoked/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The desktop rejected this device during authentication.',
|
||||
nextStep: 'Confirm the device is still paired; pair it again if the rejection repeats.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/connect-timeout|websocket connect timeout/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: isTailscaleEndpoint(args.endpoint)
|
||||
? 'The saved Tailscale endpoint did not answer before the connection timeout.'
|
||||
: 'The saved direct endpoint did not answer before the connection timeout.',
|
||||
nextStep:
|
||||
args.pendingPath === 'relay'
|
||||
? 'Relay recovery is in progress; keep Orca open while it retries.'
|
||||
: 'Check the local/VPN network and confirm the desktop is awake.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/handshake-timeout|handshake timeout/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The endpoint opened, but the encrypted Orca handshake did not finish.',
|
||||
nextStep: 'Confirm the desktop is running a compatible Orca version and retry.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (args.pendingPath === 'relay') {
|
||||
return {
|
||||
likelyCause: 'Relay recovery is selected, but no more specific failure is recorded yet.',
|
||||
nextStep: 'Keep this page open while the next recovery event is recorded.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
likelyCause: 'No single failure cause can be determined from the recorded events.',
|
||||
nextStep: 'Run diagnostics and copy the report again after the next connection attempt.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
export function getReportableConnectionIncidentId(args: DiagnoseConnectionArgs): string | null {
|
||||
if (diagnoseConnection(args).reportability !== 'orca-relay') {
|
||||
return null
|
||||
}
|
||||
return findCurrentDiagnosticFailure(args.entries)?.id ?? null
|
||||
}
|
||||
|
||||
function findCurrentDiagnosticFailure(
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
): ConnectionLogEntry | undefined {
|
||||
const boundaryIndex = entries.findLastIndex(isDiagnosticBoundary)
|
||||
return entries
|
||||
.slice(boundaryIndex + 1)
|
||||
.toReversed()
|
||||
.find(isDiagnosticFailure)
|
||||
}
|
||||
|
||||
function isDiagnosticBoundary(entry: ConnectionLogEntry): boolean {
|
||||
return (
|
||||
entry.code === 'client-session-started' ||
|
||||
entry.code === 'app-resumed' ||
|
||||
entry.code === 'network-changed' ||
|
||||
entry.code === 'relay-connected' ||
|
||||
entry.code === 'direct-connected' ||
|
||||
entry.message === 'Authenticated'
|
||||
)
|
||||
}
|
||||
|
||||
function isDiagnosticFailure(entry: ConnectionLogEntry): boolean {
|
||||
const evidence = `${entry.code ?? ''} ${entry.message} ${entry.detail ?? ''}`
|
||||
return /relay director resolve failed \((?:401|503)\)|liveness-timeout|liveness timeout|connection health check failed|relay-session-failed|active relay session failed|authentication-rejected|unauthorized|pairing may be revoked|connect-timeout|websocket connect timeout|handshake-timeout|handshake timeout/i.test(
|
||||
evidence
|
||||
)
|
||||
}
|
||||
|
||||
function parseRetryDelayMs(evidence: string): number | null {
|
||||
const match = /retry(?:-|\s)?after(?:=|\s)(\d+)ms/i.exec(evidence)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function formatDelay(ms: number): string {
|
||||
return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`
|
||||
}
|
||||
|
||||
function formatPath(path: MobileConnectionDiagnosticPath): string {
|
||||
if (path === 'relay') {
|
||||
return 'Relay'
|
||||
}
|
||||
return path === 'tailscale' ? 'Tailscale/direct' : 'LAN/direct'
|
||||
}
|
||||
@@ -13,6 +13,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
lastConnectedAt: NOW - 5 * 60_000,
|
||||
platform: 'ios 26.5.1',
|
||||
appVersion: '0.0.29',
|
||||
desktopAppVersion: '1.4.191',
|
||||
entries: [
|
||||
{
|
||||
id: 'log-1',
|
||||
@@ -26,6 +27,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
})
|
||||
|
||||
expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1')
|
||||
expect(report).toContain('Host Orca version: 1.4.191')
|
||||
expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)')
|
||||
expect(report).toContain('State: reconnecting (reconnect attempts: 12)')
|
||||
expect(report).toContain('(5m 0s ago)')
|
||||
@@ -48,8 +50,96 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
})
|
||||
|
||||
expect(report).toContain('Endpoint: 192.168.1.50:6768')
|
||||
expect(report).toContain('Host Orca version: unknown')
|
||||
expect(report).not.toContain('(Tailscale)')
|
||||
expect(report).toContain('Last connected: never this session')
|
||||
expect(report).toContain('No connection events recorded this session.')
|
||||
expect(report).toContain('No connection events recorded.')
|
||||
})
|
||||
|
||||
it('explains the most likely cause and redacts credentials before copying', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 3',
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 4,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.46',
|
||||
activePath: 'tailscale',
|
||||
pendingPath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
id: 'relay-failure',
|
||||
ts: NOW - 5_000,
|
||||
level: 'error',
|
||||
message: 'Relay: relay dial failed',
|
||||
detail:
|
||||
'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token'
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain(
|
||||
'Likely cause: Relay service was temporarily unavailable and asked Orca to retry in 30s.'
|
||||
)
|
||||
expect(report).toContain('Path: active=tailscale; recovery=relay')
|
||||
expect(report).toContain('Next step: Keep Orca open; recovery should retry automatically.')
|
||||
expect(report).toContain('resumeToken=[redacted]')
|
||||
expect(report).not.toContain('secret-resume-token')
|
||||
})
|
||||
|
||||
it('redacts quoted JSON credentials and never echoes an invalid endpoint', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 4',
|
||||
endpoint: 'not-a-url?token=endpoint-secret',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 1,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.47',
|
||||
entries: [
|
||||
{
|
||||
id: 'json-secret',
|
||||
ts: NOW,
|
||||
level: 'error',
|
||||
message: 'Relay failed',
|
||||
detail: '{"resumeToken":"json-secret","authorization":"Bearer bearer-secret"}'
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain('Endpoint: invalid endpoint')
|
||||
expect(report).not.toContain('endpoint-secret')
|
||||
expect(report).not.toContain('json-secret')
|
||||
expect(report).not.toContain('bearer-secret')
|
||||
})
|
||||
|
||||
it('bounds a single event line before submission while preserving its identity', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 5',
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 1,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.47',
|
||||
entries: [
|
||||
{
|
||||
id: 'oversized',
|
||||
ts: NOW,
|
||||
level: 'error',
|
||||
message: `newest oversized ${'😀'.repeat(2_000)}`
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain('newest oversized')
|
||||
expect(report).toContain('[truncated]')
|
||||
expect(new TextEncoder().encode(report.split('\n').at(-1)!).byteLength).toBeLessThanOrEqual(
|
||||
2048
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type { ConnectionLogEntry, ConnectionState } from '../transport/types'
|
||||
import type {
|
||||
ConnectionLogEntry,
|
||||
ConnectionState,
|
||||
MobileConnectionDiagnosticPath
|
||||
} from '../transport/types'
|
||||
import { normalizeHostAppVersion } from '../transport/host-app-version-store'
|
||||
import { formatEndpoint } from './host-reachability'
|
||||
import { diagnoseConnection } from './connection-diagnostics-analysis'
|
||||
import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction'
|
||||
|
||||
const MAX_EVENT_LINE_BYTES = 2 * 1024
|
||||
const EVENT_TRUNCATION_MARKER = ' … [truncated]'
|
||||
|
||||
// Why: one shareable text blob answering everything we historically had to
|
||||
// ask reporters one message at a time (endpoint type, state, attempt count,
|
||||
@@ -13,37 +23,83 @@ export function buildConnectionDiagnosticsReport(args: {
|
||||
lastConnectedAt: number | null
|
||||
platform: string
|
||||
appVersion: string
|
||||
desktopAppVersion?: string | null
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
activePath?: MobileConnectionDiagnosticPath
|
||||
pendingPath?: MobileConnectionDiagnosticPath | null
|
||||
nowMs?: number
|
||||
}): string {
|
||||
const now = args.nowMs ?? Date.now()
|
||||
const entries = args.entries.map(redactConnectionLogEntry)
|
||||
const diagnosis = diagnoseConnection({
|
||||
endpoint: args.endpoint,
|
||||
state: args.state,
|
||||
activePath: args.activePath,
|
||||
pendingPath: args.pendingPath,
|
||||
entries
|
||||
})
|
||||
const lines: string[] = []
|
||||
lines.push('Orca Mobile connection diagnostics')
|
||||
lines.push(`Generated: ${new Date(now).toISOString()}`)
|
||||
lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
|
||||
lines.push(`Host: ${args.hostName}`)
|
||||
const desktopAppVersion = normalizeHostAppVersion(args.desktopAppVersion)
|
||||
lines.push(`Host Orca version: ${desktopAppVersion ?? 'unknown'}`)
|
||||
lines.push(`Host: ${redactConnectionLogText(args.hostName)}`)
|
||||
lines.push(
|
||||
`Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}`
|
||||
)
|
||||
lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`)
|
||||
if (args.activePath) {
|
||||
lines.push(
|
||||
`Path: active=${args.activePath}${args.pendingPath ? `; recovery=${args.pendingPath}` : ''}`
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
args.lastConnectedAt == null
|
||||
? 'Last connected: never this session'
|
||||
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
|
||||
)
|
||||
lines.push('')
|
||||
if (args.entries.length === 0) {
|
||||
lines.push('No connection events recorded this session.')
|
||||
lines.push(`Likely cause: ${diagnosis.likelyCause}`)
|
||||
lines.push(`Next step: ${diagnosis.nextStep}`)
|
||||
lines.push('')
|
||||
if (entries.length === 0) {
|
||||
lines.push('No connection events recorded.')
|
||||
} else {
|
||||
lines.push(`Connection log (${args.entries.length} events, oldest first):`)
|
||||
for (const entry of args.entries) {
|
||||
lines.push(`Recent connection history (${entries.length} events, oldest first):`)
|
||||
for (const entry of entries) {
|
||||
const detail = entry.detail ? ` — ${entry.detail}` : ''
|
||||
lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`)
|
||||
const evidence = [entry.code, entry.path].filter(Boolean).join(' · ')
|
||||
lines.push(
|
||||
truncateUtf8WithMarker(
|
||||
`${new Date(entry.ts).toISOString()} [${entry.level}]${evidence ? ` [${evidence}]` : ''} ${entry.message}${detail}`,
|
||||
MAX_EVENT_LINE_BYTES,
|
||||
EVENT_TRUNCATION_MARKER
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function truncateUtf8WithMarker(value: string, maxBytes: number, marker: string): string {
|
||||
if (new TextEncoder().encode(value).byteLength <= maxBytes) {
|
||||
return value
|
||||
}
|
||||
const markerBytes = new TextEncoder().encode(marker).byteLength
|
||||
const characters: string[] = []
|
||||
let bytes = 0
|
||||
for (const character of value) {
|
||||
const characterBytes = new TextEncoder().encode(character).byteLength
|
||||
if (bytes + characterBytes + markerBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
characters.push(character)
|
||||
bytes += characterBytes
|
||||
}
|
||||
return `${characters.join('')}${marker}`
|
||||
}
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
const seconds = Math.max(0, Math.round(ms / 1000))
|
||||
if (seconds < 60) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getDiagnosticsSubmissionState,
|
||||
readConnectionDiagnosticsSnapshot,
|
||||
readHydratedConnectionLog,
|
||||
resolveDiagnosticsHostId,
|
||||
selectDiagnosticsHostId,
|
||||
updateDiagnosticsSubmissionState
|
||||
} from './connection-diagnostics-screen-data'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
|
||||
|
||||
const host = (id: string): HostProfile => ({
|
||||
id,
|
||||
name: id,
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key',
|
||||
lastConnected: 0
|
||||
})
|
||||
|
||||
describe('connection diagnostics screen data', () => {
|
||||
it('prefers a valid route host when navigation changes', () => {
|
||||
expect(selectDiagnosticsHostId([host('a'), host('b')], 'b', 'a')).toBe('b')
|
||||
expect(selectDiagnosticsHostId([host('a')], 'missing', 'missing')).toBe('a')
|
||||
})
|
||||
|
||||
it('applies route changes synchronously without discarding a chip choice for the same route', () => {
|
||||
const hosts = [host('a'), host('b')]
|
||||
expect(resolveDiagnosticsHostId(hosts, undefined, null)).toBe('a')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'a' })).toBe('b')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'b' })).toBe('a')
|
||||
})
|
||||
|
||||
it('does not revive a stale manual choice when routing away and back', () => {
|
||||
const hosts = [host('a'), host('b'), host('c')]
|
||||
const firstRouteA = {}
|
||||
const routeC = {}
|
||||
const secondRouteA = {}
|
||||
const manualSelection = { hostId: 'b', requestedHostId: 'a', routeKey: firstRouteA }
|
||||
|
||||
expect(resolveDiagnosticsHostId(hosts, 'c', manualSelection, routeC)).toBe('c')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'a', manualSelection, secondRouteA)).toBe('a')
|
||||
})
|
||||
|
||||
it('keeps async submission completion scoped to its host incident', () => {
|
||||
let states = updateDiagnosticsSubmissionState({}, 'host-a:incident-1', 'sending')
|
||||
states = updateDiagnosticsSubmissionState(states, 'host-b:incident-2', 'sending')
|
||||
states = updateDiagnosticsSubmissionState(states, 'host-a:incident-1', 'sent')
|
||||
|
||||
expect(getDiagnosticsSubmissionState(states, 'host-a:incident-1')).toBe('sent')
|
||||
expect(getDiagnosticsSubmissionState(states, 'host-b:incident-2')).toBe('sending')
|
||||
})
|
||||
|
||||
it('waits for hydration and then reads the refreshed log', async () => {
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
const store = {
|
||||
hydrate: vi.fn(async () => {
|
||||
entries.push({ id: 'stored', ts: 1, level: 'info', message: 'stored event' })
|
||||
}),
|
||||
get: vi.fn(() => entries)
|
||||
}
|
||||
|
||||
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
|
||||
expect(store.get).toHaveBeenCalledAfter(store.hydrate)
|
||||
})
|
||||
|
||||
it('retries one transient hydration failure before reading', async () => {
|
||||
const entries = [{ id: 'stored', ts: 1, level: 'info' as const, message: 'stored event' }]
|
||||
const store = {
|
||||
hydrate: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
.mockResolvedValueOnce(undefined),
|
||||
get: vi.fn(() => entries)
|
||||
}
|
||||
|
||||
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
|
||||
expect(store.hydrate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reads connection metadata after hydration completes', async () => {
|
||||
let state: 'connecting' | 'reconnecting' = 'connecting'
|
||||
const store = {
|
||||
hydrate: vi.fn(async () => {
|
||||
state = 'reconnecting'
|
||||
}),
|
||||
get: vi.fn(() => [{ id: 'new', ts: 2, level: 'warn' as const, message: 'new event' }])
|
||||
}
|
||||
const context = {
|
||||
getState: vi.fn(() => state),
|
||||
getReconnectAttempt: vi.fn(() => 2),
|
||||
getLastConnectedAt: vi.fn(() => 1),
|
||||
getActivePath: vi.fn(() => 'relay' as const),
|
||||
getPendingPath: vi.fn(() => null)
|
||||
}
|
||||
|
||||
await expect(readConnectionDiagnosticsSnapshot(context, store, 'host-a')).resolves.toEqual({
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 2,
|
||||
lastConnectedAt: 1,
|
||||
activePath: 'relay',
|
||||
pendingPath: null,
|
||||
entries: [{ id: 'new', ts: 2, level: 'warn', message: 'new event' }]
|
||||
})
|
||||
expect(context.getState).toHaveBeenCalledAfter(store.hydrate)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ConnectionLogStore } from '../transport/connection-log-buffer'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
|
||||
import type { RpcClientContextValue } from '../transport/rpc-client-context-contract'
|
||||
|
||||
export type DiagnosticsHostSelection = {
|
||||
hostId: string
|
||||
requestedHostId: string | undefined
|
||||
routeKey?: object
|
||||
}
|
||||
|
||||
export type DiagnosticsSubmissionState = 'sending' | 'sent' | 'failed'
|
||||
export type DiagnosticsSubmissionStates = Readonly<Record<string, DiagnosticsSubmissionState>>
|
||||
|
||||
export function selectDiagnosticsHostId(
|
||||
hosts: readonly HostProfile[],
|
||||
requestedHostId: string | undefined,
|
||||
previousHostId: string | null
|
||||
): string | null {
|
||||
if (requestedHostId && hosts.some((host) => host.id === requestedHostId)) {
|
||||
return requestedHostId
|
||||
}
|
||||
if (previousHostId && hosts.some((host) => host.id === previousHostId)) {
|
||||
return previousHostId
|
||||
}
|
||||
return hosts[0]?.id ?? null
|
||||
}
|
||||
|
||||
export function resolveDiagnosticsHostId(
|
||||
hosts: readonly HostProfile[],
|
||||
requestedHostId: string | undefined,
|
||||
manualSelection: DiagnosticsHostSelection | null,
|
||||
routeKey?: object
|
||||
): string | null {
|
||||
const selected = manualSelection
|
||||
if (selected && selected.requestedHostId === requestedHostId && selected.routeKey === routeKey) {
|
||||
const manualHostId = selected.hostId
|
||||
if (hosts.some((host) => host.id === manualHostId)) {
|
||||
return manualHostId
|
||||
}
|
||||
}
|
||||
return selectDiagnosticsHostId(hosts, requestedHostId, null)
|
||||
}
|
||||
|
||||
export function getDiagnosticsSubmissionState(
|
||||
states: DiagnosticsSubmissionStates,
|
||||
key: string | null
|
||||
): DiagnosticsSubmissionState | 'idle' {
|
||||
return key ? (states[key] ?? 'idle') : 'idle'
|
||||
}
|
||||
|
||||
export function updateDiagnosticsSubmissionState(
|
||||
states: DiagnosticsSubmissionStates,
|
||||
key: string,
|
||||
state: DiagnosticsSubmissionState | null
|
||||
): DiagnosticsSubmissionStates {
|
||||
const next = { ...states }
|
||||
if (state) {
|
||||
next[key] = state
|
||||
} else {
|
||||
delete next[key]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export async function readHydratedConnectionLog(
|
||||
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
|
||||
hostId: string
|
||||
): Promise<readonly ConnectionLogEntry[]> {
|
||||
try {
|
||||
await store.hydrate(hostId)
|
||||
} catch {
|
||||
await store.hydrate(hostId).catch(() => {})
|
||||
}
|
||||
return store.get(hostId)
|
||||
}
|
||||
|
||||
export async function readConnectionDiagnosticsSnapshot(
|
||||
context: Pick<
|
||||
RpcClientContextValue,
|
||||
'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getActivePath' | 'getPendingPath'
|
||||
>,
|
||||
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
|
||||
hostId: string
|
||||
) {
|
||||
const entries = await readHydratedConnectionLog(store, hostId)
|
||||
return {
|
||||
state: context.getState(hostId),
|
||||
reconnectAttempts: context.getReconnectAttempt(hostId),
|
||||
lastConnectedAt: context.getLastConnectedAt(hostId),
|
||||
activePath: context.getActivePath(hostId),
|
||||
pendingPath: context.getPendingPath(hostId),
|
||||
entries
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
boundConnectionDiagnosticsReport,
|
||||
submitConnectionDiagnostics
|
||||
} from './connection-diagnostics-submission'
|
||||
|
||||
describe('submitConnectionDiagnostics', () => {
|
||||
it('sends a bounded report through the diagnostics lane', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
const result = await submitConnectionDiagnostics(
|
||||
{ report: 'x'.repeat(100_000), appVersion: '0.0.47', platform: 'android 36' },
|
||||
fetchImpl
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://www.onorca.dev/v1/feedback',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
const request = fetchImpl.mock.calls[0]?.[1]
|
||||
const body = JSON.parse(String(request?.body)) as { feedback: string; submissionType: string }
|
||||
expect(body.submissionType).toBe('connection_diagnostics')
|
||||
expect(new TextEncoder().encode(body.feedback).byteLength).toBeLessThanOrEqual(64 * 1024)
|
||||
})
|
||||
|
||||
it('returns a safe failure for a rejected response', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 }))
|
||||
await expect(
|
||||
submitConnectionDiagnostics(
|
||||
{ report: 'report', appVersion: '0.0.47', platform: 'android' },
|
||||
fetchImpl
|
||||
)
|
||||
).resolves.toEqual({ ok: false, error: 'status 503' })
|
||||
})
|
||||
|
||||
it('preserves the newest complete events when bounding a UTF-8 report', () => {
|
||||
const report = [
|
||||
'Orca Mobile connection diagnostics',
|
||||
'State: reconnecting',
|
||||
'',
|
||||
'Recent connection history (3 events, oldest first):',
|
||||
`oldest ${'😀'.repeat(20)}`,
|
||||
`middle ${'😀'.repeat(20)}`,
|
||||
`newest ${'😀'.repeat(20)}`
|
||||
].join('\n')
|
||||
|
||||
const bounded = boundConnectionDiagnosticsReport(report, 180)
|
||||
|
||||
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(180)
|
||||
expect(bounded).toContain('newest')
|
||||
expect(bounded).not.toContain('oldest')
|
||||
expect(bounded).toMatch(/older omitted/)
|
||||
})
|
||||
|
||||
it('retains a bounded form of the newest event when that event exceeds the budget', () => {
|
||||
const report = [
|
||||
'Orca Mobile connection diagnostics',
|
||||
'State: reconnecting',
|
||||
'',
|
||||
'Recent connection history (2 events, oldest first):',
|
||||
'old event',
|
||||
`newest oversized ${'😀'.repeat(1_000)}`
|
||||
].join('\n')
|
||||
|
||||
const bounded = boundConnectionDiagnosticsReport(report, 220)
|
||||
|
||||
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(220)
|
||||
expect(bounded).toContain('newest oversized')
|
||||
expect(bounded).toContain('[truncated]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
const CONNECTION_DIAGNOSTICS_ENDPOINT = 'https://www.onorca.dev/v1/feedback'
|
||||
const SUBMISSION_TIMEOUT_MS = 10_000
|
||||
const MAX_SUBMISSION_BYTES = 64 * 1024
|
||||
|
||||
export type ConnectionDiagnosticsSubmission = {
|
||||
report: string
|
||||
appVersion: string
|
||||
platform: string
|
||||
}
|
||||
|
||||
export type ConnectionDiagnosticsSubmissionResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
/** Sends only the already-redacted, bounded report after an explicit user tap. */
|
||||
export async function submitConnectionDiagnostics(
|
||||
submission: ConnectionDiagnosticsSubmission,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ConnectionDiagnosticsSubmissionResult> {
|
||||
const report = boundConnectionDiagnosticsReport(submission.report)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), SUBMISSION_TIMEOUT_MS)
|
||||
try {
|
||||
const response = await fetchImpl(CONNECTION_DIAGNOSTICS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
feedback: report,
|
||||
submissionType: 'connection_diagnostics',
|
||||
githubLogin: null,
|
||||
githubEmail: null,
|
||||
appVersion: submission.appVersion,
|
||||
platform: `mobile-${submission.platform}`,
|
||||
osRelease: 'unknown',
|
||||
arch: 'unknown'
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
return response.ok ? { ok: true } : { ok: false, error: `status ${response.status}` }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: controller.signal.aborted
|
||||
? 'request timed out'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'request failed'
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export function boundConnectionDiagnosticsReport(
|
||||
report: string,
|
||||
maxBytes: number = MAX_SUBMISSION_BYTES
|
||||
): string {
|
||||
if (utf8Bytes(report) <= maxBytes) {
|
||||
return report
|
||||
}
|
||||
const lines = report.split('\n')
|
||||
const historyIndex = lines.findIndex((line) => line.startsWith('Recent connection history ('))
|
||||
if (historyIndex === -1) {
|
||||
return truncateUtf8(report, maxBytes)
|
||||
}
|
||||
const header = lines.slice(0, historyIndex)
|
||||
const events = lines.slice(historyIndex + 1)
|
||||
let kept: string[] = []
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const candidateEvents = [events[index]!, ...kept]
|
||||
const candidate = formatBoundedReport(header, candidateEvents, events.length)
|
||||
if (utf8Bytes(candidate) > maxBytes) {
|
||||
break
|
||||
}
|
||||
kept = candidateEvents
|
||||
}
|
||||
if (kept.length === 0 && events.length > 0) {
|
||||
return formatReportWithTruncatedNewestEvent(header, events, maxBytes)
|
||||
}
|
||||
return truncateUtf8(formatBoundedReport(header, kept, events.length), maxBytes)
|
||||
}
|
||||
|
||||
function formatReportWithTruncatedNewestEvent(
|
||||
header: string[],
|
||||
events: string[],
|
||||
maxBytes: number
|
||||
): string {
|
||||
const history = `Recent connection history (1 newest event; ${events.length - 1} older omitted):`
|
||||
const prefix = [...header, history].join('\n') + '\n'
|
||||
const availableBytes = maxBytes - utf8Bytes(prefix)
|
||||
if (availableBytes <= 0) {
|
||||
return truncateUtf8(prefix, maxBytes)
|
||||
}
|
||||
const marker = ' … [truncated]'
|
||||
const markerBytes = utf8Bytes(marker)
|
||||
if (availableBytes <= markerBytes) {
|
||||
return truncateUtf8(prefix, maxBytes)
|
||||
}
|
||||
return `${prefix}${truncateUtf8(events.at(-1)!, availableBytes - markerBytes)}${marker}`
|
||||
}
|
||||
|
||||
function formatBoundedReport(header: string[], events: string[], totalEvents: number): string {
|
||||
const omitted = totalEvents - events.length
|
||||
return [
|
||||
...header,
|
||||
`Recent connection history (${events.length} newest events; ${omitted} older omitted):`,
|
||||
...events
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
const characters: string[] = []
|
||||
let bytes = 0
|
||||
for (const character of value) {
|
||||
const characterBytes = utf8Bytes(character)
|
||||
if (bytes + characterBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
characters.push(character)
|
||||
bytes += characterBytes
|
||||
}
|
||||
return characters.join('')
|
||||
}
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
|
||||
const SECRET_JSON_ASSIGNMENT =
|
||||
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*\4/gi
|
||||
const SECRET_QUOTED_VALUE_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*\3/gi
|
||||
const SECRET_UNTERMINATED_JSON_ASSIGNMENT =
|
||||
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*(?=\r?\n|$)/gi
|
||||
const SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*(?=\r?\n|$)/gi
|
||||
const SECRET_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(?:Bearer\s+)?([^\s;,}"']+)/gi
|
||||
const SECRET_QUERY = /([?&](?:token|credential|resumeToken|deviceToken)=)[^&#\s]+/gi
|
||||
const URL_USERINFO = /((?:wss?|https?):\/\/)[^\s/?#]*@/gi
|
||||
|
||||
export function redactConnectionLogText(value: string): string {
|
||||
return value
|
||||
.replace(
|
||||
SECRET_JSON_ASSIGNMENT,
|
||||
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
|
||||
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]${valueQuote}`
|
||||
)
|
||||
.replace(
|
||||
SECRET_QUOTED_VALUE_ASSIGNMENT,
|
||||
(_match, label: string, separator: string, valueQuote: string) =>
|
||||
`${label}${separator}${valueQuote}[redacted]${valueQuote}`
|
||||
)
|
||||
.replace(
|
||||
SECRET_UNTERMINATED_JSON_ASSIGNMENT,
|
||||
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
|
||||
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]`
|
||||
)
|
||||
.replace(
|
||||
SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT,
|
||||
(_match, label: string, separator: string, valueQuote: string) =>
|
||||
`${label}${separator}${valueQuote}[redacted]`
|
||||
)
|
||||
.replace(SECRET_ASSIGNMENT, (_match, label: string, separator: string) => {
|
||||
return `${label}${separator}[redacted]`
|
||||
})
|
||||
.replace(SECRET_QUERY, '$1[redacted]')
|
||||
.replace(URL_USERINFO, '$1[redacted]@')
|
||||
}
|
||||
|
||||
export function redactConnectionLogEntry(entry: ConnectionLogEntry): ConnectionLogEntry {
|
||||
return {
|
||||
...entry,
|
||||
message: redactConnectionLogText(entry.message),
|
||||
...(entry.detail ? { detail: redactConnectionLogText(entry.detail) } : {})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { testHostReachability, unreachableHostDetail } from './host-reachability'
|
||||
import { formatEndpoint, testHostReachability, unreachableHostDetail } from './host-reachability'
|
||||
|
||||
describe('unreachableHostDetail', () => {
|
||||
it('points at Tailscale for tailnet CGNAT endpoints', () => {
|
||||
@@ -21,6 +21,10 @@ describe('unreachableHostDetail', () => {
|
||||
it('does not treat non-CGNAT 100.x addresses as Tailscale', () => {
|
||||
expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768')
|
||||
})
|
||||
|
||||
it('does not echo malformed endpoints that may contain credentials', () => {
|
||||
expect(formatEndpoint('not-a-url?token=secret')).toBe('invalid endpoint')
|
||||
})
|
||||
})
|
||||
|
||||
describe('testHostReachability', () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ export function formatEndpoint(endpoint: string): string {
|
||||
const url = new URL(endpoint)
|
||||
return url.host
|
||||
} catch {
|
||||
return endpoint
|
||||
return 'invalid endpoint'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ export function MobileHomeScreen() {
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId),
|
||||
onDisconnect: disconnectHostClient,
|
||||
onDiagnostics: (hostId) =>
|
||||
data.router.push({ pathname: '/connection-log', params: { hostId } }),
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: (host) => setConfirmRemove(host)
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getHostListActionSheetActions } from './host-list-action-sheet-actions'
|
||||
import type { ConnectionState, HostProfile } from './transport/types'
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Activity: vi.fn(),
|
||||
Edit3: vi.fn(),
|
||||
PowerOff: vi.fn(),
|
||||
RefreshCw: vi.fn()
|
||||
@@ -22,6 +23,7 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
|
||||
onDismiss: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
onDiagnostics: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
}
|
||||
@@ -35,14 +37,17 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
|
||||
}
|
||||
|
||||
describe('getHostListActionSheetActions', () => {
|
||||
// Why: both open a second drawer. Presenting one while this sheet's native
|
||||
// Why: these navigate or open a second drawer. Presenting while this sheet's native
|
||||
// Modal is still up freezes the whole screen on iOS — issue #8791.
|
||||
it.each(['Edit host', 'Remove'])('defers %s until the action sheet has closed', (label) => {
|
||||
const { actions } = build()
|
||||
expect(actions.find((action) => action.label === label)).toMatchObject({
|
||||
closeBeforePress: true
|
||||
})
|
||||
})
|
||||
it.each(['Network diagnostics', 'Edit host', 'Remove'])(
|
||||
'defers %s until the action sheet has closed',
|
||||
(label) => {
|
||||
const { actions } = build()
|
||||
expect(actions.find((action) => action.label === label)).toMatchObject({
|
||||
closeBeforePress: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves the in-place actions undeferred so they fire on tap', () => {
|
||||
const { actions, spies } = build()
|
||||
@@ -65,15 +70,23 @@ describe('getHostListActionSheetActions', () => {
|
||||
expect(spies.onEdit).toHaveBeenCalledWith(HOST.id)
|
||||
})
|
||||
|
||||
it('routes Network diagnostics with the selected host', () => {
|
||||
const { actions, spies } = build()
|
||||
actions.find((action) => action.label === 'Network diagnostics')?.onPress()
|
||||
expect(spies.onDiagnostics).toHaveBeenCalledWith(HOST.id)
|
||||
})
|
||||
|
||||
it('offers Disconnect only while the socket is live', () => {
|
||||
expect(build({ state: 'reconnecting' }).actions.map((action) => action.label)).toEqual([
|
||||
'Reconnect',
|
||||
'Disconnect',
|
||||
'Network diagnostics',
|
||||
'Edit host',
|
||||
'Remove'
|
||||
])
|
||||
expect(build({ state: 'disconnected' }).actions.map((action) => action.label)).toEqual([
|
||||
'Connect',
|
||||
'Network diagnostics',
|
||||
'Edit host',
|
||||
'Remove'
|
||||
])
|
||||
@@ -93,6 +106,7 @@ describe('getHostListActionSheetActions', () => {
|
||||
onDismiss: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
onDiagnostics: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
|
||||
import { Activity, Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
|
||||
import type { ActionSheetAction } from './components/ActionSheetModal'
|
||||
import type { ConnectionState, HostProfile } from './transport/types'
|
||||
|
||||
/** Builds the home-screen host long-press menu. Edit and Remove open a second
|
||||
* drawer, so both must defer until this sheet's native Modal has unmounted —
|
||||
/** Builds the home-screen host long-press menu. Navigation and second drawers
|
||||
* defer until this sheet's native Modal has unmounted —
|
||||
* presenting into a live one freezes the whole screen on iOS (issue #8791). */
|
||||
export function getHostListActionSheetActions(args: {
|
||||
host: HostProfile | null
|
||||
@@ -13,6 +13,7 @@ export function getHostListActionSheetActions(args: {
|
||||
onDismiss: () => void
|
||||
onReconnect: (hostId: string) => void
|
||||
onDisconnect: (hostId: string) => void
|
||||
onDiagnostics: (hostId: string) => void
|
||||
onEdit: (hostId: string) => void
|
||||
onRemove: (host: HostProfile) => void
|
||||
}): ActionSheetAction[] {
|
||||
@@ -47,6 +48,14 @@ export function getHostListActionSheetActions(args: {
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Network diagnostics',
|
||||
icon: Activity,
|
||||
closeBeforePress: true,
|
||||
onPress: () => {
|
||||
args.onDiagnostics(host.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Edit host',
|
||||
icon: Edit3,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const hostScreenPrimaryStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
topChrome: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 34,
|
||||
paddingTop: spacing.xs,
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sidebarCollapseButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
hostIdentity: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
marginRight: spacing.md
|
||||
},
|
||||
hostNameText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
reconnectButton: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
reconnectButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbar: {
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
embeddedFilterChip: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
embeddedModeButton: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
filterChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
filterChipActive: {
|
||||
borderColor: colors.textSecondary,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: colors.textPrimary
|
||||
},
|
||||
modeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
sortLabel: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
toolbarSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
floatingWorkspaceHeaderButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
embeddedToolbarIconButton: {
|
||||
flex: 1,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
toolbarIconDisabled: {
|
||||
opacity: 0.6
|
||||
},
|
||||
searchToggle: {
|
||||
padding: spacing.xs
|
||||
},
|
||||
searchBar: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const hostScreenSecondaryStyles = StyleSheet.create({
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
list: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs
|
||||
},
|
||||
sectionIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionRepoIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
sectionCount: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginLeft: spacing.lg + 24,
|
||||
marginRight: spacing.lg
|
||||
},
|
||||
filterModalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterModalTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterSectionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
filterGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
gap: spacing.sm
|
||||
},
|
||||
filterRowText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
filterSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
filterRepoDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
},
|
||||
confirmContent: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
confirmTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
confirmMessage: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20
|
||||
},
|
||||
confirmButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
confirmBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
confirmBtnCancel: {
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
confirmBtnDestructive: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
confirmBtnPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
confirmBtnCancelText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
confirmBtnDestructiveText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { hostScreenPrimaryStyles } from './host-screen-primary-styles'
|
||||
import { hostScreenSecondaryStyles } from './host-screen-secondary-styles'
|
||||
|
||||
export const hostScreenStyles = {
|
||||
...hostScreenPrimaryStyles,
|
||||
...hostScreenSecondaryStyles
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createTerminalAccessoryRepeatController,
|
||||
createTerminalAccessoryRepeatSender,
|
||||
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
|
||||
TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
|
||||
} from './terminal-accessory-repeat'
|
||||
|
||||
function deferred() {
|
||||
let resolve: () => void = () => {}
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('terminal accessory repeat', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('keeps one send in flight and preserves repeat order under host latency', async () => {
|
||||
vi.useFakeTimers()
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
const sent: string[] = []
|
||||
const send = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return (sent.length === 1 ? first.promise : second.promise).then(() => true)
|
||||
})
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
expect(sent).toEqual(['down'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_DELAY_MS + 500)
|
||||
expect(sent).toEqual(['down'])
|
||||
|
||||
first.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(sent).toEqual(['down', 'down'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS + 500)
|
||||
expect(sent).toEqual(['down', 'down'])
|
||||
|
||||
second.resolve()
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
|
||||
expect(sent).toEqual(['down', 'down', 'down'])
|
||||
})
|
||||
|
||||
it('does not schedule another repeat after release while a send is pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred()
|
||||
const send = vi.fn(() => pending.promise.then(() => true))
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
repeat.stop()
|
||||
pending.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dispatches distinct taps without waiting for prior acknowledgements', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = Array.from({ length: 5 }, () => deferred())
|
||||
const sent: string[] = []
|
||||
const send = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return pending[sent.length - 1]!.promise.then(() => true)
|
||||
})
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
for (let index = 0; index < pending.length; index += 1) {
|
||||
repeat.start(`tap-${index}`, send)
|
||||
repeat.stop()
|
||||
}
|
||||
|
||||
expect(sent).toEqual(['tap-0', 'tap-1', 'tap-2', 'tap-3', 'tap-4'])
|
||||
|
||||
pending.forEach(({ resolve }) => resolve())
|
||||
await vi.runAllTimersAsync()
|
||||
expect(send).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('cancels repeats without dropping a distinct tap already dispatched by the user', async () => {
|
||||
vi.useFakeTimers()
|
||||
const first = deferred()
|
||||
const sent: string[] = []
|
||||
const send = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
|
||||
})
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
repeat.stop()
|
||||
repeat.start('up', send)
|
||||
repeat.cancel()
|
||||
|
||||
first.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(sent).toEqual(['down', 'up'])
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops repeating when the transport rejects a send', async () => {
|
||||
vi.useFakeTimers()
|
||||
const send = vi.fn(() => Promise.reject(new Error('disconnected')))
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops repeating when the send is dropped before reaching the transport', async () => {
|
||||
vi.useFakeTimers()
|
||||
const send = vi.fn(async () => false)
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops sending when the terminal active at press time is no longer active', async () => {
|
||||
let activeTerminal = 'terminal-a'
|
||||
const sendToTerminal = vi.fn(async () => true)
|
||||
const send = createTerminalAccessoryRepeatSender(
|
||||
activeTerminal,
|
||||
(targetHandle) => activeTerminal === targetHandle,
|
||||
sendToTerminal
|
||||
)
|
||||
|
||||
await expect(send('down')).resolves.toBe(true)
|
||||
activeTerminal = 'terminal-b'
|
||||
await expect(send('down')).resolves.toBe(false)
|
||||
|
||||
expect(sendToTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(sendToTerminal).toHaveBeenCalledWith('down', 'terminal-a', expect.any(Function))
|
||||
})
|
||||
|
||||
it('provides a live target guard for async send preflight', async () => {
|
||||
let targetCurrent = true
|
||||
const preflight = deferred()
|
||||
const send = createTerminalAccessoryRepeatSender(
|
||||
'terminal-a',
|
||||
() => targetCurrent,
|
||||
async (_input, _targetHandle, isDeliveryTargetCurrent) => {
|
||||
await preflight.promise
|
||||
return isDeliveryTargetCurrent()
|
||||
}
|
||||
)
|
||||
|
||||
const result = send('down')
|
||||
targetCurrent = false
|
||||
preflight.resolve()
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('stops held repeats when the press-time connection is no longer current', async () => {
|
||||
vi.useFakeTimers()
|
||||
let connectionGeneration = 1
|
||||
const first = deferred()
|
||||
const sent: string[] = []
|
||||
const sendToTerminal = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
|
||||
})
|
||||
const createSender = () => {
|
||||
const pressedConnectionGeneration = connectionGeneration
|
||||
return createTerminalAccessoryRepeatSender(
|
||||
'terminal-a',
|
||||
() => connectionGeneration === pressedConnectionGeneration,
|
||||
sendToTerminal
|
||||
)
|
||||
}
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', createSender())
|
||||
|
||||
connectionGeneration = 2
|
||||
first.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(sent).toEqual(['down'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400
|
||||
export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45
|
||||
|
||||
type TerminalAccessoryRepeatSender<TInput> = (input: TInput) => Promise<boolean>
|
||||
type TerminalAccessoryRepeatSendToTerminal<TInput> = (
|
||||
input: TInput,
|
||||
targetHandle: string,
|
||||
isDeliveryTargetCurrent: () => boolean
|
||||
) => Promise<boolean>
|
||||
|
||||
export function createTerminalAccessoryRepeatSender<TInput>(
|
||||
targetHandle: string | null,
|
||||
isDeliveryTargetCurrent: (targetHandle: string) => boolean,
|
||||
sendToTerminal: TerminalAccessoryRepeatSendToTerminal<TInput>
|
||||
): TerminalAccessoryRepeatSender<TInput> {
|
||||
return (input) => {
|
||||
if (!targetHandle || !isDeliveryTargetCurrent(targetHandle)) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return sendToTerminal(input, targetHandle, () => isDeliveryTargetCurrent(targetHandle))
|
||||
}
|
||||
}
|
||||
|
||||
export function createTerminalAccessoryRepeatController<TInput>() {
|
||||
let generation = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const stop = () => {
|
||||
generation += 1
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = stop
|
||||
|
||||
const start = (input: TInput, send: TerminalAccessoryRepeatSender<TInput>) => {
|
||||
stop()
|
||||
const activeGeneration = generation
|
||||
const pressedAt = Date.now()
|
||||
|
||||
const schedule = (delayMs: number) => {
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
if (generation !== activeGeneration) {
|
||||
return
|
||||
}
|
||||
void send(input).then(
|
||||
(sent) => {
|
||||
if (sent && generation === activeGeneration) {
|
||||
schedule(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
|
||||
}
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
void send(input).then(
|
||||
(sent) => {
|
||||
if (sent && generation === activeGeneration) {
|
||||
schedule(
|
||||
Math.min(
|
||||
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
|
||||
Math.max(0, TERMINAL_ACCESSORY_REPEAT_DELAY_MS - (Date.now() - pressedAt))
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
|
||||
return { cancel, start, stop }
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
|
||||
function captureClient(result: Promise<unknown> = Promise.resolve({ ok: true })) {
|
||||
function captureClient(
|
||||
result: Promise<unknown> = Promise.resolve({
|
||||
id: 'send',
|
||||
ok: true,
|
||||
result: { send: { handle: 'terminal-a', accepted: true, bytesWritten: 1 } },
|
||||
_meta: { runtimeId: 'test-runtime' }
|
||||
})
|
||||
) {
|
||||
const sendRequest = vi.fn(() => result)
|
||||
return { client: { sendRequest } as unknown as Pick<RpcClient, 'sendRequest'>, sendRequest }
|
||||
}
|
||||
@@ -20,7 +28,7 @@ describe('terminal live accessory raw send', () => {
|
||||
it('sends raw bytes now-or-never with the device presence tag', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(true)
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
@@ -33,7 +41,9 @@ describe('terminal live accessory raw send', () => {
|
||||
it('drops the bytes instead of sending while disconnected', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -41,16 +51,28 @@ describe('terminal live accessory raw send', () => {
|
||||
it('drops the bytes when the terminal selection went stale mid-flush', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows a rejected send so accessory taps never surface transport errors', async () => {
|
||||
it('reports a rejected send without surfacing transport errors', async () => {
|
||||
const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send')))
|
||||
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
|
||||
).resolves.toBeUndefined()
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('reports a fulfilled RPC failure as a failed send', async () => {
|
||||
const response: RpcResponse = {
|
||||
id: 'send',
|
||||
ok: false,
|
||||
error: { code: 'terminal_error', message: 'failed' },
|
||||
_meta: { runtimeId: 'test-runtime' }
|
||||
}
|
||||
const { client } = captureClient(Promise.resolve(response))
|
||||
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
|
||||
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
|
||||
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
@@ -15,7 +16,7 @@ type TerminalLiveAccessoryRawSendArgs = {
|
||||
|
||||
export async function sendTerminalLiveAccessoryRawBytes(
|
||||
args: TerminalLiveAccessoryRawSendArgs
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
// Why: async IME flushing can outlive the original terminal selection.
|
||||
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
|
||||
targetHandle: args.targetHandle,
|
||||
@@ -23,9 +24,9 @@ export async function sendTerminalLiveAccessoryRawBytes(
|
||||
activeSessionTabType: args.activeSessionTabType
|
||||
})
|
||||
if (!args.client || !rawSendTarget || args.connState !== 'connected') {
|
||||
return
|
||||
return false
|
||||
}
|
||||
await args.client
|
||||
return args.client
|
||||
.sendRequest(
|
||||
'terminal.send',
|
||||
buildTerminalSendParams({
|
||||
@@ -36,8 +37,5 @@ export async function sendTerminalLiveAccessoryRawBytes(
|
||||
}),
|
||||
TERMINAL_INPUT_SEND_OPTIONS
|
||||
)
|
||||
.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
.then(isTerminalSendRpcAccepted, () => false)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ function createAccessoryInputCommitHarness({
|
||||
}
|
||||
}
|
||||
const applyLiveInputMirror = vi.fn(
|
||||
(_handle: string, _fieldText: string, _composing?: boolean) => {}
|
||||
async (_handle: string, _fieldText: string, _composing?: boolean) => true
|
||||
)
|
||||
const clearPendingLiveInputCommit = vi.fn(() => {})
|
||||
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
|
||||
@@ -171,6 +171,20 @@ describe('terminal live accessory input commit hook', () => {
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
})
|
||||
|
||||
it('Given held text with a failed control send When committed Then reports the accessory input as suppressed', async () => {
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '한',
|
||||
pendingHandle: 'terminal-a',
|
||||
sendResult: false
|
||||
})
|
||||
|
||||
const result = await harness.commit({ bytes: '\x1b' })
|
||||
|
||||
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
|
||||
expect(harness.sent).toEqual(['\x1b'])
|
||||
expect(result).toEqual({ kind: 'suppress-raw' })
|
||||
})
|
||||
|
||||
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
|
||||
|
||||
@@ -21,7 +21,11 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
|
||||
type TerminalLiveAccessoryInputCommitOptions = {
|
||||
readonly activeHandle: string | null
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
|
||||
readonly applyLiveInputMirror: (
|
||||
handle: string,
|
||||
fieldText: string,
|
||||
composing?: boolean
|
||||
) => Promise<boolean>
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
@@ -84,15 +88,20 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
setLiveInputCapture(editedText)
|
||||
liveInputRef.current?.setNativeProps({ text: editedText })
|
||||
// Preserve undefined so Android's heuristic hold still settles on its timer.
|
||||
applyLiveInputMirror(activeHandle, editedText, liveInputComposingRef.current)
|
||||
return { kind: 'handled' }
|
||||
const sent = await applyLiveInputMirror(
|
||||
activeHandle,
|
||||
editedText,
|
||||
liveInputComposingRef.current
|
||||
)
|
||||
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
|
||||
}
|
||||
case 'commit-held-then-send':
|
||||
await sendTerminalLiveControlAfterPendingFlush(
|
||||
case 'commit-held-then-send': {
|
||||
const sent = await sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
)
|
||||
return { kind: 'handled' }
|
||||
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
|
||||
}
|
||||
default:
|
||||
decision satisfies never
|
||||
return { kind: 'handled' }
|
||||
|
||||
@@ -190,6 +190,20 @@ describe('terminal live input commit hook', () => {
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given a failed mirrored Backspace When accessory input commits Then reports failure', async () => {
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
changeLiveInput(handlers, 'a', false)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['a']))
|
||||
|
||||
const result = await handlers.handleLiveInputAccessoryBytes({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace'
|
||||
})
|
||||
|
||||
expect(sent).toEqual(['a', '\x7f'])
|
||||
expect(result).toEqual({ kind: 'suppress-raw' })
|
||||
})
|
||||
|
||||
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -133,7 +133,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
// that differs from the native field text, so the controlled capture must
|
||||
// echo the field verbatim; only the PTY mirror sees normalized text.
|
||||
setLiveInputCapture(nativeEvent.text)
|
||||
applyLiveInputMirror(
|
||||
void applyLiveInputMirror(
|
||||
activeHandle,
|
||||
normalizeTerminalTextInput(nativeEvent.text),
|
||||
nativeEvent.isComposing
|
||||
|
||||
@@ -30,7 +30,11 @@ type RunTerminalLiveMirrorStep = (
|
||||
) => Promise<boolean>
|
||||
|
||||
type TerminalLivePendingInputFlush = {
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
|
||||
readonly applyLiveInputMirror: (
|
||||
handle: string,
|
||||
fieldText: string,
|
||||
composing?: boolean
|
||||
) => Promise<boolean>
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
@@ -153,9 +157,8 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
}, [runMirrorStep])
|
||||
|
||||
const applyLiveInputMirror = useCallback(
|
||||
(handle: string, fieldText: string, composing?: boolean): void => {
|
||||
void runMirrorStep(handle, fieldText, false, composing)
|
||||
},
|
||||
(handle: string, fieldText: string, composing?: boolean): Promise<boolean> =>
|
||||
runMirrorStep(handle, fieldText, false, composing),
|
||||
[runMirrorStep]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRpcClientContext, type RpcClientContextValue } from './client-context'
|
||||
import { useRpcClientContext } from './client-context'
|
||||
import type { RpcClientContextValue } from './rpc-client-context-contract'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
|
||||
export function useReconnectAttempt(hostId: string | undefined): number {
|
||||
@@ -10,6 +11,20 @@ export function useLastConnectedAt(hostId: string | undefined): number | null {
|
||||
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
|
||||
}
|
||||
|
||||
export function useConnectionPathStatus(hostId: string | undefined): {
|
||||
activePath: MobileConnectionPath
|
||||
pendingPath: MobileConnectionPath | null
|
||||
} {
|
||||
return useHostMetric(
|
||||
hostId,
|
||||
(context, id) => ({
|
||||
activePath: context.getActivePath(id),
|
||||
pendingPath: context.getPendingPath(id)
|
||||
}),
|
||||
{ activePath: 'lan', pendingPath: null }
|
||||
)
|
||||
}
|
||||
|
||||
// Both inputs classifyConnection needs about a relay recovery, read from one
|
||||
// subscription so a screen cannot render half of the escalation.
|
||||
export function useRelayRecoveryStatus(hostId: string | undefined): {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { HostOpenRetryScheduler } from './host-open-retry-scheduler'
|
||||
import { openHostClientEntry, type HostClientStoreEntry } from './host-entry-opener'
|
||||
import { shouldPreserveActiveRelay } from './relay-reconnect-preservation'
|
||||
import { recordConnectionRevival } from './persisted-connection-log-store'
|
||||
import {
|
||||
createHostClientSelectors,
|
||||
listHostClients,
|
||||
@@ -35,8 +36,6 @@ import type { ConnectionState, HostProfile } from './types'
|
||||
import type { RpcClientContextValue } from './rpc-client-context-contract'
|
||||
|
||||
type StoreEntry = HostClientStoreEntry
|
||||
export type { HostClientAcquisition } from './host-client-acquisition-registry'
|
||||
export type { RpcClientContextValue } from './rpc-client-context-contract'
|
||||
|
||||
const Ctx = createContext<RpcClientContextValue | null>(null)
|
||||
|
||||
@@ -311,7 +310,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
for (const hostId of pendingAcquisitionsRef.current.keys()) {
|
||||
retrySchedulerRef.current?.expedite(hostId)
|
||||
}
|
||||
for (const entry of storeRef.current.values()) {
|
||||
for (const [hostId, entry] of storeRef.current) {
|
||||
recordConnectionRevival(hostId, reason)
|
||||
try {
|
||||
entry.client.notifyForeground(reason)
|
||||
} catch {
|
||||
|
||||
@@ -53,4 +53,154 @@ describe('connection log buffer', () => {
|
||||
store.append('host-a', entry(2))
|
||||
expect(onA).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hydrates persisted history without dropping events recorded during app startup', async () => {
|
||||
let finishLoad: (entries: readonly ConnectionLogEntry[]) => void = () => {}
|
||||
const load = vi.fn(
|
||||
() =>
|
||||
new Promise<readonly ConnectionLogEntry[]>((resolve) => {
|
||||
finishLoad = resolve
|
||||
})
|
||||
)
|
||||
const save = vi.fn(async () => {})
|
||||
const store = createConnectionLogStore(3, { load, save })
|
||||
|
||||
store.append('host-a', entry(3))
|
||||
finishLoad([entry(1), entry(2)])
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-2', 'log-3'])
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalled())
|
||||
expect(save).toHaveBeenLastCalledWith('host-a', [entry(1), entry(2), entry(3)])
|
||||
})
|
||||
|
||||
it('redacts credentials before retaining or persisting an event', async () => {
|
||||
const save = vi.fn(async () => {})
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
detail: 'resumeToken=do-not-copy deviceToken:also-secret'
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]?.detail).toBe('resumeToken=[redacted] deviceToken:[redacted]')
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalled())
|
||||
expect(JSON.stringify(save.mock.calls)).not.toContain('do-not-copy')
|
||||
expect(JSON.stringify(save.mock.calls)).not.toContain('also-secret')
|
||||
})
|
||||
|
||||
it('redacts quoted credential values when the object key is unquoted', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
detail: `resumeToken: 'secret-one' deviceToken="secret-two"`
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]?.detail).toBe(
|
||||
`resumeToken: '[redacted]' deviceToken="[redacted]"`
|
||||
)
|
||||
})
|
||||
|
||||
it('redacts the full quoted credential when its value contains an escaped quote', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
detail: String.raw`{"token":"secret\"tail"} token: 'secret\'tail'`
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]?.detail).toBe(`{"token":"[redacted]"} token: '[redacted]'`)
|
||||
})
|
||||
|
||||
it('redacts unterminated quoted credentials', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
message: 'token="secret',
|
||||
detail: '{"authorization":"Bearer also-secret'
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]).toMatchObject({
|
||||
message: 'token="[redacted]',
|
||||
detail: '{"authorization":"[redacted]'
|
||||
})
|
||||
})
|
||||
|
||||
it('redacts URL userinfo through the last authority separator', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
message: 'wss://user:p@ss@example.com/x',
|
||||
detail: 'https://user:pass@example.com/x'
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]).toMatchObject({
|
||||
message: 'wss://[redacted]@example.com/x',
|
||||
detail: 'https://[redacted]@example.com/x'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not overwrite persisted history when hydration fails and retries later', async () => {
|
||||
const load = vi
|
||||
.fn<() => Promise<readonly ConnectionLogEntry[]>>()
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
.mockResolvedValueOnce([entry(1)])
|
||||
const save = vi.fn(async () => {})
|
||||
const store = createConnectionLogStore(3, { load, save })
|
||||
|
||||
store.append('host-a', entry(2))
|
||||
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
|
||||
await store.hydrate('host-a')
|
||||
expect(store.get('host-a').map((value) => value.id)).toEqual(['log-1', 'log-2'])
|
||||
})
|
||||
|
||||
it('preserves legacy entries that reused the same event id', async () => {
|
||||
const store = createConnectionLogStore(3, {
|
||||
load: async () => [entry(1), { ...entry(2), id: 'log-1' }],
|
||||
save: async () => {}
|
||||
})
|
||||
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a').map((value) => value.message)).toEqual(['event 1', 'event 2'])
|
||||
})
|
||||
|
||||
it('retries one transient persistence failure without requiring another append', async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save })
|
||||
|
||||
await store.hydrate('host-a')
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalled())
|
||||
save.mockReset()
|
||||
save.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(undefined)
|
||||
store.append('host-a', entry(1))
|
||||
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2))
|
||||
expect(save).toHaveBeenLastCalledWith('host-a', [entry(1)])
|
||||
})
|
||||
|
||||
it('does not retry failed automatic hydration for every appended event', async () => {
|
||||
const load = vi.fn().mockRejectedValue(new Error('storage unavailable'))
|
||||
const store = createConnectionLogStore(3, { load, save: async () => {} })
|
||||
|
||||
store.append('host-a', entry(1))
|
||||
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
|
||||
store.append('host-a', entry(2))
|
||||
store.append('host-a', entry(3))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
import { redactConnectionLogEntry } from '../diagnostics/connection-log-redaction'
|
||||
|
||||
// Why: the rpc-client's onLog entries were only wired during pairing; for
|
||||
// long-lived host connections everything went to console.log, invisible to
|
||||
@@ -12,20 +13,108 @@ const MAX_ENTRIES_PER_HOST = 200
|
||||
export type ConnectionLogStore = {
|
||||
append: (hostId: string, entry: ConnectionLogEntry) => void
|
||||
get: (hostId: string) => readonly ConnectionLogEntry[]
|
||||
hydrate: (hostId: string) => Promise<void>
|
||||
subscribe: (hostId: string, listener: () => void) => () => void
|
||||
}
|
||||
|
||||
export type ConnectionLogPersistence = {
|
||||
load: (hostId: string) => Promise<readonly ConnectionLogEntry[]>
|
||||
save: (hostId: string, entries: readonly ConnectionLogEntry[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function createConnectionLogStore(
|
||||
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST
|
||||
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST,
|
||||
persistence?: ConnectionLogPersistence
|
||||
): ConnectionLogStore {
|
||||
const entriesByHost = new Map<string, ConnectionLogEntry[]>()
|
||||
const listenersByHost = new Map<string, Set<() => void>>()
|
||||
const hydratedHosts = new Set<string>()
|
||||
const hydrationFailedHosts = new Set<string>()
|
||||
const hydrationByHost = new Map<string, Promise<void>>()
|
||||
const saveByHost = new Map<string, Promise<void>>()
|
||||
// Why: useSyncExternalStore compares snapshots by reference — getSnapshot
|
||||
// must return the SAME array until the data actually changes, or React
|
||||
// loops re-rendering. Cache per host; invalidate on append.
|
||||
const snapshotByHost = new Map<string, readonly ConnectionLogEntry[]>()
|
||||
const EMPTY: readonly ConnectionLogEntry[] = []
|
||||
|
||||
const trim = (entries: ConnectionLogEntry[]): void => {
|
||||
if (entries.length > maxEntriesPerHost) {
|
||||
entries.splice(0, entries.length - maxEntriesPerHost)
|
||||
}
|
||||
}
|
||||
|
||||
const notify = (hostId: string): void => {
|
||||
snapshotByHost.delete(hostId)
|
||||
const listeners = listenersByHost.get(hostId)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persist = (hostId: string): void => {
|
||||
if (!persistence || !hydratedHosts.has(hostId)) {
|
||||
return
|
||||
}
|
||||
const snapshot = [...(entriesByHost.get(hostId) ?? [])]
|
||||
const previous = saveByHost.get(hostId) ?? Promise.resolve()
|
||||
const pending = previous
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
try {
|
||||
await persistence.save(hostId, snapshot)
|
||||
} catch {
|
||||
await persistence.save(hostId, snapshot)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
saveByHost.set(hostId, pending)
|
||||
}
|
||||
|
||||
const hydrateHost = async (hostId: string, retryAfterFailure: boolean): Promise<void> => {
|
||||
if (!persistence || hydratedHosts.has(hostId)) {
|
||||
return
|
||||
}
|
||||
const existing = hydrationByHost.get(hostId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
if (!retryAfterFailure && hydrationFailedHosts.has(hostId)) {
|
||||
return
|
||||
}
|
||||
const pending = persistence
|
||||
.load(hostId)
|
||||
.then((stored) => {
|
||||
const live = entriesByHost.get(hostId) ?? []
|
||||
const seen = new Set<string>()
|
||||
const merged: ConnectionLogEntry[] = []
|
||||
for (const entry of [...stored, ...live]) {
|
||||
const redacted = redactConnectionLogEntry(entry)
|
||||
const fingerprint = JSON.stringify(redacted)
|
||||
if (!seen.has(fingerprint)) {
|
||||
seen.add(fingerprint)
|
||||
merged.push(redacted)
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => a.ts - b.ts)
|
||||
trim(merged)
|
||||
entriesByHost.set(hostId, merged)
|
||||
hydratedHosts.add(hostId)
|
||||
hydrationFailedHosts.delete(hostId)
|
||||
notify(hostId)
|
||||
persist(hostId)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
hydrationFailedHosts.add(hostId)
|
||||
throw error
|
||||
})
|
||||
.finally(() => hydrationByHost.delete(hostId))
|
||||
hydrationByHost.set(hostId, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
return {
|
||||
append(hostId, entry) {
|
||||
let entries = entriesByHost.get(hostId)
|
||||
@@ -33,17 +122,12 @@ export function createConnectionLogStore(
|
||||
entries = []
|
||||
entriesByHost.set(hostId, entries)
|
||||
}
|
||||
entries.push(entry)
|
||||
if (entries.length > maxEntriesPerHost) {
|
||||
entries.splice(0, entries.length - maxEntriesPerHost)
|
||||
}
|
||||
snapshotByHost.delete(hostId)
|
||||
const listeners = listenersByHost.get(hostId)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
entries.push(redactConnectionLogEntry(entry))
|
||||
trim(entries)
|
||||
notify(hostId)
|
||||
void hydrateHost(hostId, false)
|
||||
.then(() => persist(hostId))
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
get(hostId) {
|
||||
@@ -60,6 +144,8 @@ export function createConnectionLogStore(
|
||||
return snapshot
|
||||
},
|
||||
|
||||
hydrate: (hostId) => hydrateHost(hostId, true),
|
||||
|
||||
subscribe(hostId, listener) {
|
||||
let listeners = listenersByHost.get(hostId)
|
||||
if (!listeners) {
|
||||
@@ -80,5 +166,3 @@ export function createConnectionLogStore(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionLogStore = createConnectionLogStore()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type { LivenessTimeoutEvidence } from './rpc-session-liveness-watchdog'
|
||||
import type {
|
||||
ConnectionLogEntry,
|
||||
ConnectionLogLevel,
|
||||
ConnectionLogSink,
|
||||
MobileConnectionDiagnosticPath
|
||||
} from './types'
|
||||
|
||||
export class DirectConnectionLog {
|
||||
private sequence = 0
|
||||
private readonly path: MobileConnectionDiagnosticPath
|
||||
|
||||
constructor(
|
||||
endpoint: string,
|
||||
private readonly sink?: ConnectionLogSink
|
||||
) {
|
||||
this.path = isTailscaleEndpoint(endpoint) ? 'tailscale' : 'lan'
|
||||
}
|
||||
|
||||
emit = (
|
||||
level: ConnectionLogLevel,
|
||||
message: string,
|
||||
detail?: string,
|
||||
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
|
||||
): void => {
|
||||
this.sink?.({
|
||||
id: `log-${++this.sequence}-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
level,
|
||||
message,
|
||||
detail,
|
||||
...evidence,
|
||||
path: evidence?.path ?? this.path
|
||||
})
|
||||
}
|
||||
|
||||
livenessTimeout = (evidence: LivenessTimeoutEvidence): void => {
|
||||
this.emit(
|
||||
'error',
|
||||
'Connection health check failed',
|
||||
`${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`,
|
||||
{ code: 'liveness-timeout' }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client'
|
||||
import { DirectConnectionLog } from './direct-connection-log'
|
||||
import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
|
||||
import { RpcClientConnectionState } from './rpc-client-connection-state'
|
||||
import {
|
||||
@@ -16,12 +17,7 @@ import {
|
||||
} from './rpc-client-stream-registry'
|
||||
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
|
||||
import { isStaleForegroundDial } from './rpc-stale-dial'
|
||||
import type {
|
||||
ConnectionLogLevel,
|
||||
ConnectionState,
|
||||
ForegroundNudgeReason,
|
||||
RpcResponse
|
||||
} from './types'
|
||||
import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types'
|
||||
|
||||
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
|
||||
|
||||
@@ -36,7 +32,7 @@ export class DirectRpcClient implements RpcClient {
|
||||
private readonly authenticationRetry: RpcClientAuthenticationRetry
|
||||
private readonly socketClose: RpcClientSocketCloseController
|
||||
private requestCounter = 0
|
||||
private logCounter = 0
|
||||
private readonly connectionLog: DirectConnectionLog
|
||||
private intentionallyClosed = false
|
||||
private authenticationGeneration = 0
|
||||
private livenessSession: RpcClientSocketSession | null = null
|
||||
@@ -47,10 +43,12 @@ export class DirectRpcClient implements RpcClient {
|
||||
serverPublicKeyB64: string,
|
||||
private readonly options: ConnectOptions
|
||||
) {
|
||||
this.connectionLog = new DirectConnectionLog(endpoint, options.onLog)
|
||||
this.reconnect = new RpcClientReconnectSchedule({
|
||||
openConnection: () => this.openConnection(),
|
||||
rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason),
|
||||
emitLog: (message, detail) => this.emitLog('info', message, detail)
|
||||
emitLog: (message, detail) =>
|
||||
this.connectionLog.emit('info', message, detail, { code: 'retry-scheduled' })
|
||||
})
|
||||
this.connectionState = new RpcClientConnectionState({
|
||||
endpoint,
|
||||
@@ -78,7 +76,8 @@ export class DirectRpcClient implements RpcClient {
|
||||
if (identity === this.livenessSession && this.socketSession === this.livenessSession) {
|
||||
this.socketClose.forceClose(this.livenessSession)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTimeout: this.connectionLog.livenessTimeout
|
||||
})
|
||||
this.socketFactory = new RpcClientSocketFactory({
|
||||
endpoint,
|
||||
@@ -89,7 +88,7 @@ export class DirectRpcClient implements RpcClient {
|
||||
getReconnectAttempt: () => this.getReconnectAttempt(),
|
||||
getLastConnectedAt: () => this.getLastConnectedAt(),
|
||||
isIntentionallyClosed: () => this.intentionallyClosed,
|
||||
emitLog: (level, message, detail) => this.emitLog(level, message, detail),
|
||||
emitLog: this.connectionLog.emit,
|
||||
onHandshakeStarted: () => this.connectionState.publish('handshaking'),
|
||||
onAuthenticated: (session) => this.handleAuthenticated(session),
|
||||
onAuthRejected: (reason) => this.authenticationRetry.reject(reason),
|
||||
@@ -102,7 +101,8 @@ export class DirectRpcClient implements RpcClient {
|
||||
this.authenticationRetry = new RpcClientAuthenticationRetry({
|
||||
endpoint,
|
||||
stopLiveness: () => this.stopLiveness(),
|
||||
emitWarning: (message, detail) => this.emitLog('warn', message, detail),
|
||||
emitWarning: (message, detail) =>
|
||||
this.connectionLog.emit('warn', message, detail, { code: 'authentication-rejected' }),
|
||||
retry: (reason) => this.retryAuthentication(reason),
|
||||
latchFailure: (reason) => this.latchAuthenticationFailure(reason)
|
||||
})
|
||||
@@ -122,7 +122,8 @@ export class DirectRpcClient implements RpcClient {
|
||||
this.stopLiveness()
|
||||
}
|
||||
},
|
||||
emitWarning: (message, detail) => this.emitLog('warn', message, detail)
|
||||
emitWarning: (message, detail, evidence) =>
|
||||
this.connectionLog.emit('warn', message, detail, evidence)
|
||||
})
|
||||
this.openConnection()
|
||||
}
|
||||
@@ -232,7 +233,9 @@ export class DirectRpcClient implements RpcClient {
|
||||
this.reconnect.authenticated()
|
||||
this.authenticationRetry.accepted()
|
||||
this.connectionState.publish('connected')
|
||||
this.emitLog('success', 'Authenticated', 'Channel ready for RPC')
|
||||
this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', {
|
||||
code: 'direct-connected'
|
||||
})
|
||||
this.streams.replayAfterAuthentication()
|
||||
}
|
||||
|
||||
@@ -311,14 +314,4 @@ export class DirectRpcClient implements RpcClient {
|
||||
private nextId(): string {
|
||||
return `rpc-${++this.requestCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
private emitLog(level: ConnectionLogLevel, message: string, detail?: string): void {
|
||||
this.options.onLog?.({
|
||||
id: `log-${++this.logCounter}-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
level,
|
||||
message,
|
||||
detail
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: asyncStorageMock
|
||||
}))
|
||||
|
||||
import {
|
||||
loadHostAppVersion,
|
||||
normalizeHostAppVersion,
|
||||
recordHostAppVersion
|
||||
} from './host-app-version-store'
|
||||
|
||||
describe('host app version store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
asyncStorageMock.getItem.mockResolvedValue(null)
|
||||
asyncStorageMock.setItem.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('persists a bounded version once per host', async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValueOnce(null).mockResolvedValueOnce('1.4.191')
|
||||
|
||||
await recordHostAppVersion('host-1', ' 1.4.191 ')
|
||||
await recordHostAppVersion('host-1', '1.4.191')
|
||||
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledOnce()
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'orca:host-app-version:v1:host-1',
|
||||
'1.4.191'
|
||||
)
|
||||
})
|
||||
|
||||
it('loads a previously observed version without requiring a live host', async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue('1.4.188')
|
||||
|
||||
await expect(loadHostAppVersion('host-1')).resolves.toBe('1.4.188')
|
||||
})
|
||||
|
||||
it('rejects multiline, empty, and oversized host-provided values', async () => {
|
||||
expect(normalizeHostAppVersion('1.4.191\nInjected: value')).toBeNull()
|
||||
expect(normalizeHostAppVersion(' ')).toBeNull()
|
||||
expect(normalizeHostAppVersion('x'.repeat(65))).toBeNull()
|
||||
|
||||
await recordHostAppVersion('host-1', '1.4.191\nInjected: value')
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'orca:host-app-version:v1:'
|
||||
const MAX_VERSION_LENGTH = 64
|
||||
|
||||
export function normalizeHostAppVersion(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const normalized = value.trim()
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
normalized.length > MAX_VERSION_LENGTH ||
|
||||
normalized.includes('\n') ||
|
||||
normalized.includes('\r')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export async function loadHostAppVersion(hostId: string): Promise<string | null> {
|
||||
try {
|
||||
return normalizeHostAppVersion(await AsyncStorage.getItem(storageKey(hostId)))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordHostAppVersion(hostId: string, value: unknown): Promise<void> {
|
||||
const appVersion = normalizeHostAppVersion(value)
|
||||
if (!appVersion) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const key = storageKey(hostId)
|
||||
if (normalizeHostAppVersion(await AsyncStorage.getItem(key)) !== appVersion) {
|
||||
await AsyncStorage.setItem(key, appVersion)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort diagnostic metadata must never affect host connectivity.
|
||||
}
|
||||
}
|
||||
|
||||
function storageKey(hostId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${hostId}`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user