mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/sta5698-pty-id-identity
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
name: Install Node dependencies
|
||||
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
|
||||
description: Installs the Node toolchain and repository dependencies for CI jobs.
|
||||
|
||||
inputs:
|
||||
native-runtime:
|
||||
@@ -10,6 +10,18 @@ inputs:
|
||||
description: Node.js version override; defaults to the version declared in package.json.
|
||||
required: false
|
||||
default: ''
|
||||
persist-native-cache:
|
||||
description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI.
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
outputs:
|
||||
node-version:
|
||||
description: Resolved Node.js version used for the install.
|
||||
value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
|
||||
native-cache-scope:
|
||||
description: Operating-system image scope used by the native module cache.
|
||||
value: ${{ steps.native-cache-scope.outputs.scope }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -51,7 +63,7 @@ runs:
|
||||
|
||||
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
|
||||
- name: Use external node-gyp
|
||||
if: inputs.native-runtime != 'none'
|
||||
if: runner.os == 'Linux' && inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
@@ -80,12 +92,42 @@ runs:
|
||||
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
|
||||
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
|
||||
# Node version, and the patch whose contents the build has to match.
|
||||
- name: Restore compiled native modules
|
||||
# Windows extra globs are empty on Linux. No restore-keys: a partial-match key is
|
||||
# an ABI-mismatched build, and ensure-native-runtime would recompile it anyway.
|
||||
# Native addons built on a newer Linux image can require glibc symbols
|
||||
# missing from an older runner/container. ImageOS distinguishes hosted
|
||||
# Windows/macOS images; /etc/os-release also distinguishes Linux containers.
|
||||
- name: Resolve native cache scope
|
||||
id: native-cache-scope
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
scope="${ImageOS:-$RUNNER_OS}"
|
||||
if [ -r /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
scope="${ID:-linux}-${VERSION_ID:-unknown}"
|
||||
fi
|
||||
echo "scope=$scope" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore compiled native modules
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
key: native-modules-${{ runner.os }}-${{ 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') }}
|
||||
path: |
|
||||
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') }}
|
||||
|
||||
- name: Restore compiled native modules without saving
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
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') }}
|
||||
|
||||
- name: Prepare native runtime
|
||||
if: inputs.native-runtime != 'none'
|
||||
|
||||
@@ -71,30 +71,17 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
|
||||
# Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
|
||||
# permission on Linux runners; node-pty's install fallback then fails
|
||||
# before this smoke job can exercise the native package.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
|
||||
# is no root config) and falls back to the 5s default timeout with no
|
||||
# Windows worker cap, so the real csc.exe launcher-compile tests time out
|
||||
# on hosted Windows. Use the shared config so this job matches pnpm test.
|
||||
- run: >-
|
||||
pnpm vitest run --config config/vitest.config.ts
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
|
||||
@@ -159,13 +146,9 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
- name: Owner-loss benchmark process cleanup
|
||||
run: >-
|
||||
pnpm vitest run
|
||||
|
||||
+13
-99
@@ -40,36 +40,10 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: the E2E build compiles native modules via node-gyp. Mirrors the
|
||||
# install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
|
||||
# errors.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
# Why: the build's plain-Node daemon smoke load resolves node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: node
|
||||
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
@@ -130,11 +104,11 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: pnpm install rebuilds native modules, and those postinstall
|
||||
# scripts still need the Linux toolchain even though this shard reuses
|
||||
# the prebuilt Electron output.
|
||||
# Why: pnpm install used to rebuild native modules here; the composite
|
||||
# action restores them from cache and only compiles on a miss. The
|
||||
# toolchain is still required for that miss path, and for paired Quick
|
||||
# Open coverage which exercises the resource-bounded host search.
|
||||
- name: Install native build tools
|
||||
# Why: paired Quick Open coverage exercises the resource-bounded host search.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
|
||||
|
||||
# Why: Electron on Linux needs an X display even when the app
|
||||
@@ -143,33 +117,9 @@ jobs:
|
||||
- name: Install xvfb
|
||||
run: sudo apt-get install -y xvfb
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
|
||||
# release.yml so the invariant "this workaround is Linux-only" is
|
||||
# consistent across all three workflows, even though this job
|
||||
# currently pins runs-on: ubuntu-latest.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -222,25 +172,9 @@ jobs:
|
||||
# lane now receives those specs from pr.yml's SSH source mapping.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -305,29 +239,9 @@ jobs:
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
|
||||
# stays consistent across workflows even while this job is ubuntu-latest.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
|
||||
+107
-77
@@ -16,15 +16,28 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Why: a README/docs-only PR used to start the full matrix (32 test shards,
|
||||
# Why: a README/docs-only PR used to start the full matrix (test shards,
|
||||
# two package jobs, typecheck, git compat, xterm, shell contracts). Path
|
||||
# filters on `on.pull_request` would drop the `verify` check entirely; this
|
||||
# detector keeps verify as the required aggregate and skips the expensive jobs.
|
||||
# Per-job outputs also skip git-compat/xterm/packaging/shell when those
|
||||
# inputs are unchanged; empty diffs fail closed and run everything.
|
||||
code_paths:
|
||||
name: detect code-relevant changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
static_analysis: ${{ steps.filter.outputs.static_analysis }}
|
||||
typecheck: ${{ steps.filter.outputs.typecheck }}
|
||||
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
|
||||
xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
|
||||
shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
|
||||
test: ${{ steps.filter.outputs.test }}
|
||||
orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
|
||||
cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
|
||||
managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
|
||||
package: ${{ steps.filter.outputs.package }}
|
||||
package_windows: ${{ steps.filter.outputs.package_windows }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -48,14 +61,12 @@ jobs:
|
||||
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
|
||||
echo "Changed paths:"
|
||||
printf '%s\n' "$CHANGED"
|
||||
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)"
|
||||
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
|
||||
echo "should_run=$SHOULD_RUN"
|
||||
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
static_analysis:
|
||||
name: static analysis
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -70,6 +81,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Lint
|
||||
run: pnpm exec oxlint --format github
|
||||
@@ -188,7 +201,7 @@ jobs:
|
||||
|
||||
typecheck:
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -216,7 +229,7 @@ jobs:
|
||||
git_compatibility:
|
||||
name: Git compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.git_compatibility == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -283,7 +296,7 @@ jobs:
|
||||
xterm_patch_sync:
|
||||
name: xterm patch sync
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.xterm_patch_sync == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -313,7 +326,7 @@ jobs:
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.shell_contracts == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Why: this job's cost is almost entirely package download, and a stalled mirror has
|
||||
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
|
||||
@@ -432,14 +445,14 @@ jobs:
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.test == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['24', '26']
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
shard_total: [16]
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard_total: [8]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -477,12 +490,12 @@ jobs:
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
# Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix
|
||||
# would pay for it 32 times to run one file in whichever shard it landed in.
|
||||
# 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.
|
||||
orcad_browser:
|
||||
name: orcad browser provider
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.orcad_browser == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -518,7 +531,7 @@ jobs:
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.cross-version-wire == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -550,7 +563,7 @@ jobs:
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.managed_hook_node18 == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -575,7 +588,7 @@ jobs:
|
||||
package:
|
||||
name: package
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.package == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -647,7 +660,7 @@ jobs:
|
||||
package_windows:
|
||||
name: package (windows)
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.package_windows == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -657,17 +670,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
@@ -678,18 +680,22 @@ jobs:
|
||||
restore-keys: |
|
||||
electron-builder-windows-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
# Why persist-native-cache false: this job later rebuilds the same path for
|
||||
# Electron. A post-job save would store the Electron ABI under the Node key.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
id: deps
|
||||
with:
|
||||
native-runtime: node
|
||||
persist-native-cache: 'false'
|
||||
|
||||
# Why: node-pty prefers its upstream prebuild, which does not contain
|
||||
# Orca's Windows patch, so the job-object exports would be absent and the
|
||||
# suite below would test an unpatched binary. build_from_source removes
|
||||
# the prebuild, and the package's postinstall restores the ConPTY runtime
|
||||
# files that a bare node-gyp rebuild would miss.
|
||||
- name: Rebuild node-pty from patched source
|
||||
env:
|
||||
npm_config_build_from_source: 'true'
|
||||
run: pnpm rebuild node-pty
|
||||
- name: Save compiled Node native modules
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
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') }}
|
||||
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
@@ -724,9 +730,26 @@ jobs:
|
||||
# Why the :parallel variant: identical to build:release except the three
|
||||
# electron-vite targets overlap instead of running back to back. The Linux package
|
||||
# job already packages and smoke-tests an AppImage built that way.
|
||||
- name: Cache Windows CLI launcher
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: native/windows-cli-launcher/.build
|
||||
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }}
|
||||
|
||||
- name: Build package inputs
|
||||
env:
|
||||
ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1'
|
||||
run: pnpm run build:release:parallel
|
||||
|
||||
- name: Restore compiled Electron native modules
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
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') }}
|
||||
|
||||
- name: Prepare Electron native runtime
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
|
||||
@@ -807,6 +830,7 @@ jobs:
|
||||
- shell_contracts
|
||||
- test
|
||||
- orcad_browser
|
||||
- cross-version-wire
|
||||
- managed_hook_node18
|
||||
- package
|
||||
- package_windows
|
||||
@@ -826,57 +850,63 @@ jobs:
|
||||
CODE_PATHS: ${{ needs.code_paths.result }}
|
||||
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
|
||||
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
|
||||
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
|
||||
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
|
||||
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
|
||||
XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }}
|
||||
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
|
||||
ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }}
|
||||
CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }}
|
||||
CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
MANAGED_HOOK_NODE18_SHOULD_RUN: ${{ needs.code_paths.outputs.managed_hook_node18 }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }}
|
||||
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
|
||||
PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }}
|
||||
run: |
|
||||
if [ "$CODE_PATHS" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SHOULD_RUN" != "true" ]; then
|
||||
echo "Docs-only change; expensive PR checks skipped."
|
||||
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
for result in \
|
||||
"$STATIC_ANALYSIS" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$ORCAD_BROWSER" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "skipped" ]; then
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
# Require success when the PR has code-relevant changes
|
||||
for result in \
|
||||
"$CODE_PATHS" \
|
||||
"$STATIC_ANALYSIS" \
|
||||
"$ROOT_DIRECTORY_GUARD" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$ORCAD_BROWSER" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
exit 1
|
||||
failed=0
|
||||
check_job() {
|
||||
local name="$1" result="$2" should="$3"
|
||||
if [ "$should" = "true" ]; then
|
||||
if [ "$result" != "success" ]; then
|
||||
echo "$name: expected success, got $result"
|
||||
failed=1
|
||||
fi
|
||||
else
|
||||
if [ "$result" != "skipped" ]; then
|
||||
echo "$name: expected skipped, got $result"
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
# Require success when the PR has code-relevant changes
|
||||
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
|
||||
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
|
||||
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
|
||||
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
|
||||
check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
|
||||
check_job test "$TEST" "$TEST_SHOULD_RUN"
|
||||
check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN"
|
||||
check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN"
|
||||
check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
|
||||
check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
|
||||
check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN"
|
||||
exit "$failed"
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,17 +2,13 @@
|
||||
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline src/cli/handlers/automations.ts
|
||||
inline src/cli/help.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/browser/cdp-bridge.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/cli/wsl-cli-installer.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
@@ -23,14 +19,7 @@ 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/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/memory/collector.ts
|
||||
inline src/main/ports/advertised-url-watcher.ts
|
||||
inline src/main/ports/local-workspace-port-scanner.ts
|
||||
inline src/main/project-groups/nested-repo-discovery.ts
|
||||
inline src/main/providers/local-pty-provider.ts
|
||||
inline src/main/rate-limits/claude-pty.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
inline src/main/runtime/orca-runtime-files.ts
|
||||
@@ -52,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
|
||||
@@ -61,15 +49,12 @@ inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
|
||||
inline src/renderer/src/components/automations/AutomationsPage.tsx
|
||||
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.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
|
||||
inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
|
||||
@@ -82,40 +67,25 @@ inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
|
||||
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.ts
|
||||
inline src/renderer/src/runtime/runtime-linear-client.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.ts
|
||||
inline src/renderer/src/store/slices/agent-status.ts
|
||||
inline src/renderer/src/store/slices/browser.ts
|
||||
inline src/renderer/src/store/slices/diffComments.ts
|
||||
inline src/renderer/src/store/slices/hosted-review.ts
|
||||
inline src/renderer/src/store/slices/linear.ts
|
||||
inline src/renderer/src/store/slices/tabs.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/shared/automation-schedules.ts
|
||||
inline src/shared/commit-message-agent-spec.ts
|
||||
inline src/shared/constants.ts
|
||||
inline src/shared/keybindings.ts
|
||||
inline src/shared/marine-creatures.ts
|
||||
inline src/shared/telemetry-events.ts
|
||||
inline src/shared/text-search.ts
|
||||
inline tests/e2e/helpers/terminal.ts
|
||||
mobile-config app/h/*/files/*.tsx
|
||||
mobile-config app/h/*/index.tsx
|
||||
mobile-config app/h/*/session/*.tsx
|
||||
mobile-config app/h/*/source-control/*.tsx
|
||||
mobile-config app/h/*/tasks.tsx
|
||||
mobile-config app/index.tsx
|
||||
mobile-config app/pair-scan.tsx
|
||||
mobile-config app/troubleshoot.tsx
|
||||
mobile-config scripts/mock-server.ts
|
||||
mobile-config scripts/repro-terminal-colors.ts
|
||||
mobile-config scripts/repro-worktree-startup-stream.ts
|
||||
mobile-config src/browser/MobileBrowserPane.tsx
|
||||
mobile-config src/components/CustomKeyModal.tsx
|
||||
mobile-config src/components/NewWorktreeModal.tsx
|
||||
mobile-config src/components/mobile-rich-markdown-editor-html.ts
|
||||
mobile-config src/terminal/terminal-accessory-keys.ts
|
||||
mobile-config src/terminal/terminal-webview-html.ts
|
||||
mobile-config src/transport/rpc-client.ts
|
||||
|
||||
+136
-512
File diff suppressed because one or more lines are too long
@@ -1,41 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
// Why: electron-builder treats a skipped native build like success and can
|
||||
// continue toward a Windows package whose declared orca.exe does not exist.
|
||||
throw new Error(
|
||||
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
|
||||
)
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
|
||||
const compilerPath = findFrameworkCompiler(process.env)
|
||||
|
||||
if (!compilerPath) {
|
||||
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
const result = spawnSync(
|
||||
compilerPath,
|
||||
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
|
||||
{ cwd: repoRoot, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
export function shouldReuseCompiledWindowsCliLauncher(
|
||||
outputPath,
|
||||
sourcePath,
|
||||
{ reuseCached = false } = {}
|
||||
) {
|
||||
if (!existsSync(outputPath)) {
|
||||
return false
|
||||
}
|
||||
// Why reuseCached: Actions cache keys already hash the C# source, but restore
|
||||
// does not preserve mtimes, so a hit would look stale and recompile anyway.
|
||||
if (reuseCached) {
|
||||
return true
|
||||
}
|
||||
return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs
|
||||
}
|
||||
|
||||
function defaultOutputPath(projectRoot) {
|
||||
@@ -58,3 +41,47 @@ function readArg(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
return index !== -1 ? process.argv[index + 1] : undefined
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (process.platform !== 'win32') {
|
||||
// Why: electron-builder treats a skipped native build like success and can
|
||||
// continue toward a Windows package whose declared orca.exe does not exist.
|
||||
throw new Error(
|
||||
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
|
||||
)
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
|
||||
const compilerPath = findFrameworkCompiler(process.env)
|
||||
|
||||
if (!compilerPath) {
|
||||
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
if (
|
||||
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
|
||||
reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
|
||||
})
|
||||
) {
|
||||
console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
|
||||
process.exit(0)
|
||||
}
|
||||
const result = spawnSync(
|
||||
compilerPath,
|
||||
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
|
||||
{ cwd: repoRoot, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
|
||||
|
||||
const itCrossHost = process.platform === 'win32' ? it.skip : it
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
@@ -37,6 +40,33 @@ function itWindows(name, test) {
|
||||
}
|
||||
|
||||
describe('Windows CLI launcher', () => {
|
||||
it('reuses a compiled launcher that is at least as new as the C# source', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
|
||||
try {
|
||||
const sourcePath = join(root, 'OrcaCliLauncher.cs')
|
||||
const outputPath = join(root, '.build', 'orca.exe')
|
||||
mkdirSync(join(root, '.build'))
|
||||
writeFileSync(sourcePath, 'source\n')
|
||||
writeFileSync(outputPath, 'binary\n')
|
||||
const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
|
||||
utimesSync(outputPath, later, later)
|
||||
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
|
||||
writeFileSync(sourcePath, 'changed\n')
|
||||
const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
|
||||
utimesSync(sourcePath, sourceLater, sourceLater)
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
|
||||
expect(
|
||||
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
|
||||
).toBe(true)
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
|
||||
false
|
||||
)
|
||||
} finally {
|
||||
removeFixtureTree(root)
|
||||
}
|
||||
})
|
||||
|
||||
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
|
||||
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
|
||||
try {
|
||||
|
||||
@@ -76,7 +76,6 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
@@ -126,10 +125,14 @@ describe('computer-use e2e workflow', () => {
|
||||
const job = workflow.jobs['mac-native-owner-smoke']
|
||||
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
|
||||
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
|
||||
const install = job.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(job.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(job['runs-on']).toBe('macos-15')
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(install.with['native-runtime']).toBe('electron')
|
||||
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
|
||||
const cleanupRun = runs.find((run) =>
|
||||
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
|
||||
|
||||
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { release } from 'node:os'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
@@ -67,7 +67,12 @@ function ensureNodeRuntime() {
|
||||
if (!initial.ok) {
|
||||
printCheckError(initial)
|
||||
}
|
||||
runPnpm(['rebuild', 'node-pty'])
|
||||
const failedModules = initial.failures.map((failure) => failure.moduleName)
|
||||
const rebuildModules = [
|
||||
'node-pty',
|
||||
...failedModules.filter((moduleName) => moduleName !== 'node-pty')
|
||||
]
|
||||
rebuildNodeRuntimeModules(rebuildModules)
|
||||
verifyNodeRuntimeAfterRebuild()
|
||||
return
|
||||
}
|
||||
@@ -77,7 +82,7 @@ function ensureNodeRuntime() {
|
||||
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
|
||||
)
|
||||
printCheckError(initial)
|
||||
runPnpm(['rebuild', ...failedModules])
|
||||
rebuildNodeRuntimeModules(failedModules)
|
||||
verifyNodeRuntimeAfterRebuild()
|
||||
}
|
||||
|
||||
@@ -312,14 +317,10 @@ function getPatchedNodePtyRebuildReason() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
|
||||
// patch only lands in the source-built build/Release artifacts.
|
||||
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
|
||||
// Windows patches only land in the source-built build/Release artifacts.
|
||||
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir)
|
||||
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
|
||||
|
||||
if (!missingArtifact) {
|
||||
@@ -329,11 +330,24 @@ function getPatchedNodePtyRebuildReason() {
|
||||
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
||||
}
|
||||
|
||||
function requiresPatchedNodePtySourceBuild() {
|
||||
function patchedNodePtyArtifactPaths(nodePtyDir) {
|
||||
if (process.platform === 'win32') {
|
||||
return false
|
||||
const releaseDir = resolve(nodePtyDir, 'build', 'Release')
|
||||
return [
|
||||
resolve(releaseDir, 'conpty.node'),
|
||||
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename))
|
||||
]
|
||||
}
|
||||
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
return artifactPaths
|
||||
}
|
||||
|
||||
function requiresPatchedNodePtySourceBuild() {
|
||||
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
|
||||
if (!existsSync(nodePtyPatchPath)) {
|
||||
return false
|
||||
@@ -351,16 +365,28 @@ function getWindowsBuildNumber() {
|
||||
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
|
||||
}
|
||||
|
||||
function runPnpm(args) {
|
||||
function rebuildNodeRuntimeModules(moduleNames) {
|
||||
for (const moduleName of moduleNames) {
|
||||
const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
|
||||
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
|
||||
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
|
||||
if (moduleName === 'node-pty' && process.platform === 'win32') {
|
||||
runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runPnpm(args, { cwd = projectDir } = {}) {
|
||||
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: projectDir,
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32'
|
||||
shell: process.platform === 'win32',
|
||||
env: process.env
|
||||
})
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
|
||||
console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
|
||||
if (result.error) {
|
||||
console.error(formatError(result.error))
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log).toContain('pnpm rebuild node-pty\n')
|
||||
expect(log).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
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$/)
|
||||
@@ -52,6 +53,41 @@ describe('ensure-native-runtime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'rebuilds other failed Windows addons with patched node-pty',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2)
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
expect(log).toContain(join('node_modules', 'windows-native-registry'))
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rebuilds patched node-pty artifacts even when the Node load check passes',
|
||||
() => {
|
||||
@@ -80,7 +116,7 @@ describe('ensure-native-runtime', () => {
|
||||
expect(result.stderr).toContain(
|
||||
'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
||||
)
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -114,7 +150,7 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -148,7 +184,7 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -178,9 +214,15 @@ function envWithPrependedPath(binDir, extraEnv) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeFakeNativeModules(projectDir) {
|
||||
function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(nodePtyDir, 'package.json'),
|
||||
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
|
||||
)
|
||||
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
|
||||
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
@@ -200,12 +242,18 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
}
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
|
||||
}
|
||||
|
||||
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(nodePtyDir, 'package.json'),
|
||||
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
|
||||
)
|
||||
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
|
||||
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
@@ -218,23 +266,40 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
const dir = ${JSON.stringify(nativeDir)} ??
|
||||
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
|
||||
return { dir, module: {} }
|
||||
return {
|
||||
dir,
|
||||
module: {
|
||||
listJobProcessIds: () => [],
|
||||
terminateJob: () => true,
|
||||
assignCurrentProcessToJob: () => true
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
}
|
||||
|
||||
function writeFakeWindowsRegistry(projectDir) {
|
||||
function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
|
||||
mkdirSync(registryDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(registryDir, 'index.js'),
|
||||
'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
|
||||
join(registryDir, 'package.json'),
|
||||
'{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n'
|
||||
)
|
||||
const markerGate = requiresMarker
|
||||
? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }`
|
||||
: ''
|
||||
writeFileSync(
|
||||
join(registryDir, 'index.js'),
|
||||
`exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n`
|
||||
)
|
||||
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
mkdirSync(processTreeDir, { recursive: true })
|
||||
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
|
||||
}
|
||||
|
||||
function writeNodePtyPatchFile(projectDir) {
|
||||
@@ -245,6 +310,13 @@ function writeNodePtyPatchFile(projectDir) {
|
||||
function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(join(buildDir, 'conpty.node'), '')
|
||||
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
|
||||
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
|
||||
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
|
||||
return
|
||||
}
|
||||
writeFileSync(join(buildDir, 'pty.node'), '')
|
||||
if (process.platform === 'darwin') {
|
||||
writeFileSync(join(buildDir, 'spawn-helper'), '')
|
||||
@@ -260,6 +332,11 @@ function writeFakePnpm(binDir) {
|
||||
const { appendFileSync, writeFileSync } = require('node:fs')
|
||||
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
|
||||
)
|
||||
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
|
||||
`
|
||||
)
|
||||
|
||||
@@ -16,6 +16,183 @@ const DOCS_ONLY_FILES = new Set([
|
||||
|
||||
const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/']
|
||||
|
||||
export const PR_CHECK_JOBS = [
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'xterm_patch_sync',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'cross-version-wire',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
]
|
||||
|
||||
const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
|
||||
|
||||
const GLOBAL_FORCE_PREFIXES = [
|
||||
'.github/workflows/pr.yml',
|
||||
'.github/actions/install-node-dependencies/',
|
||||
'config/scripts/pr-code-change-scope'
|
||||
]
|
||||
|
||||
const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml'])
|
||||
|
||||
const GIT_COMPAT_PREFIXES = [
|
||||
'src/shared/git-',
|
||||
'src/shared/review-head-tracking-ref',
|
||||
'src/main/git/',
|
||||
'src/relay/git-',
|
||||
'config/scripts/git-binary-compatibility'
|
||||
]
|
||||
|
||||
const XTERM_PREFIXES = [
|
||||
'config/patches/xterm-upstream.json',
|
||||
'config/patches/@xterm',
|
||||
'config/patches/xterm-src/',
|
||||
'config/scripts/regenerate-xterm-patches'
|
||||
]
|
||||
|
||||
const SHELL_PREFIXES = [
|
||||
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec',
|
||||
'src/main/daemon/shell-ready',
|
||||
'src/main/daemon/daemon-bash-shell-ready',
|
||||
'src/main/daemon/daemon-shell-ready-wrapper',
|
||||
'src/main/daemon/node-pty-fd-leak',
|
||||
'src/main/providers/local-pty-shell-ready',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example',
|
||||
'src/main/pty/',
|
||||
'src/main/shell-templates',
|
||||
'src/main/shell-startup-',
|
||||
'src/main/shell-wrapper-',
|
||||
'src/main/terminal-history-fish',
|
||||
'src/main/zsh-',
|
||||
'src/renderer/src/components/terminal-pane/fish-color-scheme',
|
||||
'src/shared/fish-',
|
||||
'src/shared/pty-reply-echo-shapes',
|
||||
'src/shared/startup-shell-portability',
|
||||
'src/shared/posix-command-path-lookup',
|
||||
'config/patches/node-pty@',
|
||||
'config/scripts/ensure-native-runtime',
|
||||
'config/scripts/node-pty-job-ownership'
|
||||
]
|
||||
|
||||
const ORCAD_BROWSER_PREFIXES = [
|
||||
'src/main/orcad/external-chromium-',
|
||||
'src/main/orcad/orcad-browser-provider',
|
||||
'src/main/orcad/orcad-agent-browser-binary',
|
||||
'src/main/orcad/electron-serve-browser-process'
|
||||
]
|
||||
|
||||
const CROSS_VERSION_WIRE_PREFIXES = [
|
||||
'tests/e2e/cross-version-wire/',
|
||||
'src/shared/protocol-version',
|
||||
'src/shared/terminal-stream-protocol',
|
||||
'src/shared/browser-client-host-protocol',
|
||||
'src/shared/browser-network-tunnel-protocol',
|
||||
'src/shared/browser-client-host-placement',
|
||||
'src/main/runtime/rpc/dispatcher',
|
||||
'src/main/runtime/rpc/methods/browser-tab-create-schema',
|
||||
'src/main/runtime/rpc/methods/terminal',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
|
||||
]
|
||||
|
||||
const MANAGED_HOOK_PREFIXES = [
|
||||
'config/scripts/smoke-managed-hook-runtime-node18',
|
||||
'config/scripts/build-relay',
|
||||
'src/relay/',
|
||||
'src/shared/agent-hook',
|
||||
'src/main/agent-hooks/'
|
||||
]
|
||||
|
||||
const NATIVE_RUNTIME_PREFIXES = [
|
||||
'config/scripts/ensure-native-runtime',
|
||||
'config/scripts/rebuild-native-deps',
|
||||
'config/scripts/node-pty-job-ownership',
|
||||
'config/scripts/electron-builder-native-rebuild',
|
||||
'config/patches/node-pty@',
|
||||
'config/patches/@vscode__windows-process-tree'
|
||||
]
|
||||
|
||||
const SHARED_PACKAGE_PREFIXES = [
|
||||
'electron.vite.config.ts',
|
||||
'config/electron-builder',
|
||||
'config/packaged-runtime',
|
||||
'config/build-plugins/',
|
||||
'config/scripts/build-',
|
||||
'config/scripts/smoke-packaged',
|
||||
'config/scripts/install-electron-package-binary',
|
||||
'config/scripts/verify-packaged',
|
||||
'config/scripts/verify-linux-glibc',
|
||||
'config/scripts/run-electron-vite',
|
||||
'skills/',
|
||||
'skill-guides/',
|
||||
'resources/build/',
|
||||
'resources/onboarding/',
|
||||
'resources/plugins/',
|
||||
'resources/skills/',
|
||||
...NATIVE_RUNTIME_PREFIXES
|
||||
]
|
||||
|
||||
const LINUX_PACKAGE_PREFIXES = [
|
||||
...SHARED_PACKAGE_PREFIXES,
|
||||
'native/computer-use-linux/',
|
||||
'resources/linux/',
|
||||
'config/scripts/run-headless-serve'
|
||||
]
|
||||
|
||||
const WINDOWS_PACKAGE_PREFIXES = [
|
||||
...SHARED_PACKAGE_PREFIXES,
|
||||
'native/windows-cli-launcher/',
|
||||
'native/computer-use-windows/',
|
||||
'resources/win32/',
|
||||
'config/scripts/build-windows-cli-launcher',
|
||||
'config/scripts/windows-pty-native-capability',
|
||||
'tests/tools/windows-pty-native-capability-smoke/'
|
||||
]
|
||||
|
||||
const LINUX_PACKAGE_TESTS = [
|
||||
'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts',
|
||||
'src/main/browser/browser-route-tcp-egress.electron.test.ts',
|
||||
'src/main/browser/browser-route-webrtc-egress.electron.test.ts',
|
||||
'src/main/browser/browser-route-h3-egress.electron.test.ts',
|
||||
'src/main/browser/browser-route-dns-prefetch.electron.test.ts'
|
||||
]
|
||||
|
||||
const WINDOWS_PACKAGE_TESTS = [
|
||||
...LINUX_PACKAGE_TESTS,
|
||||
'config/scripts/rebuild-native-deps.test.mjs',
|
||||
'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
|
||||
'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
|
||||
'src/shared/child-process/windows-command-line.win32.test.ts',
|
||||
'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
|
||||
'src/main/windows/windows-pty-job.win32.test.ts',
|
||||
'src/main/windows/windows-host-job.win32.test.ts',
|
||||
'src/main/wsl/wsl-runner.test.ts',
|
||||
'src/main/wsl/wsl-guest-environment.test.ts',
|
||||
'src/main/wsl/wsl-invocation-boundary.test.ts',
|
||||
'src/main/wsl/wsl-executable-path.win32.test.ts',
|
||||
'src/main/wsl/wsl-w1-w3-contract.test.ts',
|
||||
'src/shared/source-scan/source-tree-scan.test.ts',
|
||||
'src/main/cli/wsl-cli-powershell-boundary.test.ts',
|
||||
'src/main/cursor/hook-service.test.ts',
|
||||
'src/main/orca-profiles/profile-index-store.test.ts',
|
||||
'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
|
||||
'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
|
||||
'src/shared/secure-file-fsync-flags.test.ts',
|
||||
'src/main/ipc/pty-codex-account-attribution.test.ts',
|
||||
'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts'
|
||||
]
|
||||
|
||||
const DESKTOP_IRRELEVANT_PREFIXES = [
|
||||
'mobile/',
|
||||
'.github/workflows/mobile.yml',
|
||||
'.github/workflows/mobile-ios-release.yml',
|
||||
'.github/workflows/mobile-android-release.yml'
|
||||
]
|
||||
|
||||
export function isDocsOnlyPath(file) {
|
||||
if (DOCS_ONLY_FILES.has(file)) {
|
||||
return true
|
||||
@@ -32,10 +209,83 @@ export function shouldRunPrChecks(changedFiles) {
|
||||
if (changedFiles.length === 0) {
|
||||
return true
|
||||
}
|
||||
return changedFiles.some((file) => !isDocsOnlyPath(file))
|
||||
return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
|
||||
}
|
||||
|
||||
export function classifyPrJobs(changedFiles) {
|
||||
const emptyDiff = changedFiles.length === 0
|
||||
const shouldRun = shouldRunPrChecks(changedFiles)
|
||||
const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
|
||||
const jobs = Object.fromEntries(
|
||||
PR_CHECK_JOBS.map((job) => [
|
||||
job,
|
||||
shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
|
||||
])
|
||||
)
|
||||
return { should_run: shouldRun, ...jobs }
|
||||
}
|
||||
|
||||
function jobDetector(job) {
|
||||
switch (job) {
|
||||
case 'git_compatibility':
|
||||
return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
|
||||
case 'xterm_patch_sync':
|
||||
return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
|
||||
case 'shell_contracts':
|
||||
return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES))
|
||||
case 'orcad_browser':
|
||||
return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES))
|
||||
case 'cross-version-wire':
|
||||
return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES))
|
||||
case 'managed_hook_node18':
|
||||
return (files) => files.some((file) => matchesPrefix(file, MANAGED_HOOK_PREFIXES))
|
||||
case 'package':
|
||||
return (files) => files.some(isLinuxPackagePath)
|
||||
case 'package_windows':
|
||||
return (files) => files.some(isWindowsPackagePath)
|
||||
default:
|
||||
return () => true
|
||||
}
|
||||
}
|
||||
|
||||
function isLinuxPackagePath(file) {
|
||||
return LINUX_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, LINUX_PACKAGE_PREFIXES)
|
||||
}
|
||||
|
||||
function isWindowsPackagePath(file) {
|
||||
return WINDOWS_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, WINDOWS_PACKAGE_PREFIXES)
|
||||
}
|
||||
|
||||
function isProductBundlePath(file, extraPrefixes) {
|
||||
if (isTestFile(file)) {
|
||||
return false
|
||||
}
|
||||
if (file.startsWith('src/')) {
|
||||
return true
|
||||
}
|
||||
return matchesPrefix(file, extraPrefixes)
|
||||
}
|
||||
|
||||
function isTestFile(file) {
|
||||
return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/')
|
||||
}
|
||||
|
||||
function isDesktopIrrelevantPath(file) {
|
||||
return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
|
||||
}
|
||||
|
||||
function isGlobalForcePath(file) {
|
||||
return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
|
||||
}
|
||||
|
||||
function matchesPrefix(file, prefixes) {
|
||||
return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
|
||||
process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n')
|
||||
const classification = classifyPrJobs(files)
|
||||
for (const [name, value] of Object.entries(classification)) {
|
||||
process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs'
|
||||
import {
|
||||
classifyPrJobs,
|
||||
isDocsOnlyPath,
|
||||
PR_CHECK_JOBS,
|
||||
shouldRunPrChecks
|
||||
} from './pr-code-change-scope.mjs'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
|
||||
const gatedIf = "needs.code_paths.outputs.should_run == 'true'"
|
||||
const expensiveJobs = [
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
@@ -15,12 +20,34 @@ const expensiveJobs = [
|
||||
'xterm_patch_sync',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'cross-version-wire',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
]
|
||||
|
||||
const ALWAYS_ON = ['static_analysis', 'typecheck', 'test']
|
||||
|
||||
function expectedJobs(overrides, { alwaysOn = true } = {}) {
|
||||
return Object.fromEntries(
|
||||
PR_CHECK_JOBS.map((job) => [
|
||||
job,
|
||||
(alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job])
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function expectClassification(files, overrides) {
|
||||
const result = classifyPrJobs(files)
|
||||
const shouldRun = shouldRunPrChecks(files)
|
||||
expect(result.should_run).toBe(shouldRun)
|
||||
expect(result).toMatchObject({
|
||||
should_run: shouldRun,
|
||||
...expectedJobs(overrides, { alwaysOn: shouldRun })
|
||||
})
|
||||
}
|
||||
|
||||
describe('docs-only path classification', () => {
|
||||
it('treats the WeChat README PR files as docs-only', () => {
|
||||
expect(
|
||||
@@ -54,9 +81,154 @@ describe('docs-only path classification', () => {
|
||||
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
|
||||
expect(shouldRunPrChecks([])).toBe(true)
|
||||
})
|
||||
|
||||
it('does not start desktop PR Checks for mobile-only diffs', () => {
|
||||
expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PR Checks docs-only skip wiring', () => {
|
||||
describe('per-job path classification', () => {
|
||||
it('runs every expensive job on an empty diff rather than skipping by accident', () => {
|
||||
const result = classifyPrJobs([])
|
||||
expect(result.should_run).toBe(true)
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
expect(result[job], job).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('skips every expensive job for docs-only diffs', () => {
|
||||
expectClassification(['README.md', 'docs/readme/README.zh-CN.md'], {})
|
||||
})
|
||||
|
||||
it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => {
|
||||
expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], {
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs Git compatibility only when git capability inputs change', () => {
|
||||
expectClassification(['src/shared/git-capability-cache.ts'], {
|
||||
git_compatibility: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
expectClassification(['src/shared/git-binary-compatibility.test.ts'], {
|
||||
git_compatibility: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs xterm patch sync only when xterm inputs change', () => {
|
||||
expectClassification(['config/patches/xterm-upstream.json'], {
|
||||
xterm_patch_sync: true
|
||||
})
|
||||
expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
|
||||
xterm_patch_sync: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs native package jobs only for the platform that ships the changed native', () => {
|
||||
expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], {
|
||||
package_windows: true
|
||||
})
|
||||
expectClassification(['native/computer-use-linux/runtime.py'], {
|
||||
package: true
|
||||
})
|
||||
expectClassification(['native/computer-use-macos/Package.swift'], {})
|
||||
})
|
||||
|
||||
it('runs shell contracts when live-shell inputs change', () => {
|
||||
expectClassification(['src/main/daemon/shell-ready.ts'], {
|
||||
shell_contracts: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs shell contracts when wrapper templates or live-shell fixtures change', () => {
|
||||
expectClassification(['src/main/shell-templates.ts'], {
|
||||
shell_contracts: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], {
|
||||
shell_contracts: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
|
||||
for (const file of [
|
||||
'src/main/orcad/external-chromium-browser-session.ts',
|
||||
'src/main/orcad/external-chromium-command-arguments.ts',
|
||||
'src/main/orcad/external-chromium-tab-registry.ts',
|
||||
'src/main/orcad/external-chromium-tab-projection.ts'
|
||||
]) {
|
||||
expectClassification([file], {
|
||||
orcad_browser: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
}
|
||||
expectClassification(['src/main/orcad/orcad-native-preflight.ts'], {
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs cross-version wire checks for every working-tree wire module', () => {
|
||||
for (const file of [
|
||||
'src/shared/protocol-version.ts',
|
||||
'src/shared/terminal-stream-protocol.ts',
|
||||
'src/main/runtime/rpc/dispatcher.ts',
|
||||
'src/main/runtime/rpc/methods/browser-tab-create-schema.ts',
|
||||
'src/main/runtime/rpc/methods/terminal.ts',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
|
||||
]) {
|
||||
expectClassification([file], {
|
||||
'cross-version-wire': true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
}
|
||||
expectClassification(
|
||||
['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'],
|
||||
{ 'cross-version-wire': true }
|
||||
)
|
||||
})
|
||||
|
||||
it('runs workflow-self-change and lockfile diffs as force-all', () => {
|
||||
const result = classifyPrJobs(['.github/workflows/pr.yml'])
|
||||
expect(result.should_run).toBe(true)
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
expect(result[job], job).toBe(true)
|
||||
}
|
||||
expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps unit-test-only diffs out of packaging', () => {
|
||||
expectClassification(['src/main/git/git-status.test.ts'], {
|
||||
git_compatibility: true
|
||||
})
|
||||
})
|
||||
|
||||
it('emits GitHub output pairs from the shipped CLI', () => {
|
||||
const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
input: 'config/patches/xterm-upstream.json\n'
|
||||
})
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('should_run=true\n')
|
||||
expect(result.stdout).toContain('xterm_patch_sync=true\n')
|
||||
expect(result.stdout).toContain('git_compatibility=false\n')
|
||||
expect(result.stdout).toContain('package=false\n')
|
||||
expect(result.stdout).toContain('test=true\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PR Checks skip wiring', () => {
|
||||
it('classifies the PR range with a tested script and expands renames', () => {
|
||||
const classify = prWorkflow.jobs.code_paths.steps.find(
|
||||
(step) => step.name === 'Classify changed paths'
|
||||
@@ -65,9 +237,12 @@ describe('PR Checks docs-only skip wiring', () => {
|
||||
expect(classify.run).toContain('--no-renames')
|
||||
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
|
||||
expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
|
||||
'${{ steps.filter.outputs.should_run }}'
|
||||
)
|
||||
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
|
||||
for (const jobName of ['should_run', ...expensiveJobs]) {
|
||||
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
|
||||
`\${{ steps.filter.outputs.${jobName} }}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the cheap root-directory guard on docs-only PRs', () => {
|
||||
@@ -75,10 +250,12 @@ describe('PR Checks docs-only skip wiring', () => {
|
||||
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips expensive jobs unless the detector says the PR has code', () => {
|
||||
it('gates each expensive job on its own classifier output', () => {
|
||||
for (const jobName of expensiveJobs) {
|
||||
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf)
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
|
||||
`needs.code_paths.outputs.${jobName} == 'true'`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,19 +266,23 @@ describe('PR Checks docs-only skip wiring', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => {
|
||||
it('lets verify pass skipped jobs the classifier turned off', () => {
|
||||
const verifyStep = prWorkflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
|
||||
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
|
||||
expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"')
|
||||
const docsOnlyBranch = verifyStep.run.slice(
|
||||
0,
|
||||
verifyStep.run.indexOf('# Require success when the PR has code-relevant changes')
|
||||
)
|
||||
expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]')
|
||||
expect(docsOnlyBranch).not.toContain('"$result" != "success"')
|
||||
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
|
||||
expect(verifyStep.run).toContain('# Require success when the PR has code-relevant changes')
|
||||
expect(verifyStep.run).toContain('expected skipped')
|
||||
expect(verifyStep.run).toContain('expected success')
|
||||
for (const job of prWorkflow.jobs.verify.needs) {
|
||||
if (job === 'code_paths' || job === 'root_directory_guard') {
|
||||
continue
|
||||
}
|
||||
const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
|
||||
expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
|
||||
expect(verifyStep.run).toContain(`"$${envVar}"`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,17 +100,19 @@ describe('PR E2E gate contract', () => {
|
||||
// job without adding it to the strict loop fails here instead of silently
|
||||
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
|
||||
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
|
||||
// Why lastIndexOf: the docs-only branch has its own loop that allows skipped.
|
||||
const successMarker = '# Require success when the PR has code-relevant changes'
|
||||
const successLoop = verifyStep.run.slice(
|
||||
verifyStep.run.indexOf(successMarker),
|
||||
verifyStep.run.lastIndexOf('done')
|
||||
)
|
||||
const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker))
|
||||
expect(successLoop.length).toBeGreaterThan(0)
|
||||
expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
|
||||
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
|
||||
for (const job of prWorkflow.jobs.verify.needs) {
|
||||
const envVar = job.toUpperCase()
|
||||
const envVar = job.replaceAll('-', '_').toUpperCase()
|
||||
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
|
||||
if (job === 'code_paths' || job === 'root_directory_guard') {
|
||||
continue
|
||||
}
|
||||
expect(successLoop).toContain(`"$${envVar}"`)
|
||||
expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -157,6 +159,18 @@ describe('PR E2E gate contract', () => {
|
||||
expect(sshDockerRunner).toContain("'electron-headful'")
|
||||
})
|
||||
|
||||
it('reuses the composite install action instead of duplicating pnpm setup', () => {
|
||||
const installFor = (jobName) =>
|
||||
e2eWorkflow.jobs[jobName].steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
|
||||
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
|
||||
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
|
||||
|
||||
@@ -50,9 +50,9 @@ describe('PR workflow parallelism', () => {
|
||||
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: 16 }, (_, index) => index + 1)
|
||||
Array.from({ length: 8 }, (_, index) => index + 1)
|
||||
)
|
||||
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
|
||||
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(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
@@ -233,21 +233,20 @@ describe('PR workflow parallelism', () => {
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
for (const jobName of [
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'xterm_patch_sync'
|
||||
]) {
|
||||
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(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')
|
||||
|
||||
expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
|
||||
expect(
|
||||
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
|
||||
).toBe("inputs.native-runtime != 'none'")
|
||||
).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
|
||||
const dependencyInstall = dependencyAction.runs.steps.find(
|
||||
(step) => step.name === 'Install dependencies'
|
||||
)
|
||||
@@ -295,14 +294,39 @@ describe('PR workflow parallelism', () => {
|
||||
// overwritten and one after the rebuild would never save a hit.
|
||||
expect(installIndex).toBeLessThan(cacheIndex)
|
||||
expect(cacheIndex).toBeLessThan(prepareIndex)
|
||||
expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
|
||||
expect(steps[cacheIndex].if).toBe(
|
||||
"inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
|
||||
)
|
||||
const restoreOnly = steps.find(
|
||||
(step) => step.name === 'Restore compiled native modules without saving'
|
||||
)
|
||||
expect(restoreOnly.if).toBe(
|
||||
"inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
|
||||
)
|
||||
expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
|
||||
// Native artifacts are ABI-bound: a key missing either dimension serves a build
|
||||
// that cannot load, and ensure-native-runtime would recompile it anyway.
|
||||
expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
|
||||
expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
|
||||
expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
|
||||
// No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
|
||||
expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
|
||||
for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
|
||||
expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
|
||||
expect(cacheStep.with.key).toContain('${{ runner.os }}')
|
||||
expect(cacheStep.with.key).toContain('${{ runner.arch }}')
|
||||
expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version')
|
||||
expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
|
||||
expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
|
||||
expect(cacheStep.with.key).toContain(
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
|
||||
)
|
||||
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@')
|
||||
expect(cacheStep.with['restore-keys']).toBeUndefined()
|
||||
}
|
||||
const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
|
||||
expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
|
||||
expect(cacheScope.run).toContain('/etc/os-release')
|
||||
expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
|
||||
'${{ steps.native-cache-scope.outputs.scope }}'
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses TypeScript incremental state across typecheck runs', () => {
|
||||
@@ -346,6 +370,7 @@ describe('PR workflow parallelism', () => {
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'cross-version-wire',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
@@ -359,5 +384,7 @@ describe('PR workflow parallelism', () => {
|
||||
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
|
||||
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
|
||||
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
|
||||
expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
|
||||
expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -401,14 +401,23 @@ function getPatchedNodePtyRebuildReason() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
|
||||
// load successfully in Electron while missing the patched fd/error handling.
|
||||
// Why: Orca patches node-pty's native Unix spawn path and Windows job-object
|
||||
// exports; upstream prebuilds can load while missing those patches.
|
||||
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
const artifactPaths =
|
||||
rebuildPlatform === 'win32'
|
||||
? [
|
||||
resolve(nodePtyDir, 'build', 'Release', 'conpty.node'),
|
||||
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) =>
|
||||
resolve(nodePtyDir, 'build', 'Release', 'conpty', filename)
|
||||
)
|
||||
]
|
||||
: [
|
||||
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
|
||||
...(osPlatform() === 'darwin'
|
||||
? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')]
|
||||
: [])
|
||||
]
|
||||
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
|
||||
|
||||
if (!missingArtifact) {
|
||||
@@ -422,9 +431,6 @@ function requiresPatchedNodePtySourceBuild() {
|
||||
if (!onlyModules.includes('node-pty')) {
|
||||
return false
|
||||
}
|
||||
if (rebuildPlatform === 'win32') {
|
||||
return false
|
||||
}
|
||||
if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -586,6 +586,13 @@ function writeNodePtyPatchFile(projectDir) {
|
||||
function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(join(buildDir, 'conpty.node'), '')
|
||||
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
|
||||
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
|
||||
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
|
||||
return
|
||||
}
|
||||
writeFileSync(join(buildDir, 'pty.node'), '')
|
||||
if (process.platform === 'darwin') {
|
||||
writeFileSync(join(buildDir, 'spawn-helper'), '')
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('packaged skills CLI PR gates', () => {
|
||||
|
||||
expect(job['runs-on']).toBe('windows-2022')
|
||||
expect(buildStep.run).toBe('pnpm run build:release:parallel')
|
||||
expect(buildStep.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
|
||||
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(packageStep.run).toContain('electron-builder')
|
||||
expect(packageStep.run).toContain('--dir')
|
||||
|
||||
@@ -28,8 +28,14 @@ describe('packaged Windows PTY native capability routing', () => {
|
||||
|
||||
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
|
||||
const job = workflow.jobs.package_windows
|
||||
const sourceRebuild = job.steps.find(
|
||||
(step) => step.name === 'Rebuild node-pty from patched source'
|
||||
const install = job.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const nodeCacheSave = job.steps.find(
|
||||
(step) => step.name === 'Save compiled Node native modules'
|
||||
)
|
||||
const electronCache = job.steps.find(
|
||||
(step) => step.name === 'Restore compiled Electron native modules'
|
||||
)
|
||||
const build = job.steps.find((step) => step.name === 'Build package inputs')
|
||||
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
|
||||
@@ -37,10 +43,17 @@ describe('packaged Windows PTY native capability routing', () => {
|
||||
const verify = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8')
|
||||
|
||||
expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
|
||||
expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
|
||||
expect(install.with['native-runtime']).toBe('node')
|
||||
expect(install.with['persist-native-cache']).toBe('false')
|
||||
expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
|
||||
expect(nodeCacheSave.with.key).toContain('-node-node')
|
||||
expect(electronCache.with.key).toContain('-electron-node')
|
||||
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')
|
||||
expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
|
||||
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
||||
expect(workflow.jobs.verify.needs).toContain('package_windows')
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -326,8 +326,6 @@ the Windows CI job rebuilds from source before running the win32 suites.
|
||||
it is true before asserting anything else, so an unpatched binary fails loudly
|
||||
instead of passing every case vacuously. That guard is what caught this.
|
||||
|
||||
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
|
||||
exempts win32, on the premise that the patch is Unix-only. That premise is now
|
||||
false, but lifting the exemption also needs `pnpm rebuild` to force a source
|
||||
build — otherwise the assertion fires and the remedy does not fix it. Left as a
|
||||
follow-up rather than changed blind.
|
||||
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` now covers
|
||||
win32 as well, and `pnpm rebuild node-pty` sets `npm_config_build_from_source`
|
||||
so the patched source build actually replaces the upstream prebuild.
|
||||
|
||||
@@ -209,7 +209,6 @@ 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'),
|
||||
|
||||
@@ -75,54 +75,18 @@
|
||||
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/components/mobile-rich-markdown-editor-html.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/components/CustomKeyModal.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/pair-scan.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/mock-server.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/terminal/terminal-accessory-keys.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/troubleshoot.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/repro-worktree-startup-stream.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/repro-terminal-colors.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/h/*/files/*.tsx"],
|
||||
"rules": {
|
||||
|
||||
+2
-185
@@ -2,7 +2,6 @@ import { useState, useRef, useCallback } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
@@ -19,13 +18,14 @@ import {
|
||||
} from '../src/transport/pre-profile-pairing-coordinator'
|
||||
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
|
||||
import { useRefreshHostClient } from '../src/transport/client-context'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { colors, spacing } from '../src/theme/mobile-theme'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import {
|
||||
loadMobileOnboardingSteps,
|
||||
mobileOnboardingDestination
|
||||
} from '../src/onboarding/mobile-onboarding-plan'
|
||||
import { pairScanStyles as styles } from '../src/pair-scan-styles'
|
||||
|
||||
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
|
||||
// route surfaces as a real error with the log visible instead of a
|
||||
@@ -356,186 +356,3 @@ export default function PairScanScreen() {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
steps: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
marginLeft: 7
|
||||
},
|
||||
step: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
stepBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
stepNumber: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
stepText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
cameraWrap: {
|
||||
flex: 1,
|
||||
borderRadius: radii.camera,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
// Why: holds the layout slot while the camera is unmounted during
|
||||
// paste, so the bottom action button doesn't snap up to fill the
|
||||
// empty space.
|
||||
cameraPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.camera
|
||||
},
|
||||
camera: {
|
||||
...StyleSheet.absoluteFillObject
|
||||
},
|
||||
reticle: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
reticleFrame: {
|
||||
position: 'relative'
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderColor: 'rgba(255,255,255,0.7)'
|
||||
},
|
||||
cornerTL: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderTopLeftRadius: 6
|
||||
},
|
||||
cornerTR: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderTopRightRadius: 6
|
||||
},
|
||||
cornerBL: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderBottomLeftRadius: 6
|
||||
},
|
||||
cornerBR: {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderBottomRightRadius: 6
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
subtitle: {
|
||||
maxWidth: 310,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
connectingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
logSlot: {
|
||||
width: '100%',
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.sm
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
pasteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
pasteButtonPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
pasteButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
errorActions: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
export type TerminalColorSnapshot = {
|
||||
label: string
|
||||
handle: string
|
||||
cols: number | null
|
||||
rows: number | null
|
||||
serialized: string
|
||||
lines: string
|
||||
}
|
||||
|
||||
function countMatches(value: string, pattern: RegExp): number {
|
||||
return value.match(pattern)?.length ?? 0
|
||||
}
|
||||
|
||||
export function summarizeTerminalColorSnapshot(
|
||||
snapshot: TerminalColorSnapshot
|
||||
): Record<string, string | number | null> {
|
||||
const data = snapshot.serialized
|
||||
return {
|
||||
label: snapshot.label,
|
||||
handle: snapshot.handle,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
serializedBytes: Buffer.byteLength(data),
|
||||
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
|
||||
sgrColor: countMatches(
|
||||
data,
|
||||
new RegExp(
|
||||
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
|
||||
'g'
|
||||
)
|
||||
),
|
||||
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
|
||||
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
|
||||
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
|
||||
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTerminalColorSnapshots(snapshots: TerminalColorSnapshot[]): string {
|
||||
const dir = join(process.cwd(), 'terminal-color-repro')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const snapshot of snapshots) {
|
||||
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
|
||||
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
|
||||
writeFileSync(
|
||||
join(dir, `${base}.escaped.txt`),
|
||||
JSON.stringify(snapshot.serialized || snapshot.lines)
|
||||
)
|
||||
}
|
||||
writeFileSync(
|
||||
join(dir, 'summary.json'),
|
||||
JSON.stringify(snapshots.map(summarizeTerminalColorSnapshot), null, 2)
|
||||
)
|
||||
return dir
|
||||
}
|
||||
@@ -10,10 +10,13 @@
|
||||
* ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \
|
||||
* pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import nacl from 'tweetnacl'
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
saveTerminalColorSnapshots,
|
||||
summarizeTerminalColorSnapshot,
|
||||
type TerminalColorSnapshot
|
||||
} from './repro-terminal-color-output'
|
||||
|
||||
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
|
||||
const token = process.argv[2]
|
||||
@@ -21,7 +24,6 @@ const serverPublicKeyB64 = process.argv[3]
|
||||
const worktreeSelector = process.argv[4]
|
||||
const explicitHandleA = process.argv[5]
|
||||
const explicitHandleB = process.argv[6]
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
type RpcResponse = {
|
||||
id: string
|
||||
@@ -37,15 +39,6 @@ type PendingRequest = {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
type Snapshot = {
|
||||
label: string
|
||||
handle: string
|
||||
cols: number | null
|
||||
rows: number | null
|
||||
serialized: string
|
||||
lines: string
|
||||
}
|
||||
|
||||
if (!token || !serverPublicKeyB64 || !worktreeSelector) {
|
||||
console.error(
|
||||
'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]'
|
||||
@@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise<strin
|
||||
return handle
|
||||
}
|
||||
|
||||
async function captureSnapshot(ws: WebSocket, label: string, handle: string): Promise<Snapshot> {
|
||||
async function captureSnapshot(
|
||||
ws: WebSocket,
|
||||
label: string,
|
||||
handle: string
|
||||
): Promise<TerminalColorSnapshot> {
|
||||
const id = nextId()
|
||||
const snapshot = await new Promise<Snapshot>((resolve, reject) => {
|
||||
const snapshot = await new Promise<TerminalColorSnapshot>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
streamListeners.delete(id)
|
||||
@@ -207,48 +204,6 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function countMatches(value: string, pattern: RegExp): number {
|
||||
return value.match(pattern)?.length ?? 0
|
||||
}
|
||||
|
||||
function summarize(snapshot: Snapshot): Record<string, string | number | null> {
|
||||
const data = snapshot.serialized
|
||||
return {
|
||||
label: snapshot.label,
|
||||
handle: snapshot.handle,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
serializedBytes: Buffer.byteLength(data),
|
||||
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
|
||||
sgrColor: countMatches(
|
||||
data,
|
||||
new RegExp(
|
||||
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
|
||||
'g'
|
||||
)
|
||||
),
|
||||
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
|
||||
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
|
||||
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
|
||||
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
|
||||
}
|
||||
}
|
||||
|
||||
function saveSnapshots(snapshots: Snapshot[]): string {
|
||||
const dir = join(process.cwd(), 'terminal-color-repro')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const snapshot of snapshots) {
|
||||
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
|
||||
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
|
||||
writeFileSync(
|
||||
join(dir, `${base}.escaped.txt`),
|
||||
JSON.stringify(snapshot.serialized || snapshot.lines)
|
||||
)
|
||||
}
|
||||
writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function run(ws: WebSocket): Promise<void> {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise<void> {
|
||||
const firstB = await captureSnapshot(ws, 'b-during-switch', handleB)
|
||||
const secondA = await captureSnapshot(ws, 'a-after-switch', handleA)
|
||||
const snapshots = [firstA, firstB, secondA]
|
||||
const dir = saveSnapshots(snapshots)
|
||||
const dir = saveTerminalColorSnapshots(snapshots)
|
||||
|
||||
console.table(snapshots.map(summarize))
|
||||
console.table(snapshots.map(summarizeTerminalColorSnapshot))
|
||||
console.log(`saved: ${dir}`)
|
||||
if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) {
|
||||
if (
|
||||
summarizeTerminalColorSnapshot(firstA).sgrColor !==
|
||||
summarizeTerminalColorSnapshot(secondA).sgrColor
|
||||
) {
|
||||
console.log(
|
||||
'color SGR count changed between A snapshots: serialization/state changed on desktop'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
export type StartupTerminalCapture = {
|
||||
handle: string
|
||||
title: string | null
|
||||
scrollback: Record<string, unknown> | null
|
||||
chunks: string[]
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return (
|
||||
value
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\r/g, '\n')
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePreview(value: string): string {
|
||||
return stripAnsi(value)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function summarizeStartupCapture(capture: StartupTerminalCapture): Record<string, unknown> {
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
const live = capture.chunks.join('')
|
||||
const serializedPreview = normalizePreview(serialized)
|
||||
const livePreview = normalizePreview(live)
|
||||
return {
|
||||
handle: capture.handle,
|
||||
title: capture.title,
|
||||
cols: capture.scrollback?.cols ?? null,
|
||||
rows: capture.scrollback?.rows ?? null,
|
||||
serializedBytes: Buffer.byteLength(serialized),
|
||||
liveBytes: Buffer.byteLength(live),
|
||||
liveChunks: capture.chunks.length,
|
||||
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
livePreviewContainedInSerialized:
|
||||
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
|
||||
serializedPreview,
|
||||
livePreview
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStartupCaptures(
|
||||
captures: StartupTerminalCapture[],
|
||||
worktreeName: string
|
||||
): string {
|
||||
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const capture of captures) {
|
||||
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
|
||||
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
|
||||
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
|
||||
}
|
||||
writeFileSync(
|
||||
join(dir, 'summary.json'),
|
||||
JSON.stringify(captures.map(summarizeStartupCapture), null, 2)
|
||||
)
|
||||
return dir
|
||||
}
|
||||
@@ -4,10 +4,15 @@
|
||||
* Usage:
|
||||
* pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import nacl from 'tweetnacl'
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
saveStartupCaptures,
|
||||
summarizeStartupCapture,
|
||||
type StartupTerminalCapture
|
||||
} from './repro-worktree-startup-output'
|
||||
|
||||
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
|
||||
const USER_DATA =
|
||||
@@ -15,7 +20,6 @@ const USER_DATA =
|
||||
const repoSelector = process.argv[2]
|
||||
const worktreeName = process.argv[3]
|
||||
const startupCommand = process.argv[4] || 'claude'
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
type RpcResponse = {
|
||||
id: string
|
||||
@@ -36,13 +40,6 @@ type TerminalInfo = {
|
||||
title: string | null
|
||||
}
|
||||
|
||||
type Capture = {
|
||||
handle: string
|
||||
title: string | null
|
||||
scrollback: Record<string, unknown> | null
|
||||
chunks: string[]
|
||||
}
|
||||
|
||||
if (!repoSelector || !worktreeName) {
|
||||
console.error(
|
||||
'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]'
|
||||
@@ -141,26 +138,6 @@ function formatError(response: RpcResponse): string {
|
||||
return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000)
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return (
|
||||
value
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\r/g, '\n')
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePreview(value: string): string {
|
||||
return stripAnsi(value)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<TerminalInfo[]> {
|
||||
const deadline = Date.now() + 60_000
|
||||
while (Date.now() < deadline) {
|
||||
@@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<Term
|
||||
throw new Error('Timed out waiting for startup terminals')
|
||||
}
|
||||
|
||||
async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
|
||||
async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise<void> {
|
||||
const id = nextId()
|
||||
streamListeners.set(id, (result) => {
|
||||
if (result.type === 'scrollback') {
|
||||
@@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function summarize(capture: Capture): Record<string, unknown> {
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
const live = capture.chunks.join('')
|
||||
const serializedPreview = normalizePreview(serialized)
|
||||
const livePreview = normalizePreview(live)
|
||||
return {
|
||||
handle: capture.handle,
|
||||
title: capture.title,
|
||||
cols: capture.scrollback?.cols ?? null,
|
||||
rows: capture.scrollback?.rows ?? null,
|
||||
serializedBytes: Buffer.byteLength(serialized),
|
||||
liveBytes: Buffer.byteLength(live),
|
||||
liveChunks: capture.chunks.length,
|
||||
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
livePreviewContainedInSerialized:
|
||||
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
|
||||
serializedPreview,
|
||||
livePreview
|
||||
}
|
||||
}
|
||||
|
||||
function save(captures: Capture[], worktreeName: string): string {
|
||||
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const capture of captures) {
|
||||
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
|
||||
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
|
||||
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
|
||||
}
|
||||
writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function run(ws: WebSocket): Promise<void> {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise<void> {
|
||||
for (const capture of captures) {
|
||||
await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null)
|
||||
}
|
||||
const dir = save(captures, worktreeName)
|
||||
console.table(captures.map(summarize))
|
||||
const dir = saveStartupCaptures(captures, worktreeName)
|
||||
console.table(captures.map(summarizeStartupCapture))
|
||||
console.log(`saved: ${dir}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const customKeyModalStyles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
backButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
backSpacer: {
|
||||
width: 30
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center'
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
row: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 1
|
||||
},
|
||||
rowHint: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted
|
||||
},
|
||||
shortcutForm: {
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
preview: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.lg + spacing.xs,
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
previewKeycapRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
previewPlus: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16
|
||||
},
|
||||
keycap: {
|
||||
minWidth: 48,
|
||||
height: 48,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keycapModifier: {
|
||||
minWidth: 0
|
||||
},
|
||||
keycapWarn: {
|
||||
borderColor: colors.statusAmber
|
||||
},
|
||||
keycapText: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 17,
|
||||
fontWeight: '600'
|
||||
},
|
||||
keycapTextWarn: {
|
||||
color: colors.statusAmber
|
||||
},
|
||||
keycapModifierText: {
|
||||
color: colors.textSecondary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: spacing.sm,
|
||||
paddingLeft: 2
|
||||
},
|
||||
mods: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
chip: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4
|
||||
},
|
||||
chipSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
chipPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
chipText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: '500'
|
||||
},
|
||||
chipTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
chipGlyph: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
chipGlyphSelected: {
|
||||
color: 'rgba(10,10,10,0.5)'
|
||||
},
|
||||
keyInput: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center'
|
||||
},
|
||||
moreLink: {
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center'
|
||||
},
|
||||
moreLinkPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
moreLinkText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
textDecorationLine: 'underline'
|
||||
},
|
||||
specialKeysForm: {
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.md
|
||||
},
|
||||
specialGroup: {
|
||||
gap: spacing.xs
|
||||
},
|
||||
specialGroupTitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
paddingLeft: 2,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
keyGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginHorizontal: -spacing.xs / 2
|
||||
},
|
||||
keyCellWrap: {
|
||||
paddingHorizontal: spacing.xs / 2,
|
||||
paddingVertical: spacing.xs / 2
|
||||
},
|
||||
keyCell: {
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keyCellPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
keyCellSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
keyCellText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
keyCellTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
macroForm: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
fieldInput: {
|
||||
backgroundColor: colors.bgBase,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
switchLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
saveButton: {
|
||||
marginTop: spacing.md,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: 15,
|
||||
fontWeight: '600'
|
||||
},
|
||||
saveButtonTextDisabled: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
|
||||
import { View, Text, Pressable, TextInput, Switch } from 'react-native'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import {
|
||||
buildTerminalShortcutKey,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type TerminalShortcutModifier,
|
||||
type TerminalShortcutSpecialKey
|
||||
} from '../terminal/terminal-accessory-keys'
|
||||
import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
|
||||
|
||||
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
|
||||
|
||||
@@ -409,277 +410,3 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
backButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
backSpacer: {
|
||||
width: 30
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center'
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
row: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 1
|
||||
},
|
||||
rowHint: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted
|
||||
},
|
||||
shortcutForm: {
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
preview: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.lg + spacing.xs,
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
previewKeycapRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
previewPlus: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16
|
||||
},
|
||||
keycap: {
|
||||
minWidth: 48,
|
||||
height: 48,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keycapModifier: {
|
||||
minWidth: 0
|
||||
},
|
||||
keycapWarn: {
|
||||
borderColor: colors.statusAmber
|
||||
},
|
||||
keycapText: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 17,
|
||||
fontWeight: '600'
|
||||
},
|
||||
keycapTextWarn: {
|
||||
color: colors.statusAmber
|
||||
},
|
||||
keycapModifierText: {
|
||||
color: colors.textSecondary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: spacing.sm,
|
||||
paddingLeft: 2
|
||||
},
|
||||
mods: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
chip: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4
|
||||
},
|
||||
chipSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
chipPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
chipText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: '500'
|
||||
},
|
||||
chipTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
chipGlyph: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
chipGlyphSelected: {
|
||||
color: 'rgba(10,10,10,0.5)'
|
||||
},
|
||||
keyInput: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center'
|
||||
},
|
||||
moreLink: {
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center'
|
||||
},
|
||||
moreLinkPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
moreLinkText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
textDecorationLine: 'underline'
|
||||
},
|
||||
specialKeysForm: {
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.md
|
||||
},
|
||||
specialGroup: {
|
||||
gap: spacing.xs
|
||||
},
|
||||
specialGroupTitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
paddingLeft: 2,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
keyGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginHorizontal: -spacing.xs / 2
|
||||
},
|
||||
keyCellWrap: {
|
||||
paddingHorizontal: spacing.xs / 2,
|
||||
paddingVertical: spacing.xs / 2
|
||||
},
|
||||
keyCell: {
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keyCellPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
keyCellSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
keyCellText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
keyCellTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
macroForm: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
fieldInput: {
|
||||
backgroundColor: colors.bgBase,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
switchLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
saveButton: {
|
||||
marginTop: spacing.md,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: 15,
|
||||
fontWeight: '600'
|
||||
},
|
||||
saveButtonTextDisabled: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [
|
||||
';',
|
||||
' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;',
|
||||
' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
|
||||
' }',
|
||||
' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }',
|
||||
' html, body {',
|
||||
' width: 100%;',
|
||||
' min-height: 100%;',
|
||||
' margin: 0;',
|
||||
' background: var(--editor-surface);',
|
||||
' color: var(--foreground);',
|
||||
' font-family: var(--font-sans);',
|
||||
' overscroll-behavior: contain;',
|
||||
' }',
|
||||
' body { overflow: auto; }',
|
||||
' #editor {',
|
||||
' min-height: 100vh;',
|
||||
' padding: 18px 16px 112px;',
|
||||
' outline: none;',
|
||||
' font-size: 14px;',
|
||||
' line-height: 1.7;',
|
||||
' word-wrap: break-word;',
|
||||
' overflow-wrap: anywhere;',
|
||||
' caret-color: var(--foreground);',
|
||||
' }',
|
||||
' #editor[contenteditable="false"] {',
|
||||
' opacity: 0.78;',
|
||||
' }',
|
||||
' #editor:empty::before,',
|
||||
' #editor p.is-empty:first-child::before {',
|
||||
' content: attr(data-placeholder);',
|
||||
' color: var(--muted-foreground);',
|
||||
' pointer-events: none;',
|
||||
' }',
|
||||
' #editor > :first-child { margin-top: 0; }',
|
||||
' h1, h2, h3, h4, h5, h6 {',
|
||||
' margin: 1.5em 0 0.5em;',
|
||||
' font-weight: 600;',
|
||||
' line-height: 1.3;',
|
||||
' letter-spacing: 0;',
|
||||
' }',
|
||||
' h1 { font-size: 1.85em; font-weight: 700; }',
|
||||
' h2 { font-size: 1.4em; }',
|
||||
' h3 { font-size: 1.15em; }',
|
||||
' p, ul, ol, blockquote { margin: 0.75em 0; }',
|
||||
' ul, ol { padding-left: 1.5em; }',
|
||||
' ul { list-style: disc; }',
|
||||
' ol { list-style: decimal; }',
|
||||
' li { margin: 0.15em 0; }',
|
||||
' li > p { margin: 0; }',
|
||||
' ul[data-type="taskList"] {',
|
||||
' padding-left: 0;',
|
||||
' list-style: none;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li {',
|
||||
' display: flex;',
|
||||
' align-items: flex-start;',
|
||||
' gap: 6px;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li > label {',
|
||||
' flex-shrink: 0;',
|
||||
' display: flex;',
|
||||
' align-items: center;',
|
||||
' height: 1.55em;',
|
||||
' user-select: none;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"] {',
|
||||
' appearance: none;',
|
||||
' width: 16px;',
|
||||
' height: 16px;',
|
||||
' margin: 0;',
|
||||
' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);',
|
||||
' border-radius: 4px;',
|
||||
' background: transparent;',
|
||||
' position: relative;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:checked {',
|
||||
' background: var(--primary);',
|
||||
' border-color: var(--primary);',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:checked::after {',
|
||||
' content: "";',
|
||||
' position: absolute;',
|
||||
' left: 4px;',
|
||||
' top: 1px;',
|
||||
' width: 5px;',
|
||||
' height: 9px;',
|
||||
' border: solid var(--primary-foreground);',
|
||||
' border-width: 0 2px 2px 0;',
|
||||
' transform: rotate(45deg);',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:disabled {',
|
||||
' opacity: 0.65;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li > div {',
|
||||
' flex: 1;',
|
||||
' min-width: 0;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li[data-checked="true"] > div {',
|
||||
' text-decoration: line-through;',
|
||||
' color: var(--muted-foreground);',
|
||||
' }',
|
||||
' blockquote {',
|
||||
' padding: 0.5em 1em;',
|
||||
' border-left: 3px solid var(--border);',
|
||||
' border-radius: 0 6px 6px 0;',
|
||||
' color: var(--muted-foreground);',
|
||||
' background: color-mix(in srgb, var(--foreground) 2%, transparent);',
|
||||
' }',
|
||||
' table {',
|
||||
' width: 100%;',
|
||||
' margin: 1em 0;',
|
||||
' border-collapse: collapse;',
|
||||
' font-size: 0.95em;',
|
||||
' }',
|
||||
' th, td {',
|
||||
' padding: 8px 14px;',
|
||||
' border: 1px solid var(--border);',
|
||||
' text-align: left;',
|
||||
' vertical-align: top;',
|
||||
' }',
|
||||
' th {',
|
||||
' font-weight: 600;',
|
||||
' background: color-mix(in srgb, var(--foreground) 4%, transparent);',
|
||||
' }',
|
||||
' tr:nth-child(odd) td {',
|
||||
' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);',
|
||||
' }',
|
||||
' code {',
|
||||
' padding: 0.2em 0.4em;',
|
||||
' border-radius: 5px;',
|
||||
' background: color-mix(in srgb, var(--foreground) 8%, transparent);',
|
||||
' font-size: 0.88em;',
|
||||
' font-family: var(--font-mono);',
|
||||
' }',
|
||||
' pre {',
|
||||
' margin: 0.75em 0;',
|
||||
' padding: 14px 18px;',
|
||||
' border-radius: 8px;',
|
||||
' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);',
|
||||
' overflow-x: auto;',
|
||||
' line-height: 1.55;',
|
||||
' background: color-mix(in srgb, var(--foreground) 6%, transparent);',
|
||||
' font-family: var(--font-mono);',
|
||||
' white-space: pre-wrap;',
|
||||
' }',
|
||||
' pre::before {',
|
||||
' content: attr(data-language);',
|
||||
' display: block;',
|
||||
' min-height: 13px;',
|
||||
' margin-bottom: 4px;',
|
||||
' color: var(--muted-foreground);',
|
||||
' font-size: 11px;',
|
||||
' text-transform: uppercase;',
|
||||
' }',
|
||||
' pre code {',
|
||||
' padding: 0;',
|
||||
' border-radius: 0;',
|
||||
' background: transparent;',
|
||||
' font-size: 0.92em;',
|
||||
' white-space: pre-wrap;',
|
||||
' }',
|
||||
' hr {',
|
||||
' margin: 1.5em 0;',
|
||||
' border: none;',
|
||||
' border-top: 1px solid var(--border);',
|
||||
' }',
|
||||
' a {',
|
||||
' color: var(--accent-link);',
|
||||
' text-decoration: underline;',
|
||||
' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);',
|
||||
' text-underline-offset: 2px;',
|
||||
' }',
|
||||
' img {',
|
||||
' display: block;',
|
||||
' max-width: 100%;',
|
||||
' margin: 0.75em 0;',
|
||||
' border-radius: 8px;',
|
||||
' }',
|
||||
' </style>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
' <main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>',
|
||||
' <script>',
|
||||
' (function () {',
|
||||
" var editor = document.getElementById('editor');",
|
||||
" var lastMarkdown = '';",
|
||||
' var inputTimer = null;',
|
||||
' var documentGeneration = 0;',
|
||||
' var editable = true;',
|
||||
' var suppressInput = false;',
|
||||
'',
|
||||
' function post(message) {',
|
||||
' window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));',
|
||||
' }',
|
||||
'',
|
||||
' function decodeMarkdownEntities(value) {',
|
||||
' return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {',
|
||||
' var lower = String(entity).toLowerCase();',
|
||||
" if (lower === 'amp') return '&';",
|
||||
" if (lower === 'lt') return '<';",
|
||||
" if (lower === 'gt') return '>';",
|
||||
" if (lower === 'quot') return '\"';",
|
||||
" if (lower === 'apos') return \"'\";",
|
||||
" if (lower.indexOf('#x') === 0) {",
|
||||
' var hex = Number.parseInt(lower.slice(2), 16);',
|
||||
' return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;',
|
||||
' }',
|
||||
" if (lower.indexOf('#') === 0) {",
|
||||
' var code = Number.parseInt(lower.slice(1), 10);',
|
||||
' return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;',
|
||||
' }',
|
||||
' return match;',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function escapeHtml(value) {',
|
||||
' return decodeMarkdownEntities(value).replace(/[&<>"\']/g, function (char) {',
|
||||
" return ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[char];",
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function escapeAttr(value) {',
|
||||
" return escapeHtml(value).replace(/\\n/g, ' ');",
|
||||
' }',
|
||||
'',
|
||||
' function isSafeUrl(value) {',
|
||||
" var trimmed = String(value || '').trim();",
|
||||
' return !/^javascript:/i.test(trimmed);',
|
||||
' }',
|
||||
'',
|
||||
' function splitTableRow(line) {',
|
||||
" return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {",
|
||||
' return cell.trim();',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function isTableSeparator(line) {',
|
||||
' var cells = splitTableRow(line);',
|
||||
' return cells.length > 0 && cells.every(function (cell) {',
|
||||
' return /^:?-{3,}:?$/.test(cell);',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function renderInline(text) {',
|
||||
" var output = '';",
|
||||
' var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|`[^`]+`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;',
|
||||
' var lastIndex = 0;',
|
||||
' var match;',
|
||||
' while ((match = pattern.exec(text))) {',
|
||||
' output += escapeHtml(text.slice(lastIndex, match.index));',
|
||||
' var token = match[0];',
|
||||
' var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);',
|
||||
' var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);',
|
||||
' if (image && isSafeUrl(image[2])) {',
|
||||
" output += '<img src=\"' + escapeAttr(image[2]) + '\" alt=\"' + escapeAttr(image[1] || '') + '\" />';",
|
||||
' } else if (link && isSafeUrl(link[2])) {',
|
||||
" output += '<a href=\"' + escapeAttr(link[2]) + '\">' + renderInline(link[1]) + '</a>';",
|
||||
' } else if (/^https?:\\/\\//i.test(token)) {',
|
||||
" output += '<a href=\"' + escapeAttr(token) + '\">' + escapeHtml(token) + '</a>';",
|
||||
" } else if (token.indexOf('`') === 0) {",
|
||||
" output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';",
|
||||
" } else if (token.indexOf('~~') === 0) {",
|
||||
" output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';",
|
||||
" } else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {",
|
||||
" output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';",
|
||||
' } else {',
|
||||
" output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';",
|
||||
' }',
|
||||
' lastIndex = pattern.lastIndex;',
|
||||
' }',
|
||||
' output += escapeHtml(text.slice(lastIndex));',
|
||||
' return output;',
|
||||
' }',
|
||||
'',
|
||||
' function isBlockStart(line) {',
|
||||
' return /^(```|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);'
|
||||
].join('\n')
|
||||
@@ -0,0 +1,278 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY = [
|
||||
' }',
|
||||
'',
|
||||
' function indentationWidth(value) {',
|
||||
" return String(value || '').replace(/\\t/g, ' ').length;",
|
||||
' }',
|
||||
'',
|
||||
' function parseListLine(line) {',
|
||||
" var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);",
|
||||
' if (!match) return null;',
|
||||
" var rawText = match[3] || '';",
|
||||
' var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);',
|
||||
' return {',
|
||||
" indent: indentationWidth(match[1] || ''),",
|
||||
" ordered: /^\\d/.test(match[2] || ''),",
|
||||
" orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,",
|
||||
" task: task ? task[1].toLowerCase() === 'x' : null,",
|
||||
' text: task ? task[2] : rawText,',
|
||||
' children: []',
|
||||
' };',
|
||||
' }',
|
||||
'',
|
||||
' function listKind(item) {',
|
||||
" if (item.task !== null) return 'task';",
|
||||
" return item.ordered ? 'ol' : 'ul';",
|
||||
' }',
|
||||
'',
|
||||
' function parseListTree(lines, startIndex) {',
|
||||
' var root = { indent: -1, children: [] };',
|
||||
' var stack = [root];',
|
||||
' var index = startIndex;',
|
||||
' while (index < lines.length) {',
|
||||
" var item = parseListLine(lines[index] || '');",
|
||||
' if (!item) break;',
|
||||
' while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {',
|
||||
' stack.pop();',
|
||||
' }',
|
||||
' stack[stack.length - 1].children.push(item);',
|
||||
' stack.push(item);',
|
||||
' index += 1;',
|
||||
' }',
|
||||
' return { items: root.children, nextIndex: index };',
|
||||
' }',
|
||||
'',
|
||||
' function renderListItems(items) {',
|
||||
' var html = [];',
|
||||
' var index = 0;',
|
||||
' while (index < items.length) {',
|
||||
' var kind = listKind(items[index]);',
|
||||
' var group = [];',
|
||||
' while (index < items.length && listKind(items[index]) === kind) {',
|
||||
' group.push(items[index]);',
|
||||
' index += 1;',
|
||||
' }',
|
||||
" var tag = kind === 'ol' ? 'ol' : 'ul';",
|
||||
" var attrs = kind === 'task' ? ' data-type=\"taskList\"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start=\"' + group[0].orderedNumber + '\"' : '';",
|
||||
" html.push('<' + tag + attrs + '>' + group.map(function (item) {",
|
||||
" var children = item.children.length ? renderListItems(item.children) : '';",
|
||||
" if (kind === 'task') {",
|
||||
' var checked = item.task === true;',
|
||||
" return '<li data-checked=\"' + String(checked) + '\"><label contenteditable=\"false\"><input type=\"checkbox\" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';",
|
||||
' }',
|
||||
" var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value=\"' + item.orderedNumber + '\" data-list-number=\"' + item.orderedNumber + '\"' : '';",
|
||||
" return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';",
|
||||
" }).join('') + '</' + tag + '>');",
|
||||
' }',
|
||||
" return html.join('');",
|
||||
' }',
|
||||
'',
|
||||
' function markdownToHtml(markdown) {',
|
||||
" var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');",
|
||||
' var html = [];',
|
||||
' var index = 0;',
|
||||
' while (index < lines.length) {',
|
||||
" var line = lines[index] || '';",
|
||||
' if (!line.trim()) {',
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
' var fence = line.match(/^\\```([^\\s`]*)\\s*$/);',
|
||||
' if (fence) {',
|
||||
' index += 1;',
|
||||
' var code = [];',
|
||||
" while (index < lines.length && !/^\\```\\s*$/.test(lines[index] || '')) {",
|
||||
" code.push(lines[index] || '');",
|
||||
' index += 1;',
|
||||
' }',
|
||||
' if (index < lines.length) index += 1;',
|
||||
" html.push('<pre data-language=\"' + escapeAttr(fence[1] || '') + '\"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {',
|
||||
" html.push('<hr />');",
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
" if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {",
|
||||
' var headers = splitTableRow(line);',
|
||||
' index += 2;',
|
||||
' var rows = [];',
|
||||
" while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {",
|
||||
" rows.push(splitTableRow(lines[index] || ''));",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {",
|
||||
" return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';",
|
||||
" }).join('') + '</tbody></table>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' var heading = line.match(/^(#{1,6})\\s+(.+)$/);',
|
||||
' if (heading) {',
|
||||
" html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');",
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^>\\s?/.test(line)) {',
|
||||
' var quote = [];',
|
||||
" while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {",
|
||||
" quote.push((lines[index] || '').replace(/^>\\s?/, ''));",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {',
|
||||
' var list = parseListTree(lines, index);',
|
||||
' html.push(renderListItems(list.items));',
|
||||
' index = list.nextIndex;',
|
||||
' continue;',
|
||||
' }',
|
||||
' var paragraph = [];',
|
||||
" while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {",
|
||||
" paragraph.push(lines[index] || '');",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');",
|
||||
' }',
|
||||
" return html.join('\\n') || '<p class=\"is-empty\"><br /></p>';",
|
||||
' }',
|
||||
'',
|
||||
' function textContent(node) {',
|
||||
" return (node.textContent || '').replace(/\\u00a0/g, ' ');",
|
||||
' }',
|
||||
'',
|
||||
' function inlineMarkdown(node) {',
|
||||
" if (!node) return '';",
|
||||
' if (node.nodeType === Node.TEXT_NODE) return textContent(node);',
|
||||
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
|
||||
' var el = node;',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" if (tag === 'br') return '\\n';",
|
||||
" if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';",
|
||||
" if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';",
|
||||
" if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';",
|
||||
" if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '`' + textContent(el) + '`';",
|
||||
" if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';",
|
||||
" if (tag === 'img') return ' || '') + ')';",
|
||||
" if (tag === 'label') return '';",
|
||||
' return inlineChildren(el);',
|
||||
' }',
|
||||
'',
|
||||
' function inlineChildren(el) {',
|
||||
" return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');",
|
||||
' }',
|
||||
'',
|
||||
' function listItemText(li) {',
|
||||
' var clone = li.cloneNode(true);',
|
||||
" Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });",
|
||||
" Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });",
|
||||
' return inlineChildren(clone).trim();',
|
||||
' }',
|
||||
'',
|
||||
' function directNestedLists(li) {',
|
||||
" return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {",
|
||||
" return list.closest('li') === li;",
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function listMarkdown(el, depth) {',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" var isTask = el.getAttribute('data-type') === 'taskList';",
|
||||
" var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;",
|
||||
' if (!Number.isFinite(orderedStart)) orderedStart = 1;',
|
||||
" var indent = ' '.repeat(depth);",
|
||||
' return Array.prototype.map.call(el.children, function (li, index) {',
|
||||
" if (li.tagName.toLowerCase() !== 'li') return '';",
|
||||
' var marker;',
|
||||
' if (isTask) {',
|
||||
' var input = li.querySelector(\'input[type="checkbox"]\');',
|
||||
" marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';",
|
||||
' } else {',
|
||||
' var listNumber =',
|
||||
' li.getAttribute &&',
|
||||
" (li.getAttribute('data-list-number') || li.getAttribute('value'));",
|
||||
" marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';",
|
||||
' }',
|
||||
' var line = indent + marker + listItemText(li);',
|
||||
' var nested = directNestedLists(li).map(function (list) {',
|
||||
' return listMarkdown(list, depth + 1);',
|
||||
" }).filter(Boolean).join('\\n');",
|
||||
" return nested ? line + '\\n' + nested : line;",
|
||||
" }).filter(Boolean).join('\\n');",
|
||||
' }',
|
||||
'',
|
||||
' function blockMarkdown(node) {',
|
||||
' if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();',
|
||||
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
|
||||
' var el = node;',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();",
|
||||
" if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();",
|
||||
" if (tag === 'blockquote') {",
|
||||
" return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');",
|
||||
' }',
|
||||
" if (tag === 'pre') {",
|
||||
" var lang = el.getAttribute('data-language') || '';",
|
||||
" var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');",
|
||||
" return '```' + lang + '\\n' + code + '\\n```';",
|
||||
' }',
|
||||
" if (tag === 'ul' || tag === 'ol') {",
|
||||
' return listMarkdown(el, 0);',
|
||||
' }',
|
||||
" if (tag === 'table') {",
|
||||
" var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));",
|
||||
" if (rows.length === 0) return '';",
|
||||
' var cellsFor = function (row) {',
|
||||
' return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });',
|
||||
' };',
|
||||
' var headers = cellsFor(rows[0]);',
|
||||
' var bodyRows = rows.slice(1).map(cellsFor);',
|
||||
" return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');",
|
||||
' }',
|
||||
" if (tag === 'hr') return '---';",
|
||||
" if (tag === 'img') return inlineMarkdown(el);",
|
||||
' return inlineChildren(el).trim();',
|
||||
' }',
|
||||
'',
|
||||
' function currentMarkdown() {',
|
||||
' return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {',
|
||||
' return block.trim().length > 0;',
|
||||
" }).join('\\n\\n').trimEnd();",
|
||||
' }',
|
||||
'',
|
||||
' function syncTaskCheckboxesDisabled() {',
|
||||
' Array.prototype.forEach.call(editor.querySelectorAll(\'input[type="checkbox"]\'), function (input) {',
|
||||
' input.disabled = !editable;',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function emitChange() {',
|
||||
' if (suppressInput || !editable) return;',
|
||||
' window.clearTimeout(inputTimer);',
|
||||
' var pendingGeneration = documentGeneration;',
|
||||
' lastMarkdown = currentMarkdown();',
|
||||
" post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });",
|
||||
' }',
|
||||
'',
|
||||
' function setMarkdown(markdown, generation) {',
|
||||
' window.clearTimeout(inputTimer);',
|
||||
' documentGeneration = Number(generation) || 0;',
|
||||
' suppressInput = true;',
|
||||
" // Why: replacing innerHTML detaches the remembered caret's nodes.",
|
||||
' savedSelectionRange = null;',
|
||||
' selectionDroppedOnBlur = false;',
|
||||
" lastMarkdown = String(markdown || '');",
|
||||
' editor.innerHTML = markdownToHtml(lastMarkdown);',
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' suppressInput = false;',
|
||||
' }',
|
||||
'',
|
||||
' function setEditable(nextEditable) {',
|
||||
' editable = Boolean(nextEditable);',
|
||||
" editor.setAttribute('contenteditable', editable ? 'true' : 'false');",
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' }',
|
||||
'',
|
||||
''
|
||||
].join('\n')
|
||||
@@ -0,0 +1,93 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS = [
|
||||
'',
|
||||
' function runCommand(command) {',
|
||||
" if (!editable || editor.getAttribute('contenteditable') !== 'true') return;",
|
||||
' restoreSelectionOrEnd();',
|
||||
" if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');",
|
||||
" else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');",
|
||||
" else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');",
|
||||
" else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');",
|
||||
" else if (command === 'bold') document.execCommand('bold');",
|
||||
" else if (command === 'italic') document.execCommand('italic');",
|
||||
" else if (command === 'strike') document.execCommand('strikeThrough');",
|
||||
" else if (command === 'bulletList') document.execCommand('insertUnorderedList');",
|
||||
" else if (command === 'orderedList') document.execCommand('insertOrderedList');",
|
||||
" else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');",
|
||||
" else if (command === 'inlineCode') wrapSelection('code');",
|
||||
" else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=\"\"><code>code</code></pre><p><br></p>');",
|
||||
' else if (command === \'taskList\') document.execCommand(\'insertHTML\', false, \'<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>\');',
|
||||
" else if (command === 'link') {",
|
||||
" var href = window.prompt('Link URL');",
|
||||
" if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);",
|
||||
" } else if (command === 'image') {",
|
||||
" var src = window.prompt('Image URL');",
|
||||
" if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);",
|
||||
' }',
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' emitChange();',
|
||||
' }',
|
||||
'',
|
||||
" editor.addEventListener('input', function () {",
|
||||
' selectionDroppedOnBlur = false;',
|
||||
' if (editable) emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('change', function (event) {",
|
||||
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
|
||||
' if (input) {',
|
||||
' if (!editable) {',
|
||||
' event.preventDefault();',
|
||||
' return;',
|
||||
' }',
|
||||
" var li = input.closest('li');",
|
||||
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
|
||||
' }',
|
||||
' if (editable) emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('click', function (event) {",
|
||||
" var link = event.target && event.target.closest && event.target.closest('a[href]');",
|
||||
' if (link) {',
|
||||
' event.preventDefault();',
|
||||
" post({ type: 'openLink', url: link.getAttribute('href') || '' });",
|
||||
' return;',
|
||||
' }',
|
||||
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
|
||||
' if (!input) {',
|
||||
' if (!editable) return;',
|
||||
' // Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.',
|
||||
' var uneditable = event.target && event.target.closest && event.target.closest(\'[contenteditable="false"]\');',
|
||||
' if (uneditable && uneditable !== editor) return;',
|
||||
' selectionDroppedOnBlur = false;',
|
||||
' if (document.activeElement === editor) return;',
|
||||
' // Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.',
|
||||
' var caret = caretRangeAtPoint(event.clientX, event.clientY);',
|
||||
' focusEditor();',
|
||||
' if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);',
|
||||
' return;',
|
||||
' }',
|
||||
' if (!editable) {',
|
||||
' event.preventDefault();',
|
||||
' return;',
|
||||
' }',
|
||||
" var li = input.closest('li');",
|
||||
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
|
||||
' emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('keydown', function (event) {",
|
||||
" if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {",
|
||||
' event.preventDefault();',
|
||||
" runCommand('bold');",
|
||||
' }',
|
||||
' });',
|
||||
'',
|
||||
' window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END = [
|
||||
'',
|
||||
" post({ type: 'ready' });",
|
||||
' })();',
|
||||
' </script>',
|
||||
'</body>',
|
||||
'</html>'
|
||||
].join('\n')
|
||||
@@ -2,6 +2,12 @@ import { colors } from '../theme/mobile-theme'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
|
||||
import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script'
|
||||
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY } from './mobile-rich-markdown-editor-body-primary'
|
||||
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY } from './mobile-rich-markdown-editor-body-secondary'
|
||||
import {
|
||||
MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS,
|
||||
MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END
|
||||
} from './mobile-rich-markdown-editor-document-suffix'
|
||||
|
||||
export function escapeInjectedJavaScriptString(value: string): string {
|
||||
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
|
||||
@@ -24,644 +30,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
--border: ${colors.borderSubtle};
|
||||
--primary: ${colors.textPrimary};
|
||||
--primary-foreground: ${colors.bgBase};
|
||||
--accent-link: ${colors.accentBlue};
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
background: var(--editor-surface);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
body { overflow: auto; }
|
||||
#editor {
|
||||
min-height: 100vh;
|
||||
padding: 18px 16px 112px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
#editor[contenteditable="false"] {
|
||||
opacity: 0.78;
|
||||
}
|
||||
#editor:empty::before,
|
||||
#editor p.is-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
#editor > :first-child { margin-top: 0; }
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 1.5em 0 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
h1 { font-size: 1.85em; font-weight: 700; }
|
||||
h2 { font-size: 1.4em; }
|
||||
h3 { font-size: 1.15em; }
|
||||
p, ul, ol, blockquote { margin: 0.75em 0; }
|
||||
ul, ol { padding-left: 1.5em; }
|
||||
ul { list-style: disc; }
|
||||
ol { list-style: decimal; }
|
||||
li { margin: 0.15em 0; }
|
||||
li > p { margin: 0; }
|
||||
ul[data-type="taskList"] {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
ul[data-type="taskList"] > li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
ul[data-type="taskList"] > li > label {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.55em;
|
||||
user-select: none;
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:checked {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:checked::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid var(--primary-foreground);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
ul[data-type="taskList"] > li > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
ul[data-type="taskList"] > li[data-checked="true"] > div {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
blockquote {
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid var(--border);
|
||||
border-radius: 0 6px 6px 0;
|
||||
color: var(--muted-foreground);
|
||||
background: color-mix(in srgb, var(--foreground) 2%, transparent);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
}
|
||||
tr:nth-child(odd) td {
|
||||
background: color-mix(in srgb, var(--foreground) 1.5%, transparent);
|
||||
}
|
||||
code {
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
font-size: 0.88em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
pre {
|
||||
margin: 0.75em 0;
|
||||
padding: 14px 18px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
overflow-x: auto;
|
||||
line-height: 1.55;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
pre::before {
|
||||
content: attr(data-language);
|
||||
display: block;
|
||||
min-height: 13px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
pre code {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: 0.92em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
hr {
|
||||
margin: 1.5em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-link);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0.75em 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastMarkdown = '';
|
||||
var inputTimer = null;
|
||||
var documentGeneration = 0;
|
||||
var editable = true;
|
||||
var suppressInput = false;
|
||||
|
||||
function post(message) {
|
||||
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function decodeMarkdownEntities(value) {
|
||||
return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {
|
||||
var lower = String(entity).toLowerCase();
|
||||
if (lower === 'amp') return '&';
|
||||
if (lower === 'lt') return '<';
|
||||
if (lower === 'gt') return '>';
|
||||
if (lower === 'quot') return '"';
|
||||
if (lower === 'apos') return "'";
|
||||
if (lower.indexOf('#x') === 0) {
|
||||
var hex = Number.parseInt(lower.slice(2), 16);
|
||||
return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;
|
||||
}
|
||||
if (lower.indexOf('#') === 0) {
|
||||
var code = Number.parseInt(lower.slice(1), 10);
|
||||
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return decodeMarkdownEntities(value).replace(/[&<>"']/g, function (char) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char];
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/\\n/g, ' ');
|
||||
}
|
||||
|
||||
function isSafeUrl(value) {
|
||||
var trimmed = String(value || '').trim();
|
||||
return !/^javascript:/i.test(trimmed);
|
||||
}
|
||||
|
||||
function splitTableRow(line) {
|
||||
return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {
|
||||
return cell.trim();
|
||||
});
|
||||
}
|
||||
|
||||
function isTableSeparator(line) {
|
||||
var cells = splitTableRow(line);
|
||||
return cells.length > 0 && cells.every(function (cell) {
|
||||
return /^:?-{3,}:?$/.test(cell);
|
||||
});
|
||||
}
|
||||
|
||||
function renderInline(text) {
|
||||
var output = '';
|
||||
var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|\`[^\`]+\`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;
|
||||
var lastIndex = 0;
|
||||
var match;
|
||||
while ((match = pattern.exec(text))) {
|
||||
output += escapeHtml(text.slice(lastIndex, match.index));
|
||||
var token = match[0];
|
||||
var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);
|
||||
var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);
|
||||
if (image && isSafeUrl(image[2])) {
|
||||
output += '<img src="' + escapeAttr(image[2]) + '" alt="' + escapeAttr(image[1] || '') + '" />';
|
||||
} else if (link && isSafeUrl(link[2])) {
|
||||
output += '<a href="' + escapeAttr(link[2]) + '">' + renderInline(link[1]) + '</a>';
|
||||
} else if (/^https?:\\/\\//i.test(token)) {
|
||||
output += '<a href="' + escapeAttr(token) + '">' + escapeHtml(token) + '</a>';
|
||||
} else if (token.indexOf('\`') === 0) {
|
||||
output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';
|
||||
} else if (token.indexOf('~~') === 0) {
|
||||
output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';
|
||||
} else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {
|
||||
output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';
|
||||
} else {
|
||||
output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';
|
||||
}
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
output += escapeHtml(text.slice(lastIndex));
|
||||
return output;
|
||||
}
|
||||
|
||||
function isBlockStart(line) {
|
||||
return /^(\`\`\`|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);
|
||||
}
|
||||
|
||||
function indentationWidth(value) {
|
||||
return String(value || '').replace(/\\t/g, ' ').length;
|
||||
}
|
||||
|
||||
function parseListLine(line) {
|
||||
var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);
|
||||
if (!match) return null;
|
||||
var rawText = match[3] || '';
|
||||
var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);
|
||||
return {
|
||||
indent: indentationWidth(match[1] || ''),
|
||||
ordered: /^\\d/.test(match[2] || ''),
|
||||
orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,
|
||||
task: task ? task[1].toLowerCase() === 'x' : null,
|
||||
text: task ? task[2] : rawText,
|
||||
children: []
|
||||
};
|
||||
}
|
||||
|
||||
function listKind(item) {
|
||||
if (item.task !== null) return 'task';
|
||||
return item.ordered ? 'ol' : 'ul';
|
||||
}
|
||||
|
||||
function parseListTree(lines, startIndex) {
|
||||
var root = { indent: -1, children: [] };
|
||||
var stack = [root];
|
||||
var index = startIndex;
|
||||
while (index < lines.length) {
|
||||
var item = parseListLine(lines[index] || '');
|
||||
if (!item) break;
|
||||
while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {
|
||||
stack.pop();
|
||||
}
|
||||
stack[stack.length - 1].children.push(item);
|
||||
stack.push(item);
|
||||
index += 1;
|
||||
}
|
||||
return { items: root.children, nextIndex: index };
|
||||
}
|
||||
|
||||
function renderListItems(items) {
|
||||
var html = [];
|
||||
var index = 0;
|
||||
while (index < items.length) {
|
||||
var kind = listKind(items[index]);
|
||||
var group = [];
|
||||
while (index < items.length && listKind(items[index]) === kind) {
|
||||
group.push(items[index]);
|
||||
index += 1;
|
||||
}
|
||||
var tag = kind === 'ol' ? 'ol' : 'ul';
|
||||
var attrs = kind === 'task' ? ' data-type="taskList"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start="' + group[0].orderedNumber + '"' : '';
|
||||
html.push('<' + tag + attrs + '>' + group.map(function (item) {
|
||||
var children = item.children.length ? renderListItems(item.children) : '';
|
||||
if (kind === 'task') {
|
||||
var checked = item.task === true;
|
||||
return '<li data-checked="' + String(checked) + '"><label contenteditable="false"><input type="checkbox" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';
|
||||
}
|
||||
var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value="' + item.orderedNumber + '" data-list-number="' + item.orderedNumber + '"' : '';
|
||||
return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';
|
||||
}).join('') + '</' + tag + '>');
|
||||
}
|
||||
return html.join('');
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown) {
|
||||
var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');
|
||||
var html = [];
|
||||
var index = 0;
|
||||
while (index < lines.length) {
|
||||
var line = lines[index] || '';
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
var fence = line.match(/^\\\`\`\`([^\\s\`]*)\\s*$/);
|
||||
if (fence) {
|
||||
index += 1;
|
||||
var code = [];
|
||||
while (index < lines.length && !/^\\\`\`\`\\s*$/.test(lines[index] || '')) {
|
||||
code.push(lines[index] || '');
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) index += 1;
|
||||
html.push('<pre data-language="' + escapeAttr(fence[1] || '') + '"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {
|
||||
html.push('<hr />');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {
|
||||
var headers = splitTableRow(line);
|
||||
index += 2;
|
||||
var rows = [];
|
||||
while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {
|
||||
rows.push(splitTableRow(lines[index] || ''));
|
||||
index += 1;
|
||||
}
|
||||
html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {
|
||||
return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';
|
||||
}).join('') + '</tbody></table>');
|
||||
continue;
|
||||
}
|
||||
var heading = line.match(/^(#{1,6})\\s+(.+)$/);
|
||||
if (heading) {
|
||||
html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^>\\s?/.test(line)) {
|
||||
var quote = [];
|
||||
while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {
|
||||
quote.push((lines[index] || '').replace(/^>\\s?/, ''));
|
||||
index += 1;
|
||||
}
|
||||
html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');
|
||||
continue;
|
||||
}
|
||||
if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {
|
||||
var list = parseListTree(lines, index);
|
||||
html.push(renderListItems(list.items));
|
||||
index = list.nextIndex;
|
||||
continue;
|
||||
}
|
||||
var paragraph = [];
|
||||
while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {
|
||||
paragraph.push(lines[index] || '');
|
||||
index += 1;
|
||||
}
|
||||
html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');
|
||||
}
|
||||
return html.join('\\n') || '<p class="is-empty"><br /></p>';
|
||||
}
|
||||
|
||||
function textContent(node) {
|
||||
return (node.textContent || '').replace(/\\u00a0/g, ' ');
|
||||
}
|
||||
|
||||
function inlineMarkdown(node) {
|
||||
if (!node) return '';
|
||||
if (node.nodeType === Node.TEXT_NODE) return textContent(node);
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
var el = node;
|
||||
var tag = el.tagName.toLowerCase();
|
||||
if (tag === 'br') return '\\n';
|
||||
if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';
|
||||
if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';
|
||||
if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';
|
||||
if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '\`' + textContent(el) + '\`';
|
||||
if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';
|
||||
if (tag === 'img') return ' || '') + ')';
|
||||
if (tag === 'label') return '';
|
||||
return inlineChildren(el);
|
||||
}
|
||||
|
||||
function inlineChildren(el) {
|
||||
return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');
|
||||
}
|
||||
|
||||
function listItemText(li) {
|
||||
var clone = li.cloneNode(true);
|
||||
Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });
|
||||
Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });
|
||||
return inlineChildren(clone).trim();
|
||||
}
|
||||
|
||||
function directNestedLists(li) {
|
||||
return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {
|
||||
return list.closest('li') === li;
|
||||
});
|
||||
}
|
||||
|
||||
function listMarkdown(el, depth) {
|
||||
var tag = el.tagName.toLowerCase();
|
||||
var isTask = el.getAttribute('data-type') === 'taskList';
|
||||
var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;
|
||||
if (!Number.isFinite(orderedStart)) orderedStart = 1;
|
||||
var indent = ' '.repeat(depth);
|
||||
return Array.prototype.map.call(el.children, function (li, index) {
|
||||
if (li.tagName.toLowerCase() !== 'li') return '';
|
||||
var marker;
|
||||
if (isTask) {
|
||||
var input = li.querySelector('input[type="checkbox"]');
|
||||
marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';
|
||||
} else {
|
||||
var listNumber =
|
||||
li.getAttribute &&
|
||||
(li.getAttribute('data-list-number') || li.getAttribute('value'));
|
||||
marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';
|
||||
}
|
||||
var line = indent + marker + listItemText(li);
|
||||
var nested = directNestedLists(li).map(function (list) {
|
||||
return listMarkdown(list, depth + 1);
|
||||
}).filter(Boolean).join('\\n');
|
||||
return nested ? line + '\\n' + nested : line;
|
||||
}).filter(Boolean).join('\\n');
|
||||
}
|
||||
|
||||
function blockMarkdown(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
var el = node;
|
||||
var tag = el.tagName.toLowerCase();
|
||||
if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();
|
||||
if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();
|
||||
if (tag === 'blockquote') {
|
||||
return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');
|
||||
}
|
||||
if (tag === 'pre') {
|
||||
var lang = el.getAttribute('data-language') || '';
|
||||
var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');
|
||||
return '\`\`\`' + lang + '\\n' + code + '\\n\`\`\`';
|
||||
}
|
||||
if (tag === 'ul' || tag === 'ol') {
|
||||
return listMarkdown(el, 0);
|
||||
}
|
||||
if (tag === 'table') {
|
||||
var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));
|
||||
if (rows.length === 0) return '';
|
||||
var cellsFor = function (row) {
|
||||
return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });
|
||||
};
|
||||
var headers = cellsFor(rows[0]);
|
||||
var bodyRows = rows.slice(1).map(cellsFor);
|
||||
return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');
|
||||
}
|
||||
if (tag === 'hr') return '---';
|
||||
if (tag === 'img') return inlineMarkdown(el);
|
||||
return inlineChildren(el).trim();
|
||||
}
|
||||
|
||||
function currentMarkdown() {
|
||||
return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {
|
||||
return block.trim().length > 0;
|
||||
}).join('\\n\\n').trimEnd();
|
||||
}
|
||||
|
||||
function syncTaskCheckboxesDisabled() {
|
||||
Array.prototype.forEach.call(editor.querySelectorAll('input[type="checkbox"]'), function (input) {
|
||||
input.disabled = !editable;
|
||||
});
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
if (suppressInput || !editable) return;
|
||||
window.clearTimeout(inputTimer);
|
||||
var pendingGeneration = documentGeneration;
|
||||
lastMarkdown = currentMarkdown();
|
||||
post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });
|
||||
}
|
||||
|
||||
function setMarkdown(markdown, generation) {
|
||||
window.clearTimeout(inputTimer);
|
||||
documentGeneration = Number(generation) || 0;
|
||||
suppressInput = true;
|
||||
// Why: replacing innerHTML detaches the remembered caret's nodes.
|
||||
savedSelectionRange = null;
|
||||
selectionDroppedOnBlur = false;
|
||||
lastMarkdown = String(markdown || '');
|
||||
editor.innerHTML = markdownToHtml(lastMarkdown);
|
||||
syncTaskCheckboxesDisabled();
|
||||
suppressInput = false;
|
||||
}
|
||||
|
||||
function setEditable(nextEditable) {
|
||||
editable = Boolean(nextEditable);
|
||||
editor.setAttribute('contenteditable', editable ? 'true' : 'false');
|
||||
syncTaskCheckboxesDisabled();
|
||||
}
|
||||
|
||||
${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}
|
||||
function runCommand(command) {
|
||||
if (!editable || editor.getAttribute('contenteditable') !== 'true') return;
|
||||
restoreSelectionOrEnd();
|
||||
if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');
|
||||
else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');
|
||||
else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');
|
||||
else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');
|
||||
else if (command === 'bold') document.execCommand('bold');
|
||||
else if (command === 'italic') document.execCommand('italic');
|
||||
else if (command === 'strike') document.execCommand('strikeThrough');
|
||||
else if (command === 'bulletList') document.execCommand('insertUnorderedList');
|
||||
else if (command === 'orderedList') document.execCommand('insertOrderedList');
|
||||
else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');
|
||||
else if (command === 'inlineCode') wrapSelection('code');
|
||||
else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=""><code>code</code></pre><p><br></p>');
|
||||
else if (command === 'taskList') document.execCommand('insertHTML', false, '<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>');
|
||||
else if (command === 'link') {
|
||||
var href = window.prompt('Link URL');
|
||||
if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);
|
||||
} else if (command === 'image') {
|
||||
var src = window.prompt('Image URL');
|
||||
if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);
|
||||
}
|
||||
syncTaskCheckboxesDisabled();
|
||||
emitChange();
|
||||
}
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
selectionDroppedOnBlur = false;
|
||||
if (editable) emitChange();
|
||||
});
|
||||
editor.addEventListener('change', function (event) {
|
||||
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
|
||||
if (input) {
|
||||
if (!editable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
var li = input.closest('li');
|
||||
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
|
||||
}
|
||||
if (editable) emitChange();
|
||||
});
|
||||
editor.addEventListener('click', function (event) {
|
||||
var link = event.target && event.target.closest && event.target.closest('a[href]');
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
post({ type: 'openLink', url: link.getAttribute('href') || '' });
|
||||
return;
|
||||
}
|
||||
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
|
||||
if (!input) {
|
||||
if (!editable) return;
|
||||
// Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.
|
||||
var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]');
|
||||
if (uneditable && uneditable !== editor) return;
|
||||
selectionDroppedOnBlur = false;
|
||||
if (document.activeElement === editor) return;
|
||||
// Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.
|
||||
var caret = caretRangeAtPoint(event.clientX, event.clientY);
|
||||
focusEditor();
|
||||
if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);
|
||||
return;
|
||||
}
|
||||
if (!editable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
var li = input.closest('li');
|
||||
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
|
||||
emitChange();
|
||||
});
|
||||
editor.addEventListener('keydown', function (event) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {
|
||||
event.preventDefault();
|
||||
runCommand('bold');
|
||||
}
|
||||
});
|
||||
|
||||
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
|
||||
post({ type: 'ready' });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
--accent-link: ${colors.accentBlue}${MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY}
|
||||
${MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END}`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
|
||||
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
|
||||
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
|
||||
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import type { WorktreeCreateIdempotencySupport } from '../tasks/worktree-create-idempotency-policy'
|
||||
import {
|
||||
pickPreferredNewWorktreeAgent,
|
||||
type NewWorktreeAgentOption,
|
||||
@@ -54,7 +55,7 @@ export function useNewWorkspaceCreateSubmit(args: {
|
||||
runSetup: boolean
|
||||
trustedOrcaHooks: PersistedTrustedOrcaHooks
|
||||
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
|
||||
getWorktreeCreateCutoverSupport: () => Promise<boolean>
|
||||
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
|
||||
transitionDrawer: (view: Exclude<NewWorktreeDrawerView, 'transition'>) => void
|
||||
setError: Dispatch<SetStateAction<string>>
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
@@ -163,7 +164,7 @@ export function useNewWorkspaceCreateSubmit(args: {
|
||||
workspaceName: trimmedName || undefined,
|
||||
note: trimmedNote,
|
||||
nameIsAutoManaged: args.composer.isNameAutoManaged,
|
||||
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
|
||||
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
|
||||
})
|
||||
: await createBlankWorkspace({
|
||||
client,
|
||||
@@ -173,7 +174,7 @@ export function useNewWorkspaceCreateSubmit(args: {
|
||||
createdWithAgentId,
|
||||
comment: trimmedNote,
|
||||
setupDecision,
|
||||
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
|
||||
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
|
||||
})
|
||||
if ('error' in result) {
|
||||
args.setError(result.error)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
export const pairScanStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
steps: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
marginLeft: 7
|
||||
},
|
||||
step: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
stepBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
stepNumber: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
stepText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
cameraWrap: {
|
||||
flex: 1,
|
||||
borderRadius: radii.camera,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
// Why: holds the layout slot while the camera is unmounted during
|
||||
// paste, so the bottom action button doesn't snap up to fill the
|
||||
// empty space.
|
||||
cameraPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.camera
|
||||
},
|
||||
camera: {
|
||||
...StyleSheet.absoluteFillObject
|
||||
},
|
||||
reticle: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
reticleFrame: {
|
||||
position: 'relative'
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderColor: 'rgba(255,255,255,0.7)'
|
||||
},
|
||||
cornerTL: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderTopLeftRadius: 6
|
||||
},
|
||||
cornerTR: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderTopRightRadius: 6
|
||||
},
|
||||
cornerBL: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderBottomLeftRadius: 6
|
||||
},
|
||||
cornerBR: {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderBottomRightRadius: 6
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
subtitle: {
|
||||
maxWidth: 310,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
connectingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
logSlot: {
|
||||
width: '100%',
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.sm
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
pasteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
pasteButtonPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
pasteButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
errorActions: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createBlankWorkspace } from './blank-workspace-create'
|
||||
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
|
||||
|
||||
type Call = { method: string; params: unknown }
|
||||
|
||||
const IDEMPOTENT_CREATE_SUPPORT = {
|
||||
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
}
|
||||
|
||||
function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
|
||||
return {
|
||||
sendRequest: async (method: string, params?: unknown) => {
|
||||
@@ -35,7 +40,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' })
|
||||
@@ -70,7 +75,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: true,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(calls[0]?.params).toMatchObject({ nameWasGenerated: true })
|
||||
@@ -90,7 +95,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: 'spike',
|
||||
setupDecision: 'run',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
const params = calls[0]?.params as Record<string, unknown>
|
||||
@@ -122,7 +127,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' })
|
||||
@@ -148,7 +153,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' })
|
||||
@@ -167,7 +172,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'skip',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ error: 'SSH connection is not available' })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
|
||||
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
|
||||
import {
|
||||
agentLaunchCreateFields,
|
||||
type WorkspaceCreateSetupDecision
|
||||
@@ -19,13 +20,13 @@ export async function createBlankWorkspace(args: {
|
||||
/** True when `baseName` is a generated creature name rather than one the user typed; only then
|
||||
* may the host retire it. */
|
||||
nameWasGenerated: boolean
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
return createWorktreeWithNameRetry({
|
||||
client: args.client,
|
||||
baseName: args.baseName,
|
||||
nameWasGenerated: args.nameWasGenerated,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (name) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${args.repoId}`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createWorkspaceFromComposerSource } from './source-workspace-create'
|
||||
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
|
||||
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
|
||||
|
||||
type Call = { method: string; params: Record<string, unknown> }
|
||||
|
||||
@@ -24,6 +25,9 @@ function fakeClient(handle: (method: string, call: number) => unknown, calls: Ca
|
||||
}
|
||||
|
||||
const agent = { choice: 'blank' as const }
|
||||
const IDEMPOTENT_CREATE_SUPPORT = {
|
||||
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
targetRepoId: 'repo-1',
|
||||
@@ -31,7 +35,7 @@ const baseArgs = {
|
||||
agent,
|
||||
workspaceName: undefined,
|
||||
note: undefined,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
}
|
||||
|
||||
describe('createWorkspaceFromComposerSource', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type WorkspaceCreateTaskItem
|
||||
} from './workspace-create-params'
|
||||
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
|
||||
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
|
||||
|
||||
// The agent bundle the modal resolved: `choice` drives launch resolution — the
|
||||
// host applies the agent's launch args (permission flags) and shell quoting.
|
||||
@@ -29,7 +30,7 @@ export type CreateWorkspaceFromComposerArgs = {
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}
|
||||
|
||||
export async function createWorkspaceFromComposerSource(
|
||||
@@ -92,7 +93,7 @@ async function createWorkItemWorkspace(args: {
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
|
||||
const item = selection.item
|
||||
@@ -137,7 +138,7 @@ async function createWorkItemWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (name) => ({ ...params, name })
|
||||
})
|
||||
}
|
||||
@@ -150,7 +151,7 @@ async function createBranchWorkspace(args: {
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
|
||||
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
|
||||
@@ -174,7 +175,7 @@ async function createBranchWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
maxAttempts: 1,
|
||||
buildParams: (name) =>
|
||||
applyCommon({
|
||||
@@ -196,7 +197,7 @@ async function createBranchWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (candidate) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${targetRepoId}`,
|
||||
@@ -220,7 +221,7 @@ async function createNewBranchWorkspace(args: {
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, note } = args
|
||||
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
|
||||
@@ -232,7 +233,7 @@ async function createNewBranchWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: selection.branchName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (candidate) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${targetRepoId}`,
|
||||
|
||||
@@ -2,8 +2,21 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
|
||||
import {
|
||||
WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS,
|
||||
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
} from './worktree-create-idempotency-policy'
|
||||
|
||||
function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClient {
|
||||
type StatusOutcome =
|
||||
| 'cutover'
|
||||
| 'error'
|
||||
| string[]
|
||||
| {
|
||||
capabilities?: string[]
|
||||
worktreeCreateIdempotency?: unknown
|
||||
}
|
||||
|
||||
function statusClient(outcomes: StatusOutcome[]): RpcClient {
|
||||
let call = 0
|
||||
return {
|
||||
sendRequest: async () => {
|
||||
@@ -15,10 +28,13 @@ function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClien
|
||||
if (outcome === 'error') {
|
||||
throw new Error('offline')
|
||||
}
|
||||
const result = Array.isArray(outcome)
|
||||
? { capabilities: outcome, hostPlatform: 'darwin' }
|
||||
: { ...outcome, hostPlatform: 'darwin' }
|
||||
return {
|
||||
id: '1',
|
||||
ok: true,
|
||||
result: { capabilities: outcome, hostPlatform: 'darwin' },
|
||||
result,
|
||||
_meta: { runtimeId: 'r' }
|
||||
}
|
||||
}
|
||||
@@ -29,23 +45,123 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
|
||||
it('reads task and idempotent-create support from status.get', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([['mobile.tasks.v1', 'worktree.create-idempotency.v1']])
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: true,
|
||||
idempotentWorktreeCreateSupported: true,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it('retries the safe status probe after a connection cutover', async () => {
|
||||
it('uses the bounded fallback for an old idempotent host without an advertisement', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(statusClient([['worktree.create-idempotency.v1']]))
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when a valid idempotency container omits dedupeTtlMs', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient(['cutover', ['worktree.create-idempotency.v1']])
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: {}
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: true,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'null', value: null },
|
||||
{ label: 'string', value: 'nonsense' },
|
||||
{ label: 'number', value: 20_000 },
|
||||
{ label: 'empty array', value: [] }
|
||||
])(
|
||||
'fails closed for a malformed idempotency container ($label)',
|
||||
async ({ value: worktreeCreateIdempotency }) => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('clamps an over-large host advertisement to the client fallback ceiling', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 600_000 }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['60000', -1, Number.NaN, 60_000.5, {}])(
|
||||
'fails closed for malformed host advertisement %j',
|
||||
async (advertisedDedupeTtlMs) => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: advertisedDedupeTtlMs }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('retries the safe status probe after a connection cutover', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
'cutover',
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
@@ -53,7 +169,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
|
||||
it('fails closed when capability detection is unavailable', async () => {
|
||||
await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
hostPlatform: null
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,11 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
||||
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
|
||||
import {
|
||||
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
|
||||
resolveWorktreeCreateIdempotencySupport,
|
||||
type WorktreeCreateIdempotencySupport
|
||||
} from './worktree-create-idempotency-policy'
|
||||
|
||||
// Why: older hosts strip worktree.create's clientMutationId, so mobile must not
|
||||
// replay an ambiguous create unless the host advertises idempotency support.
|
||||
@@ -14,13 +19,13 @@ const STATUS_CUTOVER_MAX_RETRIES = 5
|
||||
|
||||
export type NewWorktreeRuntimeCapabilities = {
|
||||
tasksSupported: boolean
|
||||
idempotentWorktreeCreateSupported: boolean
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
hostPlatform: null
|
||||
}
|
||||
|
||||
@@ -35,13 +40,28 @@ export async function readNewWorktreeRuntimeCapabilities(
|
||||
if (!response.ok) {
|
||||
return UNSUPPORTED_CAPABILITIES
|
||||
}
|
||||
const result = (response as RpcSuccess).result as { capabilities?: string[] }
|
||||
const result = (response as RpcSuccess).result as {
|
||||
capabilities?: string[]
|
||||
worktreeCreateIdempotency?: unknown
|
||||
}
|
||||
const capabilities = result.capabilities ?? []
|
||||
const supportsIdempotency = capabilities.includes(
|
||||
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
|
||||
)
|
||||
const advertisedIdempotency = result.worktreeCreateIdempotency
|
||||
return {
|
||||
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
|
||||
idempotentWorktreeCreateSupported: capabilities.includes(
|
||||
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
|
||||
),
|
||||
worktreeCreateIdempotency: supportsIdempotency
|
||||
? advertisedIdempotency === undefined
|
||||
? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }
|
||||
: advertisedIdempotency !== null &&
|
||||
typeof advertisedIdempotency === 'object' &&
|
||||
!Array.isArray(advertisedIdempotency)
|
||||
? resolveWorktreeCreateIdempotencySupport(
|
||||
(advertisedIdempotency as { dedupeTtlMs?: unknown }).dedupeTtlMs
|
||||
)
|
||||
: { dedupeTtlMs: 0 }
|
||||
: false,
|
||||
hostPlatform: readMobileRuntimeHostPlatform(result)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -58,7 +78,7 @@ export function useNewWorktreeRuntimeCapabilities(
|
||||
): {
|
||||
tasksSupported: boolean
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
getWorktreeCreateCutoverSupport: () => Promise<boolean>
|
||||
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
|
||||
} {
|
||||
const [tasksSupported, setTasksSupported] = useState(false)
|
||||
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
|
||||
@@ -97,7 +117,7 @@ export function useNewWorktreeRuntimeCapabilities(
|
||||
}, [client, enabled, getCapabilities, setTasksSupported])
|
||||
|
||||
const getWorktreeCreateCutoverSupport = useCallback(
|
||||
() => getCapabilities().then((capabilities) => capabilities.idempotentWorktreeCreateSupported),
|
||||
() => getCapabilities().then((capabilities) => capabilities.worktreeCreateIdempotency),
|
||||
[getCapabilities]
|
||||
)
|
||||
return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Why: old idempotent hosts omit policy, so preserve today's effective replay window.
|
||||
export const WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS = 60_000
|
||||
|
||||
// Why: a client must not trust a host advertisement beyond its tested ceiling.
|
||||
export const WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS = 60_000
|
||||
|
||||
// Why: the replay still has to reach the host before its dedupe record expires.
|
||||
const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
|
||||
|
||||
export type WorktreeCreateIdempotencySupport = {
|
||||
dedupeTtlMs: number
|
||||
}
|
||||
|
||||
export type WorktreeCreateIdempotencyProbe =
|
||||
| WorktreeCreateIdempotencySupport
|
||||
| false
|
||||
| Promise<WorktreeCreateIdempotencySupport | false>
|
||||
|
||||
export function resolveWorktreeCreateIdempotencySupport(
|
||||
advertisedDedupeTtlMs: unknown
|
||||
): WorktreeCreateIdempotencySupport {
|
||||
if (advertisedDedupeTtlMs === undefined) {
|
||||
return { dedupeTtlMs: 0 }
|
||||
}
|
||||
if (
|
||||
typeof advertisedDedupeTtlMs !== 'number' ||
|
||||
!Number.isSafeInteger(advertisedDedupeTtlMs) ||
|
||||
advertisedDedupeTtlMs < 0
|
||||
) {
|
||||
// Truthy support preserves immediate cutover retries while disabling ambiguous replay.
|
||||
return { dedupeTtlMs: 0 }
|
||||
}
|
||||
return {
|
||||
dedupeTtlMs: Math.min(advertisedDedupeTtlMs, WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS)
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorktreeCreateReplayWindowMs(support: WorktreeCreateIdempotencySupport): number {
|
||||
return Math.max(0, support.dedupeTtlMs - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS)
|
||||
}
|
||||
@@ -3,16 +3,24 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../../src/shared/new-workspace/worktree-create-retry-policy'
|
||||
import {
|
||||
getWorktreeCreateReplayWindowMs,
|
||||
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
|
||||
type WorktreeCreateIdempotencySupport
|
||||
} from './worktree-create-idempotency-policy'
|
||||
import {
|
||||
createWorktreeWithNameRetry,
|
||||
WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS,
|
||||
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
|
||||
WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS
|
||||
} from './worktree-create-retry'
|
||||
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
|
||||
|
||||
type Attempt = { method: string; params: Record<string, unknown> }
|
||||
|
||||
const IDEMPOTENT_CREATE_SUPPORT = {
|
||||
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
}
|
||||
const LEGACY_HOST_REPLAY_WINDOW_MS = getWorktreeCreateReplayWindowMs(IDEMPOTENT_CREATE_SUPPORT)
|
||||
|
||||
// Drives the transport state a replay has to wait on. Production couples the two:
|
||||
// a socket close rejects the pending frame AND drops the client off 'connected'.
|
||||
function connectionController(): {
|
||||
@@ -106,21 +114,21 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
it('waits for capability detection before sending a create', async () => {
|
||||
const attempts: Attempt[] = []
|
||||
const client = scriptedClient([{ id: 'wt-ready' }], attempts)
|
||||
let resolveSupport!: (supported: boolean) => void
|
||||
const support = new Promise<boolean>((resolve) => {
|
||||
let resolveSupport!: (supported: WorktreeCreateIdempotencySupport) => void
|
||||
const support = new Promise<WorktreeCreateIdempotencySupport>((resolve) => {
|
||||
resolveSupport = resolve
|
||||
})
|
||||
const pending = createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'puffin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: support,
|
||||
worktreeCreateIdempotency: support,
|
||||
mintMutationId: () => 'key-ready'
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(attempts).toHaveLength(0)
|
||||
resolveSupport(true)
|
||||
resolveSupport(IDEMPOTENT_CREATE_SUPPORT)
|
||||
|
||||
await expect(pending).resolves.toEqual({ worktreeId: 'wt-ready', name: 'puffin' })
|
||||
expect(attempts).toHaveLength(1)
|
||||
@@ -134,7 +142,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'otter',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-1'
|
||||
})
|
||||
expect(result).toEqual({ worktreeId: 'wt-1', name: 'otter' })
|
||||
@@ -152,7 +160,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'seal',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-mig'
|
||||
})
|
||||
expect(result).toEqual({ worktreeId: 'wt-2', name: 'seal' })
|
||||
@@ -172,7 +180,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'crab',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-x'
|
||||
})
|
||||
).rejects.toBeInstanceOf(LogicalClientCutoverError)
|
||||
@@ -190,7 +198,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'eel',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-t'
|
||||
})
|
||||
).rejects.toThrow('Request timed out')
|
||||
@@ -208,7 +216,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'topic',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => `key-${(n += 1)}`
|
||||
})
|
||||
expect(result).toEqual({ worktreeId: 'wt-3', name: 'topic-2' })
|
||||
@@ -231,7 +239,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
baseName: 'nautilus-2',
|
||||
nameWasGenerated: true,
|
||||
buildParams: (name) => ({ repo: 'id:r', name, nameWasGenerated: true }),
|
||||
supportsIdempotentCutoverRetry: false
|
||||
worktreeCreateIdempotency: false
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-generated', name: 'nautilus-3' })
|
||||
@@ -246,7 +254,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'ray',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
mintMutationId: () => 'must-not-be-used'
|
||||
})
|
||||
).rejects.toBeInstanceOf(LogicalClientCutoverError)
|
||||
@@ -273,7 +281,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'urchin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-drop'
|
||||
})
|
||||
|
||||
@@ -301,7 +309,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'limpet',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
mintMutationId: () => 'must-not-be-used'
|
||||
})
|
||||
).rejects.toThrow('Connection interrupted')
|
||||
@@ -309,6 +317,32 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
expect(attempts[0]!.params.clientMutationId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables ambiguous replay for a malformed advertisement', async () => {
|
||||
const attempts: Attempt[] = []
|
||||
const connection = connectionController()
|
||||
const client = scriptedClient(
|
||||
[
|
||||
{
|
||||
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
|
||||
dropsConnection: true
|
||||
}
|
||||
],
|
||||
attempts,
|
||||
connection
|
||||
)
|
||||
await expect(
|
||||
createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'limpet',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
mintMutationId: () => 'key-malformed'
|
||||
})
|
||||
).rejects.toThrow('Connection interrupted')
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(attempts[0]!.params.clientMutationId).toBe('key-malformed')
|
||||
})
|
||||
|
||||
it('gives up after the delivery-ambiguity replay budget, still inside the dedupe TTL', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -331,21 +365,21 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'barnacle',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-budget'
|
||||
}).catch((error: unknown) => {
|
||||
settledAt = Date.now()
|
||||
throw error
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_MS)
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
|
||||
await settled
|
||||
// Initial attempt + 2 replays.
|
||||
expect(attempts).toHaveLength(3)
|
||||
// The budget is a count, but what keeps a replay reconciling instead of building
|
||||
// a second worktree is wall clock: every attempt has to land while the host still
|
||||
// holds the record, with the watchdog's detection latency already deducted.
|
||||
expect(settledAt - startedAt).toBeLessThanOrEqual(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
|
||||
expect(settledAt - startedAt).toBeLessThanOrEqual(LEGACY_HOST_REPLAY_WINDOW_MS)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -373,7 +407,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'anemone',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-stuck'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
@@ -388,6 +422,41 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
// so vitest's default 5s real-time budget is reachable on a loaded runner.
|
||||
}, 30_000)
|
||||
|
||||
it('stops replaying when the host-advertised dedupe record has expired', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const attempts: Attempt[] = []
|
||||
const connection = connectionController()
|
||||
const client = scriptedClient(
|
||||
[
|
||||
{
|
||||
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
|
||||
dropsConnection: true,
|
||||
takesMs: 12_000,
|
||||
reconnectsAfterMs: 1_000
|
||||
},
|
||||
{ id: 'wt-duplicate' }
|
||||
],
|
||||
attempts,
|
||||
connection
|
||||
)
|
||||
|
||||
const pending = createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'mussel',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
|
||||
mintMutationId: () => 'key-short-ttl'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await settled
|
||||
expect(attempts).toHaveLength(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not replay an ambiguity that surfaced while the transport stayed connected', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -415,7 +484,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'cuttlefish',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-expired'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Request timed out')
|
||||
@@ -464,19 +533,19 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'nautilus',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-clamped'
|
||||
}).catch((error: unknown) => {
|
||||
settledAt = Date.now()
|
||||
throw error
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
|
||||
await vi.advanceTimersByTimeAsync(LEGACY_HOST_REPLAY_WINDOW_MS)
|
||||
await settled
|
||||
expect(attempts).toHaveLength(2)
|
||||
// The window is anchored at the FIRST ambiguity, so the second wait gets only the
|
||||
// remainder — it must not restart a full wait and carry the replay past the record.
|
||||
expect(settledAt - startedAt).toBe(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
|
||||
expect(settledAt - startedAt).toBe(LEGACY_HOST_REPLAY_WINDOW_MS)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -509,7 +578,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'limpet',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-stale'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
@@ -551,7 +620,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'kelp',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-long'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(250_000)
|
||||
@@ -606,7 +675,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'urchin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-anchored'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection lost')
|
||||
@@ -638,7 +707,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'urchin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-unmarked'
|
||||
})
|
||||
).rejects.toThrow('Socket closed before send')
|
||||
@@ -649,11 +718,10 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
// The window is measured from a lower bound on when the host could have resolved, so
|
||||
// it has to leave the record room for the replay to still be in flight. Widening it
|
||||
// to the whole TTL would let the last replay land on a record that just expired.
|
||||
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeGreaterThan(0)
|
||||
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_MS)
|
||||
// A single wait must not be able to consume the window on its own.
|
||||
expect(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS).toBeLessThan(
|
||||
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
|
||||
)
|
||||
expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeGreaterThan(0)
|
||||
expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
|
||||
// A reconnect wait is clamped when a short advertisement leaves less time.
|
||||
const shortReplayWindowMs = getWorktreeCreateReplayWindowMs({ dedupeTtlMs: 20_000 })
|
||||
expect(Math.min(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS, shortReplayWindowMs)).toBe(10_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
|
||||
getClientWorktreeCreateCandidate,
|
||||
getGeneratedWorktreeCreateRetryCandidate,
|
||||
isRetryableWorktreeCreateConflict,
|
||||
WORKTREE_CREATE_DEDUPE_TTL_MS
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
|
||||
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
|
||||
import {
|
||||
getWorktreeCreateReplayWindowMs,
|
||||
type WorktreeCreateIdempotencyProbe,
|
||||
type WorktreeCreateIdempotencySupport
|
||||
} from './worktree-create-idempotency-policy'
|
||||
|
||||
// Why: server-side collision checks (branch already exists locally / on a remote
|
||||
// / already has PR #N) can fire even after a pre-flight basename dedupe —
|
||||
@@ -36,17 +40,6 @@ const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5
|
||||
// finished the worktree. Replay on the same clientMutationId instead.
|
||||
const WORKTREE_CREATE_AMBIGUOUS_MAX_RETRIES = 2
|
||||
|
||||
// Why: the host keeps a settled create's dedupe record for WORKTREE_CREATE_DEDUPE_TTL_MS
|
||||
// after it resolves; a replay that lands later misses it and the host's suffix loop
|
||||
// builds a SECOND worktree — and for folder workspaces a second one with the SAME name
|
||||
// and no collision check at all. So a replay is bounded by how stale our knowledge of the
|
||||
// host is, and that has to be measured in WALL CLOCK: iOS/Android suspend JS timers while
|
||||
// the app is backgrounded, so any ceiling derived from timer intervals — a watchdog probe
|
||||
// budget, a request timeout — can be arbitrarily wrong across a background cycle, which is
|
||||
// exactly when a create sits ambiguous for minutes.
|
||||
const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
|
||||
export const WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS =
|
||||
WORKTREE_CREATE_DEDUPE_TTL_MS - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS
|
||||
// Bounded so the Create spinner doesn't sit for the whole window; the deadline still caps it.
|
||||
export const WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS = 20_000
|
||||
|
||||
@@ -55,7 +48,7 @@ export type CreateWorktreeWithNameRetryArgs = {
|
||||
baseName: string
|
||||
nameWasGenerated?: boolean
|
||||
buildParams: (name: string) => Record<string, unknown>
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
maxAttempts?: number
|
||||
// Injected in tests; production mints a fresh idempotency key per candidate.
|
||||
mintMutationId?: () => string
|
||||
@@ -72,7 +65,7 @@ export async function createWorktreeWithNameRetry(
|
||||
const { client, baseName, buildParams } = args
|
||||
// Why: creating before status.get settles would silently disable safe replay
|
||||
// during the exact slow-network window this path is meant to recover from.
|
||||
const supportsIdempotentCutoverRetry = await args.supportsIdempotentCutoverRetry
|
||||
const worktreeCreateIdempotency = await args.worktreeCreateIdempotency
|
||||
const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS
|
||||
const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId
|
||||
let lastError: string | null = null
|
||||
@@ -84,14 +77,10 @@ export async function createWorktreeWithNameRetry(
|
||||
// Why: older hosts strip unknown fields, so only stamp and replay when the
|
||||
// host advertises idempotency. One key per candidate makes cutover retries
|
||||
// safe while a name-collision bump remains a genuinely new create.
|
||||
const params = supportsIdempotentCutoverRetry
|
||||
const params = worktreeCreateIdempotency
|
||||
? { ...candidateParams, clientMutationId: mintMutationId() }
|
||||
: candidateParams
|
||||
const response = await sendWorktreeCreateResilient(
|
||||
client,
|
||||
params,
|
||||
supportsIdempotentCutoverRetry
|
||||
)
|
||||
const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency)
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktree: { id: string } }
|
||||
return { worktreeId: result.worktree.id, name: candidateName }
|
||||
@@ -112,7 +101,7 @@ export async function createWorktreeWithNameRetry(
|
||||
async function sendWorktreeCreateResilient(
|
||||
client: RpcClient,
|
||||
params: Record<string, unknown>,
|
||||
supportsIdempotentCutoverRetry: boolean
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
|
||||
): Promise<RpcResponse> {
|
||||
let migrationRetry = 0
|
||||
let ambiguousRetry = 0
|
||||
@@ -124,7 +113,7 @@ async function sendWorktreeCreateResilient(
|
||||
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
|
||||
})
|
||||
} catch (error) {
|
||||
if (!supportsIdempotentCutoverRetry) {
|
||||
if (!worktreeCreateIdempotency) {
|
||||
throw error
|
||||
}
|
||||
if (isLogicalClientCutoverError(error)) {
|
||||
@@ -152,7 +141,7 @@ async function sendWorktreeCreateResilient(
|
||||
}
|
||||
// Computed once: a later ambiguity reads a fresher lastInboundAt from the
|
||||
// replacement session, which would push the deadline past the record it respects.
|
||||
replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt)
|
||||
replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt, worktreeCreateIdempotency)
|
||||
const remainingWindowMs = replayDeadlineAt - Date.now()
|
||||
if (remainingWindowMs <= 0) {
|
||||
throw error
|
||||
@@ -179,10 +168,14 @@ async function sendWorktreeCreateResilient(
|
||||
// so it stays honest across a suspension in a way a timer budget cannot; the send is the
|
||||
// fallback, and a sound floor either way, because the host cannot resolve a create it has
|
||||
// not yet received.
|
||||
function resolveReplayDeadline(client: RpcClient, firstSentAt: number): number {
|
||||
function resolveReplayDeadline(
|
||||
client: RpcClient,
|
||||
firstSentAt: number,
|
||||
support: WorktreeCreateIdempotencySupport
|
||||
): number {
|
||||
const lastInboundAt = client.getLastInboundAt?.() ?? null
|
||||
const anchor = lastInboundAt !== null && lastInboundAt > firstSentAt ? lastInboundAt : firstSentAt
|
||||
return anchor + WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
|
||||
return anchor + getWorktreeCreateReplayWindowMs(support)
|
||||
}
|
||||
|
||||
function defaultWorktreeCreateMutationId(): string {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
SPECIAL_KEY_LABELS,
|
||||
TERMINAL_ACCESSORY_KEY_DEFINITIONS,
|
||||
TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
|
||||
} from './terminal-key-definitions'
|
||||
|
||||
export type TerminalAccessoryKey = {
|
||||
id: string
|
||||
label: string
|
||||
@@ -75,66 +81,6 @@ const CTRL_PRINTABLE_BYTES: Record<string, string> = {
|
||||
'?': '\x7f'
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
escape: 'Esc',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: '⌫',
|
||||
delete: 'Del',
|
||||
insert: 'Ins',
|
||||
arrowUp: '↑',
|
||||
arrowDown: '↓',
|
||||
arrowLeft: '←',
|
||||
arrowRight: '→',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'PgUp',
|
||||
pageDown: 'PgDn',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
|
||||
escape: 'Escape',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Forward delete',
|
||||
insert: 'Insert',
|
||||
arrowUp: 'Arrow up',
|
||||
arrowDown: 'Arrow down',
|
||||
arrowLeft: 'Arrow left',
|
||||
arrowRight: 'Arrow right',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'Page up',
|
||||
pageDown: 'Page down',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
const CSI_FINAL_SPECIAL_KEYS: Record<string, string> = {
|
||||
arrowUp: 'A',
|
||||
arrowDown: 'B',
|
||||
@@ -165,87 +111,9 @@ const CSI_TILDE_SPECIAL_KEYS: Record<string, number> = {
|
||||
f12: 24
|
||||
}
|
||||
|
||||
export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [
|
||||
'escape',
|
||||
'tab',
|
||||
'enter',
|
||||
'backspace',
|
||||
'delete',
|
||||
'insert',
|
||||
'arrowUp',
|
||||
'arrowDown',
|
||||
'arrowLeft',
|
||||
'arrowRight',
|
||||
'home',
|
||||
'end',
|
||||
'pageUp',
|
||||
'pageDown',
|
||||
'space',
|
||||
'f1',
|
||||
'f2',
|
||||
'f3',
|
||||
'f4',
|
||||
'f5',
|
||||
'f6',
|
||||
'f7',
|
||||
'f8',
|
||||
'f9',
|
||||
'f10',
|
||||
'f11',
|
||||
'f12'
|
||||
].map((id) => ({
|
||||
id,
|
||||
label: SPECIAL_KEY_LABELS[id]!,
|
||||
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
|
||||
}))
|
||||
export const TERMINAL_SHORTCUT_SPECIAL_KEYS = TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
|
||||
|
||||
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
|
||||
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
|
||||
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
|
||||
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
|
||||
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
|
||||
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
|
||||
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
|
||||
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Del',
|
||||
bytes: '\x1b[3~',
|
||||
accessibilityLabel: 'Forward delete',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
|
||||
{
|
||||
id: 'arrowDown',
|
||||
label: '↓',
|
||||
bytes: '\x1b[B',
|
||||
accessibilityLabel: 'Arrow Down',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowLeft',
|
||||
label: '←',
|
||||
bytes: '\x1b[D',
|
||||
accessibilityLabel: 'Arrow Left',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowRight',
|
||||
label: '→',
|
||||
bytes: '\x1b[C',
|
||||
accessibilityLabel: 'Arrow Right',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
|
||||
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
|
||||
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
|
||||
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
|
||||
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
|
||||
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
|
||||
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
|
||||
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
|
||||
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
|
||||
]
|
||||
export const TERMINAL_ACCESSORY_KEYS = TERMINAL_ACCESSORY_KEY_DEFINITIONS
|
||||
|
||||
export function buildTerminalShortcutKey(
|
||||
binding: TerminalShortcutBinding
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { TerminalAccessoryKey, TerminalShortcutSpecialKey } from './terminal-accessory-keys'
|
||||
|
||||
export const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
escape: 'Esc',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: '⌫',
|
||||
delete: 'Del',
|
||||
insert: 'Ins',
|
||||
arrowUp: '↑',
|
||||
arrowDown: '↓',
|
||||
arrowLeft: '←',
|
||||
arrowRight: '→',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'PgUp',
|
||||
pageDown: 'PgDn',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
|
||||
escape: 'Escape',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Forward delete',
|
||||
insert: 'Insert',
|
||||
arrowUp: 'Arrow up',
|
||||
arrowDown: 'Arrow down',
|
||||
arrowLeft: 'Arrow left',
|
||||
arrowRight: 'Arrow right',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'Page up',
|
||||
pageDown: 'Page down',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
export const TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS: TerminalShortcutSpecialKey[] = [
|
||||
'escape',
|
||||
'tab',
|
||||
'enter',
|
||||
'backspace',
|
||||
'delete',
|
||||
'insert',
|
||||
'arrowUp',
|
||||
'arrowDown',
|
||||
'arrowLeft',
|
||||
'arrowRight',
|
||||
'home',
|
||||
'end',
|
||||
'pageUp',
|
||||
'pageDown',
|
||||
'space',
|
||||
'f1',
|
||||
'f2',
|
||||
'f3',
|
||||
'f4',
|
||||
'f5',
|
||||
'f6',
|
||||
'f7',
|
||||
'f8',
|
||||
'f9',
|
||||
'f10',
|
||||
'f11',
|
||||
'f12'
|
||||
].map((id) => ({
|
||||
id,
|
||||
label: SPECIAL_KEY_LABELS[id]!,
|
||||
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
|
||||
}))
|
||||
|
||||
export const TERMINAL_ACCESSORY_KEY_DEFINITIONS: TerminalAccessoryKey[] = [
|
||||
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
|
||||
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
|
||||
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
|
||||
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
|
||||
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
|
||||
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
|
||||
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Del',
|
||||
bytes: '\x1b[3~',
|
||||
accessibilityLabel: 'Forward delete',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
|
||||
{
|
||||
id: 'arrowDown',
|
||||
label: '↓',
|
||||
bytes: '\x1b[B',
|
||||
accessibilityLabel: 'Arrow Down',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowLeft',
|
||||
label: '←',
|
||||
bytes: '\x1b[D',
|
||||
accessibilityLabel: 'Arrow Left',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowRight',
|
||||
label: '→',
|
||||
bytes: '\x1b[C',
|
||||
accessibilityLabel: 'Arrow Right',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
|
||||
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
|
||||
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
|
||||
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
|
||||
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
|
||||
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
|
||||
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
|
||||
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
|
||||
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
|
||||
]
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { AutomationPrecheck, AutomationSchedulePreset } from '../../shared/automations-types'
|
||||
import {
|
||||
normalizeTaskSourceContext,
|
||||
type TaskSourceContext
|
||||
} from '../../shared/task-source-context'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import {
|
||||
DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
|
||||
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
|
||||
} from '../../shared/automation-precheck'
|
||||
import { buildAutomationRrule } from '../../shared/automation-schedule-occurrences'
|
||||
import { isValidAutomationSchedule } from '../../shared/automation-schedule-parsing'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import {
|
||||
getOptionalPositiveIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRequiredStringFlag
|
||||
} from '../flags'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
const PRESET_TRIGGERS = new Set<AutomationSchedulePreset>(['hourly', 'daily', 'weekdays', 'weekly'])
|
||||
const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const
|
||||
|
||||
function getScheduleModifierFlag(flags: Map<string, string | boolean>): string | undefined {
|
||||
return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag))
|
||||
}
|
||||
|
||||
function validateScheduleModifierApplicability(
|
||||
flags: Map<string, string | boolean>,
|
||||
raw: string
|
||||
): void {
|
||||
const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
|
||||
if (!isPreset) {
|
||||
if (flags.has('time')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--time can only be used with preset automation triggers'
|
||||
)
|
||||
}
|
||||
if (flags.has('day')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--day can only be used with the weekly automation preset'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (raw === 'hourly' && flags.has('time')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute'
|
||||
)
|
||||
}
|
||||
if (raw !== 'weekly' && flags.has('day')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--day can only be used with the weekly automation preset'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionalDayFlag(flags: Map<string, string | boolean>): number | undefined {
|
||||
const raw = flags.get('day')
|
||||
if (raw === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN
|
||||
if (!Number.isInteger(day) || day < 0 || day > 6) {
|
||||
throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6')
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
export function getProviderFlag(flags: Map<string, string | boolean>): TuiAgent {
|
||||
const provider = getRequiredStringFlag(flags, 'provider')
|
||||
if (!isTuiAgent(provider)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
export function getOptionalProviderFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): TuiAgent | undefined {
|
||||
const provider = getOptionalStringFlag(flags, 'provider')
|
||||
if (provider === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!isTuiAgent(provider)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
function getTimeFlag(flags: Map<string, string | boolean>): { hour: number; minute: number } {
|
||||
const value = flags.get('time')
|
||||
if (value === undefined) {
|
||||
return { hour: 9, minute: 0 }
|
||||
}
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
|
||||
}
|
||||
return parseTimeFlag(value)
|
||||
}
|
||||
|
||||
function parseTimeFlag(value: string): { hour: number; minute: number } {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(value)
|
||||
if (!match) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
|
||||
}
|
||||
const hour = Number(match[1])
|
||||
const minute = Number(match[2])
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time')
|
||||
}
|
||||
return { hour, minute }
|
||||
}
|
||||
|
||||
export function getScheduleFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
required: boolean
|
||||
): { rrule: string; dtstart: number } | undefined {
|
||||
const trigger = getOptionalStringFlag(flags, 'trigger')
|
||||
const schedule = getOptionalStringFlag(flags, 'schedule')
|
||||
if (trigger && schedule) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --trigger or --schedule, not both.'
|
||||
)
|
||||
}
|
||||
const raw = trigger ?? schedule
|
||||
if (!raw) {
|
||||
const modifier = getScheduleModifierFlag(flags)
|
||||
if (modifier) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--${modifier} requires --trigger or --schedule`
|
||||
)
|
||||
}
|
||||
if (required) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (raw === 'manual') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run <id>` when needed.'
|
||||
)
|
||||
}
|
||||
validateScheduleModifierApplicability(flags, raw)
|
||||
const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags)
|
||||
const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1
|
||||
const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
|
||||
? buildAutomationRrule({
|
||||
preset: raw as Exclude<AutomationSchedulePreset, 'custom'>,
|
||||
hour,
|
||||
minute,
|
||||
dayOfWeek
|
||||
})
|
||||
: raw
|
||||
if (!isValidAutomationSchedule(rrule)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`)
|
||||
}
|
||||
return { rrule, dtstart: Date.now() }
|
||||
}
|
||||
|
||||
export function getEnabledFlag(flags: Map<string, string | boolean>): boolean | undefined {
|
||||
const enabledFlag = flags.get('enabled')
|
||||
const disabledFlag = flags.get('disabled')
|
||||
if (typeof enabledFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--enabled does not take a value')
|
||||
}
|
||||
if (typeof disabledFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--disabled does not take a value')
|
||||
}
|
||||
const enabled = enabledFlag === true
|
||||
const disabled = disabledFlag === true
|
||||
if (enabled && disabled) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --enabled or --disabled, not both.'
|
||||
)
|
||||
}
|
||||
if (enabled) {
|
||||
return true
|
||||
}
|
||||
if (disabled) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getReuseSessionFlag(flags: Map<string, string | boolean>): boolean | undefined {
|
||||
const reuseFlag = flags.get('reuse-session')
|
||||
const freshFlag = flags.get('fresh-session')
|
||||
if (typeof reuseFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value')
|
||||
}
|
||||
if (typeof freshFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value')
|
||||
}
|
||||
const reuse = reuseFlag === true
|
||||
const fresh = freshFlag === true
|
||||
if (reuse && fresh) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --reuse-session or --fresh-session, not both.'
|
||||
)
|
||||
}
|
||||
if (reuse) {
|
||||
return true
|
||||
}
|
||||
if (fresh) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getPrecheckFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): AutomationPrecheck | null | undefined {
|
||||
const hasPrecheck = flags.has('precheck')
|
||||
const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout')
|
||||
if (!hasPrecheck) {
|
||||
if (timeoutSeconds !== undefined) {
|
||||
throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get('precheck')
|
||||
if (typeof value !== 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--precheck requires a command')
|
||||
}
|
||||
const command = value.trim()
|
||||
if (!command) {
|
||||
return null
|
||||
}
|
||||
if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds`
|
||||
)
|
||||
}
|
||||
return {
|
||||
command,
|
||||
timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
export function getSourceContextFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): TaskSourceContext | null | undefined {
|
||||
if (!flags.has('source-context')) {
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get('source-context')
|
||||
if (typeof value !== 'string') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context requires a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON')
|
||||
}
|
||||
if (parsed === null) {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context must be a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
const sourceContext = normalizeTaskSourceContext(
|
||||
parsed as Parameters<typeof normalizeTaskSourceContext>[0]
|
||||
)
|
||||
if (!sourceContext) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context is not a valid TaskSourceContext'
|
||||
)
|
||||
}
|
||||
return sourceContext
|
||||
}
|
||||
|
||||
export function getWorkspaceModeFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): 'existing' | 'new_per_run' | undefined {
|
||||
const value = getOptionalStringFlag(flags, 'workspace-mode')
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (value === 'existing') {
|
||||
return 'existing'
|
||||
}
|
||||
if (value === 'new-per-run' || value === 'new_per_run') {
|
||||
return 'new_per_run'
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--workspace-mode must be existing or new-per-run'
|
||||
)
|
||||
}
|
||||
+10
-299
@@ -1,16 +1,11 @@
|
||||
/* eslint-disable max-lines -- Why: automation handlers share schedule parsing, target resolution, and RPC payload shaping for one command family. */
|
||||
import type {
|
||||
Automation,
|
||||
AutomationCreateInput,
|
||||
AutomationPrecheck,
|
||||
AutomationRun,
|
||||
AutomationSchedulePreset,
|
||||
AutomationUpdateInput
|
||||
} from '../../shared/automations-types'
|
||||
import {
|
||||
buildWorkspaceRunContext,
|
||||
normalizeTaskSourceContext,
|
||||
type TaskSourceContext,
|
||||
type WorkspaceRunContext
|
||||
} from '../../shared/task-source-context'
|
||||
import type {
|
||||
@@ -18,13 +13,6 @@ import type {
|
||||
AutomationOwnerPrecondition
|
||||
} from '../../shared/automation-owner-precondition'
|
||||
import type { ProjectHostSetup } from '../../shared/project-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import {
|
||||
DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
|
||||
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
|
||||
} from '../../shared/automation-precheck'
|
||||
import { buildAutomationRrule, isValidAutomationSchedule } from '../../shared/automation-schedules'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import {
|
||||
formatAutomationList,
|
||||
@@ -48,6 +36,16 @@ import {
|
||||
hasWorkspaceProjectTarget,
|
||||
resolveProjectCreateTarget
|
||||
} from '../worktree-project-target'
|
||||
import {
|
||||
getEnabledFlag,
|
||||
getOptionalProviderFlag,
|
||||
getPrecheckFlag,
|
||||
getProviderFlag,
|
||||
getReuseSessionFlag,
|
||||
getScheduleFlag,
|
||||
getSourceContextFlag,
|
||||
getWorkspaceModeFlag
|
||||
} from './automation-handler-flags'
|
||||
|
||||
type AutomationCreateParams = Omit<AutomationCreateInput, 'projectId' | 'timezone'> & {
|
||||
destination?: AutomationDestination
|
||||
@@ -61,293 +59,6 @@ type AutomationUpdateParams = AutomationUpdateInput & {
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
const PRESET_TRIGGERS = new Set<AutomationSchedulePreset>(['hourly', 'daily', 'weekdays', 'weekly'])
|
||||
const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const
|
||||
|
||||
function getScheduleModifierFlag(flags: Map<string, string | boolean>): string | undefined {
|
||||
return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag))
|
||||
}
|
||||
|
||||
function validateScheduleModifierApplicability(
|
||||
flags: Map<string, string | boolean>,
|
||||
raw: string
|
||||
): void {
|
||||
const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
|
||||
if (!isPreset) {
|
||||
if (flags.has('time')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--time can only be used with preset automation triggers'
|
||||
)
|
||||
}
|
||||
if (flags.has('day')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--day can only be used with the weekly automation preset'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (raw === 'hourly' && flags.has('time')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute'
|
||||
)
|
||||
}
|
||||
if (raw !== 'weekly' && flags.has('day')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--day can only be used with the weekly automation preset'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionalDayFlag(flags: Map<string, string | boolean>): number | undefined {
|
||||
const raw = flags.get('day')
|
||||
if (raw === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN
|
||||
if (!Number.isInteger(day) || day < 0 || day > 6) {
|
||||
throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6')
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
function getProviderFlag(flags: Map<string, string | boolean>): TuiAgent {
|
||||
const provider = getRequiredStringFlag(flags, 'provider')
|
||||
if (!isTuiAgent(provider)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
function getOptionalProviderFlag(flags: Map<string, string | boolean>): TuiAgent | undefined {
|
||||
const provider = getOptionalStringFlag(flags, 'provider')
|
||||
if (provider === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!isTuiAgent(provider)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
function getTimeFlag(flags: Map<string, string | boolean>): { hour: number; minute: number } {
|
||||
const value = flags.get('time')
|
||||
if (value === undefined) {
|
||||
return { hour: 9, minute: 0 }
|
||||
}
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
|
||||
}
|
||||
return parseTimeFlag(value)
|
||||
}
|
||||
|
||||
function parseTimeFlag(value: string): { hour: number; minute: number } {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(value)
|
||||
if (!match) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
|
||||
}
|
||||
const hour = Number(match[1])
|
||||
const minute = Number(match[2])
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time')
|
||||
}
|
||||
return { hour, minute }
|
||||
}
|
||||
|
||||
function getScheduleFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
required: boolean
|
||||
): { rrule: string; dtstart: number } | undefined {
|
||||
const trigger = getOptionalStringFlag(flags, 'trigger')
|
||||
const schedule = getOptionalStringFlag(flags, 'schedule')
|
||||
if (trigger && schedule) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --trigger or --schedule, not both.'
|
||||
)
|
||||
}
|
||||
const raw = trigger ?? schedule
|
||||
if (!raw) {
|
||||
const modifier = getScheduleModifierFlag(flags)
|
||||
if (modifier) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--${modifier} requires --trigger or --schedule`
|
||||
)
|
||||
}
|
||||
if (required) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (raw === 'manual') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run <id>` when needed.'
|
||||
)
|
||||
}
|
||||
validateScheduleModifierApplicability(flags, raw)
|
||||
const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags)
|
||||
const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1
|
||||
const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
|
||||
? buildAutomationRrule({
|
||||
preset: raw as Exclude<AutomationSchedulePreset, 'custom'>,
|
||||
hour,
|
||||
minute,
|
||||
dayOfWeek
|
||||
})
|
||||
: raw
|
||||
if (!isValidAutomationSchedule(rrule)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`)
|
||||
}
|
||||
return { rrule, dtstart: Date.now() }
|
||||
}
|
||||
|
||||
function getEnabledFlag(flags: Map<string, string | boolean>): boolean | undefined {
|
||||
const enabledFlag = flags.get('enabled')
|
||||
const disabledFlag = flags.get('disabled')
|
||||
if (typeof enabledFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--enabled does not take a value')
|
||||
}
|
||||
if (typeof disabledFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--disabled does not take a value')
|
||||
}
|
||||
const enabled = enabledFlag === true
|
||||
const disabled = disabledFlag === true
|
||||
if (enabled && disabled) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --enabled or --disabled, not both.'
|
||||
)
|
||||
}
|
||||
if (enabled) {
|
||||
return true
|
||||
}
|
||||
if (disabled) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getReuseSessionFlag(flags: Map<string, string | boolean>): boolean | undefined {
|
||||
const reuseFlag = flags.get('reuse-session')
|
||||
const freshFlag = flags.get('fresh-session')
|
||||
if (typeof reuseFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value')
|
||||
}
|
||||
if (typeof freshFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value')
|
||||
}
|
||||
const reuse = reuseFlag === true
|
||||
const fresh = freshFlag === true
|
||||
if (reuse && fresh) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --reuse-session or --fresh-session, not both.'
|
||||
)
|
||||
}
|
||||
if (reuse) {
|
||||
return true
|
||||
}
|
||||
if (fresh) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getPrecheckFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): AutomationPrecheck | null | undefined {
|
||||
const hasPrecheck = flags.has('precheck')
|
||||
const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout')
|
||||
if (!hasPrecheck) {
|
||||
if (timeoutSeconds !== undefined) {
|
||||
throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get('precheck')
|
||||
if (typeof value !== 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--precheck requires a command')
|
||||
}
|
||||
const command = value.trim()
|
||||
if (!command) {
|
||||
return null
|
||||
}
|
||||
if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds`
|
||||
)
|
||||
}
|
||||
return {
|
||||
command,
|
||||
timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceContextFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): TaskSourceContext | null | undefined {
|
||||
if (!flags.has('source-context')) {
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get('source-context')
|
||||
if (typeof value !== 'string') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context requires a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON')
|
||||
}
|
||||
if (parsed === null) {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context must be a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
const sourceContext = normalizeTaskSourceContext(
|
||||
parsed as Parameters<typeof normalizeTaskSourceContext>[0]
|
||||
)
|
||||
if (!sourceContext) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context is not a valid TaskSourceContext'
|
||||
)
|
||||
}
|
||||
return sourceContext
|
||||
}
|
||||
|
||||
function getWorkspaceModeFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): 'existing' | 'new_per_run' | undefined {
|
||||
const value = getOptionalStringFlag(flags, 'workspace-mode')
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (value === 'existing') {
|
||||
return 'existing'
|
||||
}
|
||||
if (value === 'new-per-run' || value === 'new_per_run') {
|
||||
return 'new_per_run'
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--workspace-mode must be existing or new-per-run'
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveDefaultTarget(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
|
||||
@@ -206,6 +206,16 @@ describe('orchestration gate commands carry caller identity', () => {
|
||||
'The Orca runtime closed the connection before responding. Restart Orca and try again. Orchestration mutation request ID: mutation_1.',
|
||||
{
|
||||
orchestrationRequestId: 'mutation_1',
|
||||
originalCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'gate-create',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--question',
|
||||
'ship?',
|
||||
'--json'
|
||||
],
|
||||
failedStage: 'dispatch_input',
|
||||
residualResources: [
|
||||
{ kind: 'worktree', id: 'repo::child' },
|
||||
|
||||
+3
-364
@@ -1,371 +1,10 @@
|
||||
/* eslint-disable max-lines -- Why: root and generated command help text live together so CLI discovery stays greppable. */
|
||||
import type { CommandSpec } from './args'
|
||||
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
|
||||
import { unknownCommandData } from './command-suggestion'
|
||||
import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary'
|
||||
import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary'
|
||||
|
||||
const ROOT_HELP_TEXT = `orca
|
||||
|
||||
Usage: orca <command> [options]
|
||||
|
||||
Startup:
|
||||
open Launch Orca and wait for the runtime to be reachable
|
||||
serve Start a headless Orca runtime server
|
||||
status Show app/runtime/graph readiness
|
||||
|
||||
Diagnostics:
|
||||
diagnostics memory Collect a memory snapshot for Orca and managed terminals
|
||||
|
||||
Agent Discovery:
|
||||
agent-context Print the machine-readable command schema for agents
|
||||
|
||||
Accounts:
|
||||
account add Add a managed Claude or Codex account on this Orca host
|
||||
account list List managed Claude and Codex accounts on this Orca host
|
||||
|
||||
Skills:
|
||||
skills installed List installed skill selectors
|
||||
skills share Publish selected skills behind one unlisted link
|
||||
skills list List version-matched skill guides bundled with this Orca CLI
|
||||
skills get Print a version-matched skill guide as Markdown
|
||||
skills install Install bundled Orca skills globally via the community skills CLI
|
||||
skills update Update already-installed Orca skills via the community skills CLI
|
||||
|
||||
Hosts:
|
||||
host list List targetable machines and how to name each one
|
||||
|
||||
Environments:
|
||||
environment add Save a remote Orca runtime from a pairing code
|
||||
environment list List saved remote Orca runtimes
|
||||
environment show Show one saved remote Orca runtime
|
||||
environment rm Remove a saved remote Orca runtime
|
||||
|
||||
Environment Recipes:
|
||||
vm recipe doctor Validate a per-workspace environment recipe
|
||||
|
||||
Automations:
|
||||
automations list List scheduled Orca automations
|
||||
automations show Show one Orca automation
|
||||
automations create Create a scheduled Orca automation
|
||||
automations edit Edit an Orca automation
|
||||
automations remove Remove an Orca automation and its run history
|
||||
automations run Run an Orca automation now
|
||||
automations runs List automation run history
|
||||
|
||||
Projects:
|
||||
project list List durable projects known to Orca
|
||||
project setups List project host setups
|
||||
project setup-existing-folder Make a project available on a host by importing an existing folder
|
||||
project setup-clone Make a project available on a host by cloning a repository
|
||||
project setup-create Create independent project host setup metadata
|
||||
project setup-update Update project host setup metadata
|
||||
project setup-delete Remove a project host setup
|
||||
|
||||
Repos:
|
||||
repo list List repos registered in Orca
|
||||
repo add Add a project to Orca by filesystem path
|
||||
repo show Show one registered repo
|
||||
repo set-base-ref Set the repo's default base ref for future worktrees
|
||||
repo search-refs Search branch/tag refs within a repo
|
||||
|
||||
Worktrees:
|
||||
worktree list List Orca-managed worktrees
|
||||
worktree show Show one worktree
|
||||
worktree current Show the Orca-managed worktree for the current directory
|
||||
worktree create Create a new Orca-managed worktree
|
||||
worktree set Update Orca metadata for a worktree
|
||||
worktree rm Remove a worktree from Orca and git
|
||||
worktree ps Show a compact orchestration summary across worktrees
|
||||
|
||||
Files:
|
||||
file open Open a workspace file in the Orca editor
|
||||
file diff Open a workspace file diff in the Orca editor
|
||||
file open-changed Open all git-changed files for a workspace
|
||||
|
||||
Terminals:
|
||||
terminal list List live Orca-managed terminals
|
||||
terminal show Show terminal metadata and preview
|
||||
terminal read Read bounded terminal output
|
||||
terminal send Send input to a live terminal
|
||||
terminal wait Wait for a terminal condition (exit, tui-idle)
|
||||
terminal stop Stop terminals for a worktree
|
||||
terminal create Create a terminal session in a worktree
|
||||
terminal rename Set or clear the title of a terminal tab
|
||||
terminal split Split an existing terminal pane
|
||||
terminal switch Bring a terminal tab to the foreground
|
||||
terminal focus Alias for terminal switch
|
||||
terminal close Close a terminal pane/session, or its whole tab with --tab
|
||||
|
||||
Orchestration:
|
||||
orchestration run-create Create and bind a lightweight orchestration Run
|
||||
orchestration run-use Bind this coordinator terminal to an existing Run
|
||||
orchestration run-current Show this terminal's bound Run
|
||||
orchestration run-list List lightweight orchestration Runs
|
||||
orchestration run-show Show one lightweight orchestration Run
|
||||
orchestration send Send an inter-agent message
|
||||
orchestration check Check the bound Run mailbox
|
||||
orchestration ask Ask the coordinator a blocking question
|
||||
orchestration reply Reply to a message
|
||||
orchestration inbox Show all messages across recipients
|
||||
orchestration task-create Create an orchestration task
|
||||
orchestration task-list List orchestration tasks
|
||||
orchestration task-update Update a task status
|
||||
orchestration dispatch Dispatch a task to a terminal
|
||||
orchestration dispatch-show Show dispatch context for a task
|
||||
orchestration worker-start Start a supervised worker locally or on a connected Orca server
|
||||
orchestration worker-show Inspect one supervised worker
|
||||
orchestration worker-read Read bounded output from one supervised worker
|
||||
orchestration worker-stop Fence one Dispatch; stop only its supervised worker
|
||||
orchestration worker-abandon Fence an uncertain worker without claiming it stopped
|
||||
orchestration worker-release Release a settled worker's terminal after archiving its output
|
||||
orchestration worker-retain Keep a worker terminal live for debugging
|
||||
orchestration worker-list Report worker terminal resource accounting
|
||||
orchestration coordinator-start Start the legacy automatic coordinator loop
|
||||
orchestration coordinator-stop Stop the legacy automatic coordinator loop
|
||||
orchestration gate-create Create a decision gate blocking a task
|
||||
orchestration gate-resolve Resolve a pending decision gate
|
||||
orchestration gate-list List decision gates
|
||||
orchestration reset Reset orchestration state
|
||||
|
||||
Computer Use:
|
||||
computer capabilities Show computer-use provider capabilities
|
||||
computer permissions Show or open computer-use permission setup
|
||||
computer list-apps List running apps available to computer-use
|
||||
computer list-windows List visible windows for a target app
|
||||
computer get-app-state Capture a compact accessibility snapshot of an app
|
||||
computer click Click an app element or window coordinate
|
||||
computer perform-secondary-action Run an advertised accessibility action
|
||||
computer scroll Scroll an app element
|
||||
computer drag Drag between app elements or window coordinates
|
||||
computer type-text Type literal text at the current app focus
|
||||
computer press-key Press a single key such as Return or Escape
|
||||
computer hotkey Press a shortcut combination such as CmdOrCtrl+A
|
||||
computer paste-text Paste text through the native clipboard path
|
||||
computer set-value Set the value of a settable app element
|
||||
|
||||
Linear:
|
||||
linear Read Linear ticket context for agents
|
||||
|
||||
Mobile Emulator (iOS Simulator):
|
||||
emulator list List available/running emulators (Orca-managed + raw serve-sim)
|
||||
emulator attach <device> Attach/start helper and make active for the worktree
|
||||
emulator tap <x> <y> Tap at normalized 0..1 coords (preferred for single taps)
|
||||
emulator type <text> Type text (US ASCII only)
|
||||
emulator gesture <json> Send begin/move/end touch points
|
||||
emulator button <name> Hardware button (home, side_button, etc.)
|
||||
emulator rotate <o> Rotate device (portrait|landscape_left|...)
|
||||
emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix)
|
||||
emulator kill Stop helper for device
|
||||
|
||||
Browser Automation:
|
||||
tab create Create a new browser tab (navigates to --url)
|
||||
tab list List open browser tabs
|
||||
tab show Show one browser tab by page id
|
||||
tab current Show the current browser tab
|
||||
tab profile list List browser session profiles
|
||||
tab profile create Create a browser session profile
|
||||
tab profile delete Delete a browser session profile
|
||||
tab profile set Switch a browser tab to a different profile
|
||||
tab profile show Show the profile bound to a browser tab
|
||||
tab profile use-default Switch a browser tab back to the default profile
|
||||
tab profile clone Clone a browser tab into another profile
|
||||
tab switch Switch the active browser tab by --index or --page
|
||||
tab close Close a browser tab by --index/--page or the current tab
|
||||
snapshot Accessibility snapshot with element refs (e.g. @e1, @e2)
|
||||
goto Navigate the active tab to --url
|
||||
click Click element by --element ref
|
||||
fill Clear and fill input by --element ref with --value
|
||||
type Type --input text at the current focus (no element needed)
|
||||
select Select dropdown option by --element ref and --value
|
||||
hover Hover element by --element ref
|
||||
keypress Press a key (e.g. --key Enter, --key Tab)
|
||||
scroll Scroll --direction (up/down) by --amount pixels
|
||||
back Navigate back in browser history
|
||||
reload Reload the active browser tab
|
||||
screenshot Capture viewport screenshot (--format png|jpeg)
|
||||
eval Evaluate --expression JavaScript in the page context
|
||||
wait Wait for page idle or --timeout ms
|
||||
check Check a checkbox by --element ref
|
||||
uncheck Uncheck a checkbox by --element ref
|
||||
focus Focus an element by --element ref
|
||||
clear Clear an input by --element ref
|
||||
drag Drag --from ref to --to ref
|
||||
upload Upload --files to a file input by --element ref
|
||||
dblclick Double-click element by --element ref
|
||||
forward Navigate forward in browser history
|
||||
scrollintoview Scroll --element into view
|
||||
get Get element property (--what: text, html, value, url, title)
|
||||
is Check element state (--what: visible, enabled, checked)
|
||||
inserttext Insert text without key events
|
||||
mouse move Move mouse to --x --y coordinates
|
||||
mouse down Press mouse button
|
||||
mouse up Release mouse button
|
||||
mouse wheel Scroll wheel --dy [--dx]
|
||||
find Find element by locator (--locator role|text|label --value <v>)
|
||||
set device Emulate device (--name "iPhone 12")
|
||||
set offline Toggle offline mode (--state on|off)
|
||||
set headers Set HTTP headers (--headers '{"key":"val"}')
|
||||
set credentials Set HTTP auth (--user <u> --pass <p>)
|
||||
set media Set color scheme (--color-scheme dark|light)
|
||||
clipboard read Read clipboard contents
|
||||
clipboard write Write --text to clipboard
|
||||
dialog accept Accept browser dialog (--text for prompt response)
|
||||
dialog dismiss Dismiss browser dialog
|
||||
storage local get Get localStorage value by --key
|
||||
storage local set Set localStorage --key --value
|
||||
storage local clear Clear localStorage
|
||||
storage session get Get sessionStorage value by --key
|
||||
storage session set Set sessionStorage --key --value
|
||||
storage session clear Clear sessionStorage
|
||||
download Download file via --selector to --path
|
||||
highlight Highlight --selector on page
|
||||
exec Run any agent-browser command (--command "...")
|
||||
|
||||
Common Commands:
|
||||
orca open [--json]
|
||||
orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--project-root <path>] [--recipe-json] [--json]
|
||||
orca status [--json]
|
||||
orca diagnostics memory [--json]
|
||||
orca agent-context [--json]
|
||||
orca account add [--agent claude|codex] [--json]
|
||||
orca account list [--json]
|
||||
orca host list [--json]
|
||||
orca environment add --name <name> --pairing-code <code> [--json]
|
||||
orca environment list [--json]
|
||||
orca environment show --environment <selector> [--json]
|
||||
orca environment rm --environment <selector> [--json]
|
||||
orca worktree list [--repo <selector>] [--limit <n>] [--json]
|
||||
orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
|
||||
orca worktree show --worktree <selector> [--json]
|
||||
orca worktree current [--json]
|
||||
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
|
||||
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
|
||||
orca worktree ps [--limit <n>] [--json]
|
||||
orca file open <path> [--worktree <selector>] [--json]
|
||||
orca file diff <path> [--staged] [--worktree <selector>] [--json]
|
||||
orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]
|
||||
orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]
|
||||
orca terminal show [--terminal <handle>] [--json]
|
||||
orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]
|
||||
orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]
|
||||
orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]
|
||||
orca terminal stop --worktree <selector> [--json]
|
||||
orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]
|
||||
orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]
|
||||
orca terminal switch [--terminal <handle>] [--json]
|
||||
orca terminal close [--terminal <handle>] [--tab] [--json]
|
||||
orca project list [--json]
|
||||
orca project setups [--project <id>] [--host <host-id>] [--json]
|
||||
orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]
|
||||
orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]
|
||||
orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]
|
||||
orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]
|
||||
orca project setup-delete --setup <setup-id> [--json]
|
||||
orca repo list [--json]
|
||||
orca repo add --path <path> [--json]
|
||||
orca repo show --repo <selector> [--json]
|
||||
orca repo set-base-ref --repo <selector> --ref <ref> [--json]
|
||||
orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]
|
||||
|
||||
Selectors:
|
||||
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
|
||||
--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
|
||||
--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--no-parent Force no parent lineage for unrelated worktree creation/update
|
||||
|
||||
Terminal Send Options:
|
||||
--text <text> Text to send to the terminal
|
||||
--enter Append Enter after sending text
|
||||
--interrupt Send as an interrupt-style input when supported
|
||||
|
||||
Terminal List Options:
|
||||
--include-visual-layouts Include tab and pane topology in JSON output
|
||||
|
||||
Wait Options:
|
||||
--for exit Wait until the target terminal exits
|
||||
--timeout-ms <ms> Maximum wait time before timing out
|
||||
|
||||
Output Options:
|
||||
--json Emit machine-readable JSON instead of human text
|
||||
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair?... code
|
||||
--environment <selector> Connect using a saved environment id or name
|
||||
--help Show this help message
|
||||
|
||||
Behavior:
|
||||
Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first.
|
||||
Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.
|
||||
Use selectors for discovery and handles for repeated live terminal operations.
|
||||
|
||||
Agent Sessions And Worktrees:
|
||||
\`worktree create --agent\` creates a new checkout with an agent.
|
||||
To start a fresh agent in the current worktree, use:
|
||||
orca terminal create --worktree active --command "codex"
|
||||
|
||||
Browser Workflow:
|
||||
1. Create or navigate: orca tab create --url https://example.com
|
||||
orca goto --url https://example.com
|
||||
2. Inspect the page: orca snapshot
|
||||
(Returns an accessibility tree with element refs like e1, e2, e3)
|
||||
For concurrent workflows, prefer: orca tab list --json
|
||||
then reuse tabs[].browserPageId with --page <id> on later commands.
|
||||
3. Interact: orca click --element e2
|
||||
orca fill --element e5 --value "search query"
|
||||
orca keypress --key Enter
|
||||
4. Re-inspect: orca snapshot
|
||||
(Element refs change after navigation — always re-snapshot before interacting)
|
||||
|
||||
Browser Options:
|
||||
--element <ref> Element ref from snapshot (e.g. @e3)
|
||||
--url <url> URL to navigate to
|
||||
--value <text> Value to fill or select
|
||||
--input <text> Text to type at current focus (no element needed)
|
||||
--expression <js> JavaScript expression to evaluate
|
||||
--key <key> Key to press (Enter, Tab, Escape, Control+a, etc.)
|
||||
--direction <dir> Scroll direction: up or down
|
||||
--amount <pixels> Scroll distance in pixels (default: viewport height)
|
||||
--index <n> Tab index (from \`tab list\`)
|
||||
--page <id> Stable browser page id (preferred for concurrent workflows)
|
||||
--profile <id> Browser profile id
|
||||
--show-profile Include the tab's browser profile in text output
|
||||
--no-ua-spoof Keep Electron's native user agent for a new profile
|
||||
--format <png|jpeg> Screenshot image format
|
||||
--from <ref> Drag source element ref
|
||||
--to <ref> Drag target element ref
|
||||
--files <path,...> Comma-separated file paths for upload
|
||||
--timeout <ms> Wait timeout in milliseconds
|
||||
--worktree <selector> Scope commands to a specific worktree's browser tabs
|
||||
|
||||
Examples:
|
||||
$ orca open
|
||||
$ orca status --json
|
||||
$ orca diagnostics memory --json
|
||||
$ orca repo list
|
||||
$ orca worktree create --name agent-task --agent codex --prompt "hi"
|
||||
$ orca worktree create --repo name:orca --name cli-test-1 --issue 273
|
||||
$ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue
|
||||
$ orca worktree create --name linear-task --linear-issue STA-335
|
||||
$ orca worktree show --worktree branch:Jinwoo-H/cli
|
||||
$ orca worktree current
|
||||
$ orca worktree set --worktree active --comment "waiting on review"
|
||||
$ orca worktree set --worktree active --linear-issue null
|
||||
$ orca worktree ps --limit 10
|
||||
$ orca file open-changed --mode diff
|
||||
$ orca file open src/App.tsx
|
||||
$ orca terminal create --worktree active --command "codex"
|
||||
$ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json
|
||||
$ orca terminal send --terminal term_123 --text "hi" --enter
|
||||
$ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json
|
||||
$ orca tab current --json
|
||||
$ orca tab show --page page_123 --json
|
||||
$ orca tab create --url https://example.com --profile work
|
||||
$ orca tab profile clone --page page_123 --profile work --json
|
||||
$ orca snapshot
|
||||
$ orca click --element e3
|
||||
$ orca fill --element e5 --value "hello"
|
||||
$ orca goto --url https://example.com/login
|
||||
$ orca keypress --key Enter
|
||||
$ orca eval --expression "document.title"
|
||||
$ orca tab list --json`
|
||||
const ROOT_HELP_TEXT = [ROOT_HELP_TEXT_PRIMARY, ROOT_HELP_TEXT_SECONDARY].join('\n')
|
||||
|
||||
export function printHelp(specs: CommandSpec[], commandPath: string[] = []): void {
|
||||
const exactSpec = findCommandSpec(specs, commandPath)
|
||||
|
||||
+4
-1
@@ -18,6 +18,7 @@ import { reportCliError } from './format'
|
||||
import { printHelp } from './help'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { COMMAND_SPECS } from './specs'
|
||||
import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command'
|
||||
|
||||
export { COMMAND_SPECS } from './specs'
|
||||
export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors'
|
||||
@@ -149,7 +150,9 @@ export async function main(
|
||||
undefined,
|
||||
undefined,
|
||||
remotePairingCode,
|
||||
remoteEnvironment
|
||||
remoteEnvironment,
|
||||
resolveOrchestrationCliExecutable(),
|
||||
argv
|
||||
)
|
||||
return client
|
||||
},
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '../shared/child-process/run-process'
|
||||
import {
|
||||
orchestrationMutationRecoveryError,
|
||||
renderCommand
|
||||
} from './orchestration-mutation-recovery'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
describe('orchestration mutation recovery', () => {
|
||||
it('queries a known dispatch before issuing the keyed retry', () => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1']
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect(result.data).toMatchObject({
|
||||
recovery: {
|
||||
orchestrationRequestId: 'request_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
queryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-show',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--json'
|
||||
],
|
||||
retryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--retry-request',
|
||||
'request_1'
|
||||
],
|
||||
workerDeathInferred: false
|
||||
}
|
||||
})
|
||||
expect(result.message.indexOf('orca orchestration worker-show')).toBeLessThan(
|
||||
result.message.indexOf('orca orchestration worker-start')
|
||||
)
|
||||
expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([
|
||||
'Run orca orchestration worker-show --dispatch dispatch_1 --json before retrying.',
|
||||
'Run orca orchestration worker-start --task task_1 --retry-request request_1.'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not invent a dispatch for an old-client-shaped error', () => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_2',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_2']
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect(result.data).toMatchObject({
|
||||
recovery: {
|
||||
orchestrationRequestId: 'request_2',
|
||||
retryCommand: expect.arrayContaining(['--retry-request', 'request_2']),
|
||||
workerDeathInferred: false
|
||||
}
|
||||
})
|
||||
expect((result.data as Record<string, unknown>).recovery).not.toHaveProperty('dispatchId')
|
||||
expect(result.message).not.toContain('worker death')
|
||||
})
|
||||
|
||||
it('renders the exact executable and safely quotes original arguments', () => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_3',
|
||||
dispatchId: 'dispatch_3',
|
||||
originalCommand: [
|
||||
'orca-dev',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task 3',
|
||||
'--comment',
|
||||
'literal $(do-not-run)'
|
||||
]
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([
|
||||
'Run orca-dev orchestration worker-show --dispatch dispatch_3 --json before retrying.',
|
||||
"Run orca-dev orchestration worker-start --task 'task 3' --comment 'literal $(do-not-run)' --retry-request request_3."
|
||||
])
|
||||
expect(result.message).toContain("'literal $(do-not-run)'")
|
||||
})
|
||||
|
||||
it('parses legacy command text without losing quoted arguments', () => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_4',
|
||||
originalCommand:
|
||||
'orca-ide orchestration worker-stop --dispatch dispatch_4 --comment "quoted value"'
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect(
|
||||
(result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand
|
||||
).toEqual([
|
||||
'orca-ide',
|
||||
'orchestration',
|
||||
'worker-stop',
|
||||
'--dispatch',
|
||||
'dispatch_4',
|
||||
'--comment',
|
||||
'quoted value',
|
||||
'--retry-request',
|
||||
'request_4'
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'gate-create',
|
||||
['orca', 'orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?']
|
||||
],
|
||||
[
|
||||
'worker-retain',
|
||||
['orca', 'orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json']
|
||||
]
|
||||
])('replays exact %s argv with the keyed retry', (_name, originalCommand) => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_exact',
|
||||
originalCommand
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect(
|
||||
(result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand
|
||||
).toEqual([...originalCommand, '--retry-request', 'request_exact'])
|
||||
})
|
||||
|
||||
it('reuses the request identity without duplicating an existing retry flag', () => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_reused',
|
||||
originalCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-retain',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--retry-request=request_reused'
|
||||
]
|
||||
})
|
||||
) as RuntimeClientError
|
||||
|
||||
expect(
|
||||
(result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand
|
||||
).toEqual([
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-retain',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--retry-request',
|
||||
'request_reused'
|
||||
])
|
||||
})
|
||||
|
||||
it('renders Windows cmd recovery guidance without quote drift or percent expansion', () => {
|
||||
expect(
|
||||
renderCommand(
|
||||
['orca', 'orchestration', 'worker-start', '--comment', 'literal "quoted" %PATH% & safe'],
|
||||
'win32',
|
||||
{ ComSpec: 'C:\\Windows\\System32\\cmd.exe' }
|
||||
)
|
||||
).toBe(
|
||||
'"orca" "orchestration" "worker-start" "--comment" "literal ""quoted"" "^%"PATH"^%" & safe"'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps PowerShell and POSIX recovery guidance literal', () => {
|
||||
expect(
|
||||
renderCommand(['orca', 'literal "quoted" $HOME'], 'win32', {
|
||||
ComSpec: 'powershell.exe'
|
||||
})
|
||||
).toBe("& 'orca' 'literal \\\"quoted\\\" $HOME'")
|
||||
expect(renderCommand(['orca', 'literal $(do-not-run)'], 'darwin')).toBe(
|
||||
"orca 'literal $(do-not-run)'"
|
||||
)
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'round trips POSIX recovery argv through /bin/sh',
|
||||
async () => {
|
||||
const values = ['with spaces', "apostrophe's", 'literal $(do-not-run)', 'line one\nline two']
|
||||
const command = renderCommand(
|
||||
[
|
||||
process.execPath,
|
||||
'-e',
|
||||
'process.stdout.write(JSON.stringify(process.argv.slice(1)))',
|
||||
...values
|
||||
],
|
||||
'darwin'
|
||||
)
|
||||
|
||||
const result = await runProcess({ program: '/bin/sh', args: ['-c', command] })
|
||||
|
||||
expect(result).toMatchObject({ code: 0, stderr: '', timedOut: false })
|
||||
expect(JSON.parse(result.stdout)).toEqual(values)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
[
|
||||
'split',
|
||||
['orca', 'orchestration', 'send', '--pairing-code', 'split-secret', '--subject', 'status'],
|
||||
'split-secret'
|
||||
],
|
||||
[
|
||||
'equals',
|
||||
['orca', 'orchestration', 'send', '--pairing-code=equals-secret', '--subject', 'status'],
|
||||
'equals-secret'
|
||||
],
|
||||
[
|
||||
'dispatch split',
|
||||
[
|
||||
'orca',
|
||||
'orchestration',
|
||||
'send',
|
||||
'--dispatch-capability',
|
||||
'split-dispatch-secret',
|
||||
'--subject',
|
||||
'status'
|
||||
],
|
||||
'split-dispatch-secret'
|
||||
],
|
||||
[
|
||||
'dispatch equals',
|
||||
[
|
||||
'orca',
|
||||
'orchestration',
|
||||
'send',
|
||||
'--dispatch-capability=equals-dispatch-secret',
|
||||
'--subject',
|
||||
'status'
|
||||
],
|
||||
'equals-dispatch-secret'
|
||||
]
|
||||
])('blocks recovery and removes %s credentials', (_name, originalCommand, secret) => {
|
||||
const result = orchestrationMutationRecoveryError(
|
||||
new RuntimeClientError('runtime_timeout', 'request timed out', {
|
||||
orchestrationRequestId: 'request_secret',
|
||||
originalCommand
|
||||
})
|
||||
) as RuntimeClientError
|
||||
const output = JSON.stringify({ message: result.message, data: result.data })
|
||||
|
||||
expect(output).not.toContain(secret)
|
||||
expect(result.message).toContain('Recovery is blocked')
|
||||
expect(result.data).toMatchObject({
|
||||
recovery: {
|
||||
orchestrationRequestId: 'request_secret',
|
||||
recoveryBlocked: true
|
||||
}
|
||||
})
|
||||
expect(result.data).not.toHaveProperty('originalCommand')
|
||||
expect((result.data as { recovery: object }).recovery).not.toHaveProperty('retryCommand')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,12 @@
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
import {
|
||||
recoverableOrchestrationArgs,
|
||||
resolveOrchestrationCliExecutable
|
||||
} from './runtime/orchestration-recovery-command'
|
||||
import { quoteWindowsCmdArgument } from '../shared/child-process/windows-command-line'
|
||||
import { quotePowerShellNativeArgument } from '../shared/powershell-native-argument'
|
||||
import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell'
|
||||
import type { AgentStartupShell } from '../shared/tui-agent-startup-shell'
|
||||
|
||||
export function orchestrationMutationRecoveryError(error: unknown): unknown {
|
||||
if (!(error instanceof RuntimeClientError) || !isUnknownMutationOutcomeCode(error.code)) {
|
||||
@@ -9,16 +17,64 @@ export function orchestrationMutationRecoveryError(error: unknown): unknown {
|
||||
if (typeof requestId !== 'string' || requestId.length === 0) {
|
||||
return error
|
||||
}
|
||||
const dispatchId = typeof data?.dispatchId === 'string' ? data.dispatchId : undefined
|
||||
const parsedOriginalCommand = commandParts(data?.originalCommand)
|
||||
const originalCommand = parsedOriginalCommand
|
||||
? recoverableOrchestrationArgs(parsedOriginalCommand)
|
||||
: undefined
|
||||
const safeData = { ...data }
|
||||
delete safeData.originalCommand
|
||||
const retryCommand = originalCommand
|
||||
? [...originalCommand, '--retry-request', requestId]
|
||||
: undefined
|
||||
const executable = originalCommand?.[0] ?? resolveOrchestrationCliExecutable()
|
||||
const queryCommand = dispatchId
|
||||
? [executable, 'orchestration', 'worker-show', '--dispatch', dispatchId, '--json']
|
||||
: undefined
|
||||
const recovery = {
|
||||
orchestrationRequestId: requestId,
|
||||
...(dispatchId ? { dispatchId } : {}),
|
||||
...(queryCommand ? { queryCommand } : {}),
|
||||
...(retryCommand ? { retryCommand } : {}),
|
||||
recoveryBlocked: !retryCommand,
|
||||
disposition: 'outcome_unknown',
|
||||
workerDeathInferred: false
|
||||
}
|
||||
const retryStep = retryCommand
|
||||
? `Run ${renderCommand(retryCommand)}.`
|
||||
: 'Recovery is blocked until the exact original command is available; no retry command was emitted.'
|
||||
const nextSteps = queryCommand
|
||||
? [`Run ${renderCommand(queryCommand)} before retrying.`, retryStep]
|
||||
: [retryStep]
|
||||
const message = [
|
||||
stripUnsafeRetryAdvice(error.message, requestId),
|
||||
'The orchestration mutation may already have taken effect; do not assume it failed.',
|
||||
`Re-issue the same command with --retry-request ${requestId} to recover idempotently. Do not retry this mutation without --retry-request.`,
|
||||
...nextSteps,
|
||||
typeof data?.failedStage === 'string' ? `Failed stage: ${data.failedStage}.` : undefined,
|
||||
Array.isArray(data?.residualResources)
|
||||
? `Residual resources: ${JSON.stringify(data.residualResources)}.`
|
||||
: undefined
|
||||
].filter((line): line is string => line !== undefined)
|
||||
return new RuntimeClientError(error.code, message.join('\n'), error.data)
|
||||
const recoveredData = {
|
||||
...safeData,
|
||||
orchestrationRequestId: requestId,
|
||||
...(originalCommand ? { originalCommand } : {}),
|
||||
recovery,
|
||||
nextSteps
|
||||
}
|
||||
// Preserve the RPC failure envelope so --json callers retain the request id and
|
||||
// runtime metadata while receiving the structured recovery guidance.
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return new RuntimeRpcFailureError({
|
||||
...error.response,
|
||||
error: {
|
||||
...error.response.error,
|
||||
message: message.join('\n'),
|
||||
data: recoveredData
|
||||
}
|
||||
})
|
||||
}
|
||||
return new RuntimeClientError(error.code, message.join('\n'), recoveredData)
|
||||
}
|
||||
|
||||
function isUnknownMutationOutcomeCode(code: string): boolean {
|
||||
@@ -36,6 +92,110 @@ function objectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
: undefined
|
||||
}
|
||||
|
||||
function commandParts(value: unknown): string[] | undefined {
|
||||
if (Array.isArray(value) && value.every((part) => typeof part === 'string')) {
|
||||
return [...value]
|
||||
}
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return parseCommandLine(value)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseCommandLine(value: string): string[] | undefined {
|
||||
const parts: string[] = []
|
||||
let part = ''
|
||||
let quote: "'" | '"' | undefined
|
||||
let tokenStarted = false
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index]
|
||||
if (quote === "'") {
|
||||
if (character === "'") {
|
||||
quote = undefined
|
||||
} else {
|
||||
part += character
|
||||
}
|
||||
tokenStarted = true
|
||||
continue
|
||||
}
|
||||
if (quote === '"') {
|
||||
if (character === '"') {
|
||||
quote = undefined
|
||||
} else if (character === '\\' && ['"', '\\'].includes(value[index + 1] ?? '')) {
|
||||
part += value[++index]
|
||||
} else {
|
||||
part += character
|
||||
}
|
||||
tokenStarted = true
|
||||
continue
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
quote = character
|
||||
tokenStarted = true
|
||||
} else if (/\s/.test(character)) {
|
||||
if (tokenStarted) {
|
||||
parts.push(part)
|
||||
part = ''
|
||||
tokenStarted = false
|
||||
}
|
||||
} else if (character === '\\' && [' ', '\\', "'", '"'].includes(value[index + 1] ?? '')) {
|
||||
part += value[++index]
|
||||
tokenStarted = true
|
||||
} else {
|
||||
part += character
|
||||
tokenStarted = true
|
||||
}
|
||||
}
|
||||
if (quote !== undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (tokenStarted) {
|
||||
parts.push(part)
|
||||
}
|
||||
return parts.length > 0 ? parts : undefined
|
||||
}
|
||||
|
||||
export function renderCommand(
|
||||
command: readonly string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): string {
|
||||
const shell = resolveRecoveryShell(platform, env)
|
||||
const rendered = command.map((value) => quoteRecoveryArgument(value, shell)).join(' ')
|
||||
return shell === 'powershell' && rendered ? `& ${rendered}` : rendered
|
||||
}
|
||||
|
||||
function resolveRecoveryShell(
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv
|
||||
): AgentStartupShell {
|
||||
if (platform !== 'win32') {
|
||||
return 'posix'
|
||||
}
|
||||
return resolveWindowsShellStartupFamily(
|
||||
env.ORCA_TERMINAL_WINDOWS_SHELL ?? env.ORCA_WINDOWS_SHELL ?? env.ComSpec ?? env.COMSPEC
|
||||
)
|
||||
}
|
||||
|
||||
function quoteRecoveryArgument(value: string, shell: AgentStartupShell): string {
|
||||
if (shell === 'cmd') {
|
||||
// cmd has no single-quote syntax and expands %VAR% inside double quotes;
|
||||
// use the argv encoder that keeps quote parity and escapes percent pairs.
|
||||
return quoteWindowsCmdArgument(value)
|
||||
}
|
||||
if (shell === 'powershell') {
|
||||
return quotePowerShellNativeArgument(value)
|
||||
}
|
||||
return shellQuote(value)
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`
|
||||
}
|
||||
|
||||
function stripUnsafeRetryAdvice(message: string, requestId: string): string {
|
||||
return message
|
||||
.replace(' Restart Orca and try again.', '')
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
export const ROOT_HELP_TEXT_PRIMARY = [
|
||||
'orca',
|
||||
'',
|
||||
'Usage: orca <command> [options]',
|
||||
'',
|
||||
'Startup:',
|
||||
' open Launch Orca and wait for the runtime to be reachable',
|
||||
' serve Start a headless Orca runtime server',
|
||||
' status Show app/runtime/graph readiness',
|
||||
'',
|
||||
'Diagnostics:',
|
||||
' diagnostics memory Collect a memory snapshot for Orca and managed terminals',
|
||||
'',
|
||||
'Agent Discovery:',
|
||||
' agent-context Print the machine-readable command schema for agents',
|
||||
'',
|
||||
'Accounts:',
|
||||
' account add Add a managed Claude or Codex account on this Orca host',
|
||||
' account list List managed Claude and Codex accounts on this Orca host',
|
||||
'',
|
||||
'Skills:',
|
||||
' skills installed List installed skill selectors',
|
||||
' skills share Publish selected skills behind one unlisted link',
|
||||
' skills list List version-matched skill guides bundled with this Orca CLI',
|
||||
' skills get Print a version-matched skill guide as Markdown',
|
||||
' skills install Install bundled Orca skills globally via the community skills CLI',
|
||||
' skills update Update already-installed Orca skills via the community skills CLI',
|
||||
'',
|
||||
'Hosts:',
|
||||
' host list List targetable machines and how to name each one',
|
||||
'',
|
||||
'Environments:',
|
||||
' environment add Save a remote Orca runtime from a pairing code',
|
||||
' environment list List saved remote Orca runtimes',
|
||||
' environment show Show one saved remote Orca runtime',
|
||||
' environment rm Remove a saved remote Orca runtime',
|
||||
'',
|
||||
'Environment Recipes:',
|
||||
' vm recipe doctor Validate a per-workspace environment recipe',
|
||||
'',
|
||||
'Automations:',
|
||||
' automations list List scheduled Orca automations',
|
||||
' automations show Show one Orca automation',
|
||||
' automations create Create a scheduled Orca automation',
|
||||
' automations edit Edit an Orca automation',
|
||||
' automations remove Remove an Orca automation and its run history',
|
||||
' automations run Run an Orca automation now',
|
||||
' automations runs List automation run history',
|
||||
'',
|
||||
'Projects:',
|
||||
' project list List durable projects known to Orca',
|
||||
' project setups List project host setups',
|
||||
' project setup-existing-folder Make a project available on a host by importing an existing folder',
|
||||
' project setup-clone Make a project available on a host by cloning a repository',
|
||||
' project setup-create Create independent project host setup metadata',
|
||||
' project setup-update Update project host setup metadata',
|
||||
' project setup-delete Remove a project host setup',
|
||||
'',
|
||||
'Repos:',
|
||||
' repo list List repos registered in Orca',
|
||||
' repo add Add a project to Orca by filesystem path',
|
||||
' repo show Show one registered repo',
|
||||
" repo set-base-ref Set the repo's default base ref for future worktrees",
|
||||
' repo search-refs Search branch/tag refs within a repo',
|
||||
'',
|
||||
'Worktrees:',
|
||||
' worktree list List Orca-managed worktrees',
|
||||
' worktree show Show one worktree',
|
||||
' worktree current Show the Orca-managed worktree for the current directory',
|
||||
' worktree create Create a new Orca-managed worktree',
|
||||
' worktree set Update Orca metadata for a worktree',
|
||||
' worktree rm Remove a worktree from Orca and git',
|
||||
' worktree ps Show a compact orchestration summary across worktrees',
|
||||
'',
|
||||
'Files:',
|
||||
' file open Open a workspace file in the Orca editor',
|
||||
' file diff Open a workspace file diff in the Orca editor',
|
||||
' file open-changed Open all git-changed files for a workspace',
|
||||
'',
|
||||
'Terminals:',
|
||||
' terminal list List live Orca-managed terminals',
|
||||
' terminal show Show terminal metadata and preview',
|
||||
' terminal read Read bounded terminal output',
|
||||
' terminal send Send input to a live terminal',
|
||||
' terminal wait Wait for a terminal condition (exit, tui-idle)',
|
||||
' terminal stop Stop terminals for a worktree',
|
||||
' terminal create Create a terminal session in a worktree',
|
||||
' terminal rename Set or clear the title of a terminal tab',
|
||||
' terminal split Split an existing terminal pane',
|
||||
' terminal switch Bring a terminal tab to the foreground',
|
||||
' terminal focus Alias for terminal switch',
|
||||
' terminal close Close a terminal pane/session, or its whole tab with --tab',
|
||||
'',
|
||||
'Orchestration:',
|
||||
' orchestration run-create Create and bind a lightweight orchestration Run',
|
||||
' orchestration run-use Bind this coordinator terminal to an existing Run',
|
||||
" orchestration run-current Show this terminal's bound Run",
|
||||
' orchestration run-list List lightweight orchestration Runs',
|
||||
' orchestration run-show Show one lightweight orchestration Run',
|
||||
' orchestration send Send an inter-agent message',
|
||||
' orchestration check Check the bound Run mailbox',
|
||||
' orchestration ask Ask the coordinator a blocking question',
|
||||
' orchestration reply Reply to a message',
|
||||
' orchestration inbox Show all messages across recipients',
|
||||
' orchestration task-create Create an orchestration task',
|
||||
' orchestration task-list List orchestration tasks',
|
||||
' orchestration task-update Update a task status',
|
||||
' orchestration dispatch Dispatch a task to a terminal',
|
||||
' orchestration dispatch-show Show dispatch context for a task',
|
||||
' orchestration worker-start Start a supervised worker locally or on a connected Orca server',
|
||||
' orchestration worker-show Inspect one supervised worker',
|
||||
' orchestration worker-read Read bounded output from one supervised worker',
|
||||
' orchestration worker-stop Fence one Dispatch; stop only its supervised worker',
|
||||
' orchestration worker-abandon Fence an uncertain worker without claiming it stopped',
|
||||
" orchestration worker-release Release a settled worker's terminal after archiving its output",
|
||||
' orchestration worker-retain Keep a worker terminal live for debugging',
|
||||
' orchestration worker-list Report worker terminal resource accounting',
|
||||
' orchestration coordinator-start Start the legacy automatic coordinator loop',
|
||||
' orchestration coordinator-stop Stop the legacy automatic coordinator loop',
|
||||
' orchestration gate-create Create a decision gate blocking a task',
|
||||
' orchestration gate-resolve Resolve a pending decision gate',
|
||||
' orchestration gate-list List decision gates',
|
||||
' orchestration reset Reset orchestration state',
|
||||
'',
|
||||
'Computer Use:',
|
||||
' computer capabilities Show computer-use provider capabilities',
|
||||
' computer permissions Show or open computer-use permission setup',
|
||||
' computer list-apps List running apps available to computer-use',
|
||||
' computer list-windows List visible windows for a target app',
|
||||
' computer get-app-state Capture a compact accessibility snapshot of an app',
|
||||
' computer click Click an app element or window coordinate',
|
||||
' computer perform-secondary-action Run an advertised accessibility action',
|
||||
' computer scroll Scroll an app element',
|
||||
' computer drag Drag between app elements or window coordinates',
|
||||
' computer type-text Type literal text at the current app focus',
|
||||
' computer press-key Press a single key such as Return or Escape',
|
||||
' computer hotkey Press a shortcut combination such as CmdOrCtrl+A',
|
||||
' computer paste-text Paste text through the native clipboard path',
|
||||
' computer set-value Set the value of a settable app element',
|
||||
'',
|
||||
'Linear:',
|
||||
' linear Read Linear ticket context for agents',
|
||||
'',
|
||||
'Mobile Emulator (iOS Simulator):',
|
||||
' emulator list List available/running emulators (Orca-managed + raw serve-sim)',
|
||||
' emulator attach <device> Attach/start helper and make active for the worktree',
|
||||
' emulator tap <x> <y> Tap at normalized 0..1 coords (preferred for single taps)',
|
||||
' emulator type <text> Type text (US ASCII only)',
|
||||
' emulator gesture <json> Send begin/move/end touch points',
|
||||
' emulator button <name> Hardware button (home, side_button, etc.)',
|
||||
' emulator rotate <o> Rotate device (portrait|landscape_left|...)',
|
||||
' emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix)',
|
||||
' emulator kill Stop helper for device',
|
||||
'',
|
||||
'Browser Automation:',
|
||||
' tab create Create a new browser tab (navigates to --url)',
|
||||
' tab list List open browser tabs',
|
||||
' tab show Show one browser tab by page id',
|
||||
' tab current Show the current browser tab',
|
||||
' tab profile list List browser session profiles',
|
||||
' tab profile create Create a browser session profile',
|
||||
' tab profile delete Delete a browser session profile',
|
||||
' tab profile set Switch a browser tab to a different profile',
|
||||
' tab profile show Show the profile bound to a browser tab',
|
||||
' tab profile use-default Switch a browser tab back to the default profile',
|
||||
' tab profile clone Clone a browser tab into another profile',
|
||||
' tab switch Switch the active browser tab by --index or --page',
|
||||
' tab close Close a browser tab by --index/--page or the current tab',
|
||||
' snapshot Accessibility snapshot with element refs (e.g. @e1, @e2)',
|
||||
' goto Navigate the active tab to --url',
|
||||
' click Click element by --element ref',
|
||||
' fill Clear and fill input by --element ref with --value',
|
||||
' type Type --input text at the current focus (no element needed)',
|
||||
' select Select dropdown option by --element ref and --value',
|
||||
' hover Hover element by --element ref',
|
||||
' keypress Press a key (e.g. --key Enter, --key Tab)',
|
||||
' scroll Scroll --direction (up/down) by --amount pixels',
|
||||
' back Navigate back in browser history',
|
||||
' reload Reload the active browser tab',
|
||||
' screenshot Capture viewport screenshot (--format png|jpeg)',
|
||||
' eval Evaluate --expression JavaScript in the page context',
|
||||
' wait Wait for page idle or --timeout ms',
|
||||
' check Check a checkbox by --element ref'
|
||||
].join('\n')
|
||||
@@ -0,0 +1,183 @@
|
||||
export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
' uncheck Uncheck a checkbox by --element ref',
|
||||
' focus Focus an element by --element ref',
|
||||
' clear Clear an input by --element ref',
|
||||
' drag Drag --from ref to --to ref',
|
||||
' upload Upload --files to a file input by --element ref',
|
||||
' dblclick Double-click element by --element ref',
|
||||
' forward Navigate forward in browser history',
|
||||
' scrollintoview Scroll --element into view',
|
||||
' get Get element property (--what: text, html, value, url, title)',
|
||||
' is Check element state (--what: visible, enabled, checked)',
|
||||
' inserttext Insert text without key events',
|
||||
' mouse move Move mouse to --x --y coordinates',
|
||||
' mouse down Press mouse button',
|
||||
' mouse up Release mouse button',
|
||||
' mouse wheel Scroll wheel --dy [--dx]',
|
||||
' find Find element by locator (--locator role|text|label --value <v>)',
|
||||
' set device Emulate device (--name "iPhone 12")',
|
||||
' set offline Toggle offline mode (--state on|off)',
|
||||
' set headers Set HTTP headers (--headers \'{"key":"val"}\')',
|
||||
' set credentials Set HTTP auth (--user <u> --pass <p>)',
|
||||
' set media Set color scheme (--color-scheme dark|light)',
|
||||
' clipboard read Read clipboard contents',
|
||||
' clipboard write Write --text to clipboard',
|
||||
' dialog accept Accept browser dialog (--text for prompt response)',
|
||||
' dialog dismiss Dismiss browser dialog',
|
||||
' storage local get Get localStorage value by --key',
|
||||
' storage local set Set localStorage --key --value',
|
||||
' storage local clear Clear localStorage',
|
||||
' storage session get Get sessionStorage value by --key',
|
||||
' storage session set Set sessionStorage --key --value',
|
||||
' storage session clear Clear sessionStorage',
|
||||
' download Download file via --selector to --path',
|
||||
' highlight Highlight --selector on page',
|
||||
' exec Run any agent-browser command (--command "...")',
|
||||
'',
|
||||
'Common Commands:',
|
||||
' orca open [--json]',
|
||||
' orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--project-root <path>] [--recipe-json] [--json]',
|
||||
' orca status [--json]',
|
||||
' orca diagnostics memory [--json]',
|
||||
' orca agent-context [--json]',
|
||||
' orca account add [--agent claude|codex] [--json]',
|
||||
' orca account list [--json]',
|
||||
' orca host list [--json]',
|
||||
' orca environment add --name <name> --pairing-code <code> [--json]',
|
||||
' orca environment list [--json]',
|
||||
' orca environment show --environment <selector> [--json]',
|
||||
' orca environment rm --environment <selector> [--json]',
|
||||
' orca worktree list [--repo <selector>] [--limit <n>] [--json]',
|
||||
' orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
|
||||
' orca worktree show --worktree <selector> [--json]',
|
||||
' orca worktree current [--json]',
|
||||
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
|
||||
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
|
||||
' orca worktree ps [--limit <n>] [--json]',
|
||||
' orca file open <path> [--worktree <selector>] [--json]',
|
||||
' orca file diff <path> [--staged] [--worktree <selector>] [--json]',
|
||||
' orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]',
|
||||
' orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]',
|
||||
' orca terminal show [--terminal <handle>] [--json]',
|
||||
' orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
|
||||
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]',
|
||||
' orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
|
||||
' orca terminal stop --worktree <selector> [--json]',
|
||||
' orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]',
|
||||
' orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]',
|
||||
' orca terminal switch [--terminal <handle>] [--json]',
|
||||
' orca terminal close [--terminal <handle>] [--tab] [--json]',
|
||||
' orca project list [--json]',
|
||||
' orca project setups [--project <id>] [--host <host-id>] [--json]',
|
||||
' orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]',
|
||||
' orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]',
|
||||
' orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]',
|
||||
' orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]',
|
||||
' orca project setup-delete --setup <setup-id> [--json]',
|
||||
' orca repo list [--json]',
|
||||
' orca repo add --path <path> [--json]',
|
||||
' orca repo show --repo <selector> [--json]',
|
||||
' orca repo set-base-ref --repo <selector> --ref <ref> [--json]',
|
||||
' orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]',
|
||||
'',
|
||||
'Selectors:',
|
||||
' --repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>',
|
||||
' --worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
' --terminal <handle> Runtime-issued terminal handle returned by `orca terminal list --json`',
|
||||
' --parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
' --no-parent Force no parent lineage for unrelated worktree creation/update',
|
||||
'',
|
||||
'Terminal Send Options:',
|
||||
' --text <text> Text to send to the terminal',
|
||||
' --enter Append Enter after sending text',
|
||||
' --interrupt Send as an interrupt-style input when supported',
|
||||
'',
|
||||
'Terminal List Options:',
|
||||
' --include-visual-layouts Include tab and pane topology in JSON output',
|
||||
'',
|
||||
'Wait Options:',
|
||||
' --for exit Wait until the target terminal exits',
|
||||
' --timeout-ms <ms> Maximum wait time before timing out',
|
||||
'',
|
||||
'Output Options:',
|
||||
' --json Emit machine-readable JSON instead of human text',
|
||||
' --pairing-code <code> Connect to a remote Orca runtime using an orca://pair?... code',
|
||||
' --environment <selector> Connect using a saved environment id or name',
|
||||
' --help Show this help message',
|
||||
'',
|
||||
'Behavior:',
|
||||
' Most commands require a running Orca runtime. If Orca is not open yet, run `orca open` first.',
|
||||
' Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.',
|
||||
' Use selectors for discovery and handles for repeated live terminal operations.',
|
||||
'',
|
||||
'Agent Sessions And Worktrees:',
|
||||
' `worktree create --agent` creates a new checkout with an agent.',
|
||||
' To start a fresh agent in the current worktree, use:',
|
||||
' orca terminal create --worktree active --command "codex"',
|
||||
'',
|
||||
'Browser Workflow:',
|
||||
' 1. Create or navigate: orca tab create --url https://example.com',
|
||||
' orca goto --url https://example.com',
|
||||
' 2. Inspect the page: orca snapshot',
|
||||
' (Returns an accessibility tree with element refs like e1, e2, e3)',
|
||||
' For concurrent workflows, prefer: orca tab list --json',
|
||||
' then reuse tabs[].browserPageId with --page <id> on later commands.',
|
||||
' 3. Interact: orca click --element e2',
|
||||
' orca fill --element e5 --value "search query"',
|
||||
' orca keypress --key Enter',
|
||||
' 4. Re-inspect: orca snapshot',
|
||||
' (Element refs change after navigation — always re-snapshot before interacting)',
|
||||
'',
|
||||
'Browser Options:',
|
||||
' --element <ref> Element ref from snapshot (e.g. @e3)',
|
||||
' --url <url> URL to navigate to',
|
||||
' --value <text> Value to fill or select',
|
||||
' --input <text> Text to type at current focus (no element needed)',
|
||||
' --expression <js> JavaScript expression to evaluate',
|
||||
' --key <key> Key to press (Enter, Tab, Escape, Control+a, etc.)',
|
||||
' --direction <dir> Scroll direction: up or down',
|
||||
' --amount <pixels> Scroll distance in pixels (default: viewport height)',
|
||||
' --index <n> Tab index (from `tab list`)',
|
||||
' --page <id> Stable browser page id (preferred for concurrent workflows)',
|
||||
' --profile <id> Browser profile id',
|
||||
" --show-profile Include the tab's browser profile in text output",
|
||||
" --no-ua-spoof Keep Electron's native user agent for a new profile",
|
||||
' --format <png|jpeg> Screenshot image format',
|
||||
' --from <ref> Drag source element ref',
|
||||
' --to <ref> Drag target element ref',
|
||||
' --files <path,...> Comma-separated file paths for upload',
|
||||
' --timeout <ms> Wait timeout in milliseconds',
|
||||
" --worktree <selector> Scope commands to a specific worktree's browser tabs",
|
||||
'',
|
||||
'Examples:',
|
||||
' $ orca open',
|
||||
' $ orca status --json',
|
||||
' $ orca diagnostics memory --json',
|
||||
' $ orca repo list',
|
||||
' $ orca worktree create --name agent-task --agent codex --prompt "hi"',
|
||||
' $ orca worktree create --repo name:orca --name cli-test-1 --issue 273',
|
||||
' $ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue',
|
||||
' $ orca worktree create --name linear-task --linear-issue STA-335',
|
||||
' $ orca worktree show --worktree branch:Jinwoo-H/cli',
|
||||
' $ orca worktree current',
|
||||
' $ orca worktree set --worktree active --comment "waiting on review"',
|
||||
' $ orca worktree set --worktree active --linear-issue null',
|
||||
' $ orca worktree ps --limit 10',
|
||||
' $ orca file open-changed --mode diff',
|
||||
' $ orca file open src/App.tsx',
|
||||
' $ orca terminal create --worktree active --command "codex"',
|
||||
' $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json',
|
||||
' $ orca terminal send --terminal term_123 --text "hi" --enter',
|
||||
' $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json',
|
||||
' $ orca tab current --json',
|
||||
' $ orca tab show --page page_123 --json',
|
||||
' $ orca tab create --url https://example.com --profile work',
|
||||
' $ orca tab profile clone --page page_123 --profile work --json',
|
||||
' $ orca snapshot',
|
||||
' $ orca click --element e3',
|
||||
' $ orca fill --element e5 --value "hello"',
|
||||
' $ orca goto --url https://example.com/login',
|
||||
' $ orca keypress --key Enter',
|
||||
' $ orca eval --expression "document.title"',
|
||||
' $ orca tab list --json'
|
||||
].join('\n')
|
||||
@@ -0,0 +1,31 @@
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './types'
|
||||
|
||||
export function attachMutationRecovery(
|
||||
error: unknown,
|
||||
requestId: string | undefined,
|
||||
originalCommand?: string[]
|
||||
): unknown {
|
||||
if (!requestId || !(error instanceof RuntimeClientError)) {
|
||||
return error
|
||||
}
|
||||
const data = {
|
||||
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
||||
orchestrationRequestId: requestId,
|
||||
...(originalCommand ? { originalCommand } : {})
|
||||
}
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return new RuntimeRpcFailureError({
|
||||
...error.response,
|
||||
error: {
|
||||
...error.response.error,
|
||||
message: `${error.message} Orchestration mutation request ID: ${requestId}.`,
|
||||
data
|
||||
}
|
||||
})
|
||||
}
|
||||
return new RuntimeClientError(
|
||||
error.code,
|
||||
`${error.message} Orchestration mutation request ID: ${requestId}.`,
|
||||
data
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createServer, type Server } from 'node:net'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import { ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS } from '../../shared/orchestration-timing-budgets'
|
||||
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
|
||||
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
|
||||
import { RuntimeClient, RuntimeRpcFailureError } from '../runtime-client'
|
||||
|
||||
const servers = new Set<Server>()
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
[...servers].map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve())
|
||||
})
|
||||
)
|
||||
)
|
||||
servers.clear()
|
||||
})
|
||||
|
||||
describe('RuntimeClient orchestration recovery identity', () => {
|
||||
it('rejects a worker-start timeout whose client grace would overflow timers', () => {
|
||||
const client = new RuntimeClient(undefined, 60_000, null, null, 'orca')
|
||||
const resolve = (
|
||||
client as unknown as {
|
||||
resolveMethodTimeoutMs: (method: string, params?: unknown) => number
|
||||
}
|
||||
).resolveMethodTimeoutMs.bind(client)
|
||||
const maxValid = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS
|
||||
expect(resolve('orchestration.workerStart', { timeoutMs: maxValid })).toBe(MAX_TIMER_DELAY_MS)
|
||||
expect(() => resolve('orchestration.workerStart', { timeoutMs: maxValid + 1 })).toThrow(
|
||||
'derived timeout must be'
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches the request and exact retry identity to a real RPC failure response', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-recovery-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk
|
||||
const newline = buffer.indexOf('\n')
|
||||
if (newline === -1) {
|
||||
return
|
||||
}
|
||||
const request = JSON.parse(buffer.slice(0, newline)) as { id: string; method: string }
|
||||
const response =
|
||||
request.method === 'status.get'
|
||||
? {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
: {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_timeout',
|
||||
message: 'request timed out',
|
||||
data: { requestId: 'request_1', dispatchId: 'dispatch_1' }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
socket.end(`${JSON.stringify(response)}\n`)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeFileSync(
|
||||
join(userDataPath, 'orca-runtime.json'),
|
||||
JSON.stringify({
|
||||
runtimeId: 'runtime-1',
|
||||
pid: 1,
|
||||
transports: [{ kind: 'unix', endpoint }],
|
||||
authToken: 'token',
|
||||
startedAt: 1
|
||||
})
|
||||
)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
try {
|
||||
await client.call('orchestration.workerStart', { task: 'task_1' })
|
||||
throw new Error('expected worker-start failure')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RuntimeRpcFailureError)
|
||||
const recovered = orchestrationMutationRecoveryError(error) as {
|
||||
data?: Record<string, unknown>
|
||||
response?: { id?: string; _meta?: { runtimeId?: string } }
|
||||
}
|
||||
expect(recovered).toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expect(recovered.response).toMatchObject({
|
||||
id: expect.any(String),
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
expect(recovered.data).toMatchObject({
|
||||
orchestrationRequestId: expect.any(String),
|
||||
dispatchId: 'dispatch_1',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'],
|
||||
recovery: {
|
||||
queryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-show',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--json'
|
||||
],
|
||||
retryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--retry-request',
|
||||
expect.any(String)
|
||||
],
|
||||
recoveryBlocked: false
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
+45
-19
@@ -11,6 +11,7 @@ import { getDefaultUserDataPath, readMetadata } from './metadata'
|
||||
import { getCliStatus, projectRemoteAppStatus } from './status'
|
||||
import { sendRequest } from './transport'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
import { attachMutationRecovery } from './client-error-recovery'
|
||||
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
|
||||
import {
|
||||
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
|
||||
@@ -19,6 +20,16 @@ import {
|
||||
import { RemoteRuntimeCompatGate } from './remote-runtime-compat-gate'
|
||||
import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope'
|
||||
import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout'
|
||||
import {
|
||||
isWorkerStartTimeoutWithinTimerLimit,
|
||||
resolveWorkerStartClientTimeoutMs,
|
||||
resolveWorkerStartReadinessTimeoutMs
|
||||
} from '../../shared/orchestration-timing-budgets'
|
||||
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
|
||||
import {
|
||||
buildOrchestrationRecoveryCommand,
|
||||
resolveOrchestrationCliExecutable
|
||||
} from './orchestration-recovery-command'
|
||||
|
||||
// Why: for long-poll methods the caller's method-level
|
||||
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
|
||||
@@ -40,6 +51,8 @@ export class RuntimeClient {
|
||||
private readonly requestTimeoutMs: number
|
||||
private readonly remotePairing: PairingOffer | null
|
||||
private readonly environmentSelector: string | null
|
||||
private readonly cliExecutable: string
|
||||
private readonly originalArgs: readonly string[] | undefined
|
||||
private readonly remoteCompat: RemoteRuntimeCompatGate
|
||||
private orchestrationContractCheck: Promise<void> | null = null
|
||||
private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope(
|
||||
@@ -53,11 +66,15 @@ export class RuntimeClient {
|
||||
userDataPath = getDefaultUserDataPath(),
|
||||
requestTimeoutMs = 60_000,
|
||||
remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null,
|
||||
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null
|
||||
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null,
|
||||
cliExecutable = resolveOrchestrationCliExecutable(),
|
||||
originalArgs?: readonly string[]
|
||||
) {
|
||||
this.userDataPath = userDataPath
|
||||
this.requestTimeoutMs = requestTimeoutMs
|
||||
this.environmentSelector = environmentSelector
|
||||
this.cliExecutable = cliExecutable
|
||||
this.originalArgs = originalArgs ? [...originalArgs] : undefined
|
||||
this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector)
|
||||
this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector)
|
||||
}
|
||||
@@ -79,6 +96,9 @@ export class RuntimeClient {
|
||||
const orchestrationRequestId = orchestrationMutation
|
||||
? (options?.orchestrationRequestId ?? randomUUID())
|
||||
: undefined
|
||||
const originalCommand = orchestrationMutation
|
||||
? buildOrchestrationRecoveryCommand(method, params, this.cliExecutable, this.originalArgs)
|
||||
: undefined
|
||||
const compatibilityEnvelope = method.startsWith('orchestration.')
|
||||
? {
|
||||
...this.orchestrationCompatibility,
|
||||
@@ -107,10 +127,14 @@ export class RuntimeClient {
|
||||
envelope
|
||||
})
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId)
|
||||
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
throw attachMutationRecovery(
|
||||
new RuntimeRpcFailureError(response),
|
||||
orchestrationRequestId,
|
||||
originalCommand
|
||||
)
|
||||
}
|
||||
if (this.environmentSelector) {
|
||||
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
|
||||
@@ -124,10 +148,14 @@ export class RuntimeClient {
|
||||
try {
|
||||
response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs, envelope)
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId)
|
||||
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
throw attachMutationRecovery(
|
||||
new RuntimeRpcFailureError(response),
|
||||
orchestrationRequestId,
|
||||
originalCommand
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -138,6 +166,18 @@ export class RuntimeClient {
|
||||
// to resolve. Without this, a 5 min wait would still die at the 60 s default.
|
||||
// See design doc §3.1.
|
||||
private resolveMethodTimeoutMs(method: string, params?: unknown): number {
|
||||
if (method === 'orchestration.workerStart') {
|
||||
const requestedValue = getTimeoutMsParam(params)
|
||||
const requested = typeof requestedValue === 'number' ? requestedValue : Number(requestedValue)
|
||||
if (!isWorkerStartTimeoutWithinTimerLimit(requested)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--timeout-ms is too large for worker-start transport grace; the derived timeout must be <= ${MAX_TIMER_DELAY_MS}ms.`
|
||||
)
|
||||
}
|
||||
const readiness = resolveWorkerStartReadinessTimeoutMs(requested)
|
||||
return Math.max(resolveWorkerStartClientTimeoutMs(readiness), this.requestTimeoutMs)
|
||||
}
|
||||
if (
|
||||
(method === 'orchestration.check' && isWaitingCheck(params)) ||
|
||||
method === 'terminal.wait'
|
||||
@@ -241,20 +281,6 @@ export class RuntimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
function attachMutationRecovery(error: unknown, requestId: string | undefined): unknown {
|
||||
if (!requestId || !(error instanceof RuntimeClientError)) {
|
||||
return error
|
||||
}
|
||||
return new RuntimeClientError(
|
||||
error.code,
|
||||
`${error.message} Orchestration mutation request ID: ${requestId}.`,
|
||||
{
|
||||
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
||||
orchestrationRequestId: requestId
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function throwDesktopActivationBlocked(): never {
|
||||
throw new RuntimeClientError(
|
||||
'desktop_activation_blocked',
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildOrchestrationRecoveryCommand,
|
||||
resolveOrchestrationCliExecutable
|
||||
} from './orchestration-recovery-command'
|
||||
|
||||
describe('orchestration recovery command identity', () => {
|
||||
it.each([
|
||||
['configured dev', { ORCA_CLI_COMMAND: 'orca-dev' }, 'darwin', 'orca-dev'],
|
||||
['configured WSL', { ORCA_CLI_COMMAND: 'orca-ide' }, 'linux', 'orca-ide'],
|
||||
['dev checkout', { ORCA_DEV_REPO_ROOT: '/repo' }, 'darwin', 'orca-dev'],
|
||||
['packaged Linux', {}, 'linux', 'orca-ide'],
|
||||
['local macOS', {}, 'darwin', 'orca'],
|
||||
['local Windows', {}, 'win32', 'orca']
|
||||
] as const)('resolves the %s CLI identity', (_name, env, platform, expected) => {
|
||||
expect(resolveOrchestrationCliExecutable(env, platform)).toBe(expected)
|
||||
})
|
||||
|
||||
it('reconstructs the keyed worker-start command from its RPC params', () => {
|
||||
expect(
|
||||
buildOrchestrationRecoveryCommand(
|
||||
'orchestration.workerStart',
|
||||
{
|
||||
task: 'task_1',
|
||||
on: 'windows',
|
||||
worktree: 'new-top-level',
|
||||
timeoutMs: 90_000,
|
||||
devMode: false
|
||||
},
|
||||
'orca'
|
||||
)
|
||||
).toEqual([
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--on',
|
||||
'windows',
|
||||
'--worktree',
|
||||
'new-top-level',
|
||||
'--timeout-ms',
|
||||
'90000'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves the selected executable and raw command arguments', () => {
|
||||
expect(
|
||||
buildOrchestrationRecoveryCommand(
|
||||
'orchestration.workerStart',
|
||||
{ task: 'task_1' },
|
||||
'orca-dev',
|
||||
[
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--comment',
|
||||
'literal $(do-not-run) "quoted"',
|
||||
'--json'
|
||||
]
|
||||
)
|
||||
).toEqual([
|
||||
'orca-dev',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--comment',
|
||||
'literal $(do-not-run) "quoted"',
|
||||
'--json'
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'gate-create',
|
||||
'orchestration.gateCreate',
|
||||
['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?']
|
||||
],
|
||||
[
|
||||
'worker-retain',
|
||||
'orchestration.workerRetain',
|
||||
['orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json']
|
||||
]
|
||||
] as const)('preserves exact raw argv for %s recovery', (_name, method, args) => {
|
||||
expect(buildOrchestrationRecoveryCommand(method, {}, 'orca', args)).toEqual(['orca', ...args])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['split', ['orchestration', 'send', '--pairing-code', 'secret', '--subject', 'status']],
|
||||
['equals', ['orchestration', 'send', '--pairing-code=secret', '--subject', 'status']],
|
||||
[
|
||||
'dispatch split',
|
||||
['orchestration', 'send', '--dispatch-capability', 'secret', '--subject', 'status']
|
||||
],
|
||||
[
|
||||
'dispatch equals',
|
||||
['orchestration', 'send', '--dispatch-capability=secret', '--subject', 'status']
|
||||
]
|
||||
])('blocks %s credential argv instead of exposing it', (_name, args) => {
|
||||
expect(
|
||||
buildOrchestrationRecoveryCommand('orchestration.send', {}, 'orca', args)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('supports the explicit executable-first form', () => {
|
||||
expect(
|
||||
buildOrchestrationRecoveryCommand('orca-ide', 'orchestration.workerStop', {
|
||||
dispatch: 'dispatch_1'
|
||||
})
|
||||
).toEqual(['orca-ide', 'orchestration', 'worker-stop', '--dispatch', 'dispatch_1'])
|
||||
})
|
||||
|
||||
it('reconstructs worker-retain when raw argv is unavailable', () => {
|
||||
expect(
|
||||
buildOrchestrationRecoveryCommand('orchestration.workerRetain', {
|
||||
dispatch: 'dispatch_1'
|
||||
})
|
||||
).toEqual([
|
||||
resolveOrchestrationCliExecutable(),
|
||||
'orchestration',
|
||||
'worker-retain',
|
||||
'--dispatch',
|
||||
'dispatch_1'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not fabricate identity for unsupported mutations or malformed params', () => {
|
||||
expect(buildOrchestrationRecoveryCommand('orchestration.send', {})).toBeUndefined()
|
||||
expect(buildOrchestrationRecoveryCommand('orchestration.workerStart', null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
export function resolveOrchestrationCliExecutable(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): string {
|
||||
const configured = env.ORCA_CLI_COMMAND?.trim()
|
||||
if (configured) {
|
||||
return configured
|
||||
}
|
||||
if (env.ORCA_DEV_REPO_ROOT) {
|
||||
return 'orca-dev'
|
||||
}
|
||||
return platform === 'linux' ? 'orca-ide' : 'orca'
|
||||
}
|
||||
|
||||
export function buildOrchestrationRecoveryCommand(
|
||||
method: string,
|
||||
params: unknown,
|
||||
executable?: string,
|
||||
originalArgs?: readonly string[]
|
||||
): string[] | undefined
|
||||
export function buildOrchestrationRecoveryCommand(
|
||||
executable: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
originalArgs?: readonly string[]
|
||||
): string[] | undefined
|
||||
export function buildOrchestrationRecoveryCommand(
|
||||
methodOrExecutable: string,
|
||||
paramsOrMethod: unknown,
|
||||
executableOrParams?: unknown,
|
||||
originalArgs?: readonly string[]
|
||||
): string[] | undefined {
|
||||
const legacyOrder = typeof paramsOrMethod === 'string' && executableOrParams !== undefined
|
||||
const method = (legacyOrder ? paramsOrMethod : methodOrExecutable) as string
|
||||
const params = legacyOrder ? executableOrParams : paramsOrMethod
|
||||
const executable = legacyOrder
|
||||
? methodOrExecutable
|
||||
: typeof executableOrParams === 'string'
|
||||
? executableOrParams
|
||||
: resolveOrchestrationCliExecutable()
|
||||
if (originalArgs && originalArgs.length > 0) {
|
||||
const recoverableArgs = recoverableOrchestrationArgs(originalArgs)
|
||||
return recoverableArgs ? [executable, ...recoverableArgs] : undefined
|
||||
}
|
||||
const command =
|
||||
method === 'orchestration.workerStart'
|
||||
? 'worker-start'
|
||||
: method === 'orchestration.workerStop'
|
||||
? 'worker-stop'
|
||||
: method === 'orchestration.workerAbandon'
|
||||
? 'worker-abandon'
|
||||
: method === 'orchestration.workerRelease'
|
||||
? 'worker-release'
|
||||
: method === 'orchestration.workerRetain'
|
||||
? 'worker-retain'
|
||||
: undefined
|
||||
if (!command || params === null || typeof params !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const record = params as Record<string, unknown>
|
||||
const requiredKey = command === 'worker-start' ? 'task' : 'dispatch'
|
||||
if (typeof record[requiredKey] !== 'string' || record[requiredKey].length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const args = [executable, 'orchestration', command]
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (value === undefined || value === null || value === false) {
|
||||
continue
|
||||
}
|
||||
const flag = `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`
|
||||
if (value === true) {
|
||||
args.push(flag)
|
||||
} else if (typeof value === 'string' || typeof value === 'number') {
|
||||
args.push(flag, String(value))
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
export function recoverableOrchestrationArgs(args: readonly string[]): string[] | undefined {
|
||||
const containsCredential = args.some((arg) =>
|
||||
['--pairing-code', '--dispatch-capability'].some(
|
||||
(flag) => arg === flag || arg.startsWith(`${flag}=`)
|
||||
)
|
||||
)
|
||||
if (containsCredential) {
|
||||
return undefined
|
||||
}
|
||||
const result: string[] = []
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]
|
||||
if (arg === '--retry-request') {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('--retry-request=')) {
|
||||
continue
|
||||
}
|
||||
result.push(arg)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
|
||||
|
||||
const { sendRemoteRuntimeRequest, sendRemoteRuntimeRequestWithStatusPreflight } = vi.hoisted(
|
||||
() => ({
|
||||
sendRemoteRuntimeRequest: vi.fn(),
|
||||
sendRemoteRuntimeRequestWithStatusPreflight: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('../../shared/remote-runtime-client', () => ({
|
||||
RemoteRuntimeClientError,
|
||||
sendRemoteRuntimeRequest,
|
||||
sendRemoteRuntimeRequestWithStatusPreflight
|
||||
}))
|
||||
|
||||
import {
|
||||
sendWebSocketRequest,
|
||||
sendWebSocketRequestWithStatusPreflight
|
||||
} from './websocket-transport'
|
||||
|
||||
const pairing = {
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:1',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key'
|
||||
} as never
|
||||
|
||||
describe('CLI WebSocket transport error identity', () => {
|
||||
it('preserves recovery data from one-shot transport errors', async () => {
|
||||
sendRemoteRuntimeRequest.mockRejectedValueOnce(
|
||||
new RemoteRuntimeClientError('runtime_timeout', 'timed out', {
|
||||
data: {
|
||||
orchestrationRequestId: 'request_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1']
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await sendWebSocketRequest(pairing, 'orchestration.workerStart', {}, 100)
|
||||
throw new Error('expected transport failure')
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({
|
||||
code: 'runtime_timeout',
|
||||
data: {
|
||||
orchestrationRequestId: 'request_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1']
|
||||
}
|
||||
})
|
||||
expect(orchestrationMutationRecoveryError(error)).toMatchObject({
|
||||
data: {
|
||||
recovery: {
|
||||
dispatchId: 'dispatch_1',
|
||||
queryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-show',
|
||||
'--dispatch',
|
||||
'dispatch_1',
|
||||
'--json'
|
||||
],
|
||||
retryCommand: [
|
||||
'orca',
|
||||
'orchestration',
|
||||
'worker-start',
|
||||
'--task',
|
||||
'task_1',
|
||||
'--retry-request',
|
||||
'request_1'
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves recovery data through status-preflight transport errors', async () => {
|
||||
sendRemoteRuntimeRequestWithStatusPreflight.mockRejectedValueOnce(
|
||||
new RemoteRuntimeClientError('runtime_timeout', 'timed out', {
|
||||
data: { orchestrationRequestId: 'request_2' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
sendWebSocketRequestWithStatusPreflight(
|
||||
pairing,
|
||||
'orchestration.workerStart',
|
||||
{},
|
||||
100,
|
||||
() => {}
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'runtime_timeout',
|
||||
data: { orchestrationRequestId: 'request_2' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@ export async function sendWebSocketRequest<TResult>(
|
||||
return await sendRemoteRuntimeRequest<TResult>(pairing, method, params, timeoutMs, envelope)
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
throw new RuntimeClientError(error.code, error.message)
|
||||
throw new RuntimeClientError(error.code, error.message, error.data)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export async function sendWebSocketRequestWithStatusPreflight<TResult>(
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
throw new RuntimeClientError(error.code, error.message)
|
||||
throw new RuntimeClientError(error.code, error.message, error.data)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -208,6 +208,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'By default this returns accumulated terminal output with escape sequences stripped, not the rendered screen. Any program that repaints a line — shells, progress bars, TUIs — comes back as stacked fragments, so one `clear` keystroke by keystroke reads as `cclclecleaclear`, and spaces a prompt draws by moving the cursor are absent.',
|
||||
'Use --screen to read what the terminal actually renders. Prefer it whenever the answer depends on how output looks rather than what was emitted over time; the default is unsuitable for verifying rendered output.',
|
||||
'The result reports source: stream when it is accumulated output, screen when it is the rendered screen, and screen-unavailable when a screen was asked for but none could be rendered and the accumulated output is being returned instead. An absent source means the host predates the field.',
|
||||
'When present, draft is UI-only composer text excluded from tail; never treat it as terminal output or a submitted instruction.',
|
||||
'--screen and --cursor are mutually exclusive: a screen read is the current frame and has no history to page.',
|
||||
'Use --cursor with the nextCursor value from a previous read to get only new output since that read.',
|
||||
'Use --limit to request more retained lines for long agent responses; output reports oldestCursor when older lines were dropped.',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatTerminalRead } from './terminal-format'
|
||||
|
||||
describe('formatTerminalRead draft separation', () => {
|
||||
it('labels composer drafts outside terminal output', () => {
|
||||
const output = formatTerminalRead({
|
||||
terminal: {
|
||||
handle: 'term_1',
|
||||
status: 'running',
|
||||
tail: ['Build passed', '❯'],
|
||||
truncated: false,
|
||||
nextCursor: null,
|
||||
source: 'screen',
|
||||
draft: 'proceed with the release'
|
||||
}
|
||||
})
|
||||
|
||||
expect(output).toContain('draft: "proceed with the release"')
|
||||
expect(output).toContain('\n\nBuild passed\n❯')
|
||||
})
|
||||
})
|
||||
@@ -141,6 +141,7 @@ export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): s
|
||||
`handle: ${terminal.handle}`,
|
||||
`status: ${terminal.status}`,
|
||||
...(terminal.source ? [`source: ${terminal.source}`] : []),
|
||||
...(terminal.draft ? [`draft: ${JSON.stringify(terminal.draft)}`] : []),
|
||||
...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []),
|
||||
...oldestCursor,
|
||||
...latestCursor,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { isMissingRemoteSessionPathError } from './remote-session-file-stat'
|
||||
import type { RemoteSessionSource } from './remote-session-scanner-types'
|
||||
import {
|
||||
clineMessagesPathForMetadata,
|
||||
isClineSessionMetadataPath,
|
||||
parseClineSessionContent
|
||||
} from './session-scanner-cline-parser'
|
||||
|
||||
export function remoteClineSource(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource {
|
||||
return {
|
||||
agent: 'cline',
|
||||
rootDir: joinRemotePath(hostPlatform, remoteHome, '.cline', 'data', 'sessions'),
|
||||
extensions: ['.json'],
|
||||
filePredicate: isClineSessionMetadataPath,
|
||||
contentDependencyPath: clineMessagesPathForMetadata,
|
||||
directoryPredicate: (_name, depth) => depth === 0,
|
||||
parse: async (file, content, context) => {
|
||||
let messagesContent: string | null = null
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
const read = await context.provider.readFile(clineMessagesPathForMetadata(file.path))
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
messagesContent = read.isBinary ? null : read.content
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(context.signal)
|
||||
if (!isMissingRemoteSessionPathError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return parseClineSessionContent(file, content, messagesContent, context.hostPlatform.os, {
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,18 +29,7 @@ export async function discoverRemoteSourceCandidates(args: {
|
||||
const files = await mapRemoteScanBatches(
|
||||
paths,
|
||||
REMOTE_DISCOVERY_CONCURRENCY,
|
||||
(path) =>
|
||||
statRemoteSessionFile(
|
||||
args.context.provider,
|
||||
path,
|
||||
args.source.agent,
|
||||
args.context.executionHostId,
|
||||
args.issues,
|
||||
{
|
||||
missingIsExpected: Boolean(args.source.fixedChildFileSegments),
|
||||
signal: args.context.signal
|
||||
}
|
||||
),
|
||||
(path) => statRemoteCandidateFile(path, args.source, args.context, args.issues),
|
||||
args.context.signal
|
||||
)
|
||||
return files
|
||||
@@ -52,6 +41,46 @@ export async function discoverRemoteSourceCandidates(args: {
|
||||
}))
|
||||
}
|
||||
|
||||
async function statRemoteCandidateFile(
|
||||
path: string,
|
||||
source: RemoteSessionSource,
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<FileWithMtime | null> {
|
||||
const file = await statRemoteSessionFile(
|
||||
context.provider,
|
||||
path,
|
||||
source.agent,
|
||||
context.executionHostId,
|
||||
issues,
|
||||
{
|
||||
missingIsExpected: Boolean(source.fixedChildFileSegments),
|
||||
signal: context.signal
|
||||
}
|
||||
)
|
||||
if (!file || !source.contentDependencyPath) {
|
||||
return file
|
||||
}
|
||||
const dependency = await statRemoteSessionFile(
|
||||
context.provider,
|
||||
source.contentDependencyPath(path),
|
||||
source.agent,
|
||||
context.executionHostId,
|
||||
issues,
|
||||
{ missingIsExpected: true, signal: context.signal }
|
||||
)
|
||||
if (!dependency) {
|
||||
return file
|
||||
}
|
||||
const mtimeMs = Math.max(file.mtimeMs, dependency.mtimeMs)
|
||||
return {
|
||||
...file,
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString(),
|
||||
sizeBytes: (file.sizeBytes ?? 0) + (dependency.sizeBytes ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
async function listRemoteFixedChildFiles(
|
||||
source: RemoteSessionSource,
|
||||
context: RemoteScannerContext,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subag
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { normalizeAgentSessionsDir } from './session-scanner-values'
|
||||
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
|
||||
import { remoteClineSource } from './remote-session-scanner-cline-source'
|
||||
import type {
|
||||
RemoteParserOptions,
|
||||
RemoteScannerContext,
|
||||
@@ -77,6 +78,7 @@ export function remoteSessionSources(
|
||||
parseCursorSessionContent,
|
||||
(path) => remotePathSegments(path).includes('agent-transcripts')
|
||||
),
|
||||
remoteClineSource(remoteHome, hostPlatform),
|
||||
source(
|
||||
'hermes',
|
||||
remoteHome,
|
||||
|
||||
@@ -33,6 +33,7 @@ export type RemoteSessionSource = {
|
||||
codexHome?: string
|
||||
extensions: readonly string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
contentDependencyPath?: (path: string) => string
|
||||
// Depth 0 denotes a direct child of rootDir.
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
// A canonical file directly beneath every top-level session directory.
|
||||
|
||||
@@ -5,6 +5,98 @@ import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-f
|
||||
import { primeAgentFixture } from './session-scanner-prime-agent-fixtures'
|
||||
|
||||
describe('scanRemoteAiVaultSessions', () => {
|
||||
it('indexes Cline manifests on the SSH-owned disk without messages-file phantoms', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}`
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.json`,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
cwd: '/home/ada/repo'
|
||||
}),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.messages.json`,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
sessionId,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fix remote Cline history' }]
|
||||
}
|
||||
]
|
||||
}),
|
||||
11
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'cline',
|
||||
sessionId,
|
||||
title: 'Fix remote Cline history',
|
||||
cwd: '/home/ada/repo',
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
filePath: `${sessionDir}/${sessionId}.json`
|
||||
})
|
||||
})
|
||||
|
||||
it('indexes and resumes Cline sessions from a Windows SSH host', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = `C:/Users/Ada/.cline/data/sessions/${sessionId}`
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.json`,
|
||||
JSON.stringify({
|
||||
session_id: sessionId,
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
cwd: 'C:/repo/app'
|
||||
}),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
`${sessionDir}/${sessionId}.messages.json`,
|
||||
JSON.stringify({
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Fix Windows history' }] }]
|
||||
}),
|
||||
11
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:win-box',
|
||||
remoteHome: 'C:/Users/Ada',
|
||||
hostPlatform: getRemoteHostPlatform('win32-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'cline',
|
||||
sessionId,
|
||||
executionHostPlatform: 'win32',
|
||||
filePath: `${sessionDir}/${sessionId}.json`,
|
||||
resumeCommand: `cmd /d /s /c "cd /d ""C:/repo/app"" && cline --id ""${sessionId}"""`
|
||||
})
|
||||
})
|
||||
|
||||
it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
|
||||
@@ -19,6 +19,37 @@ const ROVO_ROOT = join(HOME, '.rovodev', 'sessions')
|
||||
const GROK_ROOT = join(HOME, '.grok', 'sessions')
|
||||
|
||||
describe('validateAiVaultSessionDeleteTarget', () => {
|
||||
it('allows a canonical Cline manifest and removes its whole session directory', () => {
|
||||
const root = join('/tmp', 'cline-sessions')
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = join(root, sessionId)
|
||||
const result = validateAiVaultSessionDeleteTarget({
|
||||
agent: 'cline',
|
||||
filePath: join(sessionDir, `${sessionId}.json`),
|
||||
executionHostId: 'local',
|
||||
rootOptions: { clineSessionsDir: root }
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
agent: 'cline',
|
||||
removals: [{ path: sessionDir, kind: 'directory' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a Cline messages companion as an undiscoverable delete target', () => {
|
||||
const root = join('/tmp', 'cline-sessions')
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const result = validateAiVaultSessionDeleteTarget({
|
||||
agent: 'cline',
|
||||
filePath: join(root, sessionId, `${sessionId}.messages.json`),
|
||||
executionHostId: 'local',
|
||||
rootOptions: { clineSessionsDir: root }
|
||||
})
|
||||
|
||||
expect(result).toEqual({ allowed: false, agent: 'cline', reason: 'undiscoverable-path' })
|
||||
})
|
||||
|
||||
it('allows a supported agent whose file resolves inside its known root', () => {
|
||||
const result = validateAiVaultSessionDeleteTarget({
|
||||
agent: 'gemini',
|
||||
|
||||
@@ -21,7 +21,11 @@ import type { AiVaultScanOptions } from './session-scanner-types'
|
||||
// Agents whose session IS the directory holding the scanned file: everything
|
||||
// beside it belongs to the same session (rovo's session_context.json, grok's
|
||||
// chat_history.jsonl), so the directory is the only complete delete unit.
|
||||
const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set<AiVaultDeletableAgent>(['rovo', 'grok'])
|
||||
const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set<AiVaultDeletableAgent>([
|
||||
'rovo',
|
||||
'grok',
|
||||
'cline'
|
||||
])
|
||||
|
||||
export type ValidateAiVaultSessionDeleteTargetArgs = {
|
||||
agent: AiVaultAgent
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { parseDevinSessionFile } from './session-scanner-devin-parser'
|
||||
import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser'
|
||||
import { parseDroidSessionFile } from './session-scanner-droid-parser'
|
||||
import { parseClineSessionFile } from './session-scanner-cline-parser'
|
||||
import { parseGrokSessionFile } from './session-scanner-grok-parser'
|
||||
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
@@ -72,6 +73,8 @@ export async function parseAgentSessionFile(
|
||||
return parseMessageGraphSessionFile('prime-agent', candidate.file, platform)
|
||||
case 'droid':
|
||||
return parseDroidSessionFile(candidate.file, platform)
|
||||
case 'cline':
|
||||
return parseClineSessionFile(candidate.file, platform)
|
||||
case 'devin':
|
||||
return parseDevinSessionFile(candidate.file, platform)
|
||||
case 'kimi':
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import type { AiVaultDeletableAgent } from '../../shared/ai-vault-session-deletion'
|
||||
import { resolveGrokSessionsDir } from '../../shared/grok-session-paths'
|
||||
import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
|
||||
import {
|
||||
clineMessagesPathForMetadata,
|
||||
isClineSessionMetadataPath
|
||||
} from './session-scanner-cline-parser'
|
||||
import { resolveKimiSessionsDir } from './session-scanner-kimi-paths'
|
||||
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts'
|
||||
import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots'
|
||||
@@ -40,6 +44,8 @@ const DEVIN_TRANSCRIPTS_DIR = join(
|
||||
)
|
||||
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions')
|
||||
const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects')
|
||||
const CLINE_SESSIONS_DIR =
|
||||
process.env.CLINE_SESSION_DATA_DIR?.trim() || join(homedir(), '.cline', 'data', 'sessions')
|
||||
|
||||
/**
|
||||
* Where one agent's session files live and which of them count as sessions.
|
||||
@@ -55,6 +61,8 @@ export type AiVaultAgentSource = {
|
||||
rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[]
|
||||
extensions: readonly string[]
|
||||
filePredicate?: (filePath: string) => boolean
|
||||
// A sibling whose stat participates in candidate freshness and recency.
|
||||
contentDependencyPath?: (filePath: string) => string
|
||||
// Return false to skip a directory; depth 0 is a child of the root.
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
// Roots that are alternates for one install rather than distinct locations,
|
||||
@@ -221,6 +229,19 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
|
||||
],
|
||||
extensions: ['.jsonl']
|
||||
},
|
||||
cline: {
|
||||
rootDirs: (options, wslHomeDirs) =>
|
||||
sessionRootDirs(options.clineSessionsDir ?? CLINE_SESSIONS_DIR, wslHomeDirs, [
|
||||
'.cline',
|
||||
'data',
|
||||
'sessions'
|
||||
]),
|
||||
extensions: ['.json'],
|
||||
filePredicate: isClineSessionMetadataPath,
|
||||
contentDependencyPath: clineMessagesPathForMetadata,
|
||||
// Cline stores one manifest directly beneath each session directory.
|
||||
directoryPredicate: (_name, depth) => depth === 0
|
||||
},
|
||||
kimi: {
|
||||
rootDirs: (options, wslHomeDirs) =>
|
||||
sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { isolatedScanRoots } from './session-scanner-test-fixtures'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
describe('Cline AI Vault sessions', () => {
|
||||
it('indexes one manifest-backed session without surfacing its messages file as a phantom', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const sessionId = '1786466194549_xrzrl'
|
||||
const sessionDir = join(roots.clineSessionsDir, sessionId)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(sessionDir, `${sessionId}.json`),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
source: 'cli',
|
||||
started_at: '2026-08-11T16:36:34.551Z',
|
||||
status: 'idle',
|
||||
interactive: true,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
cwd: '/repo/cline',
|
||||
workspace_root: '/repo/cline'
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(sessionDir, `${sessionId}.messages.json`),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: '2026-08-11T16:38:00.000Z',
|
||||
agent: 'lead',
|
||||
sessionId,
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fix the Cline vault scanner' }]
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'I will inspect the scanner.' }],
|
||||
ts: 1_786_466_280_000,
|
||||
modelInfo: { id: 'deepseek-v4-flash', provider: 'deepseek' }
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1)
|
||||
expect(result.sessions.find((session) => session.agent === 'cline')).toMatchObject({
|
||||
sessionId,
|
||||
title: 'Fix the Cline vault scanner',
|
||||
cwd: '/repo/cline',
|
||||
model: 'deepseek-v4-flash',
|
||||
messageCount: 2,
|
||||
resumeCommand: `cd '/repo/cline' && cline --id '${sessionId}'`,
|
||||
filePath: join(sessionDir, `${sessionId}.json`)
|
||||
})
|
||||
|
||||
await writeFile(
|
||||
join(sessionDir, `${sessionId}.messages.json`),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updated_at: '2026-08-11T16:39:00.000Z',
|
||||
sessionId,
|
||||
messages: [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Fix the Cline vault scanner' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'I inspected the scanner.' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Add the regression test too' }] }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const rescanned = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
expect(rescanned.sessions.find((session) => session.agent === 'cline')).toMatchObject({
|
||||
messageCount: 3,
|
||||
updatedAt: '2026-08-11T16:39:00.000Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('only indexes manifests directly beneath a session directory', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-nested-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const sessionId = 'direct-session'
|
||||
const nestedId = 'nested-session'
|
||||
await mkdir(join(roots.clineSessionsDir, sessionId), { recursive: true })
|
||||
await mkdir(join(roots.clineSessionsDir, 'workspace', nestedId), { recursive: true })
|
||||
const metadata = JSON.stringify({ session_id: sessionId, cwd: '/repo' })
|
||||
await writeFile(join(roots.clineSessionsDir, sessionId, `${sessionId}.json`), metadata)
|
||||
await writeFile(
|
||||
join(roots.clineSessionsDir, 'workspace', nestedId, `${nestedId}.json`),
|
||||
JSON.stringify({ session_id: nestedId, cwd: '/unexpected' })
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
|
||||
expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1)
|
||||
expect(result.sessions.find((session) => session.agent === 'cline')?.sessionId).toBe(sessionId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import {
|
||||
arrayValue,
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractString,
|
||||
normalizeTitleText
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export function isClineSessionMetadataPath(filePath: string): boolean {
|
||||
const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
const fileName = segments.pop()
|
||||
const sessionId = segments.pop()
|
||||
return Boolean(fileName && sessionId && fileName === `${sessionId}.json`)
|
||||
}
|
||||
|
||||
export function clineMessagesPathForMetadata(filePath: string): string {
|
||||
return filePath.endsWith('.json') ? `${filePath.slice(0, -'.json'.length)}.messages.json` : ''
|
||||
}
|
||||
|
||||
export async function parseClineSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const metadataContent = await wslGatedReadFile(file.path, 'utf-8', 'scan')
|
||||
let messagesContent: string | null = null
|
||||
try {
|
||||
messagesContent = await wslGatedReadFile(
|
||||
clineMessagesPathForMetadata(file.path),
|
||||
'utf-8',
|
||||
'scan'
|
||||
)
|
||||
} catch (error) {
|
||||
// The manifest is written before the first turn; a missing messages file is
|
||||
// a valid metadata-only session, while a WSL gate refusal must stay visible.
|
||||
if (error instanceof WslTranscriptFsError || !isMissingSessionPathError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return parseClineSessionContent(file, metadataContent, messagesContent, platform)
|
||||
}
|
||||
|
||||
function isMissingSessionPathError(error: unknown): boolean {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
||||
? error.code
|
||||
: null
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
export function parseClineSessionContent(
|
||||
file: FileWithMtime,
|
||||
metadataContent: string,
|
||||
messagesContent: string | null,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): AiVaultSession | null {
|
||||
const metadata = parseJsonRecord(metadataContent)
|
||||
if (!metadata) {
|
||||
return null
|
||||
}
|
||||
const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
const sessionId = extractString(metadata.session_id) ?? pathSegments.at(-2) ?? ''
|
||||
const accumulator = createAccumulator({ agent: 'cline', file, sessionId })
|
||||
accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root)
|
||||
accumulator.model = extractString(metadata.model)
|
||||
updateTimeline(accumulator, metadata.started_at)
|
||||
|
||||
const messages = messagesContent ? parseJsonRecord(messagesContent) : null
|
||||
if (messages) {
|
||||
updateTimeline(accumulator, messages.updated_at)
|
||||
for (const value of arrayValue(messages.messages)) {
|
||||
const message = asRecord(value)
|
||||
const role = message?.role
|
||||
if (!message || (role !== 'user' && role !== 'assistant')) {
|
||||
continue
|
||||
}
|
||||
accumulator.messageCount++
|
||||
updateTimeline(accumulator, message.ts)
|
||||
const content = message.content
|
||||
if (role === 'user' && !accumulator.fallbackTitle) {
|
||||
accumulator.fallbackTitle = normalizeTitleText(extractContentText(content) ?? '')
|
||||
}
|
||||
if (role === 'assistant' && !accumulator.model) {
|
||||
accumulator.model = extractString(asRecord(message.modelInfo)?.id)
|
||||
}
|
||||
addPreviewContent(accumulator, role, content, message.ts)
|
||||
}
|
||||
}
|
||||
accumulator.fallbackTitle ??= normalizeTitleText(extractString(metadata.prompt) ?? '')
|
||||
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
function parseJsonRecord(content: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return asRecord(JSON.parse(content) as unknown)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export async function discoverFiles(args: {
|
||||
issues: AiVaultScanIssue[]
|
||||
extensions: string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
contentDependencyPath?: (path: string) => string
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
}): Promise<SessionFileDiscovery> {
|
||||
let paths: string[]
|
||||
@@ -41,11 +42,13 @@ export async function discoverFiles(args: {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const fileStat = await wslGatedStat(path, 'scan')
|
||||
const dependencyStat = await optionalContentDependencyStat(args.contentDependencyPath?.(path))
|
||||
const mtimeMs = Math.max(fileStat.mtimeMs, dependencyStat?.mtimeMs ?? 0)
|
||||
files.push({
|
||||
path,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
modifiedAt: fileStat.mtime.toISOString(),
|
||||
sizeBytes: fileStat.size,
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString(),
|
||||
sizeBytes: fileStat.size + (dependencyStat?.size ?? 0),
|
||||
dev: fileStat.dev,
|
||||
ino: fileStat.ino,
|
||||
nlink: fileStat.nlink
|
||||
@@ -65,6 +68,23 @@ export async function discoverFiles(args: {
|
||||
}
|
||||
}
|
||||
|
||||
async function optionalContentDependencyStat(
|
||||
filePath: string | undefined
|
||||
): Promise<{ mtimeMs: number; size: number } | null> {
|
||||
if (!filePath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const fileStat = await wslGatedStat(filePath, 'scan')
|
||||
return { mtimeMs: fileStat.mtimeMs, size: fileStat.size }
|
||||
} catch (error) {
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function walkSessionFiles(
|
||||
dirPath: string,
|
||||
agent: AiVaultAgent,
|
||||
|
||||
@@ -76,6 +76,7 @@ function resumableStateFactoryFor(
|
||||
case 'devin':
|
||||
case 'grok':
|
||||
case 'hermes':
|
||||
case 'cline':
|
||||
case 'kimi':
|
||||
case 'opencode':
|
||||
case 'rovo':
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('buildAiVaultServiceEnv', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
@@ -49,6 +50,7 @@ describe('buildAiVaultServiceEnv', () => {
|
||||
|
||||
expect(env).toEqual({
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
|
||||
@@ -34,6 +34,7 @@ export const RUNTIME_ENV_ALLOWLIST = [
|
||||
// dropping one hides every session of a user who relocated that agent's home.
|
||||
const AGENT_ROOT_ENV_ALLOWLIST = [
|
||||
'CODEX_HOME',
|
||||
'CLINE_SESSION_DATA_DIR',
|
||||
'COPILOT_HOME',
|
||||
'DEVIN_HOME',
|
||||
'GROK_HOME',
|
||||
|
||||
@@ -55,6 +55,7 @@ function agentDiscoveries(
|
||||
issues,
|
||||
extensions: [...source.extensions],
|
||||
filePredicate: source.filePredicate,
|
||||
contentDependencyPath: source.contentDependencyPath,
|
||||
directoryPredicate: source.directoryPredicate
|
||||
})
|
||||
return source.mergeRootDiscoveries
|
||||
|
||||
@@ -24,6 +24,7 @@ export function isolatedScanRoots(root: string) {
|
||||
primeAgentSessionsDir: join(root, 'prime-agent-sessions'),
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
clineSessionsDir: join(root, 'cline-sessions'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export type AiVaultScanOptions = {
|
||||
primeAgentSessionsDir?: string
|
||||
droidSessionsDir?: string
|
||||
droidProjectsDir?: string
|
||||
clineSessionsDir?: string
|
||||
kimiSessionsDir?: string
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
|
||||
@@ -647,6 +647,26 @@ describe('scanAiVaultSessions', () => {
|
||||
])
|
||||
)
|
||||
|
||||
const clineSessionId = 'cline-session'
|
||||
const clineSessionDir = join(roots.clineSessionsDir, clineSessionId)
|
||||
await mkdir(clineSessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(clineSessionDir, `${clineSessionId}.json`),
|
||||
JSON.stringify({
|
||||
session_id: clineSessionId,
|
||||
started_at: '2026-05-01T10:10:30.000Z',
|
||||
model: 'cline-model',
|
||||
cwd: '/tmp/cline'
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(clineSessionDir, `${clineSessionId}.messages.json`),
|
||||
JSON.stringify({
|
||||
updated_at: '2026-05-01T10:10:31.000Z',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Cline vault title' }] }]
|
||||
})
|
||||
)
|
||||
|
||||
// Kimi: <sessions>/wd_*/session_*/state.json + sibling agents/main/wire.jsonl,
|
||||
// with the work dir resolved from the top-level session_index.jsonl.
|
||||
const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session')
|
||||
@@ -738,6 +758,7 @@ describe('scanAiVaultSessions', () => {
|
||||
expect(commandByAgent.get('prime-agent')).toBe(
|
||||
`cd '/tmp/prime-agent' && prime-agent --resume '${primeAgentSessionFile}'`
|
||||
)
|
||||
expect(commandByAgent.get('cline')).toBe("cd '/tmp/cline' && cline --id 'cline-session'")
|
||||
expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'")
|
||||
expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'")
|
||||
expect(commandByAgent.get('kimi')).toBe(
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
export const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/
|
||||
const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/
|
||||
export const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000
|
||||
export const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024
|
||||
export const FULL_SESSION_LOG_HEADING = '## Full session log'
|
||||
export const REFERENCED_LOG_HEADING = '## Latest log file'
|
||||
const RUN_PREVIEW_LIMIT = 180
|
||||
export const LATEST_LOG_PATH_PATTERN =
|
||||
/\bLatest log path:\s*(?<path>(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value : null
|
||||
}
|
||||
|
||||
export function runAtFromHermesOutputFile(filename: string): string | null {
|
||||
const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const [, year, month, day, hour, minute, second] = match
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}`
|
||||
}
|
||||
|
||||
export function runKeyFromHermesOutputFile(filename: string): string | null {
|
||||
const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const [, year, month, day, hour, minute, second] = match
|
||||
return `${year}${month}${day}_${hour}${minute}${second}`
|
||||
}
|
||||
|
||||
export function runAtFromUnixSeconds(value: unknown): string | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(value * 1000)
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
||||
}
|
||||
|
||||
export function sortableTimeFromRunKey(runKey: string | null): number {
|
||||
if (!runKey) {
|
||||
return Number.NaN
|
||||
}
|
||||
const match = HERMES_RUN_KEY_PATTERN.exec(runKey)
|
||||
if (!match) {
|
||||
return Number.NaN
|
||||
}
|
||||
const [, year, month, day, hour, minute, second] = match
|
||||
return Date.UTC(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second)
|
||||
)
|
||||
}
|
||||
|
||||
export function escapeSqlLike(value: string): string {
|
||||
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
|
||||
}
|
||||
|
||||
type ContentRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
function cleanRunPreview(value: string, startIndex = 0, endIndex = value.length): string | null {
|
||||
const normalized = foldRunPreviewText(value, startIndex, endIndex)
|
||||
if (!normalized.text) {
|
||||
return null
|
||||
}
|
||||
return normalized.truncated
|
||||
? `${normalized.text.slice(0, RUN_PREVIEW_LIMIT - 3)}...`
|
||||
: normalized.text
|
||||
}
|
||||
|
||||
function foldRunPreviewText(
|
||||
value: string,
|
||||
startIndex: number,
|
||||
endIndex: number
|
||||
): { text: string; truncated: boolean } {
|
||||
let text = ''
|
||||
let pendingSpace = false
|
||||
let index = Math.max(0, startIndex)
|
||||
const end = Math.min(value.length, endIndex)
|
||||
while (index < end && text.length <= RUN_PREVIEW_LIMIT) {
|
||||
if (startsWithAt(value, '```', index)) {
|
||||
if (text.length > 0) {
|
||||
pendingSpace = true
|
||||
}
|
||||
index = skipFencedBlock(value, index, end)
|
||||
continue
|
||||
}
|
||||
|
||||
const code = value.charCodeAt(index)
|
||||
if (isPreviewSeparator(code)) {
|
||||
if (text.length > 0) {
|
||||
pendingSpace = true
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (pendingSpace) {
|
||||
text += ' '
|
||||
pendingSpace = false
|
||||
if (text.length > RUN_PREVIEW_LIMIT) {
|
||||
break
|
||||
}
|
||||
}
|
||||
text += value[index]
|
||||
index += 1
|
||||
}
|
||||
return { text, truncated: text.length > RUN_PREVIEW_LIMIT }
|
||||
}
|
||||
|
||||
function isPreviewSeparator(code: number): boolean {
|
||||
return (
|
||||
code === 32 ||
|
||||
(code >= 9 && code <= 13) ||
|
||||
code === 35 ||
|
||||
code === 40 ||
|
||||
code === 41 ||
|
||||
code === 42 ||
|
||||
code === 62 ||
|
||||
code === 91 ||
|
||||
code === 93 ||
|
||||
code === 95 ||
|
||||
code === 96
|
||||
)
|
||||
}
|
||||
|
||||
export function parseHermesOutput(content: string): {
|
||||
status: 'completed' | 'failed' | 'unknown'
|
||||
outputPreview: string | null
|
||||
outputContent: string
|
||||
error: string | null
|
||||
} {
|
||||
const errorHeading = findMarkdownHeading(content, '## Error')
|
||||
const responseHeading = findMarkdownHeading(content, '## Response')
|
||||
const errorRange = errorHeading ? errorContentRange(content, errorHeading.bodyStart) : null
|
||||
const failed = hasFailedCronHeading(content) || errorHeading !== null
|
||||
const error = errorRange ? cleanRunPreview(content, errorRange.start, errorRange.end) : null
|
||||
const previewRange = responseHeading
|
||||
? { start: responseHeading.bodyStart, end: content.length }
|
||||
: (errorRange ?? { start: 0, end: content.length })
|
||||
return {
|
||||
status: failed ? 'failed' : responseHeading ? 'completed' : 'unknown',
|
||||
outputPreview: cleanRunPreview(content, previewRange.start, previewRange.end),
|
||||
outputContent: content,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
function hasFailedCronHeading(content: string): boolean {
|
||||
let lineStart = 0
|
||||
while (lineStart < content.length) {
|
||||
const lineEnd = lineEndIndex(content, lineStart)
|
||||
if (
|
||||
startsWithAt(content, '#', lineStart) &&
|
||||
lineContains(content, lineStart, lineEnd, 'Cron Job:') &&
|
||||
lineContains(content, lineStart, lineEnd, '(FAILED)')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
lineStart = nextLineStart(content, lineEnd)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function findMarkdownHeading(
|
||||
content: string,
|
||||
heading: '## Error' | '## Response'
|
||||
): { bodyStart: number } | null {
|
||||
let lineStart = 0
|
||||
while (lineStart < content.length) {
|
||||
const lineEnd = lineEndIndex(content, lineStart)
|
||||
if (
|
||||
startsWithAt(content, heading, lineStart) &&
|
||||
isHeadingBoundary(content.charCodeAt(lineStart + heading.length))
|
||||
) {
|
||||
return { bodyStart: nextLineStart(content, lineEnd) }
|
||||
}
|
||||
lineStart = nextLineStart(content, lineEnd)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function errorContentRange(content: string, bodyStart: number): ContentRange {
|
||||
const start = skipPreviewWhitespace(content, bodyStart, content.length)
|
||||
if (!startsWithAt(content, '```', start)) {
|
||||
return { start, end: nextMarkdownHeadingStart(content, start) ?? content.length }
|
||||
}
|
||||
|
||||
const fencedStart = nextLineStart(content, lineEndIndex(content, start))
|
||||
const fencedEnd = findClosingFence(content, fencedStart) ?? content.length
|
||||
return { start: fencedStart, end: fencedEnd }
|
||||
}
|
||||
|
||||
function skipFencedBlock(content: string, fenceStart: number, endIndex: number): number {
|
||||
const bodyStart = nextLineStart(content, lineEndIndex(content, fenceStart))
|
||||
const closeStart = findClosingFence(content, bodyStart)
|
||||
if (closeStart === null || closeStart > endIndex) {
|
||||
return endIndex
|
||||
}
|
||||
return nextLineStart(content, lineEndIndex(content, closeStart))
|
||||
}
|
||||
|
||||
function findClosingFence(content: string, fromIndex: number): number | null {
|
||||
let lineStart = fromIndex
|
||||
while (lineStart < content.length) {
|
||||
const lineEnd = lineEndIndex(content, lineStart)
|
||||
const textStart = skipPreviewWhitespace(content, lineStart, lineEnd)
|
||||
if (startsWithAt(content, '```', textStart)) {
|
||||
return textStart
|
||||
}
|
||||
lineStart = nextLineStart(content, lineEnd)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function nextMarkdownHeadingStart(content: string, fromIndex: number): number | null {
|
||||
let lineStart = fromIndex
|
||||
while (lineStart < content.length) {
|
||||
const lineEnd = lineEndIndex(content, lineStart)
|
||||
if (startsWithAt(content, '## ', lineStart)) {
|
||||
return lineStart
|
||||
}
|
||||
lineStart = nextLineStart(content, lineEnd)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function lineEndIndex(value: string, startIndex: number): number {
|
||||
const newline = value.indexOf('\n', startIndex)
|
||||
return newline === -1 ? value.length : newline
|
||||
}
|
||||
|
||||
function nextLineStart(value: string, lineEnd: number): number {
|
||||
return lineEnd < value.length ? lineEnd + 1 : value.length
|
||||
}
|
||||
|
||||
function lineContains(
|
||||
value: string,
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
needle: string
|
||||
): boolean {
|
||||
const index = value.indexOf(needle, startIndex)
|
||||
return index !== -1 && index < endIndex
|
||||
}
|
||||
|
||||
function skipPreviewWhitespace(value: string, startIndex: number, endIndex: number): number {
|
||||
let index = startIndex
|
||||
while (index < endIndex && isPreviewWhitespace(value.charCodeAt(index))) {
|
||||
index += 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function isPreviewWhitespace(code: number): boolean {
|
||||
return code === 32 || (code >= 9 && code <= 13)
|
||||
}
|
||||
|
||||
function isHeadingBoundary(code: number): boolean {
|
||||
return Number.isNaN(code) || isPreviewWhitespace(code)
|
||||
}
|
||||
|
||||
function startsWithAt(value: string, search: string, startIndex: number): boolean {
|
||||
if (startIndex + search.length > value.length) {
|
||||
return false
|
||||
}
|
||||
for (let offset = 0; offset < search.length; offset += 1) {
|
||||
if (value.charCodeAt(startIndex + offset) !== search.charCodeAt(offset)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,31 +1,38 @@
|
||||
/* eslint-disable max-lines -- Why: Hermes run history has to reconcile
|
||||
* markdown output files with SQLite session transcripts from separate stores. */
|
||||
import { existsSync } from 'node:fs'
|
||||
import { open, readdir, readFile, realpath, stat } from 'node:fs/promises'
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import {
|
||||
HERMES_OUTPUT_FILE_PATTERN,
|
||||
escapeSqlLike,
|
||||
parseHermesOutput,
|
||||
runAtFromHermesOutputFile,
|
||||
runAtFromUnixSeconds,
|
||||
runKeyFromHermesOutputFile
|
||||
} from './hermes-cron-output-markdown'
|
||||
import {
|
||||
appendReferencedLogFile,
|
||||
formatSessionMessages,
|
||||
mergeHermesOutputAndSessionRunRefs,
|
||||
mergeHermesOutputAndSessionRuns
|
||||
} from './hermes-cron-run-content'
|
||||
import {
|
||||
clearHermesCronOutputRunCountCache as clearHermesRunCountCache,
|
||||
readCachedHermesRunCount
|
||||
} from './hermes-cron-run-count-cache'
|
||||
|
||||
const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
|
||||
const HERMES_OUTPUT_DIR = join(HERMES_HOME, 'cron', 'output')
|
||||
const HERMES_STATE_DB = join(HERMES_HOME, 'state.db')
|
||||
const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
|
||||
const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/
|
||||
const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/
|
||||
const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000
|
||||
const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024
|
||||
const FULL_SESSION_LOG_HEADING = '## Full session log'
|
||||
const REFERENCED_LOG_HEADING = '## Latest log file'
|
||||
const RUN_PREVIEW_LIMIT = 180
|
||||
const LATEST_LOG_PATH_PATTERN =
|
||||
/\bLatest log path:\s*(?<path>(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i
|
||||
|
||||
export type HermesCronOutputRunsPage = {
|
||||
total: number
|
||||
runs: unknown[]
|
||||
}
|
||||
|
||||
type HermesOutputRunRef = {
|
||||
export type HermesOutputRunRef = {
|
||||
kind: 'output'
|
||||
id: string
|
||||
job_id: string
|
||||
@@ -34,7 +41,7 @@ type HermesOutputRunRef = {
|
||||
output_path: string
|
||||
}
|
||||
|
||||
type HermesSessionRunRef = {
|
||||
export type HermesSessionRunRef = {
|
||||
kind: 'session'
|
||||
id: string
|
||||
job_id: string
|
||||
@@ -42,7 +49,7 @@ type HermesSessionRunRef = {
|
||||
run_key: string | null
|
||||
}
|
||||
|
||||
type HermesMergedRunRef = {
|
||||
export type HermesMergedRunRef = {
|
||||
id: string
|
||||
job_id: string
|
||||
run_at: string | null
|
||||
@@ -51,529 +58,6 @@ type HermesMergedRunRef = {
|
||||
session: HermesSessionRunRef | null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value : null
|
||||
}
|
||||
|
||||
function runAtFromHermesOutputFile(filename: string): string | null {
|
||||
const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const [, year, month, day, hour, minute, second] = match
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}`
|
||||
}
|
||||
|
||||
function runKeyFromHermesOutputFile(filename: string): string | null {
|
||||
const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const [, year, month, day, hour, minute, second] = match
|
||||
return `${year}${month}${day}_${hour}${minute}${second}`
|
||||
}
|
||||
|
||||
function runAtFromUnixSeconds(value: unknown): string | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(value * 1000)
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
||||
}
|
||||
|
||||
function sortableTimeFromRunKey(runKey: string | null): number {
|
||||
if (!runKey) {
|
||||
return Number.NaN
|
||||
}
|
||||
const match = HERMES_RUN_KEY_PATTERN.exec(runKey)
|
||||
if (!match) {
|
||||
return Number.NaN
|
||||
}
|
||||
const [, year, month, day, hour, minute, second] = match
|
||||
return Date.UTC(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second)
|
||||
)
|
||||
}
|
||||
|
||||
function escapeSqlLike(value: string): string {
|
||||
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')
|
||||
}
|
||||
|
||||
type ContentRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
function cleanRunPreview(value: string, startIndex = 0, endIndex = value.length): string | null {
|
||||
const normalized = foldRunPreviewText(value, startIndex, endIndex)
|
||||
if (!normalized.text) {
|
||||
return null
|
||||
}
|
||||
return normalized.truncated
|
||||
? `${normalized.text.slice(0, RUN_PREVIEW_LIMIT - 3)}...`
|
||||
: normalized.text
|
||||
}
|
||||
|
||||
function foldRunPreviewText(
|
||||
value: string,
|
||||
startIndex: number,
|
||||
endIndex: number
|
||||
): { text: string; truncated: boolean } {
|
||||
let text = ''
|
||||
let pendingSpace = false
|
||||
let index = Math.max(0, startIndex)
|
||||
const end = Math.min(value.length, endIndex)
|
||||
while (index < end && text.length <= RUN_PREVIEW_LIMIT) {
|
||||
if (startsWithAt(value, '```', index)) {
|
||||
if (text.length > 0) {
|
||||
pendingSpace = true
|
||||
}
|
||||
index = skipFencedBlock(value, index, end)
|
||||
continue
|
||||
}
|
||||
|
||||
const code = value.charCodeAt(index)
|
||||
if (isPreviewSeparator(code)) {
|
||||
if (text.length > 0) {
|
||||
pendingSpace = true
|
||||
}
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (pendingSpace) {
|
||||
text += ' '
|
||||
pendingSpace = false
|
||||
if (text.length > RUN_PREVIEW_LIMIT) {
|
||||
break
|
||||
}
|
||||
}
|
||||
text += value[index]
|
||||
index += 1
|
||||
}
|
||||
return { text, truncated: text.length > RUN_PREVIEW_LIMIT }
|
||||
}
|
||||
|
||||
function isPreviewSeparator(code: number): boolean {
|
||||
return (
|
||||
code === 32 ||
|
||||
(code >= 9 && code <= 13) ||
|
||||
code === 35 ||
|
||||
code === 40 ||
|
||||
code === 41 ||
|
||||
code === 42 ||
|
||||
code === 62 ||
|
||||
code === 91 ||
|
||||
code === 93 ||
|
||||
code === 95 ||
|
||||
code === 96
|
||||
)
|
||||
}
|
||||
|
||||
function parseHermesOutput(content: string): {
|
||||
status: 'completed' | 'failed' | 'unknown'
|
||||
outputPreview: string | null
|
||||
outputContent: string
|
||||
error: string | null
|
||||
} {
|
||||
const errorHeading = findMarkdownHeading(content, '## Error')
|
||||
const responseHeading = findMarkdownHeading(content, '## Response')
|
||||
const errorRange = errorHeading ? errorContentRange(content, errorHeading.bodyStart) : null
|
||||
const failed = hasFailedCronHeading(content) || errorHeading !== null
|
||||
const error = errorRange ? cleanRunPreview(content, errorRange.start, errorRange.end) : null
|
||||
const previewRange = responseHeading
|
||||
? { start: responseHeading.bodyStart, end: content.length }
|
||||
: (errorRange ?? { start: 0, end: content.length })
|
||||
return {
|
||||
status: failed ? 'failed' : responseHeading ? 'completed' : 'unknown',
|
||||
outputPreview: cleanRunPreview(content, previewRange.start, previewRange.end),
|
||||
outputContent: content,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
function hasFailedCronHeading(content: string): boolean {
|
||||
let lineStart = 0
|
||||
while (lineStart < content.length) {
|
||||
const lineEnd = lineEndIndex(content, lineStart)
|
||||
if (
|
||||
startsWithAt(content, '#', lineStart) &&
|
||||
lineContains(content, lineStart, lineEnd, 'Cron Job:') &&
|
||||
lineContains(content, lineStart, lineEnd, '(FAILED)')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
lineStart = nextLineStart(content, lineEnd)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function findMarkdownHeading(
|
||||
content: string,
|
||||
heading: '## Error' | '## Response'
|
||||
): { bodyStart: number } | null {
|
||||
let lineStart = 0
|
||||
while (lineStart < content.length) {
|
||||
const lineEnd = lineEndIndex(content, lineStart)
|
||||
if (
|
||||
startsWithAt(content, heading, lineStart) &&
|
||||
isHeadingBoundary(content.charCodeAt(lineStart + heading.length))
|
||||
) {
|
||||
return { bodyStart: nextLineStart(content, lineEnd) }
|
||||
}
|
||||
lineStart = nextLineStart(content, lineEnd)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function errorContentRange(content: string, bodyStart: number): ContentRange {
|
||||
const start = skipPreviewWhitespace(content, bodyStart, content.length)
|
||||
if (!startsWithAt(content, '```', start)) {
|
||||
return { start, end: nextMarkdownHeadingStart(content, start) ?? content.length }
|
||||
}
|
||||
|
||||
const fencedStart = nextLineStart(content, lineEndIndex(content, start))
|
||||
const fencedEnd = findClosingFence(content, fencedStart) ?? content.length
|
||||
return { start: fencedStart, end: fencedEnd }
|
||||
}
|
||||
|
||||
function skipFencedBlock(content: string, fenceStart: number, endIndex: number): number {
|
||||
const bodyStart = nextLineStart(content, lineEndIndex(content, fenceStart))
|
||||
const closeStart = findClosingFence(content, bodyStart)
|
||||
if (closeStart === null || closeStart > endIndex) {
|
||||
return endIndex
|
||||
}
|
||||
return nextLineStart(content, lineEndIndex(content, closeStart))
|
||||
}
|
||||
|
||||
function findClosingFence(content: string, fromIndex: number): number | null {
|
||||
let lineStart = fromIndex
|
||||
while (lineStart < content.length) {
|
||||
const lineEnd = lineEndIndex(content, lineStart)
|
||||
const textStart = skipPreviewWhitespace(content, lineStart, lineEnd)
|
||||
if (startsWithAt(content, '```', textStart)) {
|
||||
return textStart
|
||||
}
|
||||
lineStart = nextLineStart(content, lineEnd)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function nextMarkdownHeadingStart(content: string, fromIndex: number): number | null {
|
||||
let lineStart = fromIndex
|
||||
while (lineStart < content.length) {
|
||||
const lineEnd = lineEndIndex(content, lineStart)
|
||||
if (startsWithAt(content, '## ', lineStart)) {
|
||||
return lineStart
|
||||
}
|
||||
lineStart = nextLineStart(content, lineEnd)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function lineEndIndex(value: string, startIndex: number): number {
|
||||
const newline = value.indexOf('\n', startIndex)
|
||||
return newline === -1 ? value.length : newline
|
||||
}
|
||||
|
||||
function nextLineStart(value: string, lineEnd: number): number {
|
||||
return lineEnd < value.length ? lineEnd + 1 : value.length
|
||||
}
|
||||
|
||||
function lineContains(
|
||||
value: string,
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
needle: string
|
||||
): boolean {
|
||||
const index = value.indexOf(needle, startIndex)
|
||||
return index !== -1 && index < endIndex
|
||||
}
|
||||
|
||||
function skipPreviewWhitespace(value: string, startIndex: number, endIndex: number): number {
|
||||
let index = startIndex
|
||||
while (index < endIndex && isPreviewWhitespace(value.charCodeAt(index))) {
|
||||
index += 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function isPreviewWhitespace(code: number): boolean {
|
||||
return code === 32 || (code >= 9 && code <= 13)
|
||||
}
|
||||
|
||||
function isHeadingBoundary(code: number): boolean {
|
||||
return Number.isNaN(code) || isPreviewWhitespace(code)
|
||||
}
|
||||
|
||||
function startsWithAt(value: string, search: string, startIndex: number): boolean {
|
||||
if (startIndex + search.length > value.length) {
|
||||
return false
|
||||
}
|
||||
for (let offset = 0; offset < search.length; offset += 1) {
|
||||
if (value.charCodeAt(startIndex + offset) !== search.charCodeAt(offset)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function extractLatestLogPath(content: string): string | null {
|
||||
const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim()
|
||||
if (!rawPath) {
|
||||
return null
|
||||
}
|
||||
return rawPath.replace(/^`|`$/g, '').trim()
|
||||
}
|
||||
|
||||
async function readReferencedLogFile(content: string): Promise<{
|
||||
path: string
|
||||
content: string
|
||||
truncated: boolean
|
||||
} | null> {
|
||||
const logPath = extractLatestLogPath(content)
|
||||
if (!logPath || !isAbsolute(logPath)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const homeRealPath = await realpath(HERMES_HOME)
|
||||
const logRealPath = await realpath(logPath)
|
||||
const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath))
|
||||
// Why: the output body can contain agent-authored text, so only hydrate
|
||||
// referenced files that resolve inside Hermes' own data directory.
|
||||
if (
|
||||
relativeToHermesHome === '..' ||
|
||||
relativeToHermesHome.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativeToHermesHome)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const logStat = await stat(logPath)
|
||||
if (!logStat.isFile()) {
|
||||
return null
|
||||
}
|
||||
if (logStat.size <= MAX_REFERENCED_LOG_BYTES) {
|
||||
return {
|
||||
path: logPath,
|
||||
content: await readFile(logPath, 'utf-8'),
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
const file = await open(logPath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES)
|
||||
await file.read(buffer, 0, MAX_REFERENCED_LOG_BYTES, logStat.size - MAX_REFERENCED_LOG_BYTES)
|
||||
return {
|
||||
path: logPath,
|
||||
content: buffer.toString('utf-8'),
|
||||
truncated: true
|
||||
}
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function appendReferencedLogFile(content: string): Promise<string> {
|
||||
if (content.includes(REFERENCED_LOG_HEADING)) {
|
||||
return content
|
||||
}
|
||||
const logFile = await readReferencedLogFile(content)
|
||||
if (!logFile) {
|
||||
return content
|
||||
}
|
||||
const note = logFile.truncated
|
||||
? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.`
|
||||
: null
|
||||
return [
|
||||
content,
|
||||
'---',
|
||||
REFERENCED_LOG_HEADING,
|
||||
'',
|
||||
`Path: ${logFile.path}`,
|
||||
note,
|
||||
'```text',
|
||||
logFile.content.trimEnd(),
|
||||
'```'
|
||||
]
|
||||
.filter((part) => part !== null)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
function formatSessionMessages(messages: Record<string, unknown>[]): string | null {
|
||||
if (messages.length === 0) {
|
||||
return null
|
||||
}
|
||||
return messages
|
||||
.map((message) => {
|
||||
const role = typeof message.role === 'string' ? message.role : 'message'
|
||||
const content = typeof message.content === 'string' ? message.content.trim() : ''
|
||||
const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : ''
|
||||
const reasoning =
|
||||
typeof message.reasoning_content === 'string'
|
||||
? message.reasoning_content.trim()
|
||||
: typeof message.reasoning === 'string'
|
||||
? message.reasoning.trim()
|
||||
: ''
|
||||
const parts = [
|
||||
`## ${role}${toolName ? ` / ${toolName}` : ''}`,
|
||||
reasoning ? `### Reasoning\n\n${reasoning}` : null,
|
||||
content || '(empty)'
|
||||
].filter(Boolean)
|
||||
return parts.join('\n\n')
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
function getRunKey(run: unknown): string | null {
|
||||
return isRecord(run) ? asString(run.run_key) : null
|
||||
}
|
||||
|
||||
function getRunOutputContent(run: unknown): string | null {
|
||||
return isRecord(run) ? asString(run.output_content) : null
|
||||
}
|
||||
|
||||
function mergeOutputAndSessionContent(
|
||||
outputContent: string | null,
|
||||
sessionContent: string | null
|
||||
): string | null {
|
||||
if (!sessionContent) {
|
||||
return outputContent
|
||||
}
|
||||
if (!outputContent) {
|
||||
return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}`
|
||||
}
|
||||
if (outputContent.includes(FULL_SESSION_LOG_HEADING)) {
|
||||
return outputContent
|
||||
}
|
||||
return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}`
|
||||
}
|
||||
|
||||
function findMatchingSessionRunIndex(
|
||||
outputRun: unknown,
|
||||
sessionRuns: unknown[],
|
||||
usedSessionRunIndexes: Set<number>
|
||||
): number | null {
|
||||
const outputRunKey = getRunKey(outputRun)
|
||||
const exactMatchIndex = sessionRuns.findIndex(
|
||||
(sessionRun, index) =>
|
||||
!usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey
|
||||
)
|
||||
if (exactMatchIndex !== -1) {
|
||||
return exactMatchIndex
|
||||
}
|
||||
|
||||
const outputTime = sortableTimeFromRunKey(outputRunKey)
|
||||
if (!Number.isFinite(outputTime)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let bestIndex: number | null = null
|
||||
let bestGap = Number.POSITIVE_INFINITY
|
||||
for (let index = 0; index < sessionRuns.length; index += 1) {
|
||||
if (usedSessionRunIndexes.has(index)) {
|
||||
continue
|
||||
}
|
||||
const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index]))
|
||||
if (!Number.isFinite(sessionTime)) {
|
||||
continue
|
||||
}
|
||||
const gap = outputTime - sessionTime
|
||||
if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) {
|
||||
continue
|
||||
}
|
||||
bestIndex = index
|
||||
bestGap = gap
|
||||
}
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
function mergeHermesOutputAndSessionRuns(outputRuns: unknown[], sessionRuns: unknown[]): unknown[] {
|
||||
const usedSessionRunIndexes = new Set<number>()
|
||||
const mergedOutputRuns = outputRuns.map((outputRun) => {
|
||||
if (!isRecord(outputRun)) {
|
||||
return outputRun
|
||||
}
|
||||
const sessionRunIndex = findMatchingSessionRunIndex(
|
||||
outputRun,
|
||||
sessionRuns,
|
||||
usedSessionRunIndexes
|
||||
)
|
||||
if (sessionRunIndex === null) {
|
||||
return outputRun
|
||||
}
|
||||
const sessionRun = sessionRuns[sessionRunIndex]
|
||||
if (!isRecord(sessionRun)) {
|
||||
return outputRun
|
||||
}
|
||||
usedSessionRunIndexes.add(sessionRunIndex)
|
||||
// Hermes writes the markdown output at completion, while state.db keeps
|
||||
// the actual turn-by-turn transcript under the cron session start time.
|
||||
return {
|
||||
...outputRun,
|
||||
output_preview: asString(outputRun.output_preview) ?? asString(sessionRun.output_preview),
|
||||
output_content: mergeOutputAndSessionContent(
|
||||
getRunOutputContent(outputRun),
|
||||
getRunOutputContent(sessionRun)
|
||||
)
|
||||
}
|
||||
})
|
||||
return [
|
||||
...mergedOutputRuns,
|
||||
...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index))
|
||||
]
|
||||
}
|
||||
|
||||
function mergeHermesOutputAndSessionRunRefs(
|
||||
outputRefs: HermesOutputRunRef[],
|
||||
sessionRefs: HermesSessionRunRef[]
|
||||
): HermesMergedRunRef[] {
|
||||
const usedSessionRunIndexes = new Set<number>()
|
||||
const mergedOutputRefs = outputRefs.map((outputRef) => {
|
||||
const sessionRunIndex = findMatchingSessionRunIndex(
|
||||
outputRef,
|
||||
sessionRefs,
|
||||
usedSessionRunIndexes
|
||||
)
|
||||
const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex]
|
||||
if (sessionRunIndex !== null) {
|
||||
usedSessionRunIndexes.add(sessionRunIndex)
|
||||
}
|
||||
return {
|
||||
id: outputRef.id,
|
||||
job_id: outputRef.job_id,
|
||||
run_at: outputRef.run_at,
|
||||
run_key: outputRef.run_key,
|
||||
output: outputRef,
|
||||
session: sessionRef
|
||||
}
|
||||
})
|
||||
return [
|
||||
...mergedOutputRefs,
|
||||
...sessionRefs
|
||||
.filter((_, index) => !usedSessionRunIndexes.has(index))
|
||||
.map((sessionRef) => ({
|
||||
id: sessionRef.id,
|
||||
job_id: sessionRef.job_id,
|
||||
run_at: sessionRef.run_at,
|
||||
run_key: sessionRef.run_key,
|
||||
output: null,
|
||||
session: sessionRef
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
export async function readHermesCronOutputRuns(jobId: string): Promise<unknown[]> {
|
||||
return (await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: Number.MAX_SAFE_INTEGER }))
|
||||
.runs
|
||||
@@ -597,64 +81,15 @@ async function readHermesCronOutputRunRefs(jobId: string): Promise<HermesMergedR
|
||||
// pageSize:0 once per local Hermes job to populate badge counts. Without a
|
||||
// cache this performs N readdir + N sqlite open/query on every list call,
|
||||
// which scales linearly with job and run-history size on the main process.
|
||||
const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000
|
||||
const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200
|
||||
type HermesRunCountCacheEntry = {
|
||||
promise: Promise<number>
|
||||
expiresAt: number
|
||||
}
|
||||
const hermesRunCountCache = new Map<string, HermesRunCountCacheEntry>()
|
||||
|
||||
export function clearHermesCronOutputRunCountCache(jobId?: string): void {
|
||||
if (jobId) {
|
||||
hermesRunCountCache.delete(jobId)
|
||||
return
|
||||
}
|
||||
hermesRunCountCache.clear()
|
||||
}
|
||||
|
||||
function pruneHermesRunCountCache(now: number): void {
|
||||
for (const [jobId, entry] of hermesRunCountCache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
hermesRunCountCache.delete(jobId)
|
||||
}
|
||||
}
|
||||
while (hermesRunCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) {
|
||||
const oldestJobId = hermesRunCountCache.keys().next().value
|
||||
if (oldestJobId === undefined) {
|
||||
return
|
||||
}
|
||||
hermesRunCountCache.delete(oldestJobId)
|
||||
}
|
||||
clearHermesRunCountCache(jobId)
|
||||
}
|
||||
|
||||
async function readHermesCronOutputRunCount(jobId: string): Promise<number> {
|
||||
const now = Date.now()
|
||||
const cached = hermesRunCountCache.get(jobId)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.promise
|
||||
}
|
||||
if (cached) {
|
||||
hermesRunCountCache.delete(jobId)
|
||||
}
|
||||
// Why: external Hermes jobs can be created/removed outside Orca; without a
|
||||
// size bound and expired sweep, a long session can pin stale job ids forever.
|
||||
pruneHermesRunCountCache(now)
|
||||
const entry: HermesRunCountCacheEntry = {
|
||||
promise: readHermesCronOutputRunRefs(jobId).then((refs) => refs.length),
|
||||
expiresAt: Number.POSITIVE_INFINITY
|
||||
}
|
||||
hermesRunCountCache.set(jobId, entry)
|
||||
try {
|
||||
const count = await entry.promise
|
||||
entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS
|
||||
return count
|
||||
} catch (error) {
|
||||
if (hermesRunCountCache.get(jobId) === entry) {
|
||||
hermesRunCountCache.delete(jobId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return readCachedHermesRunCount(jobId, async (targetJobId) => {
|
||||
const refs = await readHermesCronOutputRunRefs(targetJobId)
|
||||
return refs.length
|
||||
})
|
||||
}
|
||||
|
||||
async function hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise<unknown> {
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { open, readFile, realpath, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import {
|
||||
FULL_SESSION_LOG_HEADING,
|
||||
LATEST_LOG_PATH_PATTERN,
|
||||
MAX_REFERENCED_LOG_BYTES,
|
||||
MAX_SESSION_OUTPUT_GAP_MS,
|
||||
REFERENCED_LOG_HEADING,
|
||||
asString,
|
||||
isRecord,
|
||||
sortableTimeFromRunKey
|
||||
} from './hermes-cron-output-markdown'
|
||||
import type {
|
||||
HermesMergedRunRef,
|
||||
HermesOutputRunRef,
|
||||
HermesSessionRunRef
|
||||
} from './hermes-cron-output'
|
||||
|
||||
const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
|
||||
|
||||
function extractLatestLogPath(content: string): string | null {
|
||||
const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim()
|
||||
if (!rawPath) {
|
||||
return null
|
||||
}
|
||||
return rawPath.replace(/^`|`$/g, '').trim()
|
||||
}
|
||||
|
||||
async function readReferencedLogFile(content: string): Promise<{
|
||||
path: string
|
||||
content: string
|
||||
truncated: boolean
|
||||
} | null> {
|
||||
const logPath = extractLatestLogPath(content)
|
||||
if (!logPath || !isAbsolute(logPath)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const homeRealPath = await realpath(HERMES_HOME)
|
||||
const logRealPath = await realpath(logPath)
|
||||
const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath))
|
||||
// Why: the output body can contain agent-authored text, so only hydrate
|
||||
// referenced files that resolve inside Hermes' own data directory.
|
||||
if (
|
||||
relativeToHermesHome === '..' ||
|
||||
relativeToHermesHome.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativeToHermesHome)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const logStat = await stat(logPath)
|
||||
if (!logStat.isFile()) {
|
||||
return null
|
||||
}
|
||||
if (logStat.size <= MAX_REFERENCED_LOG_BYTES) {
|
||||
return {
|
||||
path: logPath,
|
||||
content: await readFile(logPath, 'utf-8'),
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
const file = await open(logPath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES)
|
||||
await file.read(buffer, 0, MAX_REFERENCED_LOG_BYTES, logStat.size - MAX_REFERENCED_LOG_BYTES)
|
||||
return {
|
||||
path: logPath,
|
||||
content: buffer.toString('utf-8'),
|
||||
truncated: true
|
||||
}
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendReferencedLogFile(content: string): Promise<string> {
|
||||
if (content.includes(REFERENCED_LOG_HEADING)) {
|
||||
return content
|
||||
}
|
||||
const logFile = await readReferencedLogFile(content)
|
||||
if (!logFile) {
|
||||
return content
|
||||
}
|
||||
const note = logFile.truncated
|
||||
? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.`
|
||||
: null
|
||||
return [
|
||||
content,
|
||||
'---',
|
||||
REFERENCED_LOG_HEADING,
|
||||
'',
|
||||
`Path: ${logFile.path}`,
|
||||
note,
|
||||
'```text',
|
||||
logFile.content.trimEnd(),
|
||||
'```'
|
||||
]
|
||||
.filter((part) => part !== null)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export function formatSessionMessages(messages: Record<string, unknown>[]): string | null {
|
||||
if (messages.length === 0) {
|
||||
return null
|
||||
}
|
||||
return messages
|
||||
.map((message) => {
|
||||
const role = typeof message.role === 'string' ? message.role : 'message'
|
||||
const content = typeof message.content === 'string' ? message.content.trim() : ''
|
||||
const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : ''
|
||||
const reasoning =
|
||||
typeof message.reasoning_content === 'string'
|
||||
? message.reasoning_content.trim()
|
||||
: typeof message.reasoning === 'string'
|
||||
? message.reasoning.trim()
|
||||
: ''
|
||||
const parts = [
|
||||
`## ${role}${toolName ? ` / ${toolName}` : ''}`,
|
||||
reasoning ? `### Reasoning\n\n${reasoning}` : null,
|
||||
content || '(empty)'
|
||||
].filter(Boolean)
|
||||
return parts.join('\n\n')
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
function getRunKey(run: unknown): string | null {
|
||||
return isRecord(run) ? asString(run.run_key) : null
|
||||
}
|
||||
|
||||
function getRunOutputContent(run: unknown): string | null {
|
||||
return isRecord(run) ? asString(run.output_content) : null
|
||||
}
|
||||
|
||||
function mergeOutputAndSessionContent(
|
||||
outputContent: string | null,
|
||||
sessionContent: string | null
|
||||
): string | null {
|
||||
if (!sessionContent) {
|
||||
return outputContent
|
||||
}
|
||||
if (!outputContent) {
|
||||
return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}`
|
||||
}
|
||||
if (outputContent.includes(FULL_SESSION_LOG_HEADING)) {
|
||||
return outputContent
|
||||
}
|
||||
return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}`
|
||||
}
|
||||
|
||||
function findMatchingSessionRunIndex(
|
||||
outputRun: unknown,
|
||||
sessionRuns: unknown[],
|
||||
usedSessionRunIndexes: Set<number>
|
||||
): number | null {
|
||||
const outputRunKey = getRunKey(outputRun)
|
||||
const exactMatchIndex = sessionRuns.findIndex(
|
||||
(sessionRun, index) =>
|
||||
!usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey
|
||||
)
|
||||
if (exactMatchIndex !== -1) {
|
||||
return exactMatchIndex
|
||||
}
|
||||
|
||||
const outputTime = sortableTimeFromRunKey(outputRunKey)
|
||||
if (!Number.isFinite(outputTime)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let bestIndex: number | null = null
|
||||
let bestGap = Number.POSITIVE_INFINITY
|
||||
for (let index = 0; index < sessionRuns.length; index += 1) {
|
||||
if (usedSessionRunIndexes.has(index)) {
|
||||
continue
|
||||
}
|
||||
const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index]))
|
||||
if (!Number.isFinite(sessionTime)) {
|
||||
continue
|
||||
}
|
||||
const gap = outputTime - sessionTime
|
||||
if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) {
|
||||
continue
|
||||
}
|
||||
bestIndex = index
|
||||
bestGap = gap
|
||||
}
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
export function mergeHermesOutputAndSessionRuns(
|
||||
outputRuns: unknown[],
|
||||
sessionRuns: unknown[]
|
||||
): unknown[] {
|
||||
const usedSessionRunIndexes = new Set<number>()
|
||||
const mergedOutputRuns = outputRuns.map((outputRun) => {
|
||||
if (!isRecord(outputRun)) {
|
||||
return outputRun
|
||||
}
|
||||
const sessionRunIndex = findMatchingSessionRunIndex(
|
||||
outputRun,
|
||||
sessionRuns,
|
||||
usedSessionRunIndexes
|
||||
)
|
||||
if (sessionRunIndex === null) {
|
||||
return outputRun
|
||||
}
|
||||
const sessionRun = sessionRuns[sessionRunIndex]
|
||||
if (!isRecord(sessionRun)) {
|
||||
return outputRun
|
||||
}
|
||||
usedSessionRunIndexes.add(sessionRunIndex)
|
||||
// Hermes writes the markdown output at completion, while state.db keeps
|
||||
// the actual turn-by-turn transcript under the cron session start time.
|
||||
return {
|
||||
...outputRun,
|
||||
output_preview: asString(outputRun.output_preview) ?? asString(sessionRun.output_preview),
|
||||
output_content: mergeOutputAndSessionContent(
|
||||
getRunOutputContent(outputRun),
|
||||
getRunOutputContent(sessionRun)
|
||||
)
|
||||
}
|
||||
})
|
||||
return [
|
||||
...mergedOutputRuns,
|
||||
...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index))
|
||||
]
|
||||
}
|
||||
|
||||
export function mergeHermesOutputAndSessionRunRefs(
|
||||
outputRefs: HermesOutputRunRef[],
|
||||
sessionRefs: HermesSessionRunRef[]
|
||||
): HermesMergedRunRef[] {
|
||||
const usedSessionRunIndexes = new Set<number>()
|
||||
const mergedOutputRefs = outputRefs.map((outputRef) => {
|
||||
const sessionRunIndex = findMatchingSessionRunIndex(
|
||||
outputRef,
|
||||
sessionRefs,
|
||||
usedSessionRunIndexes
|
||||
)
|
||||
const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex]
|
||||
if (sessionRunIndex !== null) {
|
||||
usedSessionRunIndexes.add(sessionRunIndex)
|
||||
}
|
||||
return {
|
||||
id: outputRef.id,
|
||||
job_id: outputRef.job_id,
|
||||
run_at: outputRef.run_at,
|
||||
run_key: outputRef.run_key,
|
||||
output: outputRef,
|
||||
session: sessionRef
|
||||
}
|
||||
})
|
||||
return [
|
||||
...mergedOutputRefs,
|
||||
...sessionRefs
|
||||
.filter((_, index) => !usedSessionRunIndexes.has(index))
|
||||
.map((sessionRef) => ({
|
||||
id: sessionRef.id,
|
||||
job_id: sessionRef.job_id,
|
||||
run_at: sessionRef.run_at,
|
||||
run_key: sessionRef.run_key,
|
||||
output: null,
|
||||
session: sessionRef
|
||||
}))
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000
|
||||
const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200
|
||||
|
||||
type HermesRunCountCacheEntry = {
|
||||
promise: Promise<number>
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const hermesRunCountCache = new Map<string, HermesRunCountCacheEntry>()
|
||||
|
||||
export function clearHermesCronOutputRunCountCache(jobId?: string): void {
|
||||
if (jobId) {
|
||||
hermesRunCountCache.delete(jobId)
|
||||
return
|
||||
}
|
||||
hermesRunCountCache.clear()
|
||||
}
|
||||
|
||||
function pruneHermesRunCountCache(now: number): void {
|
||||
for (const [jobId, entry] of hermesRunCountCache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
hermesRunCountCache.delete(jobId)
|
||||
}
|
||||
}
|
||||
while (hermesRunCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) {
|
||||
const oldestJobId = hermesRunCountCache.keys().next().value
|
||||
if (oldestJobId === undefined) {
|
||||
return
|
||||
}
|
||||
hermesRunCountCache.delete(oldestJobId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCachedHermesRunCount(
|
||||
jobId: string,
|
||||
loadRunCount: (jobId: string) => Promise<number>
|
||||
): Promise<number> {
|
||||
const now = Date.now()
|
||||
const cached = hermesRunCountCache.get(jobId)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.promise
|
||||
}
|
||||
if (cached) {
|
||||
hermesRunCountCache.delete(jobId)
|
||||
}
|
||||
// Why: external Hermes jobs can be created/removed outside Orca; without a
|
||||
// size bound and expired sweep, a long session can pin stale job ids forever.
|
||||
pruneHermesRunCountCache(now)
|
||||
const entry: HermesRunCountCacheEntry = {
|
||||
promise: loadRunCount(jobId),
|
||||
expiresAt: Number.POSITIVE_INFINITY
|
||||
}
|
||||
hermesRunCountCache.set(jobId, entry)
|
||||
try {
|
||||
const count = await entry.promise
|
||||
entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS
|
||||
return count
|
||||
} catch (error) {
|
||||
if (hermesRunCountCache.get(jobId) === entry) {
|
||||
hermesRunCountCache.delete(jobId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const browserMocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
openPopupWithOriginBarMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: browserMocks.appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: browserMocks.shellOpenExternalMock },
|
||||
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
|
||||
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
|
||||
webContents: { fromId: browserMocks.webContentsFromIdMock }
|
||||
}))
|
||||
|
||||
vi.mock('./popup-origin-bar-window', () => ({
|
||||
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
|
||||
}))
|
||||
|
||||
import { browserManager } from './browser-manager'
|
||||
import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge'
|
||||
import {
|
||||
rendererWebContentsId,
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
} from './browser-manager-test-harness'
|
||||
import {
|
||||
createViewportGuestFactory,
|
||||
flushViewportOps
|
||||
} from './browser-manager-viewport-test-fixtures'
|
||||
|
||||
const { webContentsFromIdMock } = browserMocks
|
||||
const makeGuest = createViewportGuestFactory(browserMocks)
|
||||
|
||||
const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
}
|
||||
|
||||
function registerPage(pageId: string, guest: Record<string, unknown>): void {
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: pageId,
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
}
|
||||
|
||||
/** The production resolver's shape: read the registry now, not when the request was made. */
|
||||
function resolveFromRegistry(pageId: string): () => Electron.WebContents | null {
|
||||
return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId)
|
||||
}
|
||||
|
||||
describe('browserManager.setAnnotationViewportBridge', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('installs the bridge in an isolated world', async () => {
|
||||
const { guest } = makeGuest(4646)
|
||||
registerPage('tab-annotations', guest)
|
||||
|
||||
const ok = await browserManager.setAnnotationViewportBridge(
|
||||
'tab-annotations',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-annotations')
|
||||
)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
[
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
|
||||
})
|
||||
],
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
// Why this is the case that matters: a cross-process navigation re-registers the same page id
|
||||
// with a new WebContents and leaves the retired one alive, so a bridge op that resolved its
|
||||
// guest when the request arrived would inject into a page nobody is looking at — and the
|
||||
// annotation badges would stop tracking the document on screen.
|
||||
it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => {
|
||||
const { guest: firstGuest } = makeGuest(5101)
|
||||
let releaseFirstInjection = (): void => {}
|
||||
// One shared gate for every call, so a wrongly-routed second injection still settles and the
|
||||
// test fails on where it landed rather than on a timeout.
|
||||
const firstInjectionGate = new Promise<void>((resolve) => {
|
||||
releaseFirstInjection = () => resolve()
|
||||
})
|
||||
firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate)
|
||||
registerPage('tab-swap', firstGuest)
|
||||
|
||||
const firstDone = browserManager.setAnnotationViewportBridge(
|
||||
'tab-swap',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-swap')
|
||||
)
|
||||
await flushViewportOps()
|
||||
|
||||
// A second request arrives while the first still holds the chain — at this moment the page is
|
||||
// still on the first guest, which is the guest a request-time resolution would capture.
|
||||
const secondDone = browserManager.setAnnotationViewportBridge(
|
||||
'tab-swap',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-swap')
|
||||
)
|
||||
await flushViewportOps()
|
||||
|
||||
// Only now does the page swap renderer processes, while the second op is still queued.
|
||||
const { guest: secondGuest } = makeGuest(5102)
|
||||
registerPage('tab-swap', secondGuest)
|
||||
|
||||
releaseFirstInjection()
|
||||
await expect(firstDone).resolves.toBe(true)
|
||||
await expect(secondDone).resolves.toBe(true)
|
||||
|
||||
expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
|
||||
expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
|
||||
// is the one that can see the guest is gone. The bridge only reports the refusal.
|
||||
it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
|
||||
const { guest } = makeGuest(5103)
|
||||
registerPage('tab-dies', guest)
|
||||
expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103)
|
||||
|
||||
webContentsFromIdMock.mockReturnValue(null)
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge(
|
||||
'tab-dies',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-dies')
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
|
||||
// Why assert the registry and not just the answer: a stale guest has to clear every per-tab
|
||||
// entry, or the page keeps a dead WebContents id that later ops resolve against.
|
||||
expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull()
|
||||
})
|
||||
|
||||
// Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by
|
||||
// the wrong renderer names a page that is alive and on screen, and answering it with teardown
|
||||
// would cancel that page's in-flight downloads and grabs over a misaddressed message.
|
||||
it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => {
|
||||
const { guest } = makeGuest(5105)
|
||||
registerPage('tab-mismatch', guest)
|
||||
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
|
||||
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () =>
|
||||
browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1)
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(unregisterGuest).not.toHaveBeenCalled()
|
||||
expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a destroyed guest without injecting into it', async () => {
|
||||
const { guest } = makeGuest(5104)
|
||||
registerPage('tab-destroyed', guest)
|
||||
;(guest.isDestroyed as ReturnType<typeof vi.fn>).mockReturnValue(true)
|
||||
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge(
|
||||
'tab-destroyed',
|
||||
BRIDGE_OPTIONS,
|
||||
() => guest as never
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a document page owns no browsing state, so treating its refusal as a stale page would run
|
||||
// teardown against an id the browsing registry never held.
|
||||
it('does not run page teardown when a document page resolves to nothing', async () => {
|
||||
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
|
||||
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null)
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(unregisterGuest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -45,32 +45,6 @@ import {
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
} from './browser-manager-test-harness'
|
||||
import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy'
|
||||
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
/**
|
||||
* A page the document half of the registry really holds. Built rather than named: membership is
|
||||
* what both doors refuse on now, so an id that merely looks like a preview's would be admitted.
|
||||
*/
|
||||
function registerWorkspaceDocPage(browserPageId: string): void {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = {
|
||||
isFocused: () => false,
|
||||
isDestroyed: () => false,
|
||||
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() })
|
||||
}
|
||||
|
||||
const {
|
||||
guestOffMock,
|
||||
@@ -86,7 +60,6 @@ describe('browserManager', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -119,77 +92,6 @@ describe('browserManager', () => {
|
||||
expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work')
|
||||
})
|
||||
|
||||
// Why both doors: one id in both halves of the registry would make the tool door answer with a
|
||||
// document guest for a page the reader is browsing in.
|
||||
it.each(['registerGuest', 'registerOffscreenGuest'] as const)(
|
||||
'refuses %s for a page the document registry already holds',
|
||||
(entryPoint) => {
|
||||
const guest = {
|
||||
id: 129,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
const browserPageId = 'doc-page-1'
|
||||
registerWorkspaceDocPage(browserPageId)
|
||||
|
||||
if (entryPoint === 'registerGuest') {
|
||||
expect(
|
||||
browserManager.registerGuest({
|
||||
browserPageId,
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
).toBe(false)
|
||||
} else {
|
||||
expect(
|
||||
browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id })
|
||||
).toBe(false)
|
||||
}
|
||||
|
||||
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
// Why this answer is load-bearing: the headless backend destroys its window on false, so a true
|
||||
// for a guest that is already gone would leave a page id registered onto nothing.
|
||||
it.each(['missing', 'destroyed'] as const)(
|
||||
'refuses registerOffscreenGuest when the named guest is %s',
|
||||
(guestState) => {
|
||||
webContentsFromIdMock.mockReturnValue(
|
||||
guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) }
|
||||
)
|
||||
|
||||
expect(
|
||||
browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 })
|
||||
).toBe(false)
|
||||
|
||||
expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
// Why the exit door needs the same check: a document page withdraws by revoking its grant, so its
|
||||
// id here is misaddressed — and unregistering opens by evicting whatever grab that id names.
|
||||
it('refuses unregisterGuest for a page the document registry holds', () => {
|
||||
registerWorkspaceDocPage('doc-page-2')
|
||||
const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp')
|
||||
|
||||
browserManager.unregisterGuest('doc-page-2')
|
||||
|
||||
expect(cancelGrabOp).not.toHaveBeenCalled()
|
||||
|
||||
// The presence half: the same door does evict a browsing page's grab.
|
||||
browserManager.unregisterGuest('browser-page-1')
|
||||
expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted')
|
||||
cancelGrabOp.mockRestore()
|
||||
})
|
||||
|
||||
it('blocks non-web guest navigations after attach', () => {
|
||||
const guest = {
|
||||
isDestroyed: vi.fn(() => false),
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const browserMocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
openPopupWithOriginBarMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: browserMocks.appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: browserMocks.shellOpenExternalMock },
|
||||
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
|
||||
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
|
||||
webContents: { fromId: browserMocks.webContentsFromIdMock }
|
||||
}))
|
||||
vi.mock('./popup-origin-bar-window', () => ({
|
||||
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
|
||||
}))
|
||||
|
||||
import { browserManager } from './browser-manager'
|
||||
import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness'
|
||||
import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy'
|
||||
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
type GuestFake = {
|
||||
id: number
|
||||
url: string
|
||||
listeners: Map<string, ((...args: never[]) => void)[]>
|
||||
windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null
|
||||
webRtcPolicy: string | null
|
||||
isDestroyed: () => boolean
|
||||
isFocused: () => boolean
|
||||
getURL: () => string
|
||||
getType: () => string
|
||||
setBackgroundThrottling: (value: boolean) => void
|
||||
setWebRTCIPHandlingPolicy: (policy: string) => void
|
||||
setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void
|
||||
executeJavaScriptInIsolatedWorld: ReturnType<typeof vi.fn>
|
||||
debugger: {
|
||||
isAttached: () => boolean
|
||||
attach: ReturnType<typeof vi.fn>
|
||||
sendCommand: ReturnType<typeof vi.fn>
|
||||
}
|
||||
on: (event: string, listener: (...args: never[]) => void) => void
|
||||
once: (event: string, listener: (...args: never[]) => void) => void
|
||||
off: (event: string, listener: (...args: never[]) => void) => void
|
||||
}
|
||||
|
||||
function createGuest(id: number, url: string): GuestFake {
|
||||
const listeners = new Map<string, ((...args: never[]) => void)[]>()
|
||||
const guest: GuestFake = {
|
||||
id,
|
||||
url,
|
||||
listeners,
|
||||
windowOpenHandler: null,
|
||||
webRtcPolicy: null,
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => true,
|
||||
getURL: () => guest.url,
|
||||
getType: () => 'webview',
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: (policy: string) => {
|
||||
guest.webRtcPolicy = policy
|
||||
},
|
||||
setWindowOpenHandler: (handler) => {
|
||||
guest.windowOpenHandler = handler
|
||||
},
|
||||
executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined),
|
||||
debugger: {
|
||||
isAttached: () => true,
|
||||
attach: vi.fn(),
|
||||
sendCommand: vi.fn(async () => undefined)
|
||||
},
|
||||
on: (event, listener) => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), listener])
|
||||
},
|
||||
once: (event, listener) => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), listener])
|
||||
},
|
||||
off: (event, listener) => {
|
||||
listeners.set(
|
||||
event,
|
||||
(listeners.get(event) ?? []).filter((entry) => entry !== listener)
|
||||
)
|
||||
}
|
||||
}
|
||||
return guest
|
||||
}
|
||||
|
||||
function listenerCount(guest: GuestFake, event: string): number {
|
||||
return guest.listeners.get(event)?.length ?? 0
|
||||
}
|
||||
|
||||
/** Drives the guest's own will-navigate listeners and reports whether they refused. */
|
||||
function navigateTo(guest: GuestFake, url: string): boolean {
|
||||
let prevented = false
|
||||
const event = { preventDefault: () => (prevented = true) } as never
|
||||
for (const listener of guest.listeners.get('will-navigate') ?? []) {
|
||||
;(listener as (event: unknown, url: string) => void)(event, url)
|
||||
}
|
||||
return prevented
|
||||
}
|
||||
|
||||
const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents
|
||||
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
revokeAllDocPreviewGrants()
|
||||
browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
describe('guest policy profiles', () => {
|
||||
function attachPreviewGuest(id = 301): {
|
||||
guest: GuestFake
|
||||
grantId: string
|
||||
browserPageId: string
|
||||
} {
|
||||
const browserPageId = `doc-page-${id}`
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host })
|
||||
return { guest, grantId: grant.id, browserPageId }
|
||||
}
|
||||
|
||||
// The presence half of every absence below: a browsing guest observably takes all of it through
|
||||
// the same method, so a profile that fenced nothing — or an attach path that stopped installing
|
||||
// anything at all — cannot pass these by being uniformly empty.
|
||||
it('gives a browsing guest link routing, popups and anti-detection', () => {
|
||||
const guest = createGuest(300, 'https://example.com/')
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(1)
|
||||
expect(listenerCount(guest, 'frame-created')).toBe(1)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(1)
|
||||
expect(guest.debugger.sendCommand).toHaveBeenCalled()
|
||||
expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false)
|
||||
})
|
||||
|
||||
it('gives a workspace-document guest none of it', () => {
|
||||
const { guest } = attachPreviewGuest()
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(0)
|
||||
expect(listenerCount(guest, 'frame-created')).toBe(0)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(0)
|
||||
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('holds a workspace-document guest inside the grant it is showing', () => {
|
||||
const { guest, grantId } = attachPreviewGuest()
|
||||
|
||||
expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false)
|
||||
expect(navigateTo(guest, 'https://example.com/')).toBe(true)
|
||||
expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp')
|
||||
})
|
||||
|
||||
it('denies every window a workspace-document guest asks for', () => {
|
||||
const { guest } = attachPreviewGuest()
|
||||
|
||||
expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
})
|
||||
|
||||
// Why teardown and not just install: registration and teardown both key on the guest having been
|
||||
// policy-attached, so a profile that installs outside that bookkeeping leaves the id marked
|
||||
// attached forever and stays answerable to tools after the surface is gone.
|
||||
it('tears a workspace-document guest down through the same policy cleanup', () => {
|
||||
const { guest, browserPageId } = attachPreviewGuest(302)
|
||||
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never)
|
||||
|
||||
for (const listener of guest.listeners.get('destroyed') ?? []) {
|
||||
listener()
|
||||
}
|
||||
|
||||
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull()
|
||||
// The manager's own bookkeeping was reached too: a second attach is refused while the id is
|
||||
// still marked policy-attached, and accepted once its teardown has run.
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(1)
|
||||
})
|
||||
|
||||
// The seam the whole split rests on: one public door answers for both halves, and each page id
|
||||
// resolves in exactly one of them. Asserted against the real manager, because the IPC census
|
||||
// test has to mock it.
|
||||
describe('the one door across both halves of the registry', () => {
|
||||
const BROWSING_PAGE_ID = 'browser-page-1'
|
||||
|
||||
function registerBrowsingGuest(id = 400): GuestFake {
|
||||
const guest = createGuest(id, 'https://example.com/')
|
||||
browserMocks.webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: BROWSING_PAGE_ID,
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
webContentsId: id,
|
||||
rendererWebContentsId: host.id
|
||||
})
|
||||
return guest
|
||||
}
|
||||
|
||||
it('answers each page with the guest of its own half', () => {
|
||||
const browsing = registerBrowsingGuest()
|
||||
const { guest: document, browserPageId } = attachPreviewGuest()
|
||||
|
||||
expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never)
|
||||
expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never)
|
||||
})
|
||||
|
||||
// Why this is the containment claim and not a lookup detail: page management, agent commands,
|
||||
// download routing and certificate attribution all read the browsing map directly, so a
|
||||
// document page being absent from it is what fences them without a guard of their own.
|
||||
it('keeps a document page out of the browsing map entirely', () => {
|
||||
registerBrowsingGuest()
|
||||
const { browserPageId } = attachPreviewGuest()
|
||||
|
||||
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
|
||||
expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID])
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches a guest once whatever profile it was asked for', () => {
|
||||
const { guest } = attachPreviewGuest(303)
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(0)
|
||||
expect(listenerCount(guest, 'will-navigate')).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -882,5 +882,34 @@ describe('browserManager', () => {
|
||||
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
|
||||
expect(debuggerSendCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installs annotation viewport bridge in an isolated world', async () => {
|
||||
const { guest } = makeGuest(4646)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-annotations',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', {
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
})
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
[
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
|
||||
})
|
||||
],
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,11 +63,6 @@ import {
|
||||
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
|
||||
buildBrowserAnnotationViewportBridgeScript
|
||||
} from '../../shared/browser-annotation-viewport-bridge'
|
||||
import {
|
||||
getWorkspaceDocPageGuest,
|
||||
installDocPreviewGuestPolicy,
|
||||
isWorkspaceDocPageId
|
||||
} from './doc-preview-guest-policy'
|
||||
import type { KeybindingOverrides } from '../../shared/keybindings'
|
||||
import {
|
||||
BrowserCertificateTrustController,
|
||||
@@ -162,15 +157,6 @@ type PopupOwnerContext = {
|
||||
browserTabId: string
|
||||
rootGuestWebContentsId: number
|
||||
}
|
||||
/**
|
||||
* What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
|
||||
* anti-detection all apply. A workspace-document guest renders one granted document and gets none
|
||||
* of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
|
||||
*/
|
||||
export type BrowserGuestPolicy =
|
||||
| { profile: 'browsing' }
|
||||
| { profile: 'workspace-doc'; host: Electron.WebContents }
|
||||
const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' }
|
||||
type PendingMainFrameNavigation = {
|
||||
currentUrl: string
|
||||
supersededUrls: string[]
|
||||
@@ -685,20 +671,12 @@ export class BrowserManager {
|
||||
|
||||
attachGuestPolicies(
|
||||
guest: Electron.WebContents,
|
||||
inheritedOwnerContext: PopupOwnerContext | null = null,
|
||||
policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY
|
||||
inheritedOwnerContext: PopupOwnerContext | null = null
|
||||
): void {
|
||||
if (this.policyAttachedGuestIds.has(guest.id)) {
|
||||
return
|
||||
}
|
||||
this.policyAttachedGuestIds.add(guest.id)
|
||||
// Why one door with a profile rather than a second installer beside it: whether a guest was
|
||||
// policy-attached at all is what registration and teardown both key on, so a guest that took
|
||||
// another path into the app is invisible to both.
|
||||
if (policy.profile === 'workspace-doc') {
|
||||
this.attachWorkspaceDocGuestPolicies(guest, policy.host)
|
||||
return
|
||||
}
|
||||
if (inheritedOwnerContext) {
|
||||
this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext)
|
||||
}
|
||||
@@ -1038,30 +1016,6 @@ export class BrowserManager {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace document is not the web: no popups, no link routing, no anti-detection, and no
|
||||
* navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is
|
||||
* this method's teardown, so a retired preview drops its listeners on the same path.
|
||||
*/
|
||||
private attachWorkspaceDocGuestPolicies(
|
||||
guest: Electron.WebContents,
|
||||
host: Electron.WebContents
|
||||
): void {
|
||||
const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host)
|
||||
const handleDestroyed = (): void => {
|
||||
this.cleanupGuestPolicyAttachment(guest.id)
|
||||
}
|
||||
guest.on('destroyed', handleDestroyed)
|
||||
this.policyCleanupByGuestId.set(guest.id, () => {
|
||||
disposeDocPolicy()
|
||||
try {
|
||||
guest.off('destroyed', handleDestroyed)
|
||||
} catch {
|
||||
// guest may already be destroyed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
|
||||
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
|
||||
// must be matched here per navigation or the two layers disagree — itself a bot tell.
|
||||
@@ -1364,9 +1318,7 @@ export class BrowserManager {
|
||||
rendererWebContentsId
|
||||
}: BrowserGuestRegistration): boolean {
|
||||
const browserTabId = browserPageId ?? legacyBrowserTabId
|
||||
// Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one
|
||||
// id resolves in both and the tool door silently prefers the document guest over the page.
|
||||
if (!browserTabId || isWorkspaceDocPageId(browserTabId)) {
|
||||
if (!browserTabId) {
|
||||
return false
|
||||
}
|
||||
// Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
|
||||
@@ -1425,12 +1377,6 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
unregisterGuest(browserTabId: string): void {
|
||||
// Why the check on the exit door too: a document page withdraws by revoking its grant, never
|
||||
// through here, so its id arriving is misaddressed — and the cancel below would evict that
|
||||
// preview's live grab on the strength of it.
|
||||
if (isWorkspaceDocPageId(browserTabId)) {
|
||||
return
|
||||
}
|
||||
// Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
|
||||
this.cancelGrabOp(browserTabId, 'evicted')
|
||||
|
||||
@@ -1498,15 +1444,10 @@ export class BrowserManager {
|
||||
sessionProfileId?: string | null
|
||||
userAgentMode?: BrowserSessionUserAgentMode
|
||||
webContentsId: number
|
||||
}): boolean {
|
||||
// Why the same check on both registration doors: one id resolving in both halves is the exact
|
||||
// confusion the split registries exist to prevent.
|
||||
if (isWorkspaceDocPageId(browserPageId)) {
|
||||
return false
|
||||
}
|
||||
}): void {
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
return false
|
||||
return
|
||||
}
|
||||
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
|
||||
this.offscreenGuestIds.add(webContentsId)
|
||||
@@ -1527,7 +1468,6 @@ export class BrowserManager {
|
||||
this.worktreeIdByTabId.set(browserPageId, worktreeId)
|
||||
}
|
||||
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
|
||||
return true
|
||||
}
|
||||
|
||||
unregisterAll(): void {
|
||||
@@ -1903,13 +1843,12 @@ export class BrowserManager {
|
||||
|
||||
async setAnnotationViewportBridge(
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
options: BrowserAnnotationViewportBridgeOptions
|
||||
): Promise<boolean> {
|
||||
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.catch(() => {})
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
|
||||
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
|
||||
try {
|
||||
return await next
|
||||
@@ -1920,22 +1859,18 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Why the caller resolves the guest: the same bridge serves browsing pages and workspace
|
||||
// documents, which live in different halves of the page registry.
|
||||
// Why a resolver and not the guest itself: this op may have waited behind another one, and a
|
||||
// cross-process navigation meanwhile swaps the tab's contents without destroying the old one —
|
||||
// injecting into the guest the request named would bridge a page nobody is looking at.
|
||||
// Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and
|
||||
// taking an id it cannot act on would invite the next reader to act on it.
|
||||
private async doSetAnnotationViewportBridgeImpl(
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions
|
||||
): Promise<boolean> {
|
||||
// Why no teardown here: the resolver already unregisters a page whose guest died, and the only
|
||||
// case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would
|
||||
// cancel that page's in-flight downloads and grabs over a request that was merely misaddressed.
|
||||
const guest = resolveGuest()
|
||||
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (!webContentsId) {
|
||||
return false
|
||||
}
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
|
||||
this.unregisterGuest(browserTabId)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -2042,21 +1977,10 @@ export class BrowserManager {
|
||||
// --- Browser Context Grab — main-owned operations ---
|
||||
|
||||
/** Validate that the sender owns browserTabId; returns the guest WebContents or null. */
|
||||
/**
|
||||
* The guest a request from `senderWebContentsId` may act on, across both halves of the page
|
||||
* registry. This is the only door taught about workspace-document guests: they are kept out of
|
||||
* the browsing maps entirely, so page management, agent commands, download routing and
|
||||
* certificate attribution all miss them without a guard of their own — and a reader who opens a
|
||||
* tool on the document in front of them still gets an answer.
|
||||
*/
|
||||
getAuthorizedGuest(
|
||||
browserTabId: string,
|
||||
senderWebContentsId: number
|
||||
): Electron.WebContents | null {
|
||||
const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId)
|
||||
if (docGuest) {
|
||||
return docGuest
|
||||
}
|
||||
const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId)
|
||||
if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) {
|
||||
return null
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user