mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-3976-runtime-ssh-ai-vault
# Conflicts: # src/main/ipc/ai-vault.ts
This commit is contained in:
@@ -52,7 +52,7 @@ If your change affects UI or interaction behavior, verify it on the platforms it
|
||||
|
||||
Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for ambient shims (e.g., `env.d.ts`, `vite/client.d.ts`). TypeScript's `skipLibCheck: true` setting applies globally, including to our own `.d.ts` files, which means any unresolved type reference in a `.d.ts` silently becomes `any` at its call sites. Write your types in `.ts` files so the compiler actually checks them.
|
||||
|
||||
CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-typecheck-hole.md`.
|
||||
CI enforces this for `src/preload/` and `src/shared/`.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ Fixes #
|
||||
|
||||
|
||||
## AI Disclosure
|
||||
<!-- For all external contributors only: Which AI model if anyone was used, please state the details -->
|
||||
<!-- DO NOT FILL IN IF YOU ARE STABLYAI TEAM MEMBER (INTERNAL CONTRIBUTOR), IGNORE SECTION: -->
|
||||
<!-- Which AI model if anyone was used, please state the details -->
|
||||
|
||||
## Review
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ on:
|
||||
- 'electron.vite.config.ts'
|
||||
- 'config/build-plugins/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/providers/local-pty-provider.ts'
|
||||
- 'src/main/providers/windows-shell-args.ts'
|
||||
- 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts'
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
- 'native/computer-use-windows/**'
|
||||
@@ -106,6 +109,7 @@ jobs:
|
||||
src/main/computer/macos-native-provider-socket.test.ts
|
||||
src/main/computer/macos-computer-use-permissions.test.ts
|
||||
src/main/computer/macos-computer-use-permission-status.test.ts
|
||||
src/main/providers/windows-shell-preflight-runtime.windows.test.ts
|
||||
src/main/computer/desktop-script-provider-client.test.ts
|
||||
src/main/computer/desktop-script-provider-cache.test.ts
|
||||
src/main/computer/desktop-script-provider-actions.test.ts
|
||||
|
||||
+31
-14
@@ -126,7 +126,7 @@ jobs:
|
||||
# scripts still need the Linux toolchain even though this shard reuses
|
||||
# the prebuilt Electron output.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh
|
||||
|
||||
# Why: Electron on Linux needs an X display even when the app
|
||||
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
|
||||
# launches but never creates a BrowserWindow.
|
||||
- name: Run E2E tests (${{ matrix.shard_name }})
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
|
||||
# Why: Playwright retains traces/screenshots only on failure. Uploading
|
||||
# them as an artifact makes post-mortem debugging on CI possible without
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -232,9 +232,24 @@ jobs:
|
||||
env:
|
||||
TEST_FILES_JSON: ${{ inputs.test_files }}
|
||||
run: |
|
||||
mapfile -t TEST_FILES < <(jq -r '.[]' <<<"$TEST_FILES_JSON")
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 \
|
||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
|
||||
mapfile -t TEST_FILES < <(jq -r '.[] | select(
|
||||
. != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and
|
||||
. != "tests/e2e/paired-startup-exec-readiness.spec.ts"
|
||||
)' <<<"$TEST_FILES_JSON")
|
||||
if [ "${#TEST_FILES[@]}" -eq 0 ]; then
|
||||
echo "Changed startup-readiness specs are owned by the dedicated live lane."
|
||||
exit 0
|
||||
fi
|
||||
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1)
|
||||
if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'; then
|
||||
E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1)
|
||||
fi
|
||||
E2E_PROJECT_ARGS=()
|
||||
if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then
|
||||
E2E_PROJECT_ARGS+=(--project=electron-headful)
|
||||
fi
|
||||
xvfb-run --auto-servernum env "${E2E_ENV[@]}" \
|
||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
@@ -248,10 +263,13 @@ jobs:
|
||||
ssh-docker-watcher-isolation:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
if: inputs.test_files == ''
|
||||
if: >-
|
||||
inputs.test_files == '' ||
|
||||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
|
||||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
|
||||
runs-on: ubuntu-latest
|
||||
# Why 35: the terminal parking + retention specs below add two more
|
||||
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
|
||||
# Why 35: the parking, retention, startup-exec, and paired parity specs run
|
||||
# serially on isolated Electron/SSH fixtures after watcher isolation.
|
||||
timeout-minutes: 35
|
||||
|
||||
steps:
|
||||
@@ -261,7 +279,7 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -295,10 +313,9 @@ jobs:
|
||||
- name: Run Docker SSH watcher isolation E2E
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
|
||||
|
||||
# Why always(): these specs gate the C1 terminal parking/retention budget
|
||||
# over a real SSH host and run in no other lane, so a watcher-isolation
|
||||
# failure above must not silently skip them.
|
||||
- name: Run Docker SSH terminal parking + retention E2E
|
||||
# Why always(): this lane gates SSH parking/retention plus startup-exec
|
||||
# readiness across live SSH, headed paired, and headless serve topologies.
|
||||
- name: Run Docker SSH terminal parking + startup readiness E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
|
||||
|
||||
|
||||
@@ -23,10 +23,9 @@ jobs:
|
||||
platform: linux
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
# Why: Windows golden E2E is temporarily disabled on CI while its
|
||||
# flaky runner-only failures are investigated.
|
||||
# - os: windows-latest
|
||||
# platform: windows
|
||||
# Match the release gate: Windows runs a focused golden subset.
|
||||
- os: windows-2022
|
||||
platform: windows
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -62,17 +61,46 @@ jobs:
|
||||
- name: Build Electron app for E2E
|
||||
run: npx electron-vite build --mode e2e
|
||||
|
||||
# Why: this workflow can check out an older ref than the YAML that
|
||||
# invoked it. Skip goldens the checked-out tree does not define.
|
||||
- name: Run golden E2E tests on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
||||
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
|
||||
fi
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run golden E2E tests on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
||||
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
|
||||
fi
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run golden E2E tests on Windows
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:SKIP_BUILD = '1'
|
||||
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
||||
pnpm run --if-present test:e2e:workspace-session-golden
|
||||
pnpm run --if-present test:e2e:windows-fresh-startup-golden
|
||||
if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) {
|
||||
pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
|
||||
}
|
||||
pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
|
||||
@@ -74,14 +74,14 @@ jobs:
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# from @electron-toolkit/tsconfig) silently widens unresolved names
|
||||
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
|
||||
# past typecheck. See docs/preload-typecheck-hole.md.
|
||||
# past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts.
|
||||
- name: Guard against project-owned .d.ts in preload/shared
|
||||
run: |
|
||||
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
|
||||
if [ -n "$matches" ]; then
|
||||
echo "::error::Project-owned .d.ts files are not allowed under src/preload or src/shared."
|
||||
echo "Move type declarations into a .ts file so skipLibCheck does not hide errors."
|
||||
echo "See docs/preload-typecheck-hole.md."
|
||||
echo "See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts."
|
||||
echo "Found:"
|
||||
echo "$matches"
|
||||
exit 1
|
||||
@@ -383,7 +383,10 @@ jobs:
|
||||
- name: Package unpacked app
|
||||
env:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage --x64 --publish never
|
||||
|
||||
- name: Verify headless serve signal shutdown
|
||||
run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage
|
||||
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
|
||||
@@ -426,8 +429,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test Windows PowerShell CLI boundary
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
src/main/cli/wsl-cli-powershell-boundary.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
|
||||
|
||||
- name: Build package inputs
|
||||
run: pnpm run build:release
|
||||
|
||||
@@ -783,7 +783,8 @@ jobs:
|
||||
{
|
||||
echo "## Release E2E Signal"
|
||||
echo ""
|
||||
echo "- Terminal rendering golden is release-blocking."
|
||||
echo "- Platform golden E2E is release-blocking: terminal rendering, restrictive-umask profile writes, source control, and agent TUI launch on Linux/macOS, plus fresh startup and source control on Windows."
|
||||
echo "- Exception: every golden except terminal rendering runs with \`--if-present\`, so it is skipped (not failed) on older tags that predate its script."
|
||||
echo "- Full E2E runs separately after publication and cannot change the release result."
|
||||
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
|
||||
echo ""
|
||||
@@ -817,7 +818,7 @@ jobs:
|
||||
terminal-rendering-golden:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
name: terminal rendering golden ${{ matrix.platform }}
|
||||
name: golden e2e ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
@@ -830,10 +831,10 @@ jobs:
|
||||
platform: linux
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
# Why: Windows terminal rendering golden is temporarily disabled on
|
||||
# CI while its flaky runner-only failures are investigated.
|
||||
# - os: windows-latest
|
||||
# platform: windows
|
||||
# Windows terminal rendering remains flaky; keep its blocking signal
|
||||
# scoped to the fresh-profile startup regression from #14130.
|
||||
- os: windows-2022
|
||||
platform: windows
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -866,22 +867,58 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for terminal rendering golden
|
||||
- name: Build Electron app for platform golden
|
||||
run: npx electron-vite build --mode e2e
|
||||
|
||||
# Why: this job is defined on the dispatch ref (usually main) but checks
|
||||
# out the release tag. Cherry-pick / hotfix tags can predate a golden
|
||||
# script that main already calls; --if-present keeps those cuts green
|
||||
# instead of failing with ERR_PNPM_NO_SCRIPT.
|
||||
- name: Run terminal rendering golden on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
run: |
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run source-control golden on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run terminal rendering golden on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
run: |
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run agent TUI golden on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
|
||||
- name: Run agent TUI golden on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
|
||||
- name: Run source-control golden on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run fresh-startup golden on Windows
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:SKIP_BUILD = '1'
|
||||
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
||||
pnpm run --if-present test:e2e:workspace-session-golden
|
||||
pnpm run --if-present test:e2e:windows-fresh-startup-golden
|
||||
pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-rendering-golden-${{ matrix.platform }}-playwright-traces
|
||||
name: golden-e2e-${{ matrix.platform }}-playwright-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -104,6 +104,7 @@ docs/**
|
||||
!docs/reference/remote-wire-compatibility.md
|
||||
!docs/reference/renderer-agent-status-performance.md
|
||||
!docs/reference/windows-setup-shell.md
|
||||
!docs/reference/worktree-scan-fingerprint.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
@@ -126,6 +127,10 @@ playwright-report/
|
||||
/.agents/skills/
|
||||
/skills-lock.json
|
||||
|
||||
# Generated Clawpatch state includes machine-local paths and review records.
|
||||
/.clawpatch/
|
||||
/mobile/.clawpatch/
|
||||
|
||||
# Agent hook runtime endpoints (machine-local secrets)
|
||||
/agent-hooks/
|
||||
validation-screenshots/
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
FROM ubuntu@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
dbus-x11 \
|
||||
iproute2 \
|
||||
jq \
|
||||
libatk-bridge2.0-0 \
|
||||
libatspi2.0-0 \
|
||||
libasound2t64 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnss3 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
libxss1 \
|
||||
p7zip-full \
|
||||
procps \
|
||||
util-linux \
|
||||
xauth \
|
||||
xvfb \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash orca
|
||||
|
||||
COPY run-signal-case.sh /usr/local/bin/run-signal-case
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/run-signal-case"]
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
signal_name=${1:?signal name is required}
|
||||
app_root=${ORCA_TEST_APP_ROOT:-/artifacts/root}
|
||||
signal_target_kind=${ORCA_SIGNAL_TARGET:-app}
|
||||
entrypoint_kind=${ORCA_TEST_ENTRYPOINT:-app}
|
||||
int_delivery=${ORCA_INT_DELIVERY:-foreground-process-group}
|
||||
startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-90}
|
||||
|
||||
if ((EUID == 0)); then
|
||||
exec runuser --user orca --preserve-environment -- "$0" "$@"
|
||||
fi
|
||||
|
||||
case "$signal_name" in
|
||||
INT|TERM) ;;
|
||||
*) echo "unsupported signal: $signal_name" >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
state_dir=$(mktemp -d "/tmp/orca-shutdown-${signal_name}.XXXXXX")
|
||||
stdout_log="$state_dir/stdout.log"
|
||||
stderr_log="$state_dir/stderr.log"
|
||||
ulimit -c 0
|
||||
|
||||
sleep 300 &
|
||||
canary_pid=$!
|
||||
canary_start_ticks=$(awk '{print $22}' "/proc/$canary_pid/stat")
|
||||
cleanup() {
|
||||
kill "$canary_pid" 2>/dev/null || true
|
||||
wait "$canary_pid" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
export HOME="$state_dir/home"
|
||||
export XDG_CONFIG_HOME="$state_dir/config"
|
||||
export XDG_CACHE_HOME="$state_dir/cache"
|
||||
export XDG_RUNTIME_DIR="$state_dir/runtime"
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_RUNTIME_DIR"
|
||||
chmod 700 "$XDG_RUNTIME_DIR"
|
||||
|
||||
case "$entrypoint_kind" in
|
||||
app) entrypoint=("$app_root/AppRun" --no-sandbox) ;;
|
||||
launcher)
|
||||
export ELECTRON_DISABLE_SANDBOX=1
|
||||
entrypoint=("$app_root/resources/bin/orca-ide")
|
||||
;;
|
||||
*) echo "unsupported entrypoint: $entrypoint_kind" >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
setsid env -u DISPLAY "${entrypoint[@]}" serve --port 0 --pairing-address 127.0.0.1 --json \
|
||||
>"$stdout_log" 2>"$stderr_log" &
|
||||
app_pid=$!
|
||||
app_start_ticks=$(awk '{print $22}' "/proc/$app_pid/stat")
|
||||
|
||||
# The inner shell expands its positional parameters.
|
||||
# shellcheck disable=SC2016
|
||||
ready_line=$(timeout "$startup_timeout_seconds" bash -c '
|
||||
tail --pid="$1" -n +1 -F "$2" 2>/dev/null \
|
||||
| jq --unbuffered -nc '\''first(inputs | select(.type == "orca_server_ready" and .schemaVersion == 1))'\''
|
||||
' bash "$app_pid" "$stdout_log" || true)
|
||||
if [[ -z "$ready_line" ]]; then
|
||||
cat "$stdout_log" "$stderr_log" >&2
|
||||
echo "FAIL: AppRun exited or timed out before orca_server_ready" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bound_endpoint=$(jq -r '.boundEndpoint' <<<"$ready_line")
|
||||
bound_port=${bound_endpoint##*:}
|
||||
listener_before=$(ss -H -ltnp "sport = :$bound_port" || true)
|
||||
if [[ -z "$listener_before" ]]; then
|
||||
echo "FAIL: ready listener has no socket owner at $bound_endpoint" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tree_pids=()
|
||||
declare -A tree_start_ticks
|
||||
tree_start_ticks["$app_pid"]=$app_start_ticks
|
||||
frontier=("$app_pid")
|
||||
while ((${#frontier[@]})); do
|
||||
parent=${frontier[0]}
|
||||
frontier=("${frontier[@]:1}")
|
||||
while read -r child; do
|
||||
[[ -n "$child" ]] || continue
|
||||
child_start_ticks=$(awk '{print $22}' "/proc/$child/stat" 2>/dev/null || true)
|
||||
[[ -n "$child_start_ticks" ]] || continue
|
||||
tree_pids+=("$child")
|
||||
tree_start_ticks["$child"]=$child_start_ticks
|
||||
frontier+=("$child")
|
||||
done < <(ps -o pid= --ppid "$parent" | tr -d ' ')
|
||||
done
|
||||
|
||||
tree_pid_csv="$app_pid"
|
||||
for pid in "${tree_pids[@]}"; do
|
||||
tree_pid_csv+=",$pid"
|
||||
done
|
||||
tree_snapshot=$(ps -o pid=,ppid=,pgid=,lstart=,stat=,args= -p "$tree_pid_csv" 2>/dev/null || true)
|
||||
xvfb_pids=$(awk '/[X]vfb :99 / {print $1}' <<<"$tree_snapshot" | paste -sd, -)
|
||||
if [[ -z "$xvfb_pids" ]]; then
|
||||
echo "$tree_snapshot" >&2
|
||||
echo "FAIL: no run-owned Xvfb :99 process found after readiness" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
signal_target_pid=$app_pid
|
||||
if [[ "$signal_target_kind" == serving-electron ]]; then
|
||||
signal_target_pid=$(awk '/\/orca-ide .* --serve / {print $1; exit}' <<<"$tree_snapshot")
|
||||
[[ -n "$signal_target_pid" ]] || { echo "FAIL: serving Electron process not found" >&2; exit 1; }
|
||||
elif [[ "$signal_target_kind" != app ]]; then
|
||||
echo "unsupported signal target: $signal_target_kind" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
signal_target_start_ticks=${tree_start_ticks[$signal_target_pid]:-}
|
||||
if [[ -z "$signal_target_start_ticks" ]] \
|
||||
|| [[ $(awk '{print $22}' "/proc/$signal_target_pid/stat") != "$signal_target_start_ticks" ]]; then
|
||||
echo "FAIL: signal target identity changed before delivery" >&2
|
||||
exit 1
|
||||
fi
|
||||
signal_delivery=pid
|
||||
if [[ "$signal_name" == INT && "$int_delivery" == foreground-process-group ]]; then
|
||||
signal_delivery=$int_delivery
|
||||
kill -s "$signal_name" -- "-$signal_target_pid"
|
||||
else
|
||||
kill -s "$signal_name" "$signal_target_pid"
|
||||
fi
|
||||
|
||||
sleep 30 &
|
||||
watchdog_pid=$!
|
||||
set +e
|
||||
wait -n -p completed_pid "$app_pid" "$watchdog_pid"
|
||||
wait_status=$?
|
||||
set -e
|
||||
if [[ "$completed_pid" == "$watchdog_pid" ]]; then
|
||||
echo "FAIL: foreground AppRun did not exit after $signal_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
kill "$watchdog_pid" 2>/dev/null || true
|
||||
wait "$watchdog_pid" 2>/dev/null || true
|
||||
|
||||
listener_after=$(ss -H -ltnp "sport = :$bound_port" || true)
|
||||
survivors=()
|
||||
for pid in "${tree_pids[@]}"; do
|
||||
if [[ -r "/proc/$pid/stat" ]] \
|
||||
&& [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \
|
||||
&& ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then
|
||||
survivors+=("$pid")
|
||||
fi
|
||||
done
|
||||
owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \
|
||||
'($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true)
|
||||
|
||||
canary_alive=false
|
||||
if kill -0 "$canary_pid" 2>/dev/null \
|
||||
&& [[ $(awk '{print $22}' "/proc/$canary_pid/stat") == "$canary_start_ticks" ]]; then
|
||||
canary_alive=true
|
||||
fi
|
||||
fatal_evidence=false
|
||||
if grep -Eq 'Failed to shutdown|SIGTRAP|Trace/breakpoint trap|core dumped' \
|
||||
"$stdout_log" "$stderr_log"; then
|
||||
fatal_evidence=true
|
||||
fi
|
||||
|
||||
jq -nc \
|
||||
--arg signal "$signal_name" \
|
||||
--arg signalDelivery "$signal_delivery" \
|
||||
--arg entrypointKind "$entrypoint_kind" \
|
||||
--arg signalTargetKind "$signal_target_kind" \
|
||||
--argjson appPid "$app_pid" \
|
||||
--argjson signalTargetPid "$signal_target_pid" \
|
||||
--arg endpoint "$bound_endpoint" \
|
||||
--arg listenerBefore "$listener_before" \
|
||||
--arg listenerAfter "$listener_after" \
|
||||
--arg xvfbPids "$xvfb_pids" \
|
||||
--arg treeBefore "$tree_snapshot" \
|
||||
--argjson waitStatus "$wait_status" \
|
||||
--argjson fatalEvidence "$fatal_evidence" \
|
||||
--argjson canaryAlive "$canary_alive" \
|
||||
--arg survivors "${survivors[*]:-}" \
|
||||
--arg residue "$owned_residue" \
|
||||
--arg corePattern "$(cat /proc/sys/kernel/core_pattern)" \
|
||||
'{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}'
|
||||
|
||||
if ((wait_status != 0)) || [[ -n "$listener_after" ]] || [[ "$fatal_evidence" != false ]] \
|
||||
|| [[ "$canary_alive" != true ]] || ((${#survivors[@]})) || [[ -n "$owned_residue" ]]; then
|
||||
echo "--- stdout ---" >&2
|
||||
cat "$stdout_log" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$stderr_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -10,7 +10,6 @@ inline src/main/agent-hooks/server.test.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/amp/hook-service.ts
|
||||
inline src/main/antigravity/hook-service.ts
|
||||
inline src/main/attribution/terminal-attribution.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
inline src/main/browser/agent-browser-bridge.test.ts
|
||||
@@ -168,7 +167,6 @@ inline src/main/window/attach-main-window-services.ts
|
||||
inline src/main/window/createMainWindow.test.ts
|
||||
inline src/main/window/createMainWindow.ts
|
||||
inline src/main/workspace-space-analysis.ts
|
||||
inline src/preload/api-types.ts
|
||||
inline src/preload/index.ts
|
||||
inline src/relay/agent-hook-server.ts
|
||||
inline src/relay/dispatcher.ts
|
||||
@@ -236,7 +234,6 @@ inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.tsx
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
|
||||
@@ -261,7 +258,6 @@ inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
|
||||
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
|
||||
inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
@@ -319,7 +315,7 @@ inline src/shared/agent-hook-listener.ts
|
||||
inline src/shared/automation-schedules.ts
|
||||
inline src/shared/commit-message-agent-spec.ts
|
||||
inline src/shared/constants.ts
|
||||
inline src/shared/github-project-types.ts
|
||||
inline src/shared/github/project-types.ts
|
||||
inline src/shared/keybindings.test.ts
|
||||
inline src/shared/keybindings.ts
|
||||
inline src/shared/marine-creatures.ts
|
||||
@@ -328,7 +324,6 @@ inline src/shared/runtime-types.ts
|
||||
inline src/shared/source-control-ai.ts
|
||||
inline src/shared/telemetry-events.ts
|
||||
inline src/shared/text-search.ts
|
||||
inline src/shared/types.ts
|
||||
inline tests/e2e/helpers/terminal.ts
|
||||
inline tests/e2e/terminal-panes.spec.ts
|
||||
mobile-config app/h/*/files/*.tsx
|
||||
|
||||
+1107
-53
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ const BUDGETS = {
|
||||
maxTimerDriftMs: 150,
|
||||
// Why: mirrors MAX_TIMER_DRIFT_UNDER_LOAD_MS in artificial-opencode-terminal-load.spec.ts
|
||||
// so injected multi-pane redraw rows are not judged against the unloaded ceiling.
|
||||
maxTimerDriftUnderLoadMs: 2_500,
|
||||
maxTimerDriftUnderLoadMs: 3_500,
|
||||
maxScrollLatencyMs: 150,
|
||||
maxRestoreLatencyMs: 1000,
|
||||
maxRendererQueuedChars: 2 * 1024 * 1024,
|
||||
|
||||
@@ -132,14 +132,14 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
|
||||
it('fails multi-pane redraw scenarios that exceed the under-load timer-drift budget', () => {
|
||||
const failPath = writeReport(
|
||||
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=2501.0ms'].join(
|
||||
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=3501.0ms'].join(
|
||||
' '
|
||||
),
|
||||
'opencode-cross-workspace-typing'
|
||||
)
|
||||
const failResult = runChecker(failPath)
|
||||
expect(failResult.status).toBe(1)
|
||||
expect(failResult.stderr).toContain('timer drift 2501ms exceeded budget 2500ms')
|
||||
expect(failResult.stderr).toContain('timer drift 3501ms exceeded budget 3500ms')
|
||||
})
|
||||
|
||||
it('fails malformed metric values instead of treating them as absent', () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
|
||||
import {
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
|
||||
const projectDir = path.resolve(import.meta.dirname, '..', '..')
|
||||
const temporaryDirectories = []
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
async function createFixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
|
||||
@@ -92,6 +95,56 @@ describe('bundled skill guide generator', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the exported recipe id variable in per-workspace environment examples', async () => {
|
||||
const source = await readFile(
|
||||
path.join(projectDir, 'skill-guides', 'orca-per-workspace-env.md'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(source).toContain('ORCA_RECIPE_ID')
|
||||
expect(source).not.toContain('ORCA_VM_RECIPE_ID')
|
||||
expect(source).toContain('recipe_id="${recipe_id//./-}"')
|
||||
expect(source).toContain('max_recipe_id_length=$((128 - ${#instance_id} - 6))')
|
||||
expect(source).toContain('name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'keeps Vercel sandbox names valid while preserving the instance suffix',
|
||||
async () => {
|
||||
const source = await readFile(
|
||||
path.join(projectDir, 'skill-guides', 'orca-per-workspace-env.md'),
|
||||
'utf8'
|
||||
)
|
||||
const startMarker = 'recipe_id="${ORCA_RECIPE_ID:-vercel-sandbox}"'
|
||||
const endMarker = 'name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"'
|
||||
const start = source.indexOf(startMarker)
|
||||
const endStart = source.indexOf(endMarker, start)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(endStart).toBeGreaterThan(start)
|
||||
const script = `${source.slice(start, endStart + endMarker.length)}\nprintf '%s' "$name"`
|
||||
const renderName = async (recipeId, instanceId) =>
|
||||
(
|
||||
await execFileAsync('bash', ['-u', '-c', script], {
|
||||
env: { ...process.env, ORCA_RECIPE_ID: recipeId, ORCA_VM_INSTANCE_ID: instanceId }
|
||||
})
|
||||
).stdout
|
||||
|
||||
const instanceId = 'orca-123e4567-e89b-12d3-a456-426614174000'
|
||||
const dotted = await renderName('provider.cloud_sandbox', instanceId)
|
||||
const maximum = await renderName(`a${'.'.repeat(63)}`, instanceId)
|
||||
const longInstanceId = 'i'.repeat(100)
|
||||
const capped = await renderName(
|
||||
'provider.cloud_sandbox.with.a.long.recipe.identifier',
|
||||
longInstanceId
|
||||
)
|
||||
|
||||
expect(dotted).toBe(`orca-provider-cloud_sandbox-${instanceId}`)
|
||||
expect(maximum).toMatch(/^[a-zA-Z0-9_-]{1,128}$/u)
|
||||
expect(capped).toHaveLength(128)
|
||||
expect(capped.endsWith(`-${longInstanceId}`)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
|
||||
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
|
||||
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const headlessLinuxGuide = readFileSync('docs/reference/headless-linux-server.md', 'utf8')
|
||||
|
||||
function readSystemdUnitBlocks(doc, unitName) {
|
||||
const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return [...doc.matchAll(new RegExp(`^# /etc/systemd/system/${escapedUnitName}$`, 'gm'))].map(
|
||||
(match) => {
|
||||
const start = match.index + match[0].length
|
||||
const end = doc.indexOf('```', start)
|
||||
const nextUnitHeaderOffset = doc.slice(start).search(/^# \/etc\/systemd\/system\/.+$/m)
|
||||
const nextUnitHeader = nextUnitHeaderOffset === -1 ? -1 : start + nextUnitHeaderOffset
|
||||
if (end === -1 || (nextUnitHeader !== -1 && end > nextUnitHeader)) {
|
||||
throw new Error(`Missing closing code fence for ${unitName}`)
|
||||
}
|
||||
return doc.slice(start, end)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('headless serve shutdown PR gate', () => {
|
||||
it('reads only exact, closed systemd unit blocks', () => {
|
||||
expect(
|
||||
readSystemdUnitBlocks('# /etc/systemd/system/orca-serveXservice\n```', 'orca-serve.service')
|
||||
).toEqual([])
|
||||
expect(() =>
|
||||
readSystemdUnitBlocks('# /etc/systemd/system/orca-serve.service\n', 'orca-serve.service')
|
||||
).toThrow('Missing closing code fence for orca-serve.service')
|
||||
expect(() =>
|
||||
readSystemdUnitBlocks(
|
||||
'# /etc/systemd/system/orca-serve.service\n' +
|
||||
'KillMode=mixed\n' +
|
||||
'# /etc/systemd/system/other.service\n```',
|
||||
'orca-serve.service'
|
||||
)
|
||||
).toThrow('Missing closing code fence for orca-serve.service')
|
||||
})
|
||||
|
||||
it('packages an x64 AppImage before running the Docker signal oracle', () => {
|
||||
const steps = workflow.jobs.package.steps
|
||||
const packageStep = steps.find((step) => step.name === 'Package unpacked app')
|
||||
const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown')
|
||||
|
||||
expect(packageStep.run).toContain('--linux AppImage --x64 --publish never')
|
||||
expect(shutdownStep.run).toBe(
|
||||
'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage'
|
||||
)
|
||||
expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep))
|
||||
})
|
||||
|
||||
it('keeps owned Xvfb alive during the documented systemd graceful stop', () => {
|
||||
const serveUnits = readSystemdUnitBlocks(headlessLinuxGuide, 'orca-serve.service')
|
||||
const ownedXvfbUnits = serveUnits.filter((unit) => !/^Environment=DISPLAY=/m.test(unit))
|
||||
const managedXvfbUnits = serveUnits.filter((unit) => /^Environment=DISPLAY=/m.test(unit))
|
||||
|
||||
expect(ownedXvfbUnits).toHaveLength(1)
|
||||
expect(ownedXvfbUnits[0]).toMatch(/^ExecStart=.*orca-linux\.AppImage serve.*$/m)
|
||||
expect(ownedXvfbUnits[0]).toMatch(/^KillMode=mixed$/m)
|
||||
expect(managedXvfbUnits).toHaveLength(1)
|
||||
expect(managedXvfbUnits[0]).not.toMatch(/^KillMode=/m)
|
||||
})
|
||||
})
|
||||
@@ -115,10 +115,6 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
|
||||
zh: '评审中',
|
||||
ja: 'レビュー中'
|
||||
},
|
||||
'auto.components.skills.SkillsPage.b088e0785d': {
|
||||
zh: '测试版',
|
||||
ja: 'ベータ'
|
||||
},
|
||||
'auto.components.status.bar.WorkspaceSpaceCompactPanel.c361440dc0': {
|
||||
zh: '测试版',
|
||||
ja: 'ベータ'
|
||||
|
||||
@@ -312,11 +312,6 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
zh: '显示中',
|
||||
ja: '表示中'
|
||||
},
|
||||
'auto.components.skills.SkillsPage.a68dee6a32': {
|
||||
ko: '스킬 검색',
|
||||
zh: '搜索技能',
|
||||
ja: 'スキルを検索'
|
||||
},
|
||||
'auto.components.editor.RichMarkdownSlashMenu.550189b06c': {
|
||||
ko: '블록 검색',
|
||||
zh: '搜索块',
|
||||
@@ -435,11 +430,6 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
zh: '代理位置',
|
||||
ja: 'Agent の場所'
|
||||
},
|
||||
'auto.components.skills.SkillsPage.38e0951c3a': {
|
||||
ko: '에이전트 스킬',
|
||||
zh: '代理技能',
|
||||
ja: 'Agent のスキル'
|
||||
},
|
||||
'auto.components.sidebar.SidebarNav.e518f544b1': {
|
||||
ko: '감지된 에이전트 없음',
|
||||
zh: '未检测到代理',
|
||||
|
||||
@@ -2273,12 +2273,6 @@
|
||||
"auto.components.settings.GitPane.976afc6b3e": {
|
||||
"ko": "워크스페이스를 만들면 Orca가 원격 기준 브랜치를 새로고침하고, 일치하는 로컬 브랜치를 안전하게 fast-forward합니다. 예:"
|
||||
},
|
||||
"auto.components.settings.GitPane.b5f534717a": {
|
||||
"ko": "공동 작성"
|
||||
},
|
||||
"auto.components.settings.GitPane.b9b5771bb1": {
|
||||
"ko": "표기"
|
||||
},
|
||||
"auto.components.settings.GitPane.b9c011fbc2": {
|
||||
"ko": "속도 제한"
|
||||
},
|
||||
@@ -2288,9 +2282,6 @@
|
||||
"auto.components.settings.GitPane.db3a127eb1": {
|
||||
"ko": "이렇게 하면 다음과 같은 명령이 유지됩니다"
|
||||
},
|
||||
"auto.components.settings.GitPane.e02ea23a32": {
|
||||
"ko": "Orca 표기"
|
||||
},
|
||||
"auto.components.settings.GitPane.f35007e6e8": {
|
||||
"ko": "git-username"
|
||||
},
|
||||
@@ -4454,9 +4445,6 @@
|
||||
"auto.components.sidebar.worktree.list.groups.6798dc7c94": {
|
||||
"ko": "리뷰 중"
|
||||
},
|
||||
"auto.components.skills.SkillsPage.b088e0785d": {
|
||||
"ko": "베타"
|
||||
},
|
||||
"auto.components.source.control.SourceControlActionVariableChips.4bf6d88039": {
|
||||
"ko": "(비어 있음)"
|
||||
},
|
||||
|
||||
@@ -44,7 +44,6 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'Issue #{{value0}}': '이슈 #{{value0}}',
|
||||
'Linked issue #{{value0}}': '연결된 이슈 #{{value0}}',
|
||||
'Issue # or GitHub URL': '이슈 # 또는 GitHub URL',
|
||||
'Add Orca attribution to commits, PRs, and issues.': '커밋, PR 및 이슈에 Orca 속성을 추가합니다.',
|
||||
'Paste an issue URL, or enter a number. Leave blank to remove the link.':
|
||||
'이슈 URL을 붙여넣거나 숫자를 입력하세요. 링크를 제거하려면 비워두세요.',
|
||||
'Connect Linear in Settings to search issues.': '이슈를 검색하려면 설정에서 Linear를 연결하세요.',
|
||||
@@ -157,8 +156,7 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'저장되지 않은 Git AI Author 변경사항이 있습니다. 떠나면 폐기됩니다.',
|
||||
'Discard unsaved Git AI Author changes?':
|
||||
'저장되지 않은 Git AI Author 변경사항을 삭제하시겠습니까?',
|
||||
'Branch naming, base refs, attribution, and Git AI Author.':
|
||||
'브랜치 이름 지정, 기본 참조, 속성 및 Git AI Author.',
|
||||
'Branch naming, base refs, and Git AI Author.': '브랜치 이름 지정, 기본 참조 및 Git AI Author.',
|
||||
'Used by repositories that inherit global hosted-review defaults.':
|
||||
'전역 호스팅 PR 기본값을 상속하는 저장소에서 사용됩니다.',
|
||||
'Defaults used when the hosted-review composer opens.':
|
||||
@@ -175,7 +173,7 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'아래 워크스페이스 오류를 확인한 후 새로고침하세요.',
|
||||
'Review inactive workspaces before deleting their local files and Orca state.':
|
||||
'로컬 파일과 Orca 상태를 삭제하기 전에 비활성 워크스페이스를 확인하세요.',
|
||||
'Review inactive workspaces (': '비활성 워크스페이스 확인(',
|
||||
'Clean up workspaces': '워크스페이스 정리',
|
||||
'Review the settings that will be imported from your Ghostty config.':
|
||||
'Ghostty 구성에서 가져올 설정을 확인하세요.',
|
||||
'GitHub requires review approval before this pull request can merge':
|
||||
|
||||
@@ -164,7 +164,6 @@ export const SEARCH_KEYWORD_OVERRIDES = {
|
||||
gitlab: 'GitLab',
|
||||
gitea: 'Gitea',
|
||||
oauth: 'OAuth',
|
||||
attribution: '归因',
|
||||
skill: '技能',
|
||||
skills: '技能',
|
||||
phone: '手机',
|
||||
|
||||
@@ -25,10 +25,10 @@ describe('locale-translation-policy es PR glossary', () => {
|
||||
it('rewrites PR inside longer sentences', () => {
|
||||
expect(
|
||||
repairEs(
|
||||
'Add Orca attribution to commits, PRs, and issues.',
|
||||
'Agregue la atribución de Orca a commits, relaciones públicas y problemas.'
|
||||
'Split a large change into smaller PRs',
|
||||
'Dividir un cambio grande en relaciones públicas más pequeños'
|
||||
)
|
||||
).toBe('Agregue la atribución de Orca a commits, PR y problemas.')
|
||||
).toBe('Dividir un cambio grande en PR más pequeños')
|
||||
expect(
|
||||
repairEs(
|
||||
'Open the PR details to view current reviewers.',
|
||||
|
||||
@@ -44,22 +44,14 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('显示 Orca Mobile 按钮')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.GitPane.e02ea23a32',
|
||||
enValue: 'Orca Attribution',
|
||||
localeValue: 'Orca归属',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Orca 归因')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.hooks.useSettingsNavigationMetadata.ab4b21b58e',
|
||||
enValue: 'Branch naming, base refs, attribution, and Git AI Author.',
|
||||
localeValue: '分支命名、基本引用、归属和 Git AI 作者。',
|
||||
enValue: 'Branch naming, base refs, and Git AI Author.',
|
||||
localeValue: '分支命名、基本引用和 Git AI 作者。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('分支命名、基础引用、归因和 Git AI Author。')
|
||||
).toBe('分支命名、基础引用和 Git AI Author。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569',
|
||||
|
||||
@@ -3,7 +3,6 @@ export const ZH_PHRASE_FIXES_ROUND5 = [
|
||||
{ pattern: /Orca集成开发环境/g, replacement: 'Orca IDE', whenEnIncludes: 'Orca IDE' },
|
||||
{ pattern: /Orca第一/g, replacement: 'Orca 优先', whenEnIncludes: 'Orca first' },
|
||||
{ pattern: /Orca移动/g, replacement: 'Orca Mobile', whenEnIncludes: 'Orca Mobile' },
|
||||
{ pattern: /Orca归属/g, replacement: 'Orca 归因', whenEnIncludes: 'Orca Attribution' },
|
||||
{ pattern: /Orca标志/g, replacement: 'Orca 标志', whenEnIncludes: 'Orca logo' },
|
||||
{ pattern: /喜欢Orca/g, replacement: '喜欢 Orca', whenEnIncludes: 'Enjoying Orca' },
|
||||
{ pattern: /认识Orca/g, replacement: '了解 Orca', whenEnIncludes: 'Get to know Orca' },
|
||||
|
||||
@@ -144,7 +144,6 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'Expand Orca': '展开 Orca',
|
||||
'Support Orca': '支持 Orca',
|
||||
'Restarting Orca…': '正在重启 Orca…',
|
||||
'Orca Attribution': 'Orca 归因',
|
||||
'Show Orca Mobile Button': '显示 Orca Mobile 按钮',
|
||||
'Codex Accounts': 'Codex 账户',
|
||||
'Codex Account': 'Codex 账户',
|
||||
@@ -186,8 +185,7 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
"Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see.":
|
||||
'让代理直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。',
|
||||
'X finishes, send it the review task.”': 'X 完成后,把评审任务发给它。”',
|
||||
'Branch naming, base refs, attribution, and Git AI Author.':
|
||||
'分支命名、基础引用、归因和 Git AI Author。',
|
||||
'Branch naming, base refs, and Git AI Author.': '分支命名、基础引用和 Git AI Author。',
|
||||
'You have unsaved Git AI Author changes. Leaving will discard them.':
|
||||
'您有未保存的 Git AI Author 更改。离开将丢弃它们。',
|
||||
'Discard unsaved Git AI Author changes?': '放弃未保存的 Git AI Author 更改?',
|
||||
|
||||
@@ -221,16 +221,20 @@ describe('orchestration skill guidance', () => {
|
||||
expect(skill).toContain('the named owner edits files and creates the PR')
|
||||
})
|
||||
|
||||
it('keeps worker_done post-completion guidance idle instead of polling', () => {
|
||||
it('keeps post-completion workers idle without subordinating the user', () => {
|
||||
const skill = readSkill()
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
|
||||
expect(agentGuidance).toContain('After sending `worker_done`, end your turn')
|
||||
expect(agentGuidance).toContain('After sending `worker_done`, end that dispatched turn')
|
||||
expect(agentGuidance).toContain('idle at the agent prompt')
|
||||
expect(agentGuidance).toContain('Do not autonomously start more work, poll')
|
||||
expect(agentGuidance).toContain('A direct user instruction takes precedence')
|
||||
expect(agentGuidance).toContain('follow it without coordinator approval or a fresh Dispatch')
|
||||
expect(agentGuidance).toContain('never refuse it because of worker/coordinator roles')
|
||||
expect(agentGuidance).toContain("do not reuse the settled Dispatch's lifecycle IDs")
|
||||
expect(agentGuidance).toContain(
|
||||
'do not start more work, poll, or attempt to close the terminal yourself'
|
||||
'A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block'
|
||||
)
|
||||
expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input')
|
||||
expect(skill).not.toContain('post-completion polling messages')
|
||||
expect(skill).not.toContain('every 2 minutes')
|
||||
})
|
||||
@@ -305,7 +309,7 @@ describe('orchestration skill guidance', () => {
|
||||
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
|
||||
)
|
||||
expect(agentGuidance).toContain(
|
||||
'do not start more work, poll, or attempt to close the terminal yourself'
|
||||
'Do not autonomously start more work, poll, or attempt to close the terminal yourself'
|
||||
)
|
||||
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
|
||||
})
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('Electron runtime package contract', () => {
|
||||
'build:linux',
|
||||
'test:e2e',
|
||||
'test:e2e:terminal-rendering-golden',
|
||||
'test:e2e:posix-profile-index-golden',
|
||||
'test:e2e:terminal-rendering-release-evidence',
|
||||
'test:e2e:headful'
|
||||
]
|
||||
@@ -492,7 +493,7 @@ describe('Electron runtime package contract', () => {
|
||||
expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}')
|
||||
})
|
||||
|
||||
it('keeps terminal rendering regressions in the manual golden E2E workflow', () => {
|
||||
it('keeps platform golden regressions in the manual and release workflows', () => {
|
||||
const packageScripts = packageJson.scripts
|
||||
const goldenWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8')
|
||||
@@ -506,17 +507,19 @@ describe('Electron runtime package contract', () => {
|
||||
['mac', 'macOS'],
|
||||
['windows', 'Windows']
|
||||
])
|
||||
const goldenPlatforms = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include
|
||||
.map(({ platform }) => platform)
|
||||
.sort()
|
||||
const goldenRunSteps = goldenPlatforms.map((platform) => {
|
||||
const label = goldenPlatformLabels.get(platform)
|
||||
const goldenMatrix = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include
|
||||
const goldenPlatforms = goldenMatrix.map(({ platform }) => platform).sort()
|
||||
const goldenRunSteps = new Map(
|
||||
goldenPlatforms.map((platform) => {
|
||||
const label = goldenPlatformLabels.get(platform)
|
||||
|
||||
expect(label, platform).toBeDefined()
|
||||
expect(label, platform).toBeDefined()
|
||||
|
||||
return steps.find((step) => step.name === `Run golden E2E tests on ${label}`)
|
||||
})
|
||||
return [platform, steps.find((step) => step.name === `Run golden E2E tests on ${label}`)]
|
||||
})
|
||||
)
|
||||
const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden']
|
||||
const releaseGoldenMatrix = releaseGoldenJob.strategy.matrix.include
|
||||
const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence']
|
||||
const releaseBuildNeeds = releaseWorkflow.jobs.build.needs
|
||||
const publishReleaseNeeds = releaseWorkflow.jobs['publish-release'].needs
|
||||
@@ -535,15 +538,49 @@ describe('Electron runtime package contract', () => {
|
||||
expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain(
|
||||
'terminal-long-table-scroll-restore.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain(
|
||||
'golden-windows-fresh-startup.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain(
|
||||
'@windows-fresh-startup-golden'
|
||||
)
|
||||
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
|
||||
'golden-posix-profile-index-fsync.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
|
||||
'golden-posix-fresh-startup.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
|
||||
'@posix-profile-index-golden'
|
||||
)
|
||||
expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain(
|
||||
'terminal-opencode-emoji-table-rendering.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain(
|
||||
'terminal-long-table-scroll-restore.spec.ts'
|
||||
)
|
||||
for (const runStep of goldenRunSteps) {
|
||||
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
}
|
||||
expect(goldenMatrix).toEqual([
|
||||
{ os: 'ubuntu-latest', platform: 'linux' },
|
||||
{ os: 'macos-15', platform: 'mac' },
|
||||
{ os: 'windows-2022', platform: 'windows' }
|
||||
])
|
||||
expect(goldenRunSteps.get('linux')?.run).toContain(
|
||||
'pnpm run test:e2e:terminal-rendering-golden'
|
||||
)
|
||||
expect(goldenRunSteps.get('linux')?.run).toContain(
|
||||
'pnpm run --if-present test:e2e:posix-profile-index-golden'
|
||||
)
|
||||
expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
expect(goldenRunSteps.get('mac')?.run).toContain(
|
||||
'pnpm run --if-present test:e2e:posix-profile-index-golden'
|
||||
)
|
||||
expect(goldenRunSteps.get('windows')).toMatchObject({
|
||||
if: "runner.os == 'Windows'",
|
||||
shell: 'pwsh'
|
||||
})
|
||||
expect(goldenRunSteps.get('windows').run).toContain(
|
||||
'pnpm run --if-present test:e2e:windows-fresh-startup-golden'
|
||||
)
|
||||
expect(goldenWorkflow.on.pull_request).toBeUndefined()
|
||||
expect(goldenWorkflow.on.workflow_dispatch).toBeDefined()
|
||||
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
|
||||
@@ -552,11 +589,30 @@ describe('Electron runtime package contract', () => {
|
||||
expect(publishReleaseNeeds).toContain('build')
|
||||
expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence')
|
||||
expect(releaseGoldenJob['continue-on-error']).toBeUndefined()
|
||||
expect(releaseGoldenJob.strategy.matrix.include.map(({ platform }) => platform).sort()).toEqual(
|
||||
goldenPlatforms
|
||||
expect(releaseGoldenMatrix).toEqual(goldenMatrix)
|
||||
const releaseLinuxRunStep = releaseGoldenJob.steps.find(
|
||||
(step) => step.name === 'Run terminal rendering golden on Linux'
|
||||
)
|
||||
expect(releaseGoldenJob.steps.map((step) => step.run ?? '')).toContain(
|
||||
'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden'
|
||||
expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
expect(releaseLinuxRunStep.run).toContain(
|
||||
'pnpm run --if-present test:e2e:posix-profile-index-golden'
|
||||
)
|
||||
const releaseMacRunStep = releaseGoldenJob.steps.find(
|
||||
(step) => step.name === 'Run terminal rendering golden on macOS'
|
||||
)
|
||||
expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
expect(releaseMacRunStep.run).toContain(
|
||||
'pnpm run --if-present test:e2e:posix-profile-index-golden'
|
||||
)
|
||||
const releaseWindowsRunStep = releaseGoldenJob.steps.find(
|
||||
(step) => step.name === 'Run fresh-startup golden on Windows'
|
||||
)
|
||||
expect(releaseWindowsRunStep).toMatchObject({
|
||||
if: "runner.os == 'Windows'",
|
||||
shell: 'pwsh'
|
||||
})
|
||||
expect(releaseWindowsRunStep.run).toContain(
|
||||
'pnpm run --if-present test:e2e:windows-fresh-startup-golden'
|
||||
)
|
||||
expect(releaseEvidenceJob['continue-on-error']).toBe(true)
|
||||
expect(
|
||||
|
||||
@@ -6,6 +6,10 @@ import { parse } from 'yaml'
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
const sshDockerRunner = readFileSync(
|
||||
join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
|
||||
(step) => step.name === 'Filter changed E2E specs'
|
||||
@@ -64,12 +68,38 @@ describe('PR E2E gate contract', () => {
|
||||
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
|
||||
expect(e2eWorkflow.jobs['changed-e2e'].if).toBe("inputs.test_files != ''")
|
||||
expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined()
|
||||
expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toBe("inputs.test_files == ''")
|
||||
const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find(
|
||||
(step) => step.name === 'Run changed E2E specs'
|
||||
)
|
||||
expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}')
|
||||
expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1')
|
||||
expect(changedRun.run).toContain('. != "tests/e2e/ssh-startup-exec-readiness.spec.ts"')
|
||||
expect(changedRun.run).toContain('. != "tests/e2e/paired-startup-exec-readiness.spec.ts"')
|
||||
expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]')
|
||||
expect(changedRun.run).toContain('grep -l \'@headful\' "${TEST_FILES[@]}"')
|
||||
expect(changedRun.run).toContain('E2E_PROJECT_ARGS+=(--project=electron-headful)')
|
||||
expect(changedRun.run).toContain(
|
||||
'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps startup-exec live parity in the isolated SSH lane', () => {
|
||||
const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
|
||||
expect(sshLaneCondition).toContain("inputs.test_files == ''")
|
||||
expect(sshLaneCondition).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts')
|
||||
expect(sshLaneCondition).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts')
|
||||
expect(sshDockerRunner).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts')
|
||||
expect(sshDockerRunner).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts')
|
||||
expect(sshDockerRunner).toContain("'electron-headless'")
|
||||
expect(sshDockerRunner).toContain("'electron-headful'")
|
||||
})
|
||||
|
||||
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) =>
|
||||
step.name.startsWith('Install native build')
|
||||
)
|
||||
expect(installStep.run, jobName).toMatch(/\bzsh\b/)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps dedicated E2E workflows out of pull request CI', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveEnvironment,
|
||||
resolveEnvironmentPairingOffer
|
||||
} from '../../src/shared/runtime-environment-store'
|
||||
import type { MemorySnapshot } from '../../src/shared/types'
|
||||
import type { MemorySnapshot } from '../../src/shared/process-stats-types'
|
||||
import type { RuntimeStatus } from '../../src/shared/runtime-types'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const appImageArg = valueAfter('--appimage')
|
||||
const platform = valueAfter('--platform') ?? 'linux/amd64'
|
||||
const signalTarget = valueAfter('--signal-target') ?? 'app'
|
||||
const entrypoint = valueAfter('--entrypoint') ?? 'app'
|
||||
const intDelivery = valueAfter('--int-delivery') ?? 'foreground-process-group'
|
||||
const launcherExecOverlay = args.includes('--launcher-exec-overlay')
|
||||
if (!appImageArg) {
|
||||
fail('Usage: run-headless-serve-shutdown-docker.mjs --appimage /path/to/orca.AppImage')
|
||||
}
|
||||
if (!['app', 'serving-electron'].includes(signalTarget)) {
|
||||
fail(`Unsupported --signal-target: ${signalTarget}`)
|
||||
}
|
||||
if (!['app', 'launcher'].includes(entrypoint)) {
|
||||
fail(`Unsupported --entrypoint: ${entrypoint}`)
|
||||
}
|
||||
if (!['pid', 'foreground-process-group'].includes(intDelivery)) {
|
||||
fail(`Unsupported --int-delivery: ${intDelivery}`)
|
||||
}
|
||||
if (intDelivery === 'foreground-process-group' && signalTarget !== 'app') {
|
||||
fail('--int-delivery foreground-process-group requires --signal-target app')
|
||||
}
|
||||
if (launcherExecOverlay && entrypoint !== 'launcher') {
|
||||
fail('--launcher-exec-overlay requires --entrypoint launcher')
|
||||
}
|
||||
|
||||
const appImage = resolve(appImageArg)
|
||||
const shutdownDockerDirectory = resolve('config', 'docker', 'headless-serve-shutdown')
|
||||
const shutdownDockerfile = resolve(shutdownDockerDirectory, 'Dockerfile')
|
||||
if (!existsSync(appImage)) {
|
||||
fail(`AppImage not found: ${appImage}`)
|
||||
}
|
||||
|
||||
const suffix = `${process.pid}-${Date.now()}`
|
||||
const image = `orca-headless-serve-shutdown:${suffix}`
|
||||
const artifactVolume = `orca-headless-serve-shutdown-${suffix}`
|
||||
const sha256 = createHash('sha256').update(readFileSync(appImage)).digest('hex')
|
||||
|
||||
try {
|
||||
docker([
|
||||
'build',
|
||||
'--platform',
|
||||
platform,
|
||||
'-f',
|
||||
shutdownDockerfile,
|
||||
'-t',
|
||||
image,
|
||||
shutdownDockerDirectory
|
||||
])
|
||||
docker(['volume', 'create', artifactVolume])
|
||||
docker([
|
||||
'run',
|
||||
'--rm',
|
||||
'--platform',
|
||||
platform,
|
||||
'--entrypoint',
|
||||
'bash',
|
||||
'-v',
|
||||
`${appImage}:/input/orca.AppImage:ro`,
|
||||
'-v',
|
||||
`${artifactVolume}:/artifacts`,
|
||||
image,
|
||||
'-lc',
|
||||
[
|
||||
'7z x /input/orca.AppImage -o/artifacts/root -y >/dev/null',
|
||||
launcherExecOverlay
|
||||
? "sed -i 's/^ELECTRON_RUN_AS_NODE=1 /export ELECTRON_RUN_AS_NODE=1\\nexec /' /artifacts/root/resources/bin/orca-ide"
|
||||
: ':',
|
||||
'chmod -R a+rX /artifacts/root'
|
||||
].join(' && ')
|
||||
])
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type: 'appimage_under_test',
|
||||
appImage,
|
||||
sha256,
|
||||
platform,
|
||||
signalTarget,
|
||||
entrypoint,
|
||||
intDelivery,
|
||||
launcherExecOverlay
|
||||
})
|
||||
)
|
||||
const failedSignals = []
|
||||
for (const signal of ['INT', 'TERM']) {
|
||||
const result = docker(
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'--init',
|
||||
'--platform',
|
||||
platform,
|
||||
'--shm-size',
|
||||
'256m',
|
||||
'--name',
|
||||
`orca-headless-serve-shutdown-${signal.toLowerCase()}-${suffix}`,
|
||||
'-e',
|
||||
`ORCA_SIGNAL_TARGET=${signalTarget}`,
|
||||
'-e',
|
||||
`ORCA_TEST_ENTRYPOINT=${entrypoint}`,
|
||||
'-e',
|
||||
`ORCA_INT_DELIVERY=${intDelivery}`,
|
||||
'-v',
|
||||
`${artifactVolume}:/artifacts:ro`,
|
||||
image,
|
||||
signal
|
||||
],
|
||||
{ allowFailure: true }
|
||||
)
|
||||
process.stdout.write(result.stdout)
|
||||
process.stderr.write(result.stderr)
|
||||
if (result.status !== 0) {
|
||||
failedSignals.push(`${signal}:${result.status}`)
|
||||
}
|
||||
}
|
||||
if (failedSignals.length > 0) {
|
||||
fail(`Shutdown oracle failed: ${failedSignals.join(', ')}`)
|
||||
}
|
||||
console.log('Headless serve packaged shutdown Docker validation passed.')
|
||||
} finally {
|
||||
docker(['volume', 'rm', artifactVolume], { allowFailure: true })
|
||||
docker(['image', 'rm', image], { allowFailure: true })
|
||||
}
|
||||
|
||||
function valueAfter(flag) {
|
||||
const index = args.indexOf(flag)
|
||||
return index === -1 ? null : (args[index + 1] ?? null)
|
||||
}
|
||||
|
||||
function docker(dockerArgs, options = {}) {
|
||||
const result = spawnSync('docker', dockerArgs, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0 && !options.allowFailure) {
|
||||
process.stdout.write(result.stdout)
|
||||
process.stderr.write(result.stderr)
|
||||
fail(`docker ${dockerArgs[0]} failed with status ${result.status}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(message)
|
||||
process.exitCode = 1
|
||||
throw new Error(message)
|
||||
}
|
||||
@@ -5,7 +5,8 @@ const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArg
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1'
|
||||
ORCA_E2E_SSH_DOCKER: '1',
|
||||
ORCA_E2E_WEB_CLIENT: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
@@ -21,9 +22,8 @@ if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
// Why both specs in one runner: they share the docker SSH rig and the same
|
||||
// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and
|
||||
// restore, the retention spec proves the C1 budget bounds what they retain.
|
||||
// Why one runner: these specs share the Docker SSH rig; startup-exec also joins
|
||||
// the paired desktop/headless parity proof under the same isolated CI lane.
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
@@ -32,10 +32,14 @@ const result = spawnSync(
|
||||
'test',
|
||||
'tests/e2e/ssh-terminal-parking.spec.ts',
|
||||
'tests/e2e/terminal-retention-budget.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/paired-startup-exec-readiness.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--project',
|
||||
'electron-headful',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree-sort-order-update.ts'
|
||||
import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree/sort-order-update.ts'
|
||||
import { reuseEqualCatalogRows } from '../../src/renderer/src/store/slices/worktree-catalog-reconciliation.ts'
|
||||
|
||||
const WORKTREE_COUNT = 655
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"../src/main/codex/config-toml-trust.ts",
|
||||
"../src/main/codex/hook-service.ts",
|
||||
"../src/main/codex/hook-trust-promotion.ts",
|
||||
"../src/main/codex/managed-home-shell-preflight.ts",
|
||||
"../src/main/codex-accounts/fs-utils.ts",
|
||||
"../src/main/codex-accounts/wsl-codex-command.ts",
|
||||
"../src/main/codex-cli/command.ts",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"../src/renderer/src/**/*",
|
||||
"../src/renderer/src/**/*.tsx",
|
||||
"../src/preload/api-types.ts",
|
||||
"../src/preload/api/**/*",
|
||||
"../src/shared/**/*",
|
||||
"../src/main/gitlab/mappers.ts",
|
||||
"../src/main/ipc/worktree-branch-name.ts",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"../src/renderer/src/**/*",
|
||||
"../src/renderer/src/**/*.tsx",
|
||||
"../src/preload/api-types.ts",
|
||||
"../src/preload/api/**/*",
|
||||
"../src/shared/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 19m">
|
||||
<title>downloads: 19m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 20m">
|
||||
<title>downloads: 20m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">19m</text>
|
||||
<text x="90" y="14">19m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">20m</text>
|
||||
<text x="90" y="14">20m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -166,6 +166,7 @@ Environment=LIBGL_ALWAYS_SOFTWARE=1
|
||||
ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
KillMode=mixed
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=3
|
||||
RestartSec=5
|
||||
@@ -177,6 +178,10 @@ WantedBy=multi-user.target
|
||||
Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that
|
||||
clients should use.
|
||||
|
||||
`KillMode=mixed` sends the graceful stop signal only to Orca's main process,
|
||||
then retains systemd's cgroup-wide `SIGKILL` fallback if shutdown times out.
|
||||
This lets Orca keep its owned Xvfb alive until Electron disconnects cleanly.
|
||||
|
||||
Exit status `3` means another process already owns this userData profile, so
|
||||
`RestartPreventExitStatus=3` stops the unit instead of retrying a launch that
|
||||
cannot succeed. Any other permanent startup fault is capped at 5 starts per
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
# Steady-state worktree rescan: Git-admin fingerprint gate
|
||||
|
||||
## Status
|
||||
|
||||
Adopted for the main-process worktree resolution cache
|
||||
(`OrcaRuntimeService.listRepoWorktreesForResolution`). It keeps the existing
|
||||
30-second freshness contract for externally created, removed, moved, locked, and
|
||||
re-checked-out worktrees while removing the `git worktree list` subprocess that
|
||||
previously ran for every registered repository every 30 seconds.
|
||||
|
||||
## Context
|
||||
|
||||
A production trace (10 registered repositories, 3 h 27 min) recorded 4,272
|
||||
`git worktree` invocations and 8,663 total Git subprocesses. The invocations
|
||||
arrived in full-fleet sweeps roughly every 30.5 s — one sweep per repository per
|
||||
`WORKTREE_SCAN_CACHE_TTL_MS`.
|
||||
|
||||
### What actually expires the cache
|
||||
|
||||
The originating report assumed some specific terminal/status/orchestration
|
||||
request was invalidating the 30-second cache. That is not what happens, and the
|
||||
distinction changes the fix:
|
||||
|
||||
- `resolvedWorktreeCache` (the whole-fleet snapshot) has a **1-second** TTL
|
||||
(`RESOLVED_WORKTREE_CACHE_TTL_MS`). Any caller polling faster than 1 Hz
|
||||
recomputes the snapshot.
|
||||
- `computeResolvedWorktrees` fans out over **every** registered repo
|
||||
(`src/main/runtime/orca-runtime.ts`), calling
|
||||
`listRepoWorktreesForResolution` per repo.
|
||||
- That per-repo call is backed by `worktreeScanCache` with a **30-second** TTL.
|
||||
When it expires, the next poll shells out.
|
||||
|
||||
So no request "expires" the 30-second cache. It expires on wall-clock time, and
|
||||
whichever poller arrives first afterwards pays a full-fleet `git worktree list`
|
||||
fan-out. The many high-frequency callers — `listTerminals` without a selector,
|
||||
`showTerminal`, `getWorktreePs`, `listManagedWorktrees`,
|
||||
`resolveWorktreeSelector`, orchestration authority refresh — only determine
|
||||
*who* pays, not *how often*. Steady-state subprocess volume is therefore
|
||||
`repos / 30 s`, independent of poll rate, which is exactly the observed
|
||||
~1 sweep / 30.5 s.
|
||||
|
||||
The in-Orca mutation surface is already event-driven: create, remove, rename,
|
||||
folder-rename, sparse edits, repo add/update/remove, SSH reconnect, and
|
||||
mixed-version remote invalidation all call
|
||||
`invalidateWorktreeScanCacheForRepo` / `invalidateResolvedWorktreeCache`
|
||||
(≈40 call sites). The 30-second TTL exists for exactly one reason: discovering
|
||||
worktree changes made **outside** Orca (`git worktree add/remove/move/prune`,
|
||||
`git checkout` in another worktree, `rm -rf` of a worktree directory).
|
||||
|
||||
That is a filesystem question, and the filesystem can answer it without a
|
||||
subprocess.
|
||||
|
||||
## Goals
|
||||
|
||||
- Remove the periodic all-repository `git worktree list` fan-out in steady
|
||||
state.
|
||||
- Preserve the current ≈30 s discovery latency for externally created, removed,
|
||||
moved, pruned, locked/unlocked, and re-checked-out worktrees.
|
||||
- Keep a bounded reconciliation so anything the cheap probe cannot observe still
|
||||
converges.
|
||||
- Change nothing for SSH repos, WSL-routed repos, folder workspaces, bare repos,
|
||||
or hosts where the probe cannot resolve Git's admin layout.
|
||||
- Fail open: any probe error must behave exactly like today (run the real scan).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing `RESOLVED_WORKTREE_CACHE_TTL_MS` or the whole-fleet snapshot shape.
|
||||
- Changing the renderer-facing `worktrees:list` / `worktrees:listAll` IPC scan
|
||||
cache in `src/main/ipc/worktrees.ts` (separate 5 s cache, invalidated by
|
||||
`registerWorktreeChangeInvalidator`, not a polling source in the trace).
|
||||
- Scoping `resolveWorktreeSelector` to a single repository. See
|
||||
"Rejected alternatives".
|
||||
- Adding a filesystem watcher per repository.
|
||||
- Any wire/RPC/persisted-schema change.
|
||||
|
||||
## Design
|
||||
|
||||
Introduce a cheap, subprocess-free **Git worktree admin fingerprint** for a
|
||||
local repository, and consult it before re-running a scan whose TTL has expired.
|
||||
|
||||
### Fingerprint inputs
|
||||
|
||||
`readRepoWorktreeAdminFingerprint(repoPath)` in
|
||||
`src/main/runtime/repo-worktree-admin-fingerprint.ts` resolves the repo's Git
|
||||
common directory without a subprocess (read `.git`; if it is a `gitdir:` file,
|
||||
follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a
|
||||
bare gitdir), then records:
|
||||
|
||||
| Input | External change it catches |
|
||||
| --- | --- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) |
|
||||
|
||||
"per checkout" covers the main worktree and each linked worktree. Reading the
|
||||
ref HEAD names is what makes an ordinary commit visible: committing rewrites
|
||||
`refs/heads/<branch>` and leaves `HEAD` untouched, but moves the oid
|
||||
`git worktree list --porcelain` prints. A symref target is only followed when it
|
||||
is a relative path under `refs/`, so a hand-edited `HEAD` cannot steer the probe
|
||||
outside the ref store.
|
||||
|
||||
Every input is a `stat`, `readdir`, or small `readFile` on an already-hot inode,
|
||||
and the fingerprint depends on nothing but the repo path — no prior scan result
|
||||
is threaded in. Per-repo fan-out is capped at 8 concurrent linked-worktree
|
||||
probes, mirroring `SPARSE_CHECKOUT_DETECTION_CONCURRENCY`. A 10-repo fleet with
|
||||
10 worktrees each costs a few hundred filesystem calls per 30 s window versus 10
|
||||
process spawns; the spawns dominate by orders of magnitude, and process-table
|
||||
churn was the reported symptom.
|
||||
|
||||
The fingerprint is a NUL-delimited string; any read failure yields `null`, which
|
||||
the caller treats as "cannot prove unchanged".
|
||||
|
||||
### Cache decision
|
||||
|
||||
`listRepoWorktreesForResolution` gains one branch on the expired-TTL path:
|
||||
|
||||
```text
|
||||
cached entry exists, same generation + runtimeKey, TTL expired
|
||||
└─ probe eligible? (no connectionId, no wslDistro, fingerprint recorded)
|
||||
├─ no → real scan (today's behaviour)
|
||||
└─ yes → read fingerprint now
|
||||
├─ null or different → real scan
|
||||
├─ equal, last real scan < 5 min → extend TTL, no subprocess
|
||||
└─ equal, last real scan ≥ 5 min → real scan (bounded reconcile)
|
||||
```
|
||||
|
||||
`WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS` is 5 min, matching the existing
|
||||
`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS` precedent. A cached result whose scan
|
||||
failed (`ok: false`) is never extended, so a transient Git failure still retries
|
||||
on the 30 s TTL.
|
||||
|
||||
### Git version compatibility
|
||||
|
||||
Every path the probe reads is part of Git's on-disk layout well before the 2.25
|
||||
baseline in [`git-compatibility.md`](./git-compatibility.md): `.git` as a
|
||||
directory or `gitdir:` file, `commondir`, `worktrees/<name>/{HEAD,gitdir,locked}`,
|
||||
`packed-refs`, and loose `refs/`. `reftable` arrived in 2.45; on older Git it
|
||||
simply stats as missing, which is a stable value and therefore harmless. No new
|
||||
Git command is introduced — this change only skips one.
|
||||
|
||||
Agent-scratch repos already carry a 5-minute scan TTL
|
||||
(`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS`), which equals the reconciliation
|
||||
interval, so the gate never fires for them and their behaviour is unchanged.
|
||||
|
||||
### Ordering
|
||||
|
||||
The fingerprint stored with a scan result is captured **before** the scan runs.
|
||||
A mutation landing while the scan is in flight therefore leaves the stored
|
||||
fingerprint stale-by-construction, so the next probe sees a difference and
|
||||
rescans. Capturing after the scan would let that mutation be masked forever.
|
||||
|
||||
### Interaction with existing invalidation
|
||||
|
||||
Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint
|
||||
included) and bumps the generation, so every event-driven path still forces a
|
||||
real scan on the next read. The fingerprint only ever *extends* an entry that
|
||||
the TTL alone would have refreshed.
|
||||
|
||||
## Freshness budget
|
||||
|
||||
| Change | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
|
||||
The two regressions are bounded by the reconciliation interval and are both
|
||||
changes Orca does not make itself.
|
||||
|
||||
### Main-thread cost
|
||||
|
||||
Both the scan and the probe are asynchronous, so neither "runs on the main
|
||||
thread" in the naive sense — but they are not equally free there. Measured on
|
||||
macOS with a 1 ms interval sampling event-loop lag while each ran 30 times
|
||||
against a repo with 20 linked worktrees:
|
||||
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| --- | --- | --- | --- |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
|
||||
`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency
|
||||
is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout
|
||||
collection and decoding are real synchronous main-process work. Ten repos
|
||||
refreshing together therefore cost ≈27 ms of event-loop stall per sweep before
|
||||
this change and ≈0.1 ms after — this reduces main-thread pressure rather than
|
||||
adding to it, which is why moving either side onto a worker thread would not
|
||||
help.
|
||||
|
||||
### Residual risk
|
||||
|
||||
A repo registered at a UNC path (`\\wsl$\...`) but executed by the local Windows
|
||||
Git runtime is still probed, because it is not WSL-routed from Orca's point of
|
||||
view. The probe is correct there and strictly cheaper than the subprocess it
|
||||
replaces, but its filesystem calls cross the 9p boundary like the existing
|
||||
sparse-checkout probes already do.
|
||||
|
||||
## Measured effect
|
||||
|
||||
`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts` drives the
|
||||
reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30
|
||||
simulated minutes — and counts `git worktree list` invocations:
|
||||
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| --- | --- | --- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
|
||||
A 90 % reduction, with the remainder being the bounded reconciliation. Repos
|
||||
with genuine external activity keep rescanning at the 30 s cadence because the
|
||||
fingerprint flips.
|
||||
|
||||
Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272
|
||||
`git worktree` invocations would become ≈427.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess
|
||||
reduction, but it degrades *every* external-change latency to 5 min, including
|
||||
the common "I ran `git worktree add` in a terminal" case. The fingerprint buys
|
||||
the same reduction without that regression.
|
||||
|
||||
**Per-repo `fs.watch` on `<commonDir>/worktrees`.** Lower latency, but adds
|
||||
persistent watcher handles per repo, inherits recursive-watch platform
|
||||
differences, and would need its own dormancy/rearm story. The existing watcher
|
||||
infrastructure is scoped to workspace files; extending it to Git admin dirs is a
|
||||
larger change with a worse risk profile for the same steady-state win.
|
||||
|
||||
**Scope `resolveWorktreeSelector` to the owning repository.** The original brief
|
||||
asks for this. `listTerminals` already avoids the fan-out for explicit worktree
|
||||
ids via `buildResolvedWorktreeFromId` +
|
||||
`listKnownResolvedWorktreesForExplicitTarget`. Extending that to
|
||||
`resolveWorktreeSelector` means splitting the highest-fan-in method in the
|
||||
runtime (38 call sites) and rebuilding lineage projection for a repo subset —
|
||||
material regression risk. Once the fingerprint gate lands, the remaining
|
||||
fan-out cost for a targeted call is a batch of stats, not a subprocess, so the
|
||||
gain no longer justifies the risk. Tracked as follow-up, not in this change.
|
||||
|
||||
## Test plan
|
||||
|
||||
`src/main/runtime/repo-worktree-admin-fingerprint.test.ts` (real temp dirs, real
|
||||
`git` binary — a mocked filesystem would only restate the assumptions):
|
||||
|
||||
- stable across repeated reads with no change
|
||||
- changes after `worktree add`, `worktree remove`, `worktree move`,
|
||||
`worktree lock`, `checkout` in a linked worktree, `checkout` in the main
|
||||
worktree, a commit in either, and `rm -rf` of a worktree directory
|
||||
- still tracks a tip whose loose ref has been packed away by `git pack-refs`
|
||||
- a linked worktree path and the main repo path produce the same fingerprint
|
||||
- a bare repo resolves through its own gitdir and still tracks `worktree add`
|
||||
- `null` for a non-Git directory and for a missing path
|
||||
|
||||
`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts`:
|
||||
|
||||
- unchanged fingerprint suppresses the rescan past the 30 s TTL and re-arms it
|
||||
- changed fingerprint rescans at the 30 s TTL
|
||||
- the 5-minute reconciliation forces a rescan while the fingerprint is unchanged
|
||||
- `notifyBranchRenamed` (event invalidation) still forces an immediate rescan
|
||||
- a `null` fingerprint (probe failure) falls back to scanning
|
||||
- SSH repos never consult the probe
|
||||
- a failed scan is never extended
|
||||
- concurrent callers share one probe and one scan
|
||||
- the 1 Hz / 10-repo workload measurement above
|
||||
@@ -10,6 +10,11 @@ export default function MobileFilePreviewRoute() {
|
||||
source?: string | string[]
|
||||
absolutePath?: string | string[]
|
||||
grantId?: string | string[]
|
||||
terminal?: string | string[]
|
||||
pathText?: string | string[]
|
||||
cwd?: string | string[]
|
||||
nativeChatTab?: string | string[]
|
||||
nativeChatSession?: string | string[]
|
||||
line?: string | string[]
|
||||
column?: string | string[]
|
||||
name?: string | string[]
|
||||
|
||||
@@ -25,7 +25,7 @@ import { loadHosts, updateLastConnected } from '../../../src/transport/host-stor
|
||||
import { removeHostAndCloseClient } from '../../../src/transport/host-removal-lifecycle'
|
||||
import {
|
||||
useHostClient,
|
||||
useCloseHost,
|
||||
useForgetHostClient,
|
||||
useForceReconnect
|
||||
} from '../../../src/transport/client-context'
|
||||
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
|
||||
@@ -96,7 +96,7 @@ import {
|
||||
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
|
||||
} from '../../../src/worktree/workspace-list-picker-options'
|
||||
import type { RepoSummary } from '../../../src/worktree/host-worktree-rpc-types'
|
||||
import type { WorkspaceStatusDefinition } from '../../../../src/shared/types'
|
||||
import type { WorkspaceStatusDefinition } from '../../../../src/shared/worktree/types'
|
||||
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses'
|
||||
|
||||
function isErrorVerdict(v: ConnectionVerdict): boolean {
|
||||
@@ -148,7 +148,7 @@ export function HostScreen({
|
||||
const repoMetadataFetchedAtRef = useRef(0)
|
||||
const newWorktreeModalRef = useRef<{ open: () => void }>(null)
|
||||
const newWorktreeModalVisibleRef = useRef(false)
|
||||
const closeHostClient = useCloseHost()
|
||||
const forgetHostClient = useForgetHostClient()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
|
||||
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
|
||||
@@ -636,14 +636,14 @@ export function HostScreen({
|
||||
return
|
||||
}
|
||||
try {
|
||||
await removeHostAndCloseClient(hostId, closeHostClient)
|
||||
await removeHostAndCloseClient(hostId, forgetHostClient)
|
||||
leaveHost()
|
||||
} catch {
|
||||
// Why: removal can fail while still paired; re-open confirm (ConfirmModal closes on confirm).
|
||||
setConfirmRemoveHost(true)
|
||||
Alert.alert('Could not remove host', 'Please try again.')
|
||||
}
|
||||
}, [hostId, leaveHost, closeHostClient])
|
||||
}, [hostId, leaveHost, forgetHostClient])
|
||||
|
||||
const navigateFromHostList = useCallback(
|
||||
(target: string) => {
|
||||
|
||||
@@ -277,7 +277,8 @@ import {
|
||||
import { colors } from '../../../../src/theme/mobile-theme'
|
||||
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
|
||||
import { styles } from '../../../../src/session/mobile-session-styles'
|
||||
import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types'
|
||||
import type { DiffComment } from '../../../../../src/shared/diff-comment-types'
|
||||
import type { TerminalQuickCommand } from '../../../../../src/shared/terminal-quick-command-types'
|
||||
import type {
|
||||
DiffCommentActions,
|
||||
DiffNotesDelivery,
|
||||
@@ -917,6 +918,9 @@ export default function SessionScreen() {
|
||||
} | null>(null)
|
||||
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
|
||||
const subscribingHandlesRef = useRef<Set<string>>(new Set())
|
||||
// Why: a lease-only subscribe never renders, so the reconciler needs to tell it apart
|
||||
// from a stream that does — an uncovered handle holding one is a blank terminal.
|
||||
const leaseOnlyHandlesRef = useRef<Set<string>>(new Set())
|
||||
const initializedHandlesRef = useRef<Set<string>>(new Set())
|
||||
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
|
||||
// Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337).
|
||||
@@ -1233,6 +1237,7 @@ export default function SessionScreen() {
|
||||
terminalUnsubsRef.current.get(handle)?.()
|
||||
terminalUnsubsRef.current.delete(handle)
|
||||
subscribingHandlesRef.current.delete(handle)
|
||||
leaseOnlyHandlesRef.current.delete(handle)
|
||||
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
|
||||
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
|
||||
// Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale.
|
||||
@@ -1265,6 +1270,7 @@ export default function SessionScreen() {
|
||||
clearNativeChatInputLease()
|
||||
terminalUnsubsRef.current.clear()
|
||||
subscribingHandlesRef.current.clear()
|
||||
leaseOnlyHandlesRef.current.clear()
|
||||
initializedHandlesRef.current.clear()
|
||||
terminalDiagnosticsRef.current.clearTerminalCache()
|
||||
viewportResubscribeBudgetRef.current.clear()
|
||||
@@ -1331,6 +1337,11 @@ export default function SessionScreen() {
|
||||
}
|
||||
|
||||
subscribingHandlesRef.current.add(handle)
|
||||
if (covered) {
|
||||
leaseOnlyHandlesRef.current.add(handle)
|
||||
} else {
|
||||
leaseOnlyHandlesRef.current.delete(handle)
|
||||
}
|
||||
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
|
||||
subscribeSeqRef.current.set(handle, seq)
|
||||
diagnostics.streamArmed(handle, seq, viewportRef.current)
|
||||
@@ -1519,6 +1530,7 @@ export default function SessionScreen() {
|
||||
streamRevision: coveredStreamRevision,
|
||||
subscriptionsRef: terminalUnsubsRef,
|
||||
subscribingRef: subscribingHandlesRef,
|
||||
leaseOnlyRef: leaseOnlyHandlesRef,
|
||||
webReadyRef: webReadyHandlesRef,
|
||||
initializedRef: initializedHandlesRef,
|
||||
subscribe: subscribeToTerminal,
|
||||
@@ -3085,6 +3097,10 @@ export default function SessionScreen() {
|
||||
hostId,
|
||||
worktreeId,
|
||||
worktreeName: routeWorktreeName,
|
||||
nativeChatSessionId:
|
||||
activeSessionTab?.type === 'terminal'
|
||||
? (activeSessionTab.agentStatus?.providerSession?.id ?? null)
|
||||
: null,
|
||||
activeHandleRef,
|
||||
terminalCwdRef,
|
||||
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
|
||||
|
||||
@@ -119,11 +119,11 @@ import {
|
||||
isIterationCurrent,
|
||||
sortRows,
|
||||
type ProjectGroup
|
||||
} from '../../../../src/shared/github-project-group-sort'
|
||||
} from '../../../../src/shared/github/project-group-sort'
|
||||
import type {
|
||||
GitHubProjectSortDirection,
|
||||
GitHubProjectTable as SharedGitHubProjectTable
|
||||
} from '../../../../src/shared/github-project-types'
|
||||
} from '../../../../src/shared/github/project-types'
|
||||
import {
|
||||
CROSS_REPO_DISPLAY_LIMIT,
|
||||
isGitHubWorkItemsSshRemoteRequiredError,
|
||||
@@ -149,19 +149,19 @@ import {
|
||||
scheduleMobileTaskCopyFeedbackReset
|
||||
} from '../../../src/tasks/mobile-task-copy-feedback-timer'
|
||||
import type {
|
||||
BaseRefSearchResult,
|
||||
GitHubOwnerRepo,
|
||||
ProviderCheckSummary,
|
||||
PersistedTrustedOrcaHooks,
|
||||
SparsePreset,
|
||||
TuiAgent
|
||||
} from '../../../../src/shared/types'
|
||||
ProviderCheckSummary
|
||||
} from '../../../../src/shared/github/pull-request-types'
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../../src/shared/orca-yaml-hook-types'
|
||||
import type { BaseRefSearchResult } from '../../../../src/shared/repo-types'
|
||||
import type { TuiAgent } from '../../../../src/shared/tui-agent'
|
||||
import type { SparsePreset } from '../../../../src/shared/worktree/create-types'
|
||||
import type { SshConnectionState } from '../../../../src/shared/ssh-types'
|
||||
import type { HostedReviewDecision } from '../../../../src/shared/hosted-review'
|
||||
import {
|
||||
githubProjectHost,
|
||||
githubProjectIdentityKey as githubProjectKey
|
||||
} from '../../../../src/shared/github-project-identity'
|
||||
} from '../../../../src/shared/github/project-identity'
|
||||
|
||||
type RepoSummary = {
|
||||
id: string
|
||||
|
||||
+10
-4
@@ -25,7 +25,12 @@ import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate'
|
||||
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
|
||||
import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context'
|
||||
import {
|
||||
useDisconnectHostClient,
|
||||
useForceReconnect,
|
||||
useForgetHostClient,
|
||||
usePrimeHosts
|
||||
} from '../src/transport/client-context'
|
||||
import { useAllHostClients } from '../src/transport/use-all-host-clients'
|
||||
import {
|
||||
resolveHomeHostConnectionState,
|
||||
@@ -254,7 +259,8 @@ export default function HomeScreen() {
|
||||
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
|
||||
[allClients]
|
||||
)
|
||||
const closeHostClient = useCloseHost()
|
||||
const disconnectHostClient = useDisconnectHostClient()
|
||||
const forgetHostClient = useForgetHostClient()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const primeHosts = usePrimeHosts()
|
||||
// Why: prime the cache with loaded HostProfiles to avoid a second serialized Keychain pass (multi-second connect latency) on cold start.
|
||||
@@ -643,7 +649,7 @@ export default function HomeScreen() {
|
||||
}
|
||||
const hostToRemove = confirmRemove
|
||||
try {
|
||||
await removeHostAndCloseClient(hostToRemove.id, closeHostClient)
|
||||
await removeHostAndCloseClient(hostToRemove.id, forgetHostClient)
|
||||
setConfirmRemove(null)
|
||||
setHostCatalog(await loadHostCatalog())
|
||||
} catch {
|
||||
@@ -949,7 +955,7 @@ export default function HomeScreen() {
|
||||
: false,
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId),
|
||||
onDisconnect: closeHostClient,
|
||||
onDisconnect: disconnectHostClient,
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: (host) => setConfirmRemove(host)
|
||||
})}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type PreProfilePairingAttempt
|
||||
} from '../src/transport/pre-profile-pairing-coordinator'
|
||||
import type { ConnectionLogEntry } from '../src/transport/types'
|
||||
import { useCloseHost } from '../src/transport/client-context'
|
||||
import { useRefreshHostClient } from '../src/transport/client-context'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import {
|
||||
@@ -28,7 +28,7 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000
|
||||
|
||||
export default function PairConfirmScreen() {
|
||||
const router = useRouter()
|
||||
const closeHost = useCloseHost()
|
||||
const refreshHostClient = useRefreshHostClient()
|
||||
const insets = useSafeAreaInsets()
|
||||
const params = useLocalSearchParams<{ code?: string }>()
|
||||
const [status, setStatus] = useState<Status>('awaiting-confirm')
|
||||
@@ -112,10 +112,8 @@ export default function PairConfirmScreen() {
|
||||
// Why: re-pairing the same desktop now reuses its existing host id
|
||||
// (STA-1840 dedup), so a client cached under that id from an earlier
|
||||
// pairing would keep the stale endpoint/relay. Close it so the
|
||||
// destination screen opens a fresh client with the newly-paired
|
||||
// profile — the removeHost() path already refreshes on re-pair, and a
|
||||
// brand-new host has no cached entry so this is a no-op.
|
||||
closeHost(hostId)
|
||||
// Refresh any cached client from the newly persisted pairing profile.
|
||||
refreshHostClient(hostId)
|
||||
const onboardingSteps = await loadMobileOnboardingSteps()
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type PreProfilePairingAttempt
|
||||
} from '../src/transport/pre-profile-pairing-coordinator'
|
||||
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
|
||||
import { useCloseHost } from '../src/transport/client-context'
|
||||
import { useRefreshHostClient } from '../src/transport/client-context'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
@@ -47,7 +47,7 @@ function Step({ number, text }: { number: number; text: string }) {
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const router = useRouter()
|
||||
const closeHost = useCloseHost()
|
||||
const refreshHostClient = useRefreshHostClient()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [permission, requestPermission] = useCameraPermissions()
|
||||
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
|
||||
@@ -156,10 +156,8 @@ export default function PairScanScreen() {
|
||||
// Why: re-pairing the same desktop now reuses its existing host id
|
||||
// (STA-1840 dedup), so a client cached under that id from an earlier
|
||||
// pairing would keep the stale endpoint/relay. Close it so the
|
||||
// destination screen opens a fresh client with the newly-paired
|
||||
// profile — the removeHost() path already refreshes on re-pair, and a
|
||||
// brand-new host has no cached entry so this is a no-op.
|
||||
closeHost(hostId)
|
||||
// Refresh any cached client from the newly persisted pairing profile.
|
||||
refreshHostClient(hostId)
|
||||
const onboardingSteps = await loadMobileOnboardingSteps()
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
applyTerminalQuickCommandMutation,
|
||||
type TerminalQuickCommandMutation
|
||||
} from '../../src/shared/terminal-quick-commands'
|
||||
import type { TerminalQuickCommand } from '../../src/shared/types'
|
||||
import type { TerminalQuickCommand } from '../../src/shared/terminal-quick-command-types'
|
||||
import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
|
||||
import { handleMockGitRequest } from './mock-server-git-state'
|
||||
import { handleMockAccountRequest } from './mock-server-account-rpc'
|
||||
|
||||
@@ -7,11 +7,11 @@ import { colors } from '../theme/mobile-theme'
|
||||
import { useHostClient } from '../transport/client-context'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
createMobileAiVaultResumeMutationRegistry,
|
||||
readMobileRuntimeHostPlatform,
|
||||
readMobileRuntimeTerminalWindowsShell,
|
||||
resolveMobileAiVaultResumePlatform,
|
||||
resumeAiVaultSessionInTerminal,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isPathInsideOrEqual, isRuntimePathAbsolute } from '../../../src/shared/cross-platform-path'
|
||||
import { parseWslUncPath } from '../../../src/shared/wsl-paths'
|
||||
import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree-id'
|
||||
import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree/id'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Platform, StyleSheet, Text, TextInput, View } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { compactMobileBrowserFileAddress } from './browser-url'
|
||||
|
||||
type Props = {
|
||||
disabled: boolean
|
||||
focused: boolean
|
||||
onBlur: () => void
|
||||
onChangeText: (value: string) => void
|
||||
onFocus: () => void
|
||||
onSubmit: () => void
|
||||
value: string
|
||||
}
|
||||
|
||||
export function MobileBrowserAddressField({
|
||||
disabled,
|
||||
focused,
|
||||
onBlur,
|
||||
onChangeText,
|
||||
onFocus,
|
||||
onSubmit,
|
||||
value
|
||||
}: Props): React.JSX.Element {
|
||||
const fileLabel = focused ? null : compactMobileBrowserFileAddress(value)
|
||||
const selection = focused ? undefined : { start: 0, end: 0 }
|
||||
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onSubmitEditing={onSubmit}
|
||||
selectTextOnFocus
|
||||
selection={selection}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
|
||||
numberOfLines={1}
|
||||
returnKeyType="go"
|
||||
placeholder="URL"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
editable={!disabled}
|
||||
/>
|
||||
{fileLabel ? (
|
||||
<View pointerEvents="none" style={styles.fileLabelHost}>
|
||||
<Text style={styles.fileLabel} numberOfLines={1} ellipsizeMode="middle">
|
||||
{fileLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
field: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 28
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
borderRadius: radii.input,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 0,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
includeFontPadding: false,
|
||||
textAlignVertical: 'center',
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
fileLabelHost: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.input,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
fileLabel: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
fontFamily: typography.monoFamily
|
||||
}
|
||||
})
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Image,
|
||||
PanResponder,
|
||||
PixelRatio,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -54,6 +53,7 @@ import {
|
||||
type BrowserZoomState
|
||||
} from './browser-touch-geometry'
|
||||
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
|
||||
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
|
||||
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
|
||||
|
||||
export type MobileBrowserTab = {
|
||||
@@ -140,7 +140,7 @@ export function MobileBrowserPane({
|
||||
onToast
|
||||
}: MobileBrowserPaneProps) {
|
||||
const [browserViewMode, setBrowserViewMode] = useState<MobileBrowserViewMode>(() =>
|
||||
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId)
|
||||
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId, tab.url)
|
||||
)
|
||||
const cacheKey = makeBrowserFrameCacheKey(worktreeId, tab.browserPageId, browserViewMode)
|
||||
const cachedInitialFrame = peekCachedBrowserFrame(cacheKey)
|
||||
@@ -265,6 +265,10 @@ export function MobileBrowserPane({
|
||||
resetBrowserZoomState()
|
||||
}, [resetBrowserZoomState, tab.browserPageId, tab.url])
|
||||
|
||||
useEffect(() => {
|
||||
setBrowserViewMode(getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId, tab.url))
|
||||
}, [tab.browserPageId, tab.url, worktreeId])
|
||||
|
||||
const pageParams = useCallback(() => {
|
||||
if (!tab.browserPageId) {
|
||||
return null
|
||||
@@ -1018,10 +1022,6 @@ export function MobileBrowserPane({
|
||||
)
|
||||
|
||||
const controlsDisabled = !client || !tab.browserPageId || screencastSupported !== true
|
||||
const addressSelection = useMemo(
|
||||
() => (addressFocused ? undefined : { start: 0, end: 0 }),
|
||||
[addressFocused]
|
||||
)
|
||||
const goBack = useCallback(() => {
|
||||
if (controlsDisabled || !tab.canGoBack) {
|
||||
return
|
||||
@@ -1045,8 +1045,7 @@ export function MobileBrowserPane({
|
||||
if (browserViewMode === mode) {
|
||||
return
|
||||
}
|
||||
// Why: browser panes can remount during normal tab/workspace navigation;
|
||||
// keep a page-scoped choice while new browser pages still default to Web.
|
||||
// Why: preserve explicit page-scoped choices across normal browser pane remounts.
|
||||
saveMobileBrowserViewMode(worktreeId, tab.browserPageId, mode)
|
||||
setBrowserViewMode(mode)
|
||||
resetBrowserZoomState()
|
||||
@@ -1104,23 +1103,14 @@ export function MobileBrowserPane({
|
||||
>
|
||||
<RefreshCw size={15} color={buttonColor(!controlsDisabled)} />
|
||||
</MobileBrowserToolbarIconButton>
|
||||
<TextInput
|
||||
style={styles.addressInput}
|
||||
<MobileBrowserAddressField
|
||||
value={addressValue}
|
||||
onChangeText={setAddressValue}
|
||||
onFocus={() => setAddressFocused(true)}
|
||||
onBlur={() => setAddressFocused(false)}
|
||||
onSubmitEditing={() => void navigateToAddress()}
|
||||
selectTextOnFocus
|
||||
selection={addressSelection}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
|
||||
numberOfLines={1}
|
||||
returnKeyType="go"
|
||||
placeholder="URL"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
editable={!controlsDisabled}
|
||||
onSubmit={() => void navigateToAddress()}
|
||||
focused={addressFocused}
|
||||
disabled={controlsDisabled}
|
||||
/>
|
||||
<MobileBrowserViewModeSwitch
|
||||
disabled={controlsDisabled}
|
||||
@@ -1494,21 +1484,6 @@ const styles = StyleSheet.create({
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
addressInput: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 28,
|
||||
borderRadius: radii.input,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 0,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
includeFontPadding: false,
|
||||
textAlignVertical: 'center',
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
viewport: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import { Pressable, StyleSheet, View } from 'react-native'
|
||||
import { Monitor, Smartphone, type LucideIcon } from 'lucide-react-native'
|
||||
import { colors, radii } from '../theme/mobile-theme'
|
||||
import type { MobileBrowserViewMode } from './browser-screencast-request'
|
||||
|
||||
type Props = {
|
||||
@@ -8,9 +9,9 @@ type Props = {
|
||||
onChange: (mode: MobileBrowserViewMode) => void
|
||||
}
|
||||
|
||||
const VIEW_MODES: { id: MobileBrowserViewMode; label: string }[] = [
|
||||
{ id: 'web', label: 'Web' },
|
||||
{ id: 'mobile', label: 'Mobile' }
|
||||
const VIEW_MODES: { id: MobileBrowserViewMode; label: string; icon: LucideIcon }[] = [
|
||||
{ id: 'web', label: 'Web', icon: Monitor },
|
||||
{ id: 'mobile', label: 'Mobile', icon: Smartphone }
|
||||
]
|
||||
|
||||
export function MobileBrowserViewModeSwitch({
|
||||
@@ -23,6 +24,7 @@ export function MobileBrowserViewModeSwitch({
|
||||
{VIEW_MODES.map((mode) => (
|
||||
<ViewModeButton
|
||||
key={mode.id}
|
||||
Icon={mode.icon}
|
||||
label={mode.label}
|
||||
selected={value === mode.id}
|
||||
disabled={disabled}
|
||||
@@ -34,11 +36,13 @@ export function MobileBrowserViewModeSwitch({
|
||||
}
|
||||
|
||||
function ViewModeButton({
|
||||
Icon,
|
||||
disabled,
|
||||
label,
|
||||
onPress,
|
||||
selected
|
||||
}: {
|
||||
Icon: LucideIcon
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onPress: () => void
|
||||
@@ -58,7 +62,7 @@ function ViewModeButton({
|
||||
accessibilityState={{ selected, disabled }}
|
||||
accessibilityLabel={`Show ${label.toLowerCase()} website view`}
|
||||
>
|
||||
<Text style={[styles.buttonText, selected && styles.buttonTextSelected]}>{label}</Text>
|
||||
<Icon size={14} color={selected ? colors.bgBase : colors.textSecondary} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -74,11 +78,10 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
button: {
|
||||
minHeight: 24,
|
||||
minWidth: 52,
|
||||
width: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.sm
|
||||
borderRadius: radii.button
|
||||
},
|
||||
buttonPressed: {
|
||||
backgroundColor: colors.borderSubtle
|
||||
@@ -86,14 +89,6 @@ const styles = StyleSheet.create({
|
||||
buttonSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
buttonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: '600'
|
||||
},
|
||||
buttonTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.35
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeBrowserUrl } from './browser-url'
|
||||
import { compactMobileBrowserFileAddress, normalizeBrowserUrl } from './browser-url'
|
||||
|
||||
describe('normalizeBrowserUrl', () => {
|
||||
it('keeps localhost-style addresses as http URLs', () => {
|
||||
@@ -14,3 +14,19 @@ describe('normalizeBrowserUrl', () => {
|
||||
expect(normalizeBrowserUrl('github.com/stablyai/orca')).toBe('https://github.com/stablyai/orca')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compactMobileBrowserFileAddress', () => {
|
||||
it('shows the decoded filename for local and Windows-style file URLs', () => {
|
||||
expect(compactMobileBrowserFileAddress('file:///Users/me/reports/status%20report.html')).toBe(
|
||||
'file: …/status report.html'
|
||||
)
|
||||
expect(compactMobileBrowserFileAddress('file:///C:/Users/me/report.htm')).toBe(
|
||||
'file: …/report.htm'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not compact ordinary or malformed URLs', () => {
|
||||
expect(compactMobileBrowserFileAddress('https://example.com/report.html')).toBeNull()
|
||||
expect(compactMobileBrowserFileAddress('not a url')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,27 @@ export function displayBrowserUrl(value: string | null | undefined): string {
|
||||
return isBlankBrowserUrl(trimmed) ? 'about:blank' : trimmed
|
||||
}
|
||||
|
||||
export function compactMobileBrowserFileAddress(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'file:') {
|
||||
return null
|
||||
}
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
const encodedFilename = segments.at(-1)
|
||||
if (!encodedFilename) {
|
||||
return 'file:'
|
||||
}
|
||||
try {
|
||||
return `file: …/${decodeURIComponent(encodedFilename)}`
|
||||
} catch {
|
||||
return `file: …/${encodedFilename}`
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isBlankBrowserUrl(value: string | null | undefined): boolean {
|
||||
const trimmed = value?.trim() ?? ''
|
||||
return !trimmed || trimmed === 'about:blank' || trimmed.startsWith('data:text/html')
|
||||
|
||||
@@ -15,6 +15,28 @@ describe('mobile browser view mode state', () => {
|
||||
expect(getInitialMobileBrowserViewMode('worktree-1', null)).toBe('web')
|
||||
})
|
||||
|
||||
it('defaults local HTML reports to mobile view', () => {
|
||||
expect(
|
||||
getInitialMobileBrowserViewMode(
|
||||
'folder:workspace-1',
|
||||
'page-1',
|
||||
'file:///Users/me/report%20one.HTML?generated=1'
|
||||
)
|
||||
).toBe('mobile')
|
||||
expect(
|
||||
getInitialMobileBrowserViewMode('worktree-1', 'page-2', 'file:///C:/Users/me/report.htm')
|
||||
).toBe('mobile')
|
||||
})
|
||||
|
||||
it('keeps remote HTML URLs and non-HTML files in web view', () => {
|
||||
expect(
|
||||
getInitialMobileBrowserViewMode('worktree-1', 'page-1', 'https://example.com/report.html')
|
||||
).toBe('web')
|
||||
expect(getInitialMobileBrowserViewMode('worktree-1', 'page-2', 'file:///repo/notes.txt')).toBe(
|
||||
'web'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the last mode for the same browser page after remount', () => {
|
||||
saveMobileBrowserViewMode('worktree-1', 'page-1', 'mobile')
|
||||
|
||||
@@ -22,4 +44,12 @@ describe('mobile browser view mode state', () => {
|
||||
expect(getInitialMobileBrowserViewMode('worktree-1', 'page-2')).toBe('web')
|
||||
expect(getInitialMobileBrowserViewMode('worktree-2', 'page-1')).toBe('web')
|
||||
})
|
||||
|
||||
it('preserves an explicit web choice for a local HTML report', () => {
|
||||
saveMobileBrowserViewMode('worktree-1', 'page-1', 'web')
|
||||
|
||||
expect(
|
||||
getInitialMobileBrowserViewMode('worktree-1', 'page-1', 'file:///repo/report.html')
|
||||
).toBe('web')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,13 +5,23 @@ const browserViewModeByPageKey = new Map<string, MobileBrowserViewMode>()
|
||||
|
||||
export function getInitialMobileBrowserViewMode(
|
||||
worktreeId: string,
|
||||
browserPageId: string | null
|
||||
browserPageId: string | null,
|
||||
url = 'about:blank'
|
||||
): MobileBrowserViewMode {
|
||||
const pageKey = makeBrowserViewModePageKey(worktreeId, browserPageId)
|
||||
if (!pageKey) {
|
||||
return defaultMobileBrowserViewMode(url)
|
||||
}
|
||||
return browserViewModeByPageKey.get(pageKey) ?? defaultMobileBrowserViewMode(url)
|
||||
}
|
||||
|
||||
function defaultMobileBrowserViewMode(url: string): MobileBrowserViewMode {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.protocol === 'file:' && /\.html?$/i.test(parsed.pathname) ? 'mobile' : 'web'
|
||||
} catch {
|
||||
return 'web'
|
||||
}
|
||||
return browserViewModeByPageKey.get(pageKey) ?? 'web'
|
||||
}
|
||||
|
||||
export function saveMobileBrowserViewMode(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Image, StyleSheet, Text, View } from 'react-native'
|
||||
import type { ImageSourcePropType } from 'react-native'
|
||||
import { Terminal } from 'lucide-react-native'
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import Svg, { Defs, G, LinearGradient, Path, Stop } from 'react-native-svg'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActivityIndicator, FlatList, Pressable, Text, View } from 'react-native'
|
||||
import { RefreshCw } from 'lucide-react-native'
|
||||
import type { RefObject } from 'react'
|
||||
import type { DiffComment } from '../../../src/shared/types'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MobileDiffReviewLine } from './MobileDiffReviewLine'
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { Check, Copy, FileText, Plus, Send, Trash2, X } from 'lucide-react-native'
|
||||
import type { DiffComment } from '../../../src/shared/types'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import type { ActionSheetAction } from './ActionSheetModal'
|
||||
import { ActionSheetModal } from './ActionSheetModal'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react-native'
|
||||
import type { DiffComment } from '../../../src/shared/types'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import type { MobileDiffReviewQueueItem } from '../session/mobile-diff-review-queue'
|
||||
import { MOBILE_GIT_STATUS_LABELS } from '../source-control/mobile-git-status'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { MessageSquare } from 'lucide-react-native'
|
||||
import type { DiffComment } from '../../../src/shared/types'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import type { MobileDiffLine } from '../session/mobile-diff-lines'
|
||||
import type { MobileHighlightedDiffLine } from '../session/mobile-file-syntax'
|
||||
import { mobileDiffLineNumber, mobileDiffLinePrefix } from '../source-control/mobile-diff-format'
|
||||
|
||||
@@ -44,18 +44,32 @@ vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
|
||||
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
|
||||
|
||||
import { setCachedRepos } from '../cache/repo-cache'
|
||||
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
|
||||
import { NewWorktreeModal } from './NewWorktreeModal'
|
||||
|
||||
const repos = [{ id: 'repo-1', displayName: 'orca', path: '/src/orca', kind: 'git' }]
|
||||
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
|
||||
|
||||
function repoPickerNames(renderer: ReactTestRenderer | null): string[] {
|
||||
const pickers = renderer?.root.findAll((node) => node.type === 'PickerListDrawer') ?? []
|
||||
const repoPicker = pickers.find((node) => node.props.title === 'Repository')
|
||||
return ((repoPicker?.props.items ?? []) as { label: string }[]).map((item) => item.label)
|
||||
const repos = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
displayName: 'orca',
|
||||
path: '/src/orca',
|
||||
kind: 'git',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
}
|
||||
]
|
||||
|
||||
function pickerItems(
|
||||
renderer: ReactTestRenderer,
|
||||
title: string
|
||||
): { label: string; detail: string }[] {
|
||||
const pickers = renderer.root.findAll((node) => node.type === 'PickerListDrawer')
|
||||
const picker = pickers.find((node) => node.props.title === title)
|
||||
return picker?.props.items ?? []
|
||||
}
|
||||
|
||||
describe('NewWorktreeModal repo list', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
describe('NewWorktreeModal project targets', () => {
|
||||
let renderer: ReactTestRenderer
|
||||
|
||||
beforeEach(() => {
|
||||
setCachedRepos('host-1', repos)
|
||||
@@ -63,7 +77,6 @@ describe('NewWorktreeModal repo list', () => {
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('keeps the cached repos when the in-flight repo.list rejects on a dropped connection', async () => {
|
||||
@@ -71,6 +84,9 @@ describe('NewWorktreeModal repo list', () => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.reject(new Error('connection closed'))
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
@@ -92,6 +108,60 @@ describe('NewWorktreeModal repo list', () => {
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('repo.list')
|
||||
expect(repoPickerNames(renderer)).toEqual(['orca'])
|
||||
expect(pickerItems(renderer, 'Project')).toEqual([
|
||||
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
|
||||
])
|
||||
expect(pickerItems(renderer, 'Run on')).toEqual([
|
||||
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
|
||||
])
|
||||
})
|
||||
|
||||
it('groups same-name checkouts under one project with separate run targets', async () => {
|
||||
const listedRepos = [
|
||||
...repos,
|
||||
{
|
||||
id: 'repo-2',
|
||||
displayName: 'orca',
|
||||
path: '/home/dev/orca',
|
||||
connectionId: 'build-server',
|
||||
kind: 'git',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
}
|
||||
]
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.resolve({ ok: true, result: { repos: listedRepos } })
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(pickerItems(renderer, 'Project')).toEqual([
|
||||
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
|
||||
])
|
||||
expect(pickerItems(renderer, 'Run on')).toEqual([
|
||||
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }),
|
||||
expect.objectContaining({
|
||||
label: 'SSH · build-server',
|
||||
detail: '/home/dev/orca'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Keyboard
|
||||
} from 'react-native'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react-native'
|
||||
import { ChevronDown, ChevronUp, Monitor } from 'lucide-react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
@@ -29,8 +29,11 @@ import {
|
||||
type SetupHookTrust
|
||||
} from '../tasks/setup-hook-trust'
|
||||
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
|
||||
import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types'
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
|
||||
import type { Repo as SharedRepo } from '../../../src/shared/repo-types'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import type { SshConnectionState } from '../../../src/shared/ssh-types'
|
||||
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
|
||||
import {
|
||||
NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS,
|
||||
NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL,
|
||||
@@ -63,17 +66,26 @@ import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
|
||||
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
|
||||
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
|
||||
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
|
||||
import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields'
|
||||
import {
|
||||
buildNewWorkspaceProjectOptions,
|
||||
buildNewWorkspaceRunTargetOptions,
|
||||
getNewWorkspaceRunTarget
|
||||
} from './new-workspace-project-targets'
|
||||
|
||||
type Repo = {
|
||||
id: string
|
||||
displayName: string
|
||||
path: string
|
||||
badgeColor?: string
|
||||
connectionId?: string | null
|
||||
kind?: 'git' | 'folder'
|
||||
upstream?: { owner: string; repo: string } | null
|
||||
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
|
||||
}
|
||||
type Repo = Pick<SharedRepo, 'id' | 'displayName' | 'path'> &
|
||||
Partial<
|
||||
Pick<
|
||||
SharedRepo,
|
||||
| 'badgeColor'
|
||||
| 'connectionId'
|
||||
| 'executionHostId'
|
||||
| 'kind'
|
||||
| 'upstream'
|
||||
| 'repoIcon'
|
||||
| 'gitRemoteIdentity'
|
||||
>
|
||||
>
|
||||
|
||||
type SetupDecision = 'inherit' | 'run' | 'skip'
|
||||
type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
|
||||
@@ -200,10 +212,8 @@ function NewWorktreeModalContent({
|
||||
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
|
||||
const [note, setNote] = useState('')
|
||||
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
|
||||
const { tasksSupported, getWorktreeCreateCutoverSupport } = useNewWorktreeRuntimeCapabilities(
|
||||
client,
|
||||
visible
|
||||
)
|
||||
const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } =
|
||||
useNewWorktreeRuntimeCapabilities(client, visible)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
|
||||
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
|
||||
@@ -694,10 +704,17 @@ function NewWorktreeModalContent({
|
||||
isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents)
|
||||
)
|
||||
const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL]
|
||||
const repoPickerItems = useMemo(
|
||||
() => repos.map((repo) => ({ id: repo.id, label: repo.displayName, repo })),
|
||||
[repos]
|
||||
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
|
||||
const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null
|
||||
const selectedProject =
|
||||
projectPickerItems.find((project) => project.id === selectedProjectId) ?? null
|
||||
const runTargetPickerItems = useMemo(
|
||||
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform),
|
||||
[hostPlatform, repos, selectedProjectId]
|
||||
)
|
||||
const selectedRunTarget = selectedRepo
|
||||
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform)
|
||||
: null
|
||||
|
||||
function prepareSelectionPickerOpen(): void {
|
||||
// Why: picker taps can beat an open soft keyboard; dismissing it prevents the
|
||||
@@ -785,10 +802,7 @@ function NewWorktreeModalContent({
|
||||
>
|
||||
<BottomDrawer visible={formSheetVisible} interactive={formSheetInteractive} onClose={onClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create Workspace</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Pick a repository and agent to spin up a new workspace.
|
||||
</Text>
|
||||
<Text style={styles.title}>Create worktree</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
@@ -797,33 +811,23 @@ function NewWorktreeModalContent({
|
||||
</View>
|
||||
) : repos.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.emptyText}>No repositories found</Text>
|
||||
<Text style={styles.emptyText}>No projects found</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Repository</Text>
|
||||
<Pressable
|
||||
style={styles.fieldButton}
|
||||
onPress={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
transitionDrawer('repo')
|
||||
}}
|
||||
>
|
||||
{selectedRepo ? (
|
||||
<View
|
||||
style={[styles.repoDot, { backgroundColor: repoBadgeColor(selectedRepo) }]}
|
||||
/>
|
||||
) : null}
|
||||
<Text
|
||||
style={[styles.fieldButtonText, !selectedRepo && styles.fieldButtonPlaceholder]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selectedRepo?.displayName ?? 'Select a repository'}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<NewWorktreeProjectTargetFields
|
||||
project={selectedProject}
|
||||
runTarget={selectedRunTarget}
|
||||
projectBadgeColor={selectedRepo ? repoBadgeColor(selectedRepo) : null}
|
||||
onOpenProject={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
transitionDrawer('project')
|
||||
}}
|
||||
onOpenRunTarget={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
transitionDrawer('runTarget')
|
||||
}}
|
||||
/>
|
||||
|
||||
<SmartWorkspaceSourceField
|
||||
composer={composer}
|
||||
@@ -893,7 +897,7 @@ function NewWorktreeModalContent({
|
||||
>
|
||||
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
|
||||
<Text style={styles.fieldButtonText} numberOfLines={1}>
|
||||
{sshGate.requiresConnection ? 'Connect repository first' : selectedAgent.label}
|
||||
{sshGate.requiresConnection ? 'Connect target first' : selectedAgent.label}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
@@ -995,7 +999,7 @@ function NewWorktreeModalContent({
|
||||
<ActivityIndicator size="small" color={colors.bgBase} />
|
||||
) : (
|
||||
<Text style={styles.createText}>
|
||||
{sshGate.requiresConnection ? 'Connect Repository' : 'Create Workspace'}
|
||||
{sshGate.requiresConnection ? 'Connect target' : 'Create worktree'}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -1024,10 +1028,10 @@ function NewWorktreeModalContent({
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && drawerView === 'repo'}
|
||||
title="Repository"
|
||||
items={repoPickerItems}
|
||||
selectedId={selectedRepo?.id ?? ''}
|
||||
visible={visible && drawerView === 'project'}
|
||||
title="Project"
|
||||
items={projectPickerItems}
|
||||
selectedId={selectedProjectId ?? ''}
|
||||
onSelect={(item) => handleRepoSelected(item.repo)}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
renderIcon={(item) => {
|
||||
@@ -1035,6 +1039,16 @@ function NewWorktreeModalContent({
|
||||
}}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && drawerView === 'runTarget'}
|
||||
title="Run on"
|
||||
items={runTargetPickerItems}
|
||||
selectedId={selectedRepo?.id ?? ''}
|
||||
onSelect={(item) => handleRepoSelected(item.repo)}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && drawerView === 'agent'}
|
||||
title="Agent"
|
||||
@@ -1071,11 +1085,6 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: spacing.xl,
|
||||
alignItems: 'center'
|
||||
@@ -1109,7 +1118,6 @@ const styles = StyleSheet.create({
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
fieldButtonText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'
|
||||
import { ChevronDown, Monitor } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
type Selection = { label: string; detail?: string }
|
||||
|
||||
export function NewWorktreeProjectTargetFields({
|
||||
project,
|
||||
runTarget,
|
||||
projectBadgeColor,
|
||||
onOpenProject,
|
||||
onOpenRunTarget
|
||||
}: {
|
||||
project: Selection | null
|
||||
runTarget: Selection | null
|
||||
projectBadgeColor: string | null
|
||||
onOpenProject: () => void
|
||||
onOpenRunTarget: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Project</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={onOpenProject}>
|
||||
{projectBadgeColor ? (
|
||||
<View style={[styles.projectDot, { backgroundColor: projectBadgeColor }]} />
|
||||
) : null}
|
||||
<SelectionCopy selection={project} placeholder="Select a project" />
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Run on</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={onOpenRunTarget}>
|
||||
<Monitor size={14} color={colors.textMuted} />
|
||||
<SelectionCopy selection={runTarget} placeholder="Select a run target" />
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionCopy({
|
||||
selection,
|
||||
placeholder
|
||||
}: {
|
||||
selection: Selection | null
|
||||
placeholder: string
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.fieldButtonCopy}>
|
||||
<Text
|
||||
style={[styles.fieldButtonText, !selection && styles.fieldButtonPlaceholder]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selection?.label ?? placeholder}
|
||||
</Text>
|
||||
{selection?.detail ? (
|
||||
<Text style={styles.fieldButtonDetail} numberOfLines={1}>
|
||||
{selection.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
field: {
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
fieldButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm
|
||||
},
|
||||
projectDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999
|
||||
},
|
||||
fieldButtonCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
fieldButtonText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
fieldButtonDetail: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
marginTop: 1
|
||||
},
|
||||
fieldButtonPlaceholder: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -6,7 +6,9 @@ import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
|
||||
|
||||
type Props<T extends { id: string; label: string }> = {
|
||||
type PickerListItem = { id: string; label: string; detail?: string }
|
||||
|
||||
type Props<T extends PickerListItem> = {
|
||||
visible: boolean
|
||||
title: string
|
||||
items: T[]
|
||||
@@ -16,7 +18,7 @@ type Props<T extends { id: string; label: string }> = {
|
||||
renderIcon?: (item: T) => ReactNode
|
||||
}
|
||||
|
||||
export function PickerListDrawer<T extends { id: string; label: string }>({
|
||||
export function PickerListDrawer<T extends PickerListItem>({
|
||||
visible,
|
||||
title,
|
||||
items,
|
||||
@@ -87,12 +89,19 @@ export function PickerListDrawer<T extends { id: string; label: string }>({
|
||||
onPress={() => closeThenSelect(item)}
|
||||
>
|
||||
{renderIcon?.(item)}
|
||||
<Text
|
||||
style={[styles.itemText, selected && styles.itemTextSelected]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
<View style={styles.itemCopy}>
|
||||
<Text
|
||||
style={[styles.itemText, selected && styles.itemTextSelected]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.detail ? (
|
||||
<Text style={styles.itemDetail} numberOfLines={1}>
|
||||
{item.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{selected && <Check size={14} color={colors.textPrimary} />}
|
||||
</Pressable>
|
||||
)
|
||||
@@ -142,10 +151,18 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
itemText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
itemCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
itemDetail: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
marginTop: 1
|
||||
},
|
||||
itemTextSelected: {
|
||||
fontWeight: '600'
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// viewer). We deliberately favor precision over recall: a missed path is a minor
|
||||
// annoyance, but a false positive on prose or a version number is a broken tap.
|
||||
|
||||
import { parseFileLinkLocation } from '../../../src/shared/file-link-location'
|
||||
|
||||
export type FilePathSegment =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'file'; value: string; path: string }
|
||||
@@ -101,8 +103,6 @@ function hasMidTokenAt(candidate: string): boolean {
|
||||
return /[^\\/]@/.test(candidate)
|
||||
}
|
||||
|
||||
const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/
|
||||
|
||||
/**
|
||||
* Split an agent-style `path:line(:col)` citation into its parts. Windows drive
|
||||
* colons are safe: only a trailing all-digit suffix is treated as a line ref.
|
||||
@@ -112,14 +112,14 @@ export function splitFilePathLineSuffix(pathText: string): {
|
||||
line: number | null
|
||||
column: number | null
|
||||
} {
|
||||
const match = LINE_SUFFIX_PATTERN.exec(pathText)
|
||||
if (!match) {
|
||||
const parsed = parseFileLinkLocation(pathText)
|
||||
if (!parsed || (parsed.line === null && parsed.column === null)) {
|
||||
return { path: pathText, line: null, column: null }
|
||||
}
|
||||
return {
|
||||
path: match[1]!,
|
||||
line: Number.parseInt(match[2]!, 10),
|
||||
column: match[3] ? Number.parseInt(match[3], 10) : null
|
||||
path: parsed.pathText,
|
||||
line: parsed.line,
|
||||
column: parsed.column
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import { MOBILE_TUI_AGENT_FAVICON_DOMAINS } from '../tasks/mobile-tui-agents'
|
||||
|
||||
// Agents that render a hand-authored glyph in MobileAgentIcon and therefore
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ImageSourcePropType } from 'react-native'
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
|
||||
// Why: mobile previously rendered these agent icons from Google's favicon
|
||||
// service, which is unreachable in some regions (e.g. mainland China) and
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
|
||||
import {
|
||||
buildNewWorkspaceProjectOptions,
|
||||
buildNewWorkspaceRunTargetOptions,
|
||||
getNewWorkspaceRunTarget
|
||||
} from './new-workspace-project-targets'
|
||||
|
||||
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
|
||||
|
||||
describe('new workspace project targets', () => {
|
||||
it('groups local and SSH checkouts of the same project', () => {
|
||||
const upstream = { owner: 'stablyai', repo: 'orca' }
|
||||
const options = buildNewWorkspaceProjectOptions([
|
||||
{ id: 'local', displayName: 'orca', path: '/src/orca', upstream },
|
||||
{
|
||||
id: 'ssh',
|
||||
displayName: 'orca',
|
||||
path: '/home/dev/orca',
|
||||
connectionId: 'build-server',
|
||||
upstream
|
||||
}
|
||||
])
|
||||
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
|
||||
})
|
||||
|
||||
it('shows the provider slug recovered from canonical git identity', () => {
|
||||
const options = buildNewWorkspaceProjectOptions([
|
||||
{
|
||||
id: 'local',
|
||||
displayName: 'orca',
|
||||
path: '/src/orca',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'github.com/stablyai/orca',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@github.com:stablyai/orca.git'
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
|
||||
})
|
||||
|
||||
it('labels local, SSH, and paired runtime targets', () => {
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: '/src/orca' }, 'darwin')
|
||||
).toEqual({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' })
|
||||
).toEqual({ label: 'This computer', detail: 'C:\\src\\orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' }, 'win32')
|
||||
).toEqual({ label: 'Local Windows', detail: 'C:\\src\\orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({
|
||||
id: 'ssh',
|
||||
displayName: 'orca',
|
||||
path: 'C:\\src\\orca',
|
||||
executionHostId: 'ssh:Windows%20VM'
|
||||
})
|
||||
).toEqual({ label: 'SSH · Windows VM', detail: 'C:\\src\\orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({
|
||||
id: 'runtime',
|
||||
displayName: 'orca',
|
||||
path: '/src/orca',
|
||||
executionHostId: 'runtime:devbox'
|
||||
})
|
||||
).toEqual({ label: 'Remote · devbox', detail: '/src/orca' })
|
||||
})
|
||||
|
||||
it('shows one target per host when the project has multiple local worktrees', () => {
|
||||
const upstream = { owner: 'stablyai', repo: 'orca' }
|
||||
const repos = [
|
||||
{ id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream },
|
||||
{ id: 'local-b', displayName: 'orca-b', path: '/src/orca-b', upstream },
|
||||
{
|
||||
id: 'ssh',
|
||||
displayName: 'orca',
|
||||
path: '/home/dev/orca',
|
||||
connectionId: 'build-server',
|
||||
upstream
|
||||
}
|
||||
]
|
||||
const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null
|
||||
|
||||
expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([
|
||||
expect.objectContaining({ id: 'local-a', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }),
|
||||
expect.objectContaining({ id: 'ssh', label: 'SSH · build-server', detail: '/home/dev/orca' })
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Repo } from '../../../src/shared/repo-types'
|
||||
import {
|
||||
getExecutionHostLabel,
|
||||
getLocalExecutionHostLabel,
|
||||
getRepoExecutionHostId,
|
||||
parseExecutionHostId
|
||||
} from '../../../src/shared/execution-host'
|
||||
import {
|
||||
getProjectIdentityKey,
|
||||
getProjectProviderIdentity
|
||||
} from '../../../src/shared/project-host-setup-projection'
|
||||
|
||||
type WorkspaceRepo = Pick<Repo, 'id' | 'displayName' | 'path'> &
|
||||
Partial<
|
||||
Pick<Repo, 'connectionId' | 'executionHostId' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
|
||||
>
|
||||
|
||||
export type NewWorkspaceProjectOption<TRepo extends WorkspaceRepo> = {
|
||||
id: string
|
||||
label: string
|
||||
detail?: string
|
||||
repo: TRepo
|
||||
}
|
||||
|
||||
export type NewWorkspaceRunTargetOption<TRepo extends WorkspaceRepo> = {
|
||||
id: string
|
||||
label: string
|
||||
detail: string
|
||||
repo: TRepo
|
||||
}
|
||||
|
||||
export function buildNewWorkspaceProjectOptions<TRepo extends WorkspaceRepo>(
|
||||
repos: readonly TRepo[]
|
||||
): NewWorkspaceProjectOption<TRepo>[] {
|
||||
const options = new Map<string, NewWorkspaceProjectOption<TRepo>>()
|
||||
const hostIdsByProject = new Map<string, Set<string>>()
|
||||
for (const repo of repos) {
|
||||
const id = getProjectIdentityKey(repo)
|
||||
const hostIds = hostIdsByProject.get(id) ?? new Set<string>()
|
||||
hostIds.add(getRepoExecutionHostId(repo))
|
||||
hostIdsByProject.set(id, hostIds)
|
||||
if (!options.has(id)) {
|
||||
options.set(id, {
|
||||
id,
|
||||
label: repo.displayName,
|
||||
repo
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...options.values()].map((option) => {
|
||||
const providerIdentity = getProjectProviderIdentity(option.repo)
|
||||
const providerDetail = providerIdentity
|
||||
? `${providerIdentity.owner}/${providerIdentity.repo}`
|
||||
: ''
|
||||
const hostCount = hostIdsByProject.get(option.id)?.size ?? 0
|
||||
const detail = providerDetail || (hostCount > 1 ? `${hostCount} hosts configured` : '')
|
||||
return detail ? { ...option, detail } : option
|
||||
})
|
||||
}
|
||||
|
||||
export function getNewWorkspaceRunTarget(
|
||||
repo: WorkspaceRepo,
|
||||
localPlatform: NodeJS.Platform | null = null
|
||||
): {
|
||||
label: string
|
||||
detail: string
|
||||
} {
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
const host = parseExecutionHostId(hostId)
|
||||
const hostLabel = getExecutionHostLabel(hostId)
|
||||
if (host?.kind === 'ssh') {
|
||||
return { label: `SSH · ${hostLabel}`, detail: repo.path }
|
||||
}
|
||||
if (host?.kind === 'runtime') {
|
||||
return { label: `Remote · ${hostLabel}`, detail: repo.path }
|
||||
}
|
||||
return {
|
||||
label: localPlatform ? getLocalExecutionHostLabel(localPlatform) : 'This computer',
|
||||
detail: repo.path
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
|
||||
repos: readonly TRepo[],
|
||||
projectId: string | null,
|
||||
localPlatform: NodeJS.Platform | null = null
|
||||
): NewWorkspaceRunTargetOption<TRepo>[] {
|
||||
if (!projectId) {
|
||||
return []
|
||||
}
|
||||
const options = new Map<string, NewWorkspaceRunTargetOption<TRepo>>()
|
||||
for (const repo of repos) {
|
||||
if (getProjectIdentityKey(repo) !== projectId) {
|
||||
continue
|
||||
}
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
if (!options.has(hostId)) {
|
||||
options.set(hostId, {
|
||||
id: repo.id,
|
||||
...getNewWorkspaceRunTarget(repo, localPlatform),
|
||||
repo
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...options.values()]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
|
||||
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
|
||||
import { pickWorkspaceAgent } from '../tasks/workspace-agent-selection'
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('resolveNewWorktreeFormSheetVisible', () => {
|
||||
expect(
|
||||
resolveNewWorktreeFormSheetVisible({
|
||||
modalVisible: true,
|
||||
drawerView: 'repo',
|
||||
drawerView: 'project',
|
||||
formPinnedUnderSource: false
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import { GitMerge, Link2Off } from 'lucide-react-native'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { GitHubPRMergeMethod, PRInfo } from '../../../../src/shared/types'
|
||||
import type { GitHubPRMergeMethod, PRInfo } from '../../../../src/shared/github/pull-request-types'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
|
||||
import { unlinkMobilePr } from '../../source-control/mobile-pr-link'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ActivityIndicator, ScrollView, Text, View } from 'react-native'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { PRCheckRunDetails } from '../../../../src/shared/types'
|
||||
import type { PRCheckRunDetails } from '../../../../src/shared/github/check-types'
|
||||
import { presentCheckDetail, type CheckDetailJob } from './pr-check-detail-content'
|
||||
import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ActivityIndicator, Linking, Pressable, Text, View } from 'react-native'
|
||||
import { ChevronDown, ChevronRight, ExternalLink, RotateCw, Sparkles } from 'lucide-react-native'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { PRCheckDetail } from '../../../../src/shared/types'
|
||||
import type { PRCheckDetail } from '../../../../src/shared/github/check-types'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import { fetchPRCheckDetails, type GitHubPrRepoSlug } from '../../session/github-pr-rpc'
|
||||
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { Image, Linking, Pressable, Text, View } from 'react-native'
|
||||
import { Check, CornerDownRight, ExternalLink, Pencil, Trash2, Undo2 } from 'lucide-react-native'
|
||||
import type { GitHubReaction, GitHubReactionContent, PRComment } from '../../../../src/shared/types'
|
||||
import type {
|
||||
GitHubReaction,
|
||||
GitHubReactionContent,
|
||||
PRComment
|
||||
} from '../../../../src/shared/github/comment-types'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import { canEditComment, isResolvableComment } from '../../session/pr-comment-actions'
|
||||
import { ConfirmModal } from '../ConfirmModal'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitHubWorkItemDetails, PRComment } from '../../../../src/shared/types'
|
||||
import type { PRComment } from '../../../../src/shared/github/comment-types'
|
||||
import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types'
|
||||
import { PRCommentsSection } from './PRCommentsSection'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/types'
|
||||
import type { PRState } from '../../../../src/shared/github/pull-request-types'
|
||||
import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types'
|
||||
import type { GitHubPrRepoSlug } from '../../session/github-pr-rpc'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import { canAddRootComment } from '../../session/pr-comment-actions'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-nati
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { Check, Copy, FileWarning, Sparkles } from 'lucide-react-native'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { PRInfo } from '../../../../src/shared/types'
|
||||
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
|
||||
import { PRSection } from './PRSection'
|
||||
import { resolveConflictDisplay } from './pr-conflict-presentation'
|
||||
import { prConflictStyles as styles } from './pr-conflict-styles'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import { UserPlus, X } from 'lucide-react-native'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { GitHubWorkItemDetails } from '../../../../src/shared/types'
|
||||
import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
|
||||
import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state'
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { ArrowRight, ExternalLink, Pencil } from 'lucide-react-native'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { GitHubWorkItemDetails, PRInfo } from '../../../../src/shared/types'
|
||||
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
|
||||
import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types'
|
||||
import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-action'
|
||||
import { prStateBadge } from './pr-checks-presentation'
|
||||
import { statusColor } from './pr-sidebar-status-color'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { Check } from 'lucide-react-native'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { GitHubAssignableUser } from '../../../../src/shared/types'
|
||||
import type { GitHubAssignableUser } from '../../../../src/shared/github/pull-request-types'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import { fetchAssignableUsers } from '../../session/github-pr-rpc'
|
||||
import { BottomDrawer } from '../BottomDrawer'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GitHubPRMergeMethod, PRState } from '../../../../src/shared/types'
|
||||
import type { GitHubPRMergeMethod, PRState } from '../../../../src/shared/github/pull-request-types'
|
||||
|
||||
// Which actions the PR actions section may offer for a given PR state. Merged PRs
|
||||
// expose only unlink (+ open-on-host elsewhere); closed PRs add reopen; open/draft
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PRInfo } from '../../../../src/shared/types'
|
||||
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
|
||||
|
||||
type MobilePRAutoMergeAvailabilityInput = Pick<
|
||||
PRInfo,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRCheckRunDetails } from '../../../../src/shared/types'
|
||||
import type { PRCheckRunDetails } from '../../../../src/shared/github/check-types'
|
||||
import { presentCheckDetail } from './pr-check-detail-content'
|
||||
|
||||
function details(over: Partial<PRCheckRunDetails>): PRCheckRunDetails {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
PRCheckJob,
|
||||
PRCheckRunDetails,
|
||||
PRCheckStep
|
||||
} from '../../../../src/shared/types'
|
||||
} from '../../../../src/shared/github/check-types'
|
||||
|
||||
// Pure mapping from the github.prCheckDetails payload to the rows the mobile
|
||||
// expanded check detail renders. No React/native imports so it stays unit-testable
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRCheckDetail } from '../../../../src/shared/types'
|
||||
import type { PRCheckDetail } from '../../../../src/shared/github/check-types'
|
||||
import {
|
||||
checkOutcome,
|
||||
firstFailingCheckKey,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { PRCheckDetail, PRState, ProviderCheckSummary } from '../../../../src/shared/types'
|
||||
import type { PRCheckDetail } from '../../../../src/shared/github/check-types'
|
||||
import type {
|
||||
PRState,
|
||||
ProviderCheckSummary
|
||||
} from '../../../../src/shared/github/pull-request-types'
|
||||
import {
|
||||
classifyCheckOutcome,
|
||||
summarizeProviderChecks,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRInfo } from '../../../../src/shared/types'
|
||||
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
|
||||
import {
|
||||
buildMergeabilityRefreshCommands,
|
||||
hasMergeConflicts,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PRInfo } from '../../../../src/shared/types'
|
||||
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
|
||||
|
||||
// Pure presentation logic for the PR sidebar's conflicting-files section. No
|
||||
// React/native imports so it is unit-testable under the node Vitest config (KTD5).
|
||||
|
||||
@@ -2,7 +2,14 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
|
||||
import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility'
|
||||
|
||||
export type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
|
||||
export type NewWorktreeDrawerView =
|
||||
| 'form'
|
||||
| 'transition'
|
||||
| 'source'
|
||||
| 'project'
|
||||
| 'runTarget'
|
||||
| 'agent'
|
||||
| 'trust'
|
||||
|
||||
// Why: iOS cannot reliably present a second native modal until the first drawer's
|
||||
// exit commits; one extra frame keeps transitions sequential on slower devices.
|
||||
|
||||
@@ -39,6 +39,21 @@ describe('MobileFilePreviewScreen', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats native-chat artifact grants as read-only', () => {
|
||||
expect(
|
||||
isEditableMobileTerminalArtifactPreview(
|
||||
{
|
||||
status: 'ready',
|
||||
kind: 'text',
|
||||
content: '<h1>Result</h1>',
|
||||
truncated: false,
|
||||
byteLength: 15
|
||||
},
|
||||
true
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => {
|
||||
const sourceKey = sourceKeyForPreview({
|
||||
source: 'terminalArtifact',
|
||||
|
||||
@@ -181,7 +181,8 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
)
|
||||
const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview'
|
||||
const isEditableTerminalArtifact =
|
||||
previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview)
|
||||
previewSource?.source === 'terminalArtifact' &&
|
||||
isEditableMobileTerminalArtifactPreview(preview, previewSource.readOnly === true)
|
||||
const canSaveArtifact =
|
||||
isEditableTerminalArtifact &&
|
||||
draftSourceKeyRef.current === previewSourceKey &&
|
||||
|
||||
@@ -3,7 +3,13 @@ import type {
|
||||
MobileFilePreviewSource
|
||||
} from './mobile-file-preview-request'
|
||||
|
||||
export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean {
|
||||
export function isEditableMobileTerminalArtifactPreview(
|
||||
preview: MobileFilePreviewResult,
|
||||
readOnly = false
|
||||
): boolean {
|
||||
if (readOnly) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
(preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) ||
|
||||
preview.status === 'empty'
|
||||
|
||||
@@ -179,6 +179,47 @@ describe('mobile-file-preview-request', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes a native-chat artifact grant with the same transcript provenance', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_expired'),
|
||||
ok({
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/Users/ada/orca-plans/result.html',
|
||||
grantId: 'grant-2',
|
||||
readOnly: true
|
||||
}
|
||||
}),
|
||||
ok({ content: '<h1>Result</h1>', truncated: false, byteLength: 15 })
|
||||
])
|
||||
|
||||
const onTerminalArtifactSourceRefreshed = vi.fn()
|
||||
await loadMobileFilePreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/Users/ada/orca-plans/result.html',
|
||||
grantId: 'grant-1',
|
||||
pathText: '~/orca-plans/result.html',
|
||||
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
|
||||
},
|
||||
undefined,
|
||||
{ onTerminalArtifactSourceRefreshed }
|
||||
)
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
|
||||
worktree: 'id:wt-1',
|
||||
pathText: '~/orca-plans/result.html',
|
||||
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
|
||||
})
|
||||
expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ grantId: 'grant-2', readOnly: true })
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['terminal_file_grant_expired'],
|
||||
['terminal_file_grant_mismatch'],
|
||||
@@ -242,6 +283,25 @@ describe('mobile-file-preview-request', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not send writes for read-only native-chat artifacts', async () => {
|
||||
const client = clientWith(ok({ ok: true }))
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/Users/ada/orca-plans/result.html',
|
||||
grantId: 'grant-1',
|
||||
readOnly: true
|
||||
},
|
||||
'<h1>Changed</h1>'
|
||||
)
|
||||
).resolves.toMatchObject({ status: 'error' })
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not refresh and retry a failed terminal artifact save without a base content check', async () => {
|
||||
const client = clientWithResponses([fail('terminal_file_grant_mismatch')])
|
||||
|
||||
|
||||
@@ -116,6 +116,9 @@ export async function saveMobileTerminalArtifactPreview(
|
||||
content: string,
|
||||
options: TerminalArtifactSaveOptions = {}
|
||||
): Promise<MobileFilePreviewResult | { status: 'saved' }> {
|
||||
if (source.readOnly) {
|
||||
return previewError('This file is read-only')
|
||||
}
|
||||
let writeSource = source
|
||||
if (typeof options.baseContent === 'string') {
|
||||
const verified = await verifyTerminalArtifactBaseContent(
|
||||
|
||||
@@ -65,6 +65,8 @@ describe('mobile-file-preview-route', () => {
|
||||
terminal: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
nativeChatTab: 'tab-1',
|
||||
nativeChatSession: 'session-1',
|
||||
line: '12',
|
||||
column: '3'
|
||||
})
|
||||
@@ -80,6 +82,8 @@ describe('mobile-file-preview-route', () => {
|
||||
terminal: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
nativeChatTab: 'tab-1',
|
||||
nativeChatSession: 'session-1',
|
||||
line: '12',
|
||||
column: '3'
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export type MobileFilePreviewRouteParams = {
|
||||
terminal?: string
|
||||
pathText?: string
|
||||
cwd?: string
|
||||
nativeChatTab?: string
|
||||
nativeChatSession?: string
|
||||
line?: string
|
||||
column?: string
|
||||
name?: string
|
||||
@@ -35,6 +37,8 @@ type RawPreviewRouteParams = {
|
||||
terminal?: MobileFilePreviewParamValue
|
||||
pathText?: MobileFilePreviewParamValue
|
||||
cwd?: MobileFilePreviewParamValue
|
||||
nativeChatTab?: MobileFilePreviewParamValue
|
||||
nativeChatSession?: MobileFilePreviewParamValue
|
||||
line?: MobileFilePreviewParamValue
|
||||
column?: MobileFilePreviewParamValue
|
||||
name?: MobileFilePreviewParamValue
|
||||
@@ -76,6 +80,8 @@ export function normalizeMobileFilePreviewRouteParams(
|
||||
terminal: optionalSingleParam(params.terminal),
|
||||
pathText: optionalSingleParam(params.pathText),
|
||||
cwd: optionalSingleParam(params.cwd),
|
||||
nativeChatTab: optionalSingleParam(params.nativeChatTab),
|
||||
nativeChatSession: optionalSingleParam(params.nativeChatSession),
|
||||
line: optionalSingleParam(params.line),
|
||||
column: optionalSingleParam(params.column),
|
||||
name: optionalSingleParam(params.name),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sourceKeyForPreview } from './mobile-file-preview-source'
|
||||
import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source'
|
||||
|
||||
describe('mobile-file-preview-source', () => {
|
||||
it('uses structured preview keys so colons in paths cannot collide', () => {
|
||||
@@ -25,4 +25,21 @@ describe('mobile-file-preview-source', () => {
|
||||
sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' })
|
||||
).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts']))
|
||||
})
|
||||
|
||||
it('makes native-chat artifact previews read-only', () => {
|
||||
expect(
|
||||
previewSourceFromRoute({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
source: 'terminalArtifact',
|
||||
absolutePath: '/tmp/result.html',
|
||||
grantId: 'grant-1',
|
||||
nativeChatTab: 'tab-1',
|
||||
nativeChatSession: 'session-1'
|
||||
})
|
||||
).toMatchObject({
|
||||
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' },
|
||||
readOnly: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,16 @@ export function previewSourceFromRoute(
|
||||
grantId: params.grantId,
|
||||
...(params.terminal ? { terminalHandle: params.terminal } : {}),
|
||||
...(params.pathText ? { pathText: params.pathText } : {}),
|
||||
...(params.cwd ? { cwd: params.cwd } : {})
|
||||
...(params.cwd ? { cwd: params.cwd } : {}),
|
||||
...(params.nativeChatTab && params.nativeChatSession
|
||||
? {
|
||||
nativeChatContext: {
|
||||
tabId: params.nativeChatTab,
|
||||
sessionId: params.nativeChatSession
|
||||
},
|
||||
readOnly: true as const
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
if (!params.relativePath) {
|
||||
@@ -28,12 +37,12 @@ export function sourceKeyForPreview(source: MobileFilePreviewSource | null): str
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
return source.source === 'terminalArtifact'
|
||||
? JSON.stringify([
|
||||
'terminal',
|
||||
source.worktreeId,
|
||||
source.absolutePath,
|
||||
source.terminalHandle ?? ''
|
||||
])
|
||||
: JSON.stringify(['worktree', source.worktreeId, source.relativePath])
|
||||
if (source.source !== 'terminalArtifact') {
|
||||
return JSON.stringify(['worktree', source.worktreeId, source.relativePath])
|
||||
}
|
||||
const key = ['terminal', source.worktreeId, source.absolutePath, source.terminalHandle ?? '']
|
||||
if (source.nativeChatContext) {
|
||||
key.push(source.nativeChatContext.tabId, source.nativeChatContext.sessionId)
|
||||
}
|
||||
return JSON.stringify(key)
|
||||
}
|
||||
|
||||
@@ -138,6 +138,17 @@ describe('resolveMobileFileTabDoc', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Why: the host now caps oversized diffs with an error envelope, and a client that knows nothing
|
||||
// about the code must still surface the message instead of rendering an empty diff.
|
||||
it('propagates a host diff_too_large failure instead of rendering an empty diff', async () => {
|
||||
const client = clientOf({
|
||||
'git.diff': fail('diff_too_large', 'This diff is too large to open over a remote connection.')
|
||||
})
|
||||
await expect(
|
||||
resolveMobileFileTabDoc(client, { ...WT, relativePath: 'a.ts', diffSource: 'staged' })
|
||||
).rejects.toThrow('This diff is too large to open over a remote connection.')
|
||||
})
|
||||
|
||||
it('propagates the RPC error message when a read fails', async () => {
|
||||
const client = clientOf({ 'files.read': fail('EIO', 'file_too_large') })
|
||||
await expect(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user