mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
Merge remote-tracking branch 'origin/main' into skills-share
# Conflicts: # .github/pull_request_template.md # src/main/ipc/runtime-environment-request-connections.ts # src/main/ipc/runtime-environment-transport-routing.ts # src/main/ipc/skills.ts # src/main/runtime/rpc/methods/skills.ts # src/main/skills/discovery.ts # src/main/skills/skill-discovery-target.ts # src/renderer/src/components/skills/SkillsPage.test.tsx # src/renderer/src/components/skills/SkillsPage.tsx # src/renderer/src/components/skills/skill-display-labels.ts # src/renderer/src/components/skills/skills-filter.test.ts # src/renderer/src/components/skills/skills-filter.ts # src/renderer/src/i18n/locales/en.json # src/renderer/src/store/slices/ui.ts # src/shared/remote-runtime-shared-control-connection.ts
This commit is contained in:
@@ -52,21 +52,19 @@ 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
|
||||
|
||||
Each pull request should:
|
||||
Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular:
|
||||
|
||||
- explain the user-visible change
|
||||
- stay focused on a single topic when possible
|
||||
- include screenshots or screen recordings for new UI or behavior changes
|
||||
- open with an ELI5 of the change (plain language paragraph; the PR title is the one-liner)
|
||||
- explain what changed and why, and stay focused on a single topic when possible
|
||||
- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual change, say `No visual change` and why
|
||||
- include high-quality tests when behavior changes or bug fixes warrant them
|
||||
- include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk
|
||||
- mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes
|
||||
- **Include your X (Twitter) handle!** We love giving shoutouts to our contributors when we merge features on [@orca_build](https://x.com/orca_build).
|
||||
|
||||
If there is no visual change, say that explicitly in the PR description.
|
||||
- **Include your X (Twitter) handle** in the PR template Author section — we shout out contributors when we merge features on [@orca_build](https://x.com/orca_build).
|
||||
|
||||
## Release Process
|
||||
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
## Summary
|
||||
## ELI5
|
||||
|
||||
Describe the user-visible change.
|
||||
<!-- Simple high-level explanation -->
|
||||
|
||||
## Screenshots
|
||||
## What Changed
|
||||
|
||||
- Add screenshots or a screen recording for any new or changed UI behavior.
|
||||
- If there is no visual change, say `No visual change`.
|
||||
<!-- Describe the change clearly and keep scope tight. -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- What problem does this solve, and why is this approach right? -->
|
||||
|
||||
## Linked Issue
|
||||
|
||||
<!-- Link the issue this PR addresses, there should ALWAYS be one -->
|
||||
|
||||
Fixes #
|
||||
|
||||
## Visual Proof
|
||||
|
||||
<!-- REQUIRED for UI / behavior changes. Please attach a BEFORE and AFTER that can easily tabbed/switched. Use videos for when appropriate over screenshots -->
|
||||
<!-- If there is truly no visual or interaction change, write exactly: `N/A` and briefly say why. -->
|
||||
<!-- For attachments NEVER add directly to the PR files (do not commit to files), use `gh image` extension or drag + drop (works for any attachment) -->
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `pnpm lint`
|
||||
- [ ] `pnpm typecheck`
|
||||
- [ ] `pnpm test`
|
||||
- [ ] `pnpm build`
|
||||
- [ ] Added or updated high-quality tests that would catch regressions, or explained why tests were not needed
|
||||
<!-- How did you verify this? Steps a reviewer can follow. Which platforms did you actually test (macOS / Linux / Windows / SSH)? -->
|
||||
|
||||
## AI Review Report
|
||||
- [ ] I manually tested these changes locally
|
||||
- [ ] Automated tests added/updated, or explained why not below
|
||||
|
||||
Summarize the code review you ran with your AI coding agent. Include the main risks it checked, what it flagged, and what you changed or verified as a result.
|
||||
Confirm that the review explicitly checked cross-platform compatibility for macOS, Linux, and Windows, including shortcuts, labels, paths, shell behavior, and any Electron-specific platform differences touched by this PR.
|
||||
## AI Disclosure
|
||||
|
||||
## Security Audit
|
||||
<!-- 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 -->
|
||||
|
||||
Provide a basic security audit summary from your AI coding agent. Call out any input handling, command execution, path handling, auth, secrets, dependency, or IPC risks that were reviewed, plus any follow-up needed.
|
||||
## Review
|
||||
|
||||
## Agent skill upstream boundary
|
||||
|
||||
@@ -30,4 +42,18 @@ Provide a basic security audit summary from your AI coding agent. Call out any i
|
||||
|
||||
## Notes
|
||||
|
||||
Call out any platform-specific behavior, risks, or follow-up work.
|
||||
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] This PR is small and focused
|
||||
- [ ] I explained what changed and why (including ELI5)
|
||||
- [ ] Before/after screenshots or videos attached for UI changes, or `N/A` with reason
|
||||
- [ ] Self-reviewed for correctness, security, and performance
|
||||
- [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
|
||||
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred)
|
||||
|
||||
## Author
|
||||
|
||||
- X / Twitter: @your_handle
|
||||
<!-- Optional but appreciated -->
|
||||
|
||||
+26
-13
@@ -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,8 +232,19 @@ 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 \
|
||||
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
|
||||
xvfb-run --auto-servernum env "${E2E_ENV[@]}" \
|
||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
|
||||
|
||||
- name: Upload Playwright traces
|
||||
@@ -248,10 +259,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 +275,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 +309,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
|
||||
@@ -176,6 +176,12 @@ jobs:
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Why: the suites below gate their live fish tests on the binary, which is
|
||||
# right on a developer machine and wrong here — this job is a required check
|
||||
# and its fish lane is the only end-to-end guard for #9993, so a skip would
|
||||
# report green with nothing exercised. Turns those skips into failures.
|
||||
ORCA_REQUIRE_FISH: '1'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -186,8 +192,46 @@ jobs:
|
||||
# Why fish: shell-ready.test.ts gates its live fish test on the binary being
|
||||
# present, so without this the fish barrier is only covered by config-shape
|
||||
# assertions and never actually exercised.
|
||||
# Why release-4: DECSET 2031 arming lives in the fish 4.0 Rust tty_handoff, and
|
||||
# fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships
|
||||
# 3.7, so the PPA is what makes that lane real.
|
||||
- name: Install zsh and fish
|
||||
run: sudo apt-get update && sudo apt-get install -y zsh fish
|
||||
run: |
|
||||
# Why the update/PPA/fish steps are tolerant: a repo the runner image already
|
||||
# ships can lack a Release file for this suite, and a failed add-apt-repository
|
||||
# still leaves its list entry behind — either makes `apt-get update` exit
|
||||
# non-zero and would red this required check over something unrelated to the
|
||||
# PR. Every fish outcome is judged by the version gate below instead, so only
|
||||
# the zsh install (which has no such gate) stays fatal here.
|
||||
sudo apt-get update || true
|
||||
# Why retry only here: adding the PPA is the network-flaky step, and the
|
||||
# version gate below is fatal, so a transient Launchpad blip would
|
||||
# otherwise red a required check on PRs unrelated to shells.
|
||||
for attempt in 1 2 3; do
|
||||
sudo add-apt-repository -y ppa:fish-shell/release-4 && break
|
||||
echo "add-apt-repository attempt ${attempt} failed; retrying" >&2
|
||||
sudo add-apt-repository -y -r ppa:fish-shell/release-4 || true
|
||||
sleep 5
|
||||
done
|
||||
sudo apt-get update || true
|
||||
# Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the
|
||||
# first install command in this step to prove the lane really installs them.
|
||||
sudo apt-get install -y zsh fish
|
||||
|
||||
# Separate from the install so the failure names the contract, not an apt error.
|
||||
# ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds
|
||||
# instead of after a full dependency install.
|
||||
- name: Require fish 4+
|
||||
run: |
|
||||
version="$(fish --version 2>/dev/null || true)"
|
||||
major="${version##*version }"
|
||||
major="${major%%.*}"
|
||||
case "$major" in '' | *[!0-9]*) major=0 ;; esac
|
||||
echo "${version:-<fish not installed>}"
|
||||
if [ "$major" -lt 4 ]; then
|
||||
echo "::error::shell contracts needs fish 4+ (DECSET 2031 arming, #9993) but got '${version:-none}'. Fix the ppa:fish-shell/release-4 install rather than letting the fish lane skip." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -195,12 +239,14 @@ jobs:
|
||||
|
||||
- name: Test real shell contracts
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \
|
||||
src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
src/main/daemon/shell-ready.test.ts \
|
||||
src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
src/main/providers/local-pty-shell-ready.test.ts \
|
||||
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
@@ -230,11 +276,13 @@ jobs:
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
@@ -335,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
|
||||
@@ -378,6 +429,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- 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
|
||||
|
||||
@@ -77,7 +77,6 @@ jobs:
|
||||
'!config/reliability-gates.jsonc',
|
||||
'!config/max-lines-baseline.txt',
|
||||
'!config/vitest.config.ts',
|
||||
'config/build-plugins/**',
|
||||
'native/**',
|
||||
'resources/**',
|
||||
'electron.vite.config.ts',
|
||||
|
||||
@@ -104,7 +104,9 @@ docs/**
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!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/*
|
||||
@@ -127,6 +129,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/
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"typescript/array-type": "error",
|
||||
"typescript/consistent-generic-constructors": "error",
|
||||
"typescript/consistent-indexed-object-style": "error",
|
||||
"typescript/consistent-type-assertions": "error",
|
||||
"typescript/consistent-type-definitions": ["error", "type"],
|
||||
@@ -68,12 +69,16 @@
|
||||
"typescript/prefer-function-type": "error",
|
||||
"typescript/prefer-includes": "error",
|
||||
"typescript/prefer-optional-chain": "error",
|
||||
"typescript/prefer-ts-expect-error": "error",
|
||||
"typescript/switch-exhaustiveness-check": [
|
||||
"error",
|
||||
{ "allowDefaultCaseForExhaustiveSwitch": false }
|
||||
],
|
||||
"curly": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-else-return": "error",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-object-has-own": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
@@ -90,11 +95,16 @@
|
||||
"no-useless-return": "error",
|
||||
"prefer-template": "error",
|
||||
"unicorn/consistent-empty-array-spread": "error",
|
||||
"unicorn/consistent-existence-index-check": "error",
|
||||
"unicorn/empty-brace-spaces": "error",
|
||||
"unicorn/error-message": "error",
|
||||
"unicorn/no-array-fill-with-reference-type": "warn",
|
||||
"unicorn/no-array-reverse": "error",
|
||||
"unicorn/no-instanceof-builtins": "error",
|
||||
"unicorn/no-typeof-undefined": "error",
|
||||
"unicorn/no-unnecessary-array-splice-count": "error",
|
||||
"unicorn/no-useless-promise-resolve-reject": "error",
|
||||
"unicorn/no-zero-fractions": "error",
|
||||
"unicorn/prefer-array-find": "error",
|
||||
"unicorn/prefer-array-flat-map": "warn",
|
||||
"unicorn/prefer-array-index-of": "error",
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
|
||||
|
||||
## Electron UI Validation
|
||||
|
||||
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
|
||||
|
||||
# Style
|
||||
## Concise/Brief Non-obviosu comments ONLY
|
||||
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
|
||||
type NormalizedInputOptions = Rollup.NormalizedInputOptions
|
||||
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
|
||||
type OutputBundle = Rollup.OutputBundle
|
||||
type OutputChunk = Rollup.OutputChunk
|
||||
@@ -42,9 +43,34 @@ const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'port-scan-command-worker-entry'
|
||||
] as const
|
||||
|
||||
export const GUARDED_ENTRY_NAMES = [
|
||||
...PLAIN_NODE_ENTRY_NAMES,
|
||||
...WORKER_THREAD_ENTRY_NAMES
|
||||
] as const
|
||||
|
||||
type EntryRuntime = 'plain-Node process' | 'worker thread'
|
||||
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
|
||||
// Subpaths (electron/main) are as unloadable as the bare module under plain Node.
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/
|
||||
|
||||
// Why: writeBundle skips any name missing from the bundle, so a renamed or
|
||||
// removed rollup input would silently drop that entry from the guard and let the
|
||||
// regression back in. Pin the lists to the input keys at build start instead.
|
||||
function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void {
|
||||
if (typeof input === 'string' || Array.isArray(input)) {
|
||||
return
|
||||
}
|
||||
const inputNames = new Set(Object.keys(input))
|
||||
const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` +
|
||||
`${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` +
|
||||
`Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` +
|
||||
`the current entry names — a stale name silently stops guarding that entry.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function collectReachableChunks(
|
||||
entry: OutputChunk,
|
||||
@@ -89,41 +115,125 @@ function assertNoElectronRequire(
|
||||
}
|
||||
}
|
||||
|
||||
// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync.
|
||||
const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry'
|
||||
|
||||
export type SmokeTimings = {
|
||||
timeoutMs: number
|
||||
// daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline
|
||||
// needs an uncatchable follow-up to stay a deadline.
|
||||
killGraceMs: number
|
||||
}
|
||||
|
||||
const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 }
|
||||
|
||||
// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio
|
||||
// can hold the pipes open long after the child is gone.
|
||||
const SMOKE_STDERR_DRAIN_MS = 250
|
||||
|
||||
type SmokeResult = {
|
||||
status: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
stderr: string
|
||||
error?: Error
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
// Why not spawnSync({ timeout }): its timeout only sends killSignal and then
|
||||
// keeps blocking until the child exits, so a child that traps SIGTERM hangs the
|
||||
// build forever. Escalate to SIGKILL instead.
|
||||
function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise<SmokeResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
let stderr = ''
|
||||
let timedOut = false
|
||||
let settled = false
|
||||
let forceKillTimer: NodeJS.Timeout | undefined
|
||||
let drainTimer: NodeJS.Timeout | undefined
|
||||
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => {
|
||||
stderr += chunk
|
||||
})
|
||||
|
||||
const deadlineTimer = setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill('SIGTERM')
|
||||
forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs)
|
||||
}, timings.timeoutMs)
|
||||
|
||||
const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(deadlineTimer)
|
||||
clearTimeout(forceKillTimer)
|
||||
clearTimeout(drainTimer)
|
||||
resolve({ status, signal, stderr, error, timedOut })
|
||||
}
|
||||
|
||||
child.on('error', (error: Error) => finish(null, null, error))
|
||||
// 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe
|
||||
// cannot outlast the process itself.
|
||||
child.on('close', (status, signal) => finish(status, signal))
|
||||
child.on('exit', (status, signal) => {
|
||||
drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Why: proves the whole daemon-entry graph resolves under plain Node (no
|
||||
// unresolved requires). require("electron") does not throw in a dev tree with
|
||||
// node_modules present, so the static scan above — not this smoke — is the
|
||||
// electron regression guard; this only catches gross load failures.
|
||||
function smokeLoadDaemonEntry(outputDir: string): void {
|
||||
async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise<void> {
|
||||
const entryPath = join(outputDir, 'daemon-entry.js')
|
||||
const result = spawnSync(process.execPath, [entryPath], {
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000
|
||||
})
|
||||
const result = await runDaemonEntry(entryPath, timings)
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
|
||||
`${result.error.message}`
|
||||
)
|
||||
}
|
||||
const stderr = result.stderr ?? ''
|
||||
// Almost always means the daemon stopped rejecting an empty argv and started
|
||||
// listening instead.
|
||||
if (result.timedOut) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` +
|
||||
`empty argv under plain Node, so the smoke killed it.`
|
||||
)
|
||||
}
|
||||
if (result.signal) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.`
|
||||
)
|
||||
}
|
||||
const stderr = result.stderr
|
||||
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
|
||||
)
|
||||
}
|
||||
if (!stderr.includes('Usage: daemon-entry')) {
|
||||
if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
|
||||
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
|
||||
`[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` +
|
||||
`(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` +
|
||||
`${result.status}). stderr:\n${stderr}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
export function createPlainNodeEntryGuardPlugin(
|
||||
smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS
|
||||
): Plugin {
|
||||
let daemonOutputDir: string | undefined
|
||||
|
||||
return {
|
||||
name: 'orca-plain-node-entry-guard',
|
||||
buildStart(options: NormalizedInputOptions) {
|
||||
assertEntryNamesAreRollupInputs(options.input)
|
||||
},
|
||||
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
|
||||
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
|
||||
// every rebuild, and the guard only needs to gate produced builds.
|
||||
@@ -159,11 +269,11 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
daemonOutputDir = options.dir
|
||||
}
|
||||
},
|
||||
closeBundle() {
|
||||
async closeBundle() {
|
||||
if (daemonOutputDir) {
|
||||
const outputDir = daemonOutputDir
|
||||
daemonOutputDir = undefined
|
||||
smokeLoadDaemonEntry(outputDir)
|
||||
await smokeLoadDaemonEntry(outputDir, smokeTimings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -167,6 +167,12 @@ module.exports = {
|
||||
// Why: sherpa-onnx native bindings (platform-specific subpackages) must be
|
||||
// unpacked because they ship .node addons + .dylib/.so files that cannot be
|
||||
// dlopen()'d from inside the asar archive.
|
||||
// Why: the OpenCode SQLite worker entry is also spawned by the scanner
|
||||
// service, which runs under ELECTRON_RUN_AS_NODE and so cannot see into
|
||||
// app.asar. Left packed, that spawn fails closed and every OpenCode session
|
||||
// disappears from Agent Session History in packaged builds only. Worker
|
||||
// entries reached solely from the Electron main process stay packed, since
|
||||
// asar redirects their app.asar paths.
|
||||
asarUnpack: [
|
||||
'out/package.json',
|
||||
'out/cli/**',
|
||||
@@ -184,6 +190,7 @@ module.exports = {
|
||||
'out/main/hermes/**',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/session-scanner-service-entry.js',
|
||||
'out/main/session-scanner-opencode-sqlite-worker-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
|
||||
@@ -8,7 +8,7 @@ const configByTarget = {
|
||||
renderer: { renderer: electronViteConfig.renderer }
|
||||
}
|
||||
|
||||
if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) {
|
||||
if (!target || !Object.hasOwn(configByTarget, target)) {
|
||||
throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? '<unset>'}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"import/namespace": "warn",
|
||||
"import/no-cycle": ["warn", { "maxDepth": 3 }],
|
||||
"import/no-duplicates": "warn",
|
||||
"import/no-empty-named-blocks": "warn",
|
||||
"import/no-self-import": "warn"
|
||||
},
|
||||
"overrides": [
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"rules": {
|
||||
"typescript/await-thenable": "warn",
|
||||
"typescript/no-redundant-type-constituents": "warn",
|
||||
"typescript/restrict-plus-operands": "warn",
|
||||
"typescript/restrict-template-expressions": "warn",
|
||||
"typescript/switch-exhaustiveness-check": [
|
||||
|
||||
+1429
-48
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,7 @@ try {
|
||||
|
||||
function readArg(name) {
|
||||
const index = args.indexOf(name)
|
||||
return index >= 0 ? args[index + 1] : undefined
|
||||
return index !== -1 ? args[index + 1] : undefined
|
||||
}
|
||||
|
||||
function embeddedInfoPlist(identifier) {
|
||||
|
||||
@@ -56,5 +56,5 @@ function findFrameworkCompiler(env) {
|
||||
|
||||
function readArg(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
return index >= 0 ? process.argv[index + 1] : undefined
|
||||
return index !== -1 ? process.argv[index + 1] : undefined
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ function source(path) {
|
||||
function sourceBetween(contents, startMarker, endMarker) {
|
||||
const start = contents.indexOf(startMarker)
|
||||
const end = contents.indexOf(endMarker, start + startMarker.length)
|
||||
if (start < 0 || end < 0) {
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
|
||||
}
|
||||
return contents.slice(start, end)
|
||||
|
||||
@@ -4,6 +4,9 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const REPO_ROOT = join(import.meta.dirname, '..', '..')
|
||||
const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main')
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const electronBuilderConfig = require('../electron-builder.config.cjs')
|
||||
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
|
||||
@@ -171,6 +174,29 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is
|
||||
// invisible to it and a packed worker entry fails closed — dropping every
|
||||
// OpenCode session in packaged builds while dev stays green. Three legs must
|
||||
// agree on the filename, so all three are read rather than hardcoded.
|
||||
it('unpacks the OpenCode SQLite worker entry the scanner service forks', async () => {
|
||||
const spawnSource = await readFile(
|
||||
join(SRC_MAIN_DIR, 'ai-vault', 'session-scanner-opencode-sqlite-worker-spawn.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const entryFilename = spawnSource.match(/WORKER_ENTRY_FILENAME = '([^']+)'/)?.[1]
|
||||
|
||||
expect(entryFilename).toBeDefined()
|
||||
expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`)
|
||||
|
||||
// Why: the emitted path comes from the rollup input key under
|
||||
// entryFileNames '[name].js', not from the source filename — renaming the
|
||||
// key alone would leave the other two legs agreeing on a file that no
|
||||
// longer exists.
|
||||
const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8')
|
||||
expect(viteConfig).toContain("entryFileNames: '[name].js'")
|
||||
expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`))
|
||||
})
|
||||
|
||||
it('keeps the worker-thread hang watchdog inside app.asar', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).not.toContain(
|
||||
'out/main/main-thread-hang-watchdog-entry.js'
|
||||
|
||||
@@ -196,7 +196,8 @@ describe('Electron Vite output contract', () => {
|
||||
})
|
||||
|
||||
it('rejects prototype properties as build targets', () => {
|
||||
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
|
||||
// Own-property check only: an inherited key like `constructor` must not select a build target.
|
||||
expect(targetConfig).toContain('Object.hasOwn(configByTarget, target)')
|
||||
})
|
||||
|
||||
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ function readRuntimeArg() {
|
||||
}
|
||||
|
||||
const runtimeIndex = process.argv.indexOf('--runtime')
|
||||
if (runtimeIndex >= 0) {
|
||||
if (runtimeIndex !== -1) {
|
||||
return process.argv[runtimeIndex + 1]
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -227,7 +227,7 @@ function readGitBlobs(objectShas) {
|
||||
let offset = 0
|
||||
for (const requestedSha of uniqueShas) {
|
||||
const headerEnd = output.indexOf(10, offset)
|
||||
if (headerEnd < 0) {
|
||||
if (headerEnd === -1) {
|
||||
throw new Error(`Missing git cat-file header for ${requestedSha}`)
|
||||
}
|
||||
const header = output.subarray(offset, headerEnd).toString('utf8')
|
||||
@@ -648,8 +648,8 @@ async function main() {
|
||||
const argv = process.argv.slice(2)
|
||||
const rebuildFromTags = argv.includes('--rebuild-from-tags')
|
||||
const releaseIndex = argv.indexOf('--release')
|
||||
const releaseVersion = releaseIndex >= 0 ? argv[releaseIndex + 1] : null
|
||||
if (releaseIndex >= 0 && !releaseVersion) {
|
||||
const releaseVersion = releaseIndex !== -1 ? argv[releaseIndex + 1] : null
|
||||
if (releaseIndex !== -1 && !releaseVersion) {
|
||||
throw new Error('--release requires a version argument, e.g. --release 1.4.160')
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
|
||||
function parseCpuTimeSeconds(value) {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
|
||||
const days = dayOrTime === null ? 0 : Number(dayOrTime)
|
||||
const parts = maybeTime.split(':').map(Number)
|
||||
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
|
||||
return null
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return days * 86400 + parts[0] * 60 + parts[1]
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return days * 86400 + parts[0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseUnixProcesses(stdout) {
|
||||
const rows = []
|
||||
for (const raw of stdout.split('\n')) {
|
||||
const line = raw.trim()
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
percentCpu: Number(match[3]),
|
||||
rssBytes: Number(match[4]) * 1024,
|
||||
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
|
||||
command: match[6]
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function readUnixProcesses() {
|
||||
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
return parseUnixProcesses(stdout)
|
||||
}
|
||||
|
||||
function readWindowsProcesses() {
|
||||
const script =
|
||||
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress'
|
||||
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || 'PowerShell process enumeration failed')
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout || '[]')
|
||||
const entries = Array.isArray(parsed) ? parsed : [parsed]
|
||||
return entries.map((entry) => ({
|
||||
pid: Number(entry.ProcessId),
|
||||
ppid: Number(entry.ParentProcessId),
|
||||
percentCpu: 0,
|
||||
cpuTimeSeconds: null,
|
||||
rssBytes: Number(entry.WorkingSetSize) || 0,
|
||||
command: String(entry.CommandLine || '')
|
||||
}))
|
||||
}
|
||||
|
||||
export function readProcessRows() {
|
||||
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
|
||||
}
|
||||
|
||||
export function descendantsOf(rows, rootPid) {
|
||||
const children = new Map()
|
||||
for (const row of rows) {
|
||||
const list = children.get(row.ppid) ?? []
|
||||
list.push(row)
|
||||
children.set(row.ppid, list)
|
||||
}
|
||||
const result = []
|
||||
const stack = [rootPid]
|
||||
const seen = new Set()
|
||||
while (stack.length > 0) {
|
||||
const pid = stack.pop()
|
||||
if (seen.has(pid)) {
|
||||
continue
|
||||
}
|
||||
seen.add(pid)
|
||||
const row = rows.find((candidate) => candidate.pid === pid)
|
||||
if (row) {
|
||||
result.push(row)
|
||||
}
|
||||
for (const child of children.get(pid) ?? []) {
|
||||
stack.push(child.pid)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function classify(row, rootPid) {
|
||||
const command = row.command.toLowerCase()
|
||||
if (row.pid === rootPid) {
|
||||
return 'main'
|
||||
}
|
||||
if (command.includes('daemon-entry')) {
|
||||
return 'daemon'
|
||||
}
|
||||
if (command.includes('--type=gpu-process')) {
|
||||
return 'gpu'
|
||||
}
|
||||
if (command.includes('--type=renderer')) {
|
||||
return 'renderer'
|
||||
}
|
||||
if (command.includes('--type=utility')) {
|
||||
return 'utility'
|
||||
}
|
||||
if (command.includes('--type=')) {
|
||||
return 'electron-other'
|
||||
}
|
||||
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
|
||||
return 'agent-or-node'
|
||||
}
|
||||
return 'other-descendant'
|
||||
}
|
||||
|
||||
const DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS = 120_000
|
||||
|
||||
export async function sampleProcessTreeUntilWorkloadsComplete({
|
||||
rootPid,
|
||||
requestedDurationMs,
|
||||
intervalMs,
|
||||
workloadPromise,
|
||||
maxWorkloadOverrunMs = DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS,
|
||||
readRows = readProcessRows,
|
||||
now = Date.now,
|
||||
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}) {
|
||||
const samplingStartedAt = now()
|
||||
const requestedDeadline = samplingStartedAt + requestedDurationMs
|
||||
const hardDeadline = requestedDeadline + maxWorkloadOverrunMs
|
||||
let workloadSettled = false
|
||||
let workloadResult
|
||||
let workloadError
|
||||
let workloadSettledAt = null
|
||||
void workloadPromise.then(
|
||||
(result) => {
|
||||
workloadResult = result
|
||||
workloadSettled = true
|
||||
workloadSettledAt = now()
|
||||
},
|
||||
(error) => {
|
||||
workloadError = error
|
||||
workloadSettled = true
|
||||
workloadSettledAt = now()
|
||||
}
|
||||
)
|
||||
const samples = []
|
||||
let previousSnapshot = null
|
||||
const needsFinalWorkloadSample = () =>
|
||||
workloadSettledAt !== null && (previousSnapshot?.at ?? -Infinity) < workloadSettledAt
|
||||
while (
|
||||
now() <= requestedDeadline ||
|
||||
samples.length === 0 ||
|
||||
!workloadSettled ||
|
||||
needsFinalWorkloadSample()
|
||||
) {
|
||||
const sampledAt = now()
|
||||
if (workloadError) {
|
||||
throw workloadError
|
||||
}
|
||||
if (
|
||||
(!workloadSettled && sampledAt >= hardDeadline) ||
|
||||
(workloadSettledAt !== null && workloadSettledAt > hardDeadline)
|
||||
) {
|
||||
throw new Error(
|
||||
`Benchmark workload exceeded the ${maxWorkloadOverrunMs}ms sampling overrun limit`
|
||||
)
|
||||
}
|
||||
const processRows = descendantsOf(readRows(), rootPid)
|
||||
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
|
||||
if (previousSnapshot) {
|
||||
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
|
||||
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
|
||||
const processes = rawProcesses.map((row) => {
|
||||
const previous = previousByPid.get(row.pid)
|
||||
const canComputeDelta =
|
||||
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
|
||||
const cpu = canComputeDelta
|
||||
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
|
||||
: row.percentCpu
|
||||
return { ...row, cpu }
|
||||
})
|
||||
samples.push({
|
||||
at: sampledAt,
|
||||
elapsedMs: sampledAt - previousSnapshot.at,
|
||||
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
|
||||
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
|
||||
processes
|
||||
})
|
||||
}
|
||||
previousSnapshot = { at: sampledAt, processes: rawProcesses }
|
||||
await wait(intervalMs)
|
||||
}
|
||||
if (workloadError) {
|
||||
throw workloadError
|
||||
}
|
||||
const measuredDurationMs = Math.max(
|
||||
0,
|
||||
(previousSnapshot?.at ?? samplingStartedAt) - samplingStartedAt
|
||||
)
|
||||
return {
|
||||
samples,
|
||||
workloadResult,
|
||||
samplingWindow: {
|
||||
requestedDurationMs,
|
||||
measuredDurationMs,
|
||||
maxWorkloadOverrunMs,
|
||||
extendedForWorkload: workloadSettledAt !== null && workloadSettledAt > requestedDeadline,
|
||||
workloadSettledElapsedMs:
|
||||
workloadSettledAt === null ? null : Math.max(0, workloadSettledAt - samplingStartedAt),
|
||||
workloadSettledBeforeStop: workloadSettled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mean(values) {
|
||||
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function percentile(sorted, fraction) {
|
||||
if (sorted.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
|
||||
return sorted[index]
|
||||
}
|
||||
|
||||
export function summarizeSamples(samples) {
|
||||
const byKind = new Map()
|
||||
for (const sample of samples) {
|
||||
for (const proc of sample.processes) {
|
||||
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
|
||||
bucket.cpuValues.push(proc.cpu)
|
||||
bucket.rssValues.push(proc.rssBytes)
|
||||
byKind.set(proc.kind, bucket)
|
||||
}
|
||||
const counts = new Map()
|
||||
for (const proc of sample.processes) {
|
||||
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
||||
}
|
||||
for (const [kind, count] of counts) {
|
||||
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
|
||||
}
|
||||
}
|
||||
const summary = {}
|
||||
for (const [kind, values] of byKind) {
|
||||
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
|
||||
const rssSumBySample = samples.map((sample) =>
|
||||
sample.processes
|
||||
.filter((proc) => proc.kind === kind)
|
||||
.reduce((sum, proc) => sum + proc.rssBytes, 0)
|
||||
)
|
||||
summary[kind] = {
|
||||
meanCpuPercent: mean(values.cpuValues),
|
||||
p95CpuPercent: percentile(cpuSorted, 0.95),
|
||||
maxCpuPercent: Math.max(0, ...values.cpuValues),
|
||||
meanRssBytes: mean(rssSumBySample),
|
||||
maxProcessCount: values.maxProcessCount
|
||||
}
|
||||
}
|
||||
summary.total = {
|
||||
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
|
||||
p95CpuPercent: percentile(
|
||||
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
|
||||
0.95
|
||||
),
|
||||
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
export function summarizeProcessInventory(samples) {
|
||||
const inventory = {}
|
||||
for (const sample of samples) {
|
||||
const counts = new Map()
|
||||
for (const proc of sample.processes) {
|
||||
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
||||
const entry = inventory[proc.kind] ?? {
|
||||
maxProcessCount: 0,
|
||||
maxCpuPercent: 0,
|
||||
commandSamples: []
|
||||
}
|
||||
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
|
||||
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
|
||||
entry.commandSamples.push(proc.command)
|
||||
}
|
||||
inventory[proc.kind] = entry
|
||||
}
|
||||
for (const [kind, count] of counts) {
|
||||
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
|
||||
}
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
|
||||
export function terminateProcesses(processes) {
|
||||
for (const proc of processes) {
|
||||
try {
|
||||
process.kill(proc.pid)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sampleProcessTreeUntilWorkloadsComplete } from './idle-cpu-process-sampling.mjs'
|
||||
|
||||
function createClock(workloadCompletesAt) {
|
||||
let currentMs = 0
|
||||
let resolveWorkload
|
||||
const workloadPromise = new Promise((resolve) => {
|
||||
resolveWorkload = resolve
|
||||
})
|
||||
const wait = async (durationMs) => {
|
||||
currentMs += durationMs
|
||||
if (currentMs >= workloadCompletesAt) {
|
||||
resolveWorkload('complete')
|
||||
}
|
||||
await Promise.resolve()
|
||||
}
|
||||
const readRows = () => [
|
||||
{
|
||||
pid: 10,
|
||||
ppid: 0,
|
||||
percentCpu: 0,
|
||||
rssBytes: 1_024,
|
||||
cpuTimeSeconds: currentMs / 2_000,
|
||||
command: 'electron'
|
||||
}
|
||||
]
|
||||
return { now: () => currentMs, readRows, wait, workloadPromise }
|
||||
}
|
||||
|
||||
describe('idle CPU process sampling window', () => {
|
||||
it('extends through a slow workload and captures a final CPU delta', async () => {
|
||||
const clock = createClock(40)
|
||||
const result = await sampleProcessTreeUntilWorkloadsComplete({
|
||||
rootPid: 10,
|
||||
requestedDurationMs: 20,
|
||||
intervalMs: 10,
|
||||
maxWorkloadOverrunMs: 100,
|
||||
...clock
|
||||
})
|
||||
|
||||
expect(result.workloadResult).toBe('complete')
|
||||
expect(result.samples.map((sample) => sample.at)).toEqual([10, 20, 30, 40])
|
||||
expect(result.samplingWindow).toEqual({
|
||||
requestedDurationMs: 20,
|
||||
measuredDurationMs: 40,
|
||||
maxWorkloadOverrunMs: 100,
|
||||
extendedForWorkload: true,
|
||||
workloadSettledElapsedMs: 40,
|
||||
workloadSettledBeforeStop: true
|
||||
})
|
||||
})
|
||||
|
||||
it('invalidates a run that reaches the workload overrun guard', async () => {
|
||||
const clock = createClock(Infinity)
|
||||
await expect(
|
||||
sampleProcessTreeUntilWorkloadsComplete({
|
||||
rootPid: 10,
|
||||
requestedDurationMs: 20,
|
||||
intervalMs: 10,
|
||||
maxWorkloadOverrunMs: 20,
|
||||
...clock
|
||||
})
|
||||
).rejects.toThrow('exceeded the 20ms sampling overrun limit')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
export async function configureRendererScaleFixture(page, options, repoPath) {
|
||||
return page.evaluate(
|
||||
({ agentsPerWorktree, lineageDepth, repoPath }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const normalizePath = (value) =>
|
||||
String(value ?? '')
|
||||
.replaceAll('\\', '/')
|
||||
.toLowerCase()
|
||||
const primaryPath = normalizePath(repoPath)
|
||||
const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0)
|
||||
const state = store.getState()
|
||||
const worktrees = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.filter((worktree) => !worktree.isArchived)
|
||||
.sort((left, right) => {
|
||||
const primaryOrder =
|
||||
Number(normalizePath(right.path) === primaryPath) -
|
||||
Number(normalizePath(left.path) === primaryPath)
|
||||
return (
|
||||
primaryOrder ||
|
||||
compare(normalizePath(left.path), normalizePath(right.path)) ||
|
||||
compare(left.id, right.id)
|
||||
)
|
||||
})
|
||||
const appliedLineageDepth = Math.min(lineageDepth, Math.max(0, worktrees.length - 1))
|
||||
if (lineageDepth === 0 && agentsPerWorktree === 0) {
|
||||
return {
|
||||
applied: false,
|
||||
requestedLineageDepth: lineageDepth,
|
||||
appliedLineageDepth,
|
||||
agentsPerWorktree,
|
||||
seededAgentRows: 0
|
||||
}
|
||||
}
|
||||
|
||||
state.setActiveView('terminal')
|
||||
state.setSidebarOpen(true)
|
||||
state.setGroupBy('none')
|
||||
state.setSortBy('recent')
|
||||
state.setShowActiveOnly(false)
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
state.setHideDefaultBranchWorkspace(false)
|
||||
state.setFilterRepoIds([])
|
||||
|
||||
const lineageById = { ...store.getState().worktreeLineageById }
|
||||
const lineageParentIds = new Set()
|
||||
if (appliedLineageDepth > 0) {
|
||||
for (const worktree of worktrees) {
|
||||
delete lineageById[worktree.id]
|
||||
if (!worktree.instanceId) {
|
||||
throw new Error(`Worktree ${worktree.id} has no instanceId for lineage seeding`)
|
||||
}
|
||||
}
|
||||
for (let index = 1; index < worktrees.length; index += 1) {
|
||||
const child = worktrees[index]
|
||||
const parent = worktrees[Math.min(index - 1, appliedLineageDepth - 1)]
|
||||
lineageParentIds.add(parent.id)
|
||||
lineageById[child.id] = {
|
||||
worktreeId: child.id,
|
||||
worktreeInstanceId: child.instanceId,
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: parent.instanceId,
|
||||
origin: 'manual',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' },
|
||||
createdAt: 1_700_000_000_000 + index
|
||||
}
|
||||
}
|
||||
}
|
||||
const collapsedGroups = new Set(store.getState().collapsedGroups)
|
||||
for (const parentId of lineageParentIds) {
|
||||
collapsedGroups.delete(`lineage:${parentId}`)
|
||||
}
|
||||
store.setState({ worktreeLineageById: lineageById, collapsedGroups })
|
||||
|
||||
let seededAgentRows = 0
|
||||
if (agentsPerWorktree > 0) {
|
||||
store.getState().setWorktreeCardMode('Default')
|
||||
store.getState().setAgentActivityDisplayMode('full')
|
||||
const fixtureNow = Date.now()
|
||||
worktrees.forEach((worktree, worktreeIndex) => {
|
||||
const next = store.getState()
|
||||
const tab =
|
||||
next.tabsByWorktree[worktree.id]?.[0] ??
|
||||
next.createTab(worktree.id, undefined, undefined, {
|
||||
activate: false,
|
||||
id: `idle-cpu-tab-${worktreeIndex}`
|
||||
})
|
||||
for (let agentIndex = 0; agentIndex < agentsPerWorktree; agentIndex += 1) {
|
||||
const agentType = agentIndex % 2 === 0 ? 'codex' : 'claude'
|
||||
const leafSequence =
|
||||
BigInt(worktreeIndex) * BigInt(agentsPerWorktree) + BigInt(agentIndex + 1)
|
||||
const leafId = `00000000-0000-4000-8000-${leafSequence.toString(16).padStart(12, '0')}`
|
||||
store.getState().setAgentStatus(
|
||||
`${tab.id}:${leafId}`,
|
||||
{
|
||||
state: 'working',
|
||||
prompt: `Idle CPU agent ${worktreeIndex + 1}.${agentIndex + 1}`,
|
||||
agentType
|
||||
},
|
||||
agentType,
|
||||
{ updatedAt: fixtureNow, stateStartedAt: fixtureNow },
|
||||
{ tabId: tab.id, worktreeId: worktree.id }
|
||||
)
|
||||
seededAgentRows += 1
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
requestedLineageDepth: lineageDepth,
|
||||
appliedLineageDepth,
|
||||
lineageEdges: appliedLineageDepth > 0 ? Math.max(0, worktrees.length - 1) : 0,
|
||||
expandedLineageGroups: lineageParentIds.size,
|
||||
agentsPerWorktree,
|
||||
seededAgentRows,
|
||||
orderedWorktreeIds: worktrees.map((worktree) => worktree.id)
|
||||
}
|
||||
},
|
||||
{ agentsPerWorktree: options.agentsPerWorktree, lineageDepth: options.lineageDepth, repoPath }
|
||||
)
|
||||
}
|
||||
|
||||
export async function collectRendererCensus(page, configuredLineageDepth) {
|
||||
return page.evaluate((configuredDepth) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const worktrees = Object.values(state.worktreesByRepo).flat()
|
||||
const worktreeIds = new Set(worktrees.map((worktree) => worktree.id))
|
||||
const lineageDepthById = new Map()
|
||||
const getDepth = (worktreeId, trail = new Set()) => {
|
||||
if (lineageDepthById.has(worktreeId)) {
|
||||
return lineageDepthById.get(worktreeId)
|
||||
}
|
||||
const lineage = state.worktreeLineageById[worktreeId]
|
||||
if (!lineage || !worktreeIds.has(lineage.parentWorktreeId) || trail.has(worktreeId)) {
|
||||
return 0
|
||||
}
|
||||
const nextTrail = new Set(trail)
|
||||
nextTrail.add(worktreeId)
|
||||
const depth = 1 + getDepth(lineage.parentWorktreeId, nextTrail)
|
||||
lineageDepthById.set(worktreeId, depth)
|
||||
return depth
|
||||
}
|
||||
const logicalDepths = worktrees.map((worktree) => getDepth(worktree.id))
|
||||
const lineageParentIds = new Set(
|
||||
Object.values(state.worktreeLineageById)
|
||||
.filter((lineage) => worktreeIds.has(lineage.worktreeId))
|
||||
.map((lineage) => lineage.parentWorktreeId)
|
||||
)
|
||||
const sidebar = document.querySelector('[data-worktree-sidebar]')
|
||||
const mountedWorktreeIds = [
|
||||
...new Set(
|
||||
[...(sidebar?.querySelectorAll('[data-worktree-id]') ?? [])]
|
||||
.map((element) => element.getAttribute('data-worktree-id'))
|
||||
.filter(Boolean)
|
||||
)
|
||||
]
|
||||
const mountedAgentRows = [...(sidebar?.querySelectorAll('*') ?? [])].filter(
|
||||
(element) =>
|
||||
element.classList.contains('group/agent-row') ||
|
||||
element.classList.contains('compact-agent-row')
|
||||
).length
|
||||
let diagnosticCensus = null
|
||||
try {
|
||||
diagnosticCensus = window.__orcaTypingDiagnostic?.report().census ?? null
|
||||
} catch {}
|
||||
const collapsedLineageGroups = [...lineageParentIds].filter((parentId) =>
|
||||
state.collapsedGroups.has(`lineage:${parentId}`)
|
||||
).length
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
worktrees: {
|
||||
store: worktrees.length,
|
||||
mountedCards: sidebar?.querySelectorAll('[data-worktree-card-surface]').length ?? 0,
|
||||
mountedUnique: mountedWorktreeIds.length,
|
||||
mountedIds: mountedWorktreeIds.slice(0, 100),
|
||||
mountedIdsTruncated: Math.max(0, mountedWorktreeIds.length - 100)
|
||||
},
|
||||
agentRows: {
|
||||
storeLive: Object.keys(state.agentStatusByPaneKey ?? {}).length,
|
||||
storeRetained: Object.keys(state.retainedAgentsByPaneKey ?? {}).length,
|
||||
mounted: mountedAgentRows,
|
||||
diagnosticMounted: diagnosticCensus?.agentRows.mountedDom ?? null
|
||||
},
|
||||
storeListeners: diagnosticCensus?.storeListeners ?? null,
|
||||
lineage: {
|
||||
configuredDepth,
|
||||
measuredMaxDepth: Math.max(0, ...logicalDepths),
|
||||
edges: logicalDepths.filter((depth) => depth > 0).length,
|
||||
groups: lineageParentIds.size,
|
||||
expandedGroups: lineageParentIds.size - collapsedLineageGroups,
|
||||
collapsedGroups: collapsedLineageGroups
|
||||
},
|
||||
diagnostic: diagnosticCensus
|
||||
}
|
||||
}, configuredLineageDepth)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
const RENDERER_TIMER_INTERVAL_MS = 100
|
||||
|
||||
export async function startRendererTimingProbe(page) {
|
||||
await page.evaluate((timerIntervalMs) => {
|
||||
const maxSamples = 5_000
|
||||
const maxEntries = 80
|
||||
let phaseStartedAt = performance.now()
|
||||
let phaseStartedAtIso = new Date().toISOString()
|
||||
let timerId = null
|
||||
let driftCount = 0
|
||||
let driftSamples = []
|
||||
let longTaskEntries = []
|
||||
let observer = null
|
||||
let longTaskSupported = false
|
||||
|
||||
const round = (value) => Math.round(value * 100) / 100
|
||||
const summarize = (values, totalCount = values.length) => {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
const percentile = (fraction) =>
|
||||
sorted.length === 0
|
||||
? null
|
||||
: sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]
|
||||
return {
|
||||
count: totalCount,
|
||||
retainedCount: sorted.length,
|
||||
mean:
|
||||
sorted.length === 0
|
||||
? null
|
||||
: round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length),
|
||||
p50: sorted.length === 0 ? null : round(percentile(0.5)),
|
||||
p95: sorted.length === 0 ? null : round(percentile(0.95)),
|
||||
max: sorted.length === 0 ? null : round(sorted.at(-1))
|
||||
}
|
||||
}
|
||||
const recordLongTasks = (entries) => {
|
||||
for (const entry of entries) {
|
||||
longTaskEntries.push({
|
||||
startTime: entry.startTime,
|
||||
duration: entry.duration,
|
||||
name: entry.name
|
||||
})
|
||||
if (longTaskEntries.length > maxSamples) {
|
||||
longTaskEntries.shift()
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
longTaskSupported = PerformanceObserver.supportedEntryTypes?.includes('longtask') === true
|
||||
if (longTaskSupported) {
|
||||
observer = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
|
||||
observer.observe({ type: 'longtask', buffered: true })
|
||||
}
|
||||
} catch {
|
||||
observer = null
|
||||
longTaskSupported = false
|
||||
}
|
||||
const scheduleTimer = () => {
|
||||
const expectedAt = performance.now() + timerIntervalMs
|
||||
timerId = setTimeout(() => {
|
||||
driftCount += 1
|
||||
driftSamples.push(Math.max(0, performance.now() - expectedAt))
|
||||
if (driftSamples.length > maxSamples) {
|
||||
driftSamples.shift()
|
||||
}
|
||||
scheduleTimer()
|
||||
}, timerIntervalMs)
|
||||
}
|
||||
const snapshot = (reset) => {
|
||||
if (observer) {
|
||||
recordLongTasks(observer.takeRecords())
|
||||
}
|
||||
const capturedAt = performance.now()
|
||||
const phaseLongTasks = longTaskEntries.filter((entry) => entry.startTime >= phaseStartedAt)
|
||||
const durations = phaseLongTasks.map((entry) => entry.duration)
|
||||
const result = {
|
||||
startedAt: phaseStartedAtIso,
|
||||
capturedAt: new Date().toISOString(),
|
||||
durationMs: round(capturedAt - phaseStartedAt),
|
||||
timerIntervalMs,
|
||||
timerDriftMs: summarize(driftSamples, driftCount),
|
||||
longTasks: {
|
||||
supported: longTaskSupported,
|
||||
...summarize(durations),
|
||||
totalDurationMs: round(durations.reduce((sum, value) => sum + value, 0)),
|
||||
entries: phaseLongTasks.slice(-maxEntries).map((entry) => ({
|
||||
startMs: round(entry.startTime - phaseStartedAt),
|
||||
durationMs: round(entry.duration),
|
||||
name: entry.name
|
||||
})),
|
||||
entriesTruncated: Math.max(0, phaseLongTasks.length - maxEntries)
|
||||
}
|
||||
}
|
||||
if (reset) {
|
||||
phaseStartedAt = capturedAt
|
||||
phaseStartedAtIso = new Date().toISOString()
|
||||
driftCount = 0
|
||||
driftSamples = []
|
||||
longTaskEntries = []
|
||||
}
|
||||
return result
|
||||
}
|
||||
scheduleTimer()
|
||||
window.__orcaIdleCpuTimingProbe = {
|
||||
snapshot: () => snapshot(true),
|
||||
stop: () => {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId)
|
||||
}
|
||||
const result = snapshot(false)
|
||||
observer?.disconnect()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}, RENDERER_TIMER_INTERVAL_MS)
|
||||
}
|
||||
|
||||
export async function snapshotRendererTimingProbe(page) {
|
||||
return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.snapshot() ?? null)
|
||||
}
|
||||
|
||||
export async function stopRendererTimingProbe(page) {
|
||||
return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.stop() ?? null)
|
||||
}
|
||||
|
||||
export async function runZustandPublications(page, count, intervalMs) {
|
||||
return page.evaluate(
|
||||
({ count, intervalMs }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
reject(new Error('window.__store is not available'))
|
||||
return
|
||||
}
|
||||
const maxSamples = 5_000
|
||||
const startedAt = performance.now()
|
||||
const startedAtIso = new Date().toISOString()
|
||||
const schedulingDriftMs = []
|
||||
let completed = 0
|
||||
const finish = () => {
|
||||
const sorted = [...schedulingDriftMs].sort((left, right) => left - right)
|
||||
const percentile = (fraction) =>
|
||||
sorted.length === 0
|
||||
? null
|
||||
: sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]
|
||||
const round = (value) => Math.round(value * 100) / 100
|
||||
resolve({
|
||||
requested: count,
|
||||
completed,
|
||||
intervalMs,
|
||||
startedAt: startedAtIso,
|
||||
completedAt: new Date().toISOString(),
|
||||
durationMs: round(performance.now() - startedAt),
|
||||
schedulingDriftMs: {
|
||||
retainedCount: sorted.length,
|
||||
p50: sorted.length === 0 ? null : round(percentile(0.5)),
|
||||
p95: sorted.length === 0 ? null : round(percentile(0.95)),
|
||||
max: sorted.length === 0 ? null : round(sorted.at(-1))
|
||||
}
|
||||
})
|
||||
}
|
||||
const publish = () => {
|
||||
const publishedAt = performance.now()
|
||||
const scheduledAt = startedAt + completed * intervalMs
|
||||
schedulingDriftMs.push(Math.max(0, publishedAt - scheduledAt))
|
||||
if (schedulingDriftMs.length > maxSamples) {
|
||||
schedulingDriftMs.shift()
|
||||
}
|
||||
try {
|
||||
// Why: an empty partial notifies every subscriber without changing domain state.
|
||||
store.setState({})
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
completed += 1
|
||||
if (completed >= count) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
const nextAt = startedAt + completed * intervalMs
|
||||
setTimeout(publish, Math.max(0, nextAt - performance.now()))
|
||||
}
|
||||
if (count === 0) {
|
||||
finish()
|
||||
} else {
|
||||
setTimeout(publish, 0)
|
||||
}
|
||||
}),
|
||||
{ count, intervalMs }
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,8 @@ const transientDownloadErrorCodes = new Set([
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
// GitHub release CDN can refuse HTTP/2 streams under load; retry like socket resets.
|
||||
'ERR_HTTP2_STREAM_ERROR',
|
||||
'ENETDOWN',
|
||||
'ENETRESET',
|
||||
'ENETUNREACH',
|
||||
@@ -185,7 +187,9 @@ async function downloadElectronArtifactWithRetry(downloadOptions) {
|
||||
function getDownloadRetryDelays() {
|
||||
const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS
|
||||
if (!configured) {
|
||||
return [1_000, 3_000]
|
||||
// Why: GitHub release CDN returns intermittent 503 / HTTP2 stream refusals
|
||||
// under CI fan-out; a few short attempts still exhaust during outages.
|
||||
return [1_000, 3_000, 5_000, 10_000]
|
||||
}
|
||||
|
||||
const delays = configured.split(',').map(Number)
|
||||
@@ -200,7 +204,7 @@ function isTransientDownloadError(error) {
|
||||
if (transientDownloadErrorCodes.has(candidate?.code)) {
|
||||
return true
|
||||
}
|
||||
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
|
||||
const statusCode = getDownloadErrorStatusCode(candidate)
|
||||
if (
|
||||
statusCode === 408 ||
|
||||
statusCode === 425 ||
|
||||
@@ -213,6 +217,13 @@ function isTransientDownloadError(error) {
|
||||
return false
|
||||
}
|
||||
|
||||
function getDownloadErrorStatusCode(error) {
|
||||
// @electron/get FetchDownloader uses Fetch Response.status; older got uses statusCode.
|
||||
return (
|
||||
error?.statusCode ?? error?.status ?? error?.response?.statusCode ?? error?.response?.status
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorChain(error) {
|
||||
const errors = []
|
||||
let candidate = error
|
||||
@@ -225,7 +236,7 @@ function getErrorChain(error) {
|
||||
|
||||
function formatDownloadError(error) {
|
||||
for (const candidate of getErrorChain(error)) {
|
||||
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
|
||||
const statusCode = getDownloadErrorStatusCode(candidate)
|
||||
if (statusCode) {
|
||||
return `HTTP ${statusCode}`
|
||||
}
|
||||
|
||||
@@ -118,6 +118,58 @@ describe('install-electron-package-binary', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries HTTP/2 stream refusal Electron download failures', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 1,
|
||||
downloadErrorCode: 'ERR_HTTP2_STREAM_ERROR'
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(2)
|
||||
expect(result.stderr).toContain(
|
||||
'Transient Electron download failure (ERR_HTTP2_STREAM_ERROR)'
|
||||
)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('retries Electron download HTTP 503 failures from Fetch Response.status', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, {
|
||||
downloadFailures: 1,
|
||||
downloadHttpStatus: 503
|
||||
})
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(
|
||||
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
|
||||
).toHaveLength(2)
|
||||
expect(result.stderr).toContain('Transient Electron download failure (HTTP 503)')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails after exhausting transient Electron download retries', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
@@ -279,7 +331,12 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
|
||||
|
||||
function writeFakeElectronGet(
|
||||
projectDir,
|
||||
{ downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {}
|
||||
{
|
||||
downloadNeverSettles = false,
|
||||
downloadFailures = 0,
|
||||
downloadErrorCode = 'ECONNRESET',
|
||||
downloadHttpStatus = null
|
||||
} = {}
|
||||
) {
|
||||
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
|
||||
mkdirSync(getDir, { recursive: true })
|
||||
@@ -299,6 +356,11 @@ exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
|
||||
if (${JSON.stringify(downloadHttpStatus)} != null) {
|
||||
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
|
||||
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
|
||||
throw error
|
||||
}
|
||||
const cause = Object.assign(new Error('download failed'), {
|
||||
code: ${JSON.stringify(downloadErrorCode)}
|
||||
})
|
||||
|
||||
@@ -30,8 +30,8 @@ const recoveryEnd = runtimeSource.indexOf(
|
||||
)
|
||||
const recoverySource = runtimeSource.slice(recoveryStart, recoveryEnd)
|
||||
if (
|
||||
recoveryStart < 0 ||
|
||||
recoveryEnd < 0 ||
|
||||
recoveryStart === -1 ||
|
||||
recoveryEnd === -1 ||
|
||||
!recoverySource.includes('await this.flushWorkspaceSessionOrThrowAsync()') ||
|
||||
recoverySource.includes('flushOrThrow()')
|
||||
) {
|
||||
|
||||
@@ -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.':
|
||||
|
||||
@@ -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 更改?',
|
||||
|
||||
@@ -210,7 +210,7 @@ function connectInvalidPeer(socketPath) {
|
||||
const onData = (chunk) => {
|
||||
buffer += chunk
|
||||
const newline = buffer.indexOf('\n')
|
||||
if (newline < 0) {
|
||||
if (newline === -1) {
|
||||
return
|
||||
}
|
||||
let response
|
||||
|
||||
@@ -54,7 +54,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
|
||||
const frames = []
|
||||
while (cursor.length > 0) {
|
||||
const frameStart = cursor.indexOf(JPEG_START)
|
||||
if (frameStart < 0) {
|
||||
if (frameStart === -1) {
|
||||
const keepLastByte = cursor.at(-1) === 0xff
|
||||
return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) }
|
||||
}
|
||||
@@ -62,7 +62,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
|
||||
cursor = cursor.subarray(frameStart)
|
||||
}
|
||||
const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length)
|
||||
if (frameEnd < 0) {
|
||||
if (frameEnd === -1) {
|
||||
const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes)
|
||||
return { frames, pending: trackedCopy(tail) }
|
||||
}
|
||||
@@ -79,7 +79,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
|
||||
const frames = []
|
||||
while (cursor.length > 0) {
|
||||
const frameStart = cursor.indexOf(JPEG_START)
|
||||
if (frameStart < 0) {
|
||||
if (frameStart === -1) {
|
||||
const keepLastByte = cursor.at(-1) === 0xff
|
||||
return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) }
|
||||
}
|
||||
@@ -87,7 +87,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
|
||||
cursor = cursor.subarray(frameStart)
|
||||
}
|
||||
const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length)
|
||||
if (frameEnd < 0) {
|
||||
if (frameEnd === -1) {
|
||||
const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes)
|
||||
return { frames, pending: trackedCopy(tail) }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -281,7 +285,7 @@ describe('orchestration skill guidance', () => {
|
||||
expect(workerLoop).toContain('opaque provider model id with `--model`')
|
||||
expect(workerLoop).toContain('`--effort` requires `--model`')
|
||||
expect(workerLoop).toContain('neither option can combine with `--terminal`')
|
||||
expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json')
|
||||
expect(workerLoop).toContain('--agent claude --model opus --effort high --json')
|
||||
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,7 +3,10 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard'
|
||||
import {
|
||||
createPlainNodeEntryGuardPlugin,
|
||||
GUARDED_ENTRY_NAMES
|
||||
} from '../build-plugins/plain-node-entry-guard'
|
||||
|
||||
let outputDir: string | undefined
|
||||
|
||||
@@ -45,16 +48,16 @@ function runWriteBundle(plugin: Plugin, dir: string, code = ''): void {
|
||||
)
|
||||
}
|
||||
|
||||
function runCloseBundle(plugin: Plugin): void {
|
||||
async function runCloseBundle(plugin: Plugin): Promise<void> {
|
||||
const hook = plugin.closeBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected closeBundle hook')
|
||||
}
|
||||
hook.call({} as never)
|
||||
await hook.call({} as never)
|
||||
}
|
||||
|
||||
describe('plain Node entry guard', () => {
|
||||
it('smoke-loads the daemon after output files are written', () => {
|
||||
it('smoke-loads the daemon after output files are written', async () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
@@ -64,17 +67,17 @@ describe('plain Node entry guard', () => {
|
||||
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n'
|
||||
)
|
||||
|
||||
expect(() => runCloseBundle(plugin)).not.toThrow()
|
||||
await expect(runCloseBundle(plugin)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('runs the deferred smoke from closeBundle', () => {
|
||||
it('runs the deferred smoke from closeBundle', async () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
runWriteBundle(plugin, dir)
|
||||
writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n")
|
||||
|
||||
expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node')
|
||||
await expect(runCloseBundle(plugin)).rejects.toThrow('failed to load under plain Node')
|
||||
})
|
||||
|
||||
it('rejects Electron imports during the static bundle scan', () => {
|
||||
@@ -84,6 +87,70 @@ describe('plain Node entry guard', () => {
|
||||
'requires electron'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects Electron subpath requires', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron/main")')).toThrow(
|
||||
'requires electron'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails the smoke when the daemon exits zero on an empty argv', async () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
runWriteBundle(plugin, dir)
|
||||
writeFileSync(join(dir, 'daemon-entry.js'), 'console.error("Usage: daemon-entry <socket>")\n')
|
||||
|
||||
await expect(runCloseBundle(plugin)).rejects.toThrow('did not reject an empty argv')
|
||||
})
|
||||
|
||||
// daemon-entry installs a SIGTERM handler, so the smoke deadline only holds if
|
||||
// it escalates to SIGKILL — spawnSync's own timeout would block here forever.
|
||||
it('kills a daemon that traps SIGTERM and never exits', async () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin({ timeoutMs: 250, killGraceMs: 250 })
|
||||
|
||||
runWriteBundle(plugin, dir)
|
||||
writeFileSync(
|
||||
join(dir, 'daemon-entry.js'),
|
||||
"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\n"
|
||||
)
|
||||
|
||||
await expect(runCloseBundle(plugin)).rejects.toThrow('did not exit within 250ms')
|
||||
}, 10_000)
|
||||
})
|
||||
|
||||
// Why: writeBundle skips names absent from the bundle, so a renamed rollup input
|
||||
// would silently stop guarding that entry instead of failing the build.
|
||||
describe('guarded entry names', () => {
|
||||
function runBuildStart(plugin: Plugin, input: unknown): void {
|
||||
const hook = plugin.buildStart
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected buildStart hook')
|
||||
}
|
||||
hook.call({} as never, { input } as Rollup.NormalizedInputOptions)
|
||||
}
|
||||
|
||||
it('rejects a guarded name that is no longer a rollup input', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const input = Object.fromEntries(
|
||||
GUARDED_ENTRY_NAMES.filter((name) => name !== 'stt-worker').map((name) => [
|
||||
name,
|
||||
`${name}.ts`
|
||||
])
|
||||
)
|
||||
|
||||
expect(() => runBuildStart(plugin, input)).toThrow('"stt-worker"')
|
||||
})
|
||||
|
||||
it('passes when every guarded name is a rollup input', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const input = Object.fromEntries(GUARDED_ENTRY_NAMES.map((name) => [name, `${name}.ts`]))
|
||||
|
||||
expect(() => runBuildStart(plugin, input)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// Why (#11161): Electron's module is not registered on worker threads either —
|
||||
|
||||
@@ -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,14 +68,36 @@ 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('. != "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('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1')
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const dedicatedWorkflows = [
|
||||
'golden-e2e-experiment.yml',
|
||||
|
||||
@@ -8,6 +8,7 @@ const dependencyAction = parse(
|
||||
)
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
const shellContractFiles = [
|
||||
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
|
||||
'src/main/daemon/shell-ready.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
@@ -75,6 +76,7 @@ describe('PR workflow parallelism', () => {
|
||||
|
||||
expect(shellStep).toBeDefined()
|
||||
expect(shellInstall).toBeDefined()
|
||||
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
|
||||
// Why the whole workflow, not just the general shards: any other lane installing
|
||||
// these shells would silently start running the real-shell tests twice.
|
||||
expect(jobsInstallingPackages).toEqual(['shell_contracts'])
|
||||
|
||||
@@ -94,7 +94,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) {
|
||||
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
|
||||
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
|
||||
const index = stdoutBuffer.indexOf(sentinel)
|
||||
if (index < 0) {
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
@@ -148,7 +148,7 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
|
||||
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
|
||||
const index = stdoutBuffer.indexOf(sentinel)
|
||||
if (index < 0) {
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
ready = true
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
resolveEnvironment,
|
||||
resolveEnvironmentPairingOffer
|
||||
} from '../../src/shared/runtime-environment-store'
|
||||
import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types'
|
||||
import type { MemorySnapshot } from '../../src/shared/types'
|
||||
import type { RuntimeStatus } from '../../src/shared/runtime-types'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME
|
||||
|
||||
+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)
|
||||
}
|
||||
@@ -1,15 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
import { _electron as electron } from '@stablyai/playwright-test'
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
descendantsOf,
|
||||
readProcessRows,
|
||||
sampleProcessTreeUntilWorkloadsComplete,
|
||||
summarizeProcessInventory,
|
||||
summarizeSamples,
|
||||
terminateProcesses
|
||||
} from './idle-cpu-process-sampling.mjs'
|
||||
import {
|
||||
collectRendererCensus,
|
||||
configureRendererScaleFixture
|
||||
} from './idle-cpu-renderer-scale-fixture.mjs'
|
||||
import {
|
||||
runZustandPublications,
|
||||
snapshotRendererTimingProbe,
|
||||
startRendererTimingProbe,
|
||||
stopRendererTimingProbe
|
||||
} from './idle-cpu-renderer-timing-probe.mjs'
|
||||
import { installSyntheticVisibleSpinners } from './idle-cpu-synthetic-spinners.mjs'
|
||||
|
||||
const DEFAULT_WARMUP_MS = 15_000
|
||||
const DEFAULT_SAMPLE_MS = 30_000
|
||||
const DEFAULT_INTERVAL_MS = 1_000
|
||||
const DEFAULT_WORKTREE_COUNT = 1
|
||||
const DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS = 100
|
||||
const ONBOARDING_FINAL_STEP = 3
|
||||
const ONBOARDING_FLOW_VERSION = 2
|
||||
|
||||
@@ -19,6 +38,10 @@ function parseArgs(argv) {
|
||||
sampleMs: DEFAULT_SAMPLE_MS,
|
||||
intervalMs: DEFAULT_INTERVAL_MS,
|
||||
worktrees: DEFAULT_WORKTREE_COUNT,
|
||||
lineageDepth: 0,
|
||||
agentsPerWorktree: 0,
|
||||
zustandPublications: 0,
|
||||
zustandPublicationIntervalMs: DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS,
|
||||
skipBuild: false,
|
||||
headful: false,
|
||||
output: null,
|
||||
@@ -47,6 +70,14 @@ function parseArgs(argv) {
|
||||
options.intervalMs = Number(readValue())
|
||||
} else if (arg === '--worktrees') {
|
||||
options.worktrees = Number(readValue())
|
||||
} else if (arg === '--lineage-depth') {
|
||||
options.lineageDepth = Number(readValue())
|
||||
} else if (arg === '--agents-per-worktree') {
|
||||
options.agentsPerWorktree = Number(readValue())
|
||||
} else if (arg === '--zustand-publications') {
|
||||
options.zustandPublications = Number(readValue())
|
||||
} else if (arg === '--zustand-publication-interval-ms') {
|
||||
options.zustandPublicationIntervalMs = Number(readValue())
|
||||
} else if (arg === '--output') {
|
||||
options.output = readValue()
|
||||
} else if (arg === '--skip-build') {
|
||||
@@ -73,6 +104,10 @@ function parseArgs(argv) {
|
||||
'sampleMs',
|
||||
'intervalMs',
|
||||
'worktrees',
|
||||
'lineageDepth',
|
||||
'agentsPerWorktree',
|
||||
'zustandPublications',
|
||||
'zustandPublicationIntervalMs',
|
||||
'syntheticVisibleSpinners',
|
||||
'syntheticSpinnerSteps'
|
||||
]) {
|
||||
@@ -82,16 +117,33 @@ function parseArgs(argv) {
|
||||
}
|
||||
options.worktrees = Math.max(1, Math.floor(options.worktrees))
|
||||
options.intervalMs = Math.max(250, Math.floor(options.intervalMs))
|
||||
options.lineageDepth = Math.floor(options.lineageDepth)
|
||||
options.agentsPerWorktree = Math.floor(options.agentsPerWorktree)
|
||||
options.zustandPublications = Math.floor(options.zustandPublications)
|
||||
options.zustandPublicationIntervalMs = Math.max(
|
||||
1,
|
||||
Math.floor(options.zustandPublicationIntervalMs)
|
||||
)
|
||||
options.syntheticVisibleSpinners = Math.max(0, Math.floor(options.syntheticVisibleSpinners))
|
||||
options.syntheticSpinnerSteps = Math.max(1, Math.floor(options.syntheticSpinnerSteps))
|
||||
if (!['smooth', 'steps'].includes(options.syntheticSpinnerAnimation)) {
|
||||
throw new Error(`Invalid --synthetic-spinner-animation: ${options.syntheticSpinnerAnimation}`)
|
||||
}
|
||||
if (options.lineageDepth > 0 && options.worktrees < 2) {
|
||||
throw new Error('--lineage-depth requires at least two --worktrees')
|
||||
}
|
||||
const publicationSpanMs =
|
||||
Math.max(0, options.zustandPublications - 1) * options.zustandPublicationIntervalMs
|
||||
if (publicationSpanMs > options.sampleMs) {
|
||||
throw new Error(
|
||||
`Zustand publication span ${publicationSpanMs}ms exceeds --sample-ms ${options.sampleMs}`
|
||||
)
|
||||
}
|
||||
return options
|
||||
}
|
||||
function printUsage() {
|
||||
console.log(
|
||||
`Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> Step count for --synthetic-spinner-animation steps (default 12)\n`
|
||||
`Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --lineage-depth <n> Nest all worktrees under one expanded lineage, up to this depth\n --agents-per-worktree <n> Seed this many visible inline agent rows per worktree\n --zustand-publications <n> Publish exactly this many store updates during sampling\n --zustand-publication-interval-ms <n> Publication cadence (default ${DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> Step count for --synthetic-spinner-animation steps (default 12)\n`
|
||||
)
|
||||
}
|
||||
function run(command, args, options = {}) {
|
||||
@@ -188,140 +240,6 @@ function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function parseCpuTimeSeconds(value) {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
|
||||
const days = dayOrTime === null ? 0 : Number(dayOrTime)
|
||||
const parts = maybeTime.split(':').map(Number)
|
||||
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
|
||||
return null
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return days * 86400 + parts[0] * 60 + parts[1]
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return days * 86400 + parts[0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseUnixProcesses(stdout) {
|
||||
const rows = []
|
||||
for (const raw of stdout.split('\n')) {
|
||||
const line = raw.trim()
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
percentCpu: Number(match[3]),
|
||||
rssBytes: Number(match[4]) * 1024,
|
||||
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
|
||||
command: match[6]
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function readUnixProcesses() {
|
||||
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
return parseUnixProcesses(stdout)
|
||||
}
|
||||
|
||||
function readWindowsProcesses() {
|
||||
const script =
|
||||
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress'
|
||||
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || 'PowerShell process enumeration failed')
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout || '[]')
|
||||
const entries = Array.isArray(parsed) ? parsed : [parsed]
|
||||
return entries.map((entry) => ({
|
||||
pid: Number(entry.ProcessId),
|
||||
ppid: Number(entry.ParentProcessId),
|
||||
percentCpu: 0,
|
||||
cpuTimeSeconds: null,
|
||||
rssBytes: Number(entry.WorkingSetSize) || 0,
|
||||
command: String(entry.CommandLine || '')
|
||||
}))
|
||||
}
|
||||
|
||||
function readProcessRows() {
|
||||
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
|
||||
}
|
||||
|
||||
function descendantsOf(rows, rootPid) {
|
||||
const children = new Map()
|
||||
for (const row of rows) {
|
||||
const list = children.get(row.ppid) ?? []
|
||||
list.push(row)
|
||||
children.set(row.ppid, list)
|
||||
}
|
||||
const result = []
|
||||
const stack = [rootPid]
|
||||
const seen = new Set()
|
||||
while (stack.length > 0) {
|
||||
const pid = stack.pop()
|
||||
if (seen.has(pid)) {
|
||||
continue
|
||||
}
|
||||
seen.add(pid)
|
||||
const row = rows.find((candidate) => candidate.pid === pid)
|
||||
if (row) {
|
||||
result.push(row)
|
||||
}
|
||||
for (const child of children.get(pid) ?? []) {
|
||||
stack.push(child.pid)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function classify(row, rootPid) {
|
||||
const command = row.command.toLowerCase()
|
||||
if (row.pid === rootPid) {
|
||||
return 'main'
|
||||
}
|
||||
if (command.includes('daemon-entry')) {
|
||||
return 'daemon'
|
||||
}
|
||||
if (command.includes('--type=gpu-process')) {
|
||||
return 'gpu'
|
||||
}
|
||||
if (command.includes('--type=renderer')) {
|
||||
return 'renderer'
|
||||
}
|
||||
if (command.includes('--type=utility')) {
|
||||
return 'utility'
|
||||
}
|
||||
if (command.includes('--type=')) {
|
||||
return 'electron-other'
|
||||
}
|
||||
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
|
||||
return 'agent-or-node'
|
||||
}
|
||||
return 'other-descendant'
|
||||
}
|
||||
|
||||
async function collectRendererIdleState(page) {
|
||||
return page.evaluate(() => {
|
||||
const describeElement = (element) => {
|
||||
@@ -364,93 +282,6 @@ async function collectRendererIdleState(page) {
|
||||
})
|
||||
}
|
||||
|
||||
function summarizeSamples(samples) {
|
||||
const byKind = new Map()
|
||||
for (const sample of samples) {
|
||||
for (const proc of sample.processes) {
|
||||
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
|
||||
bucket.cpuValues.push(proc.cpu)
|
||||
bucket.rssValues.push(proc.rssBytes)
|
||||
byKind.set(proc.kind, bucket)
|
||||
}
|
||||
const counts = new Map()
|
||||
for (const proc of sample.processes) {
|
||||
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
||||
}
|
||||
for (const [kind, count] of counts) {
|
||||
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
|
||||
}
|
||||
}
|
||||
const summary = {}
|
||||
for (const [kind, values] of byKind) {
|
||||
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
|
||||
const rssSumBySample = samples.map((sample) =>
|
||||
sample.processes
|
||||
.filter((proc) => proc.kind === kind)
|
||||
.reduce((sum, proc) => sum + proc.rssBytes, 0)
|
||||
)
|
||||
summary[kind] = {
|
||||
meanCpuPercent: mean(values.cpuValues),
|
||||
p95CpuPercent: percentile(cpuSorted, 0.95),
|
||||
maxCpuPercent: Math.max(0, ...values.cpuValues),
|
||||
meanRssBytes: mean(rssSumBySample),
|
||||
maxProcessCount: values.maxProcessCount
|
||||
}
|
||||
}
|
||||
summary.total = {
|
||||
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
|
||||
p95CpuPercent: percentile(
|
||||
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
|
||||
0.95
|
||||
),
|
||||
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
function summarizeProcessInventory(samples) {
|
||||
const inventory = {}
|
||||
for (const sample of samples) {
|
||||
const counts = new Map()
|
||||
for (const proc of sample.processes) {
|
||||
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
|
||||
const entry = inventory[proc.kind] ?? {
|
||||
maxProcessCount: 0,
|
||||
maxCpuPercent: 0,
|
||||
commandSamples: []
|
||||
}
|
||||
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
|
||||
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
|
||||
entry.commandSamples.push(proc.command)
|
||||
}
|
||||
inventory[proc.kind] = entry
|
||||
}
|
||||
for (const [kind, count] of counts) {
|
||||
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
|
||||
}
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
function mean(values) {
|
||||
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function percentile(sorted, fraction) {
|
||||
if (sorted.length === 0) {
|
||||
return 0
|
||||
}
|
||||
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
|
||||
return sorted[index]
|
||||
}
|
||||
|
||||
function terminateProcesses(processes) {
|
||||
for (const proc of processes) {
|
||||
try {
|
||||
process.kill(proc.pid)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const root = path.resolve(import.meta.dirname, '..', '..')
|
||||
@@ -491,6 +322,11 @@ async function main() {
|
||||
const page = await app.firstWindow({ timeout: 120_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
|
||||
await page.waitForFunction(
|
||||
() => window.__store?.getState().workspaceSessionReady === true,
|
||||
null,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
const measurementCss = []
|
||||
if (options.disableRendererAnimations) {
|
||||
measurementCss.push(
|
||||
@@ -506,63 +342,111 @@ async function main() {
|
||||
options.syntheticSpinnerAnimation,
|
||||
options.syntheticSpinnerSteps
|
||||
)
|
||||
await page.evaluate(async (repoPath) => {
|
||||
await window.api.repos.add({ path: repoPath })
|
||||
const fixtureState = await page.evaluate(async (repoPath) => {
|
||||
const added = await window.api.repos.add({ path: repoPath })
|
||||
if ('error' in added) {
|
||||
return { error: added.error }
|
||||
}
|
||||
const store = window.__store
|
||||
await store?.getState().fetchRepos()
|
||||
const repo = store?.getState().repos.find((candidate) => candidate.path === repoPath)
|
||||
const repo = store?.getState().repos.find((candidate) => candidate.id === added.repo.id)
|
||||
if (repo) {
|
||||
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
|
||||
await store.getState().fetchWorktrees(repo.id)
|
||||
const detected = await store
|
||||
.getState()
|
||||
.fetchWorktrees(repo.id, { requireAuthoritative: true })
|
||||
const importedWorktreePaths = (
|
||||
store.getState().detectedWorktreesByRepo[repo.id]?.worktrees ?? []
|
||||
)
|
||||
.filter((worktree) => !worktree.selectedCheckout)
|
||||
.map((worktree) => worktree.path)
|
||||
const updated = await store.getState().updateRepo(repo.id, {
|
||||
externalWorktreeVisibility: 'show',
|
||||
importedExternalWorktreePaths: importedWorktreePaths,
|
||||
externalWorktreeInboxBaselinePaths: importedWorktreePaths
|
||||
})
|
||||
const refreshed = await store
|
||||
.getState()
|
||||
.fetchWorktrees(repo.id, { requireAuthoritative: true })
|
||||
return {
|
||||
detected,
|
||||
updated,
|
||||
refreshed,
|
||||
detectedCount: store.getState().detectedWorktreesByRepo[repo.id]?.worktrees.length ?? 0,
|
||||
importedCount: importedWorktreePaths.length,
|
||||
visibleCount: store.getState().worktreesByRepo[repo.id]?.length ?? 0
|
||||
}
|
||||
}
|
||||
return { error: 'repo-not-found' }
|
||||
}, repoDir)
|
||||
console.log(`[idle-cpu] fixture ${JSON.stringify(fixtureState)}`)
|
||||
await page.waitForFunction(
|
||||
() => window.__store?.getState().workspaceSessionReady === true,
|
||||
(expectedWorktrees) => {
|
||||
const state = window.__store?.getState()
|
||||
return (
|
||||
state?.workspaceSessionReady === true &&
|
||||
Object.values(state.worktreesByRepo).flat().length === expectedWorktrees
|
||||
)
|
||||
},
|
||||
options.worktrees,
|
||||
{ timeout: 180_000 }
|
||||
)
|
||||
const scaleFixtureState = await configureRendererScaleFixture(page, options, repoDir)
|
||||
if (scaleFixtureState.applied) {
|
||||
console.log(`[idle-cpu] scale fixture ${JSON.stringify(scaleFixtureState)}`)
|
||||
}
|
||||
await page.waitForFunction(
|
||||
(expectedAgentRows) => {
|
||||
const state = window.__store?.getState()
|
||||
return (
|
||||
state !== undefined &&
|
||||
Object.keys(state.agentStatusByPaneKey ?? {}).length >= expectedAgentRows
|
||||
)
|
||||
},
|
||||
scaleFixtureState.seededAgentRows,
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
await page.waitForFunction(
|
||||
() => Boolean(document.querySelector('[data-worktree-sidebar] [data-worktree-id]')),
|
||||
null,
|
||||
{ timeout: 60_000 }
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
console.log(
|
||||
`[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees}`
|
||||
`[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees} lineage-depth=${options.lineageDepth} agents/worktree=${options.agentsPerWorktree} publications=${options.zustandPublications}`
|
||||
)
|
||||
await startRendererTimingProbe(page)
|
||||
await sleep(options.warmupMs)
|
||||
const rendererIdleState = await collectRendererIdleState(page)
|
||||
const deadline = Date.now() + options.sampleMs
|
||||
const samples = []
|
||||
let previousSnapshot = null
|
||||
while (Date.now() <= deadline || samples.length === 0) {
|
||||
const sampledAt = Date.now()
|
||||
const processRows = descendantsOf(readProcessRows(), rootPid)
|
||||
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
|
||||
if (previousSnapshot) {
|
||||
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
|
||||
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
|
||||
const processes = rawProcesses.map((row) => {
|
||||
const previous = previousByPid.get(row.pid)
|
||||
const canComputeDelta =
|
||||
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
|
||||
const cpu = canComputeDelta
|
||||
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
|
||||
: row.percentCpu
|
||||
return { ...row, cpu }
|
||||
})
|
||||
samples.push({
|
||||
at: sampledAt,
|
||||
elapsedMs: sampledAt - previousSnapshot.at,
|
||||
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
|
||||
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
|
||||
processes
|
||||
})
|
||||
}
|
||||
previousSnapshot = { at: sampledAt, processes: rawProcesses }
|
||||
await sleep(options.intervalMs)
|
||||
}
|
||||
const rendererCensusBefore = await collectRendererCensus(page, options.lineageDepth)
|
||||
const rendererTimingBefore = await snapshotRendererTimingProbe(page)
|
||||
const publicationPromise = runZustandPublications(
|
||||
page,
|
||||
options.zustandPublications,
|
||||
options.zustandPublicationIntervalMs
|
||||
)
|
||||
const sampled = await sampleProcessTreeUntilWorkloadsComplete({
|
||||
rootPid,
|
||||
requestedDurationMs: options.sampleMs,
|
||||
intervalMs: options.intervalMs,
|
||||
workloadPromise: publicationPromise
|
||||
})
|
||||
const samples = sampled.samples
|
||||
const zustandPublications = sampled.workloadResult
|
||||
const rendererTimingAfter = await stopRendererTimingProbe(page)
|
||||
const rendererCensusAfter = await collectRendererCensus(page, options.lineageDepth)
|
||||
const report = {
|
||||
benchmark: 'orca-idle-cpu',
|
||||
createdAt: new Date().toISOString(),
|
||||
options,
|
||||
rootPid,
|
||||
platform: { platform: process.platform, arch: process.arch, cpus: os.cpus().length },
|
||||
fixtureState,
|
||||
scaleFixtureState,
|
||||
rendererIdleState,
|
||||
rendererCensusBefore,
|
||||
rendererCensusAfter,
|
||||
rendererTiming: { before: rendererTimingBefore, after: rendererTimingAfter },
|
||||
zustandPublications,
|
||||
samplingWindow: sampled.samplingWindow,
|
||||
sampleCount: samples.length,
|
||||
summary: summarizeSamples(samples),
|
||||
processInventory: summarizeProcessInventory(samples),
|
||||
@@ -578,7 +462,13 @@ async function main() {
|
||||
{
|
||||
summary: report.summary,
|
||||
processInventory: report.processInventory,
|
||||
sampleCount: report.sampleCount
|
||||
sampleCount: report.sampleCount,
|
||||
scaleFixtureState: report.scaleFixtureState,
|
||||
rendererCensusBefore: report.rendererCensusBefore,
|
||||
rendererCensusAfter: report.rendererCensusAfter,
|
||||
rendererTiming: report.rendererTiming,
|
||||
zustandPublications: report.zustandPublications,
|
||||
samplingWindow: report.samplingWindow
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -54,7 +54,7 @@ export function packagedNodePtyFloorDockerArgs({ workspaceDirectory, appDirector
|
||||
|
||||
function parseAppDirectory(argv) {
|
||||
const index = argv.indexOf('--app-dir')
|
||||
const value = index >= 0 ? argv[index + 1] : undefined
|
||||
const value = index !== -1 ? argv[index + 1] : undefined
|
||||
if (!value || isAbsolute(value) || value.startsWith('-')) {
|
||||
throw new Error('Usage: run-linux-packaged-node-pty-floor-smoke.mjs --app-dir <relative-path>')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -17,8 +17,8 @@ const defaultFiles = [
|
||||
const cliArguments = process.argv.slice(2)
|
||||
const powerShellFlag = cliArguments.indexOf('--powershell')
|
||||
const configuredPowerShell =
|
||||
powerShellFlag >= 0 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE
|
||||
if (powerShellFlag >= 0 && !configuredPowerShell) {
|
||||
powerShellFlag !== -1 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE
|
||||
if (powerShellFlag !== -1 && !configuredPowerShell) {
|
||||
console.error('--powershell requires an executable path')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
@@ -222,8 +222,8 @@ if (process.argv[1]?.endsWith('terminal-e2e-helpers.mjs')) {
|
||||
const args = process.argv.slice(2)
|
||||
const portIdx = args.indexOf('--port')
|
||||
const cmdIdx = args.indexOf('--command')
|
||||
const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 9444
|
||||
const command = cmdIdx >= 0 ? args[cmdIdx + 1] : null
|
||||
const port = portIdx !== -1 ? Number(args[portIdx + 1]) : 9444
|
||||
const command = cmdIdx !== -1 ? args[cmdIdx + 1] : null
|
||||
|
||||
const term = new OrcaTerminal(port)
|
||||
console.log('Connecting to Orca on CDP port', port, '...')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
accessSync,
|
||||
chmodSync,
|
||||
constants as fsConstants,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
@@ -141,7 +142,7 @@ function readGeneratedScripts(home, minMtime) {
|
||||
const body = readFileSync(path, 'utf8')
|
||||
const captureIndex = body.indexOf('payload=$(cat)')
|
||||
const firstExitIndex = body.indexOf('exit 0')
|
||||
if (captureIndex < 0 || firstExitIndex <= captureIndex) {
|
||||
if (captureIndex === -1 || firstExitIndex <= captureIndex) {
|
||||
throw new Error([fileName, ' can exit before capturing stdin'].join(''))
|
||||
}
|
||||
return { body, fileName, path, source }
|
||||
@@ -311,43 +312,37 @@ async function verifyForwarding(scripts, home, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: rewrite from the path embedded in the installed command, not a
|
||||
// reconstructed join(home, ...). That way missing/failing-script cases cannot
|
||||
// silently re-run the real script if the install layout changes.
|
||||
function rewriteLauncherScriptPath(command, nextPath) {
|
||||
const match = /if \[ -f '([^']+)'/.exec(command)
|
||||
if (!match) {
|
||||
throw new Error('Installed launcher command did not reference a quoted script path')
|
||||
}
|
||||
return command.replaceAll(match[1], nextPath)
|
||||
}
|
||||
|
||||
async function verifyInstalledLauncher(home, payload) {
|
||||
const settingsPath = join(home, '.claude', 'settings.json')
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
const command = findStrings(settings).find(
|
||||
(value) => value.includes('claude-hook.sh') && value.includes('if [ -f ')
|
||||
)
|
||||
if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) {
|
||||
if (
|
||||
!command ||
|
||||
!command.includes('"$HOME/.orca/agent-hooks/claude-hook.sh"') ||
|
||||
!command.includes('] && [ -r ') ||
|
||||
!command.includes('else { command -p cat')
|
||||
) {
|
||||
throw new Error('Electron did not install the guarded Claude launcher')
|
||||
}
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-'))
|
||||
try {
|
||||
const missingPath = join(scratch, 'missing-hook.sh')
|
||||
const missingResult = await runShell(
|
||||
rewriteLauncherScriptPath(command, missingPath),
|
||||
command,
|
||||
payload,
|
||||
withoutOrcaEnvironment({ HOME: home })
|
||||
withoutOrcaEnvironment({ HOME: scratch })
|
||||
)
|
||||
assertSuccessfulWrite(missingResult, 'installed missing-script launcher')
|
||||
|
||||
const failingPath = join(scratch, 'failing-hook.sh')
|
||||
const failingPath = join(scratch, '.orca', 'agent-hooks', 'claude-hook.sh')
|
||||
mkdirSync(join(scratch, '.orca', 'agent-hooks'), { recursive: true })
|
||||
writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8')
|
||||
chmodSync(failingPath, 0o755)
|
||||
const failingResult = await runShell(
|
||||
rewriteLauncherScriptPath(command, failingPath),
|
||||
command,
|
||||
payload,
|
||||
withoutOrcaEnvironment({ HOME: home })
|
||||
withoutOrcaEnvironment({ HOME: scratch })
|
||||
)
|
||||
if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) {
|
||||
throw new Error('Installed launcher did not preserve a running script failure')
|
||||
|
||||
@@ -489,11 +489,11 @@ function verifyNativeArgumentGuardrails() {
|
||||
|
||||
function powerShellFunctionBody(source, name) {
|
||||
const start = source.indexOf(`function ${name}`)
|
||||
if (start < 0) {
|
||||
if (start === -1) {
|
||||
return null
|
||||
}
|
||||
const bodyStart = source.indexOf('{', start)
|
||||
if (bodyStart < 0) {
|
||||
if (bodyStart === -1) {
|
||||
return null
|
||||
}
|
||||
let depth = 0
|
||||
@@ -513,11 +513,11 @@ function powerShellFunctionBody(source, name) {
|
||||
|
||||
function swiftFunctionBody(source, name) {
|
||||
const start = source.indexOf(`func ${name}`)
|
||||
if (start < 0) {
|
||||
if (start === -1) {
|
||||
return null
|
||||
}
|
||||
const bodyStart = source.indexOf('{', start)
|
||||
if (bodyStart < 0) {
|
||||
if (bodyStart === -1) {
|
||||
return null
|
||||
}
|
||||
let depth = 0
|
||||
|
||||
@@ -175,7 +175,7 @@ async function main() {
|
||||
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)])
|
||||
)
|
||||
const markerOffset = result.stdout.indexOf(outputMarker)
|
||||
if (markerOffset < 0) {
|
||||
if (markerOffset === -1) {
|
||||
throw new Error('Login shell did not emit the Git output marker')
|
||||
}
|
||||
result.stdout = result.stdout.subarray(markerOffset + Buffer.byteLength(outputMarker))
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
|
||||
"../src/main/agent-hooks/runtime-home-hook-command.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
@@ -55,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",
|
||||
@@ -75,6 +77,7 @@
|
||||
"../src/main/openclaude/hook-service.ts",
|
||||
"../src/main/rolling-file-backup.ts",
|
||||
"../src/main/startup/hydrate-shell-path.ts",
|
||||
"../src/main/startup/windows-shell-path-ownership.ts",
|
||||
// Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this
|
||||
// project's serve spec; the module has no imports, so listing it pulls in nothing else.
|
||||
"../src/main/startup/serve-mode-argv.ts",
|
||||
|
||||
+2
-2
@@ -67,9 +67,9 @@ A common point of drift. Use these conventions for any list-style row (worktrees
|
||||
|
||||
- **Idle:** transparent background.
|
||||
- **Hover:** `bg-accent` (in the worktree sidebar, `bg-sidebar-accent`).
|
||||
- **Keyboard-selected (cmdk highlight):** `data-[selected=true]:bg-accent` plus a `border-border` outline so the active row stays visible while the user types. The `data-selected` attribute is set by `cmdk` automatically.
|
||||
- **Keyboard-selected (cmdk highlight):** do **not** rely on flat `bg-accent` alone on light popover/dialog surfaces — `--accent` (#f5f5f5) is nearly identical to `--background` (#fff), so the cursor vanishes. Use the jump-palette recipe in `main.css` (`.jump-palette-item[data-selected='true']`): `color-mix` foreground into background (~12%) plus an inset ring. Expose the mix as `--jump-palette-selection-surface` when nested cutouts (status pips) must match. The `data-selected` attribute is set by `cmdk` automatically.
|
||||
- **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, _plus_ a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight.
|
||||
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. The accent token already adapts to light/dark and matches the rest of the app.
|
||||
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. Mix from existing tokens (`foreground`/`background`/`accent`) so light/dark stay aligned.
|
||||
|
||||
### Color mixing
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 18m">
|
||||
<title>downloads: 18m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 19m">
|
||||
<title>downloads: 19m</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">18m</text>
|
||||
<text x="90" y="14">18m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">19m</text>
|
||||
<text x="90" y="14">19m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -179,12 +179,14 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Devin logo" width="16" valign="middle" /> Devin</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
@@ -211,6 +213,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
|
||||
|
||||
- **[onOrca.dev에서 다운로드](https://onorca.dev/download)**
|
||||
- 또는 빌드를 직접 받기: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [전체 빌드](https://github.com/stablyai/orca/releases/latest)
|
||||
- headless Linux 서버에서 `orca serve`를 실행하시나요? [Headless Linux 서버 가이드](../reference/headless-linux-server.md)를 확인하세요.
|
||||
|
||||
_또는 패키지 매니저로 설치:_
|
||||
|
||||
@@ -226,8 +229,8 @@ yay -S stably-orca-bin
|
||||
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [APK 0.0.42 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -235,6 +238,10 @@ yay -S stably-orca-bin
|
||||
|
||||
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
|
||||
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
|
||||
- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 7에 참여하세요.
|
||||
|
||||
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 커뮤니티 WeChat 그룹 7 QR 코드" width="160" />
|
||||
|
||||
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
|
||||
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
|
||||
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요.
|
||||
@@ -249,6 +256,14 @@ yay -S stably-orca-bin
|
||||
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca 기여자" />
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
<img src="../assets/star-history.png" alt="stablyai/orca GitHub Star 히스토리 차트" width="880" />
|
||||
</p>
|
||||
|
||||
## 서명된 빌드
|
||||
|
||||
Windows 코드 서명은 [SignPath.io](https://signpath.io)가 후원·제공하며, 인증서는 [SignPath Foundation](https://signpath.org)에서 제공합니다.
|
||||
|
||||
## 라이선스
|
||||
|
||||
Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다.
|
||||
|
||||
@@ -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,328 @@
|
||||
# Renderer agent-status performance
|
||||
|
||||
## Status
|
||||
|
||||
This design is adopted for the renderer's high-frequency agent-status path. It
|
||||
keeps the existing event semantics while bounding the amount of synchronous
|
||||
store fanout performed for one IPC burst.
|
||||
|
||||
It lands in slices. This document and the `bench:idle-cpu` harness land first so
|
||||
the store changes can be reviewed against a baseline someone else measured. Until
|
||||
the store slice lands, the `setAgentStatuses` / `transactAgentStatuses` actions
|
||||
and the agent-status write workload described below are not yet on `main`; the
|
||||
harness measures scale, listener census, and raw publication fanout only.
|
||||
|
||||
## Context
|
||||
|
||||
Orca can display a large expanded worktree lineage inside one virtualized list
|
||||
row. Virtualizing the root row does not virtualize its descendants, so a
|
||||
100-worktree lineage can mount 100 `WorktreeCard` instances at once.
|
||||
|
||||
Agent-status IPC events are bursty. The renderer already groups live events into
|
||||
a 33 ms window, but the original flush applied every queued event with a
|
||||
separate Zustand write. Zustand synchronously visits every listener for every
|
||||
publication. The resulting work therefore grew with both the number of status
|
||||
events and the number of mounted subscriptions:
|
||||
|
||||
```text
|
||||
burst work ~= status events x store listeners x selector work
|
||||
```
|
||||
|
||||
A production trace captured the renderer repeatedly entering
|
||||
`flushLiveAgentStatusBurst -> applyAgentStatus -> setAgentStatus -> setState`
|
||||
through `Set.forEach`. A deterministic 100-worktree fixture reproduces the
|
||||
structural multiplier; see "Baseline on `main`" below for the currently measured
|
||||
listener count and publication cost.
|
||||
|
||||
The production app later recovered substantially when all configured remote
|
||||
hosts were removed. Host removal can stop relay/reconnect traffic, remove
|
||||
mounted remote worktrees, or both, depending on host type and removal options.
|
||||
That observation identifies remote presence as the production trigger but does
|
||||
not by itself distinguish traffic volume from mounted-listener fanout.
|
||||
|
||||
A read-only reconnect audit ruled out systematic double status emission from a
|
||||
full PTY replay: replay bytes bypass OSC status parsing. Reconnect still causes
|
||||
a full terminal-buffer repaint for every attached remote pane, which is a
|
||||
separate source of renderer work and remains a follow-up investigation.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep a large expanded lineage responsive during dense agent-status traffic.
|
||||
- Preserve every ordered status transition, including repeated updates for one
|
||||
pane inside the same burst.
|
||||
- Publish agent-status state once for a deferred live burst.
|
||||
- Preserve selector identity and child render isolation.
|
||||
- Make the regression reproducible without relying on a user's production data.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing the client/server status payload or remote protocol.
|
||||
- Deduplicating status events by pane.
|
||||
- Changing agent freshness, retention, history, title, completion, or provider
|
||||
session behavior.
|
||||
- Changing remote reconnect, PTY replay, or terminal repaint behavior.
|
||||
- Redesigning lineage presentation or collapsing worktrees automatically.
|
||||
|
||||
## Design
|
||||
|
||||
### Bound mounted subscription fanout
|
||||
|
||||
Sidebar components select cohesive state bundles with shallow equality instead
|
||||
of registering one listener per field. Derived arrays and maps retain their
|
||||
existing shallow identity behavior so unrelated store writes do not rerender a
|
||||
card. Full agent-list mode keeps its child-level subscription boundary; compact
|
||||
mode passes the already selected rows to avoid selecting the same inputs twice.
|
||||
|
||||
The deterministic 100-worktree fixture pins the resulting listener budget:
|
||||
|
||||
| Surface | Listener budget |
|
||||
| ------------------------------ | --------------: |
|
||||
| Worktree card state and caches | 2 |
|
||||
| Agent-row inputs | 1 |
|
||||
| Worktree activity status | 1 |
|
||||
| Closed context menu | 1 |
|
||||
|
||||
Unmount tests require the listener count to return to its prior baseline. In the
|
||||
bundled prototype, the fixture without seeded agents fell from 8,518 listeners
|
||||
to 1,218; with 100 visible agent rows the candidate mounted 1,618. Compare
|
||||
against the census in "Baseline on `main`", which the harness reports directly.
|
||||
|
||||
### Share working-spinner phase without per-element animation queries
|
||||
|
||||
Working rows keep the existing compositor-driven CSS animation and shared
|
||||
visual phase. Each mount derives one negative animation delay from the document
|
||||
timeline instead of querying `getAnimations()` and mutating the animation start
|
||||
time. This removes per-row Web Animations setup from dense status transitions
|
||||
without adding a JavaScript animation clock.
|
||||
|
||||
### Fold a burst in event order
|
||||
|
||||
The store exposes the single-update action and two batch forms:
|
||||
|
||||
- `setAgentStatus(paneKey, payload, ...)` retains the positional single-update
|
||||
API for the immediate live path.
|
||||
- `setAgentStatuses(updates)` applies a prebuilt ordered list, while
|
||||
`transactAgentStatuses(operation)` lets IPC derive each update against the
|
||||
exact staged state before the single commit.
|
||||
|
||||
Both entry points reuse the same single-update state transition. The batch
|
||||
reducer passes each resulting state into the next update, so a sequence such as
|
||||
`working -> waiting -> done` retains the same history and timestamps as three
|
||||
sequential calls. Updates are never keyed or deduplicated before the fold.
|
||||
|
||||
The live IPC queue is spliced before it is processed. This preserves the
|
||||
existing reentrancy guarantee: a synchronous subscriber can enqueue another
|
||||
event without causing the current queue to be drained recursively. The first
|
||||
event outside an active burst remains immediate; events accumulated within the
|
||||
33 ms window are applied as one ordered transaction. Startup snapshots and
|
||||
bounded pending-hydration retries use the same transaction path instead of
|
||||
publishing once per restored pane.
|
||||
|
||||
Each transaction builds pane-routing ownership once with the same first-match
|
||||
semantics as the standalone resolver. Split-layout leaf membership is indexed
|
||||
once per layout root, so a large snapshot performs linear tab and leaf work
|
||||
instead of rescanning every mounted worktree for every pane.
|
||||
|
||||
### Run effects after the transaction
|
||||
|
||||
Generated-title work that requires committed state is deferred until after the
|
||||
transaction. Accepted updates also request freshness scheduling; the outer batch
|
||||
coalesces those requests and schedules the shared freshness timer once after its
|
||||
single commit. Generated-title requests are folded in event order and published
|
||||
together, including first-write and forced-replacement semantics. Resolved tab
|
||||
titles are projected while the transaction folds, then final title changes are
|
||||
published together. Completion-triggered review refreshes remain deferred
|
||||
microtasks.
|
||||
|
||||
Bulk title application preserves event order and duplicate-tab behavior while
|
||||
indexing owners once, cloning each changed owner array once, and replacing each
|
||||
top-level map once. This keeps the post-commit title phase linear in mounted
|
||||
tabs plus changed titles.
|
||||
|
||||
This separation is important: invoking store actions from inside a Zustand
|
||||
updater would re-enter the store, while running an effect before the commit
|
||||
would let it observe stale state.
|
||||
|
||||
## Semantic invariants
|
||||
|
||||
Sequential and batched application must agree on:
|
||||
|
||||
- live and retained agent maps;
|
||||
- state history, `updatedAt`, and `stateStartedAt`;
|
||||
- agent identity, model, prompt, tools, assistant messages, and subagents;
|
||||
- orchestration and provider-session continuity;
|
||||
- sleeping-session and launch-config recovery records;
|
||||
- retired/closed-pane rejection and inherited-status suppression;
|
||||
- retention cleanup and live-map eviction;
|
||||
- `agentStatusEpoch` and `sortEpoch`;
|
||||
- automation completion observation across intermediate transitions;
|
||||
- generated-title inputs, freshness scheduling, and completion refreshes.
|
||||
|
||||
Equivalence tests use fixed timestamps and include repeated same-pane
|
||||
transitions. A publication-count test subscribes to the real store and requires
|
||||
one notification for a non-empty batch and none for an empty batch.
|
||||
|
||||
## Benchmark contract
|
||||
|
||||
The benchmark launches an E2E-mode Electron build with the store exposed only
|
||||
for instrumentation. It creates 100 worktrees in one expanded lineage, verifies
|
||||
100 mounted cards, captures the store listener census, and then applies seeded
|
||||
ordered agent-status traffic through the real store action.
|
||||
|
||||
The benchmark measures the synchronous store action, not the live IPC leading
|
||||
edge or post-commit notification path. A real-store snapshot test covers the
|
||||
end-to-end budget for 100 panes with auto-generated titles enabled: one status,
|
||||
one bulk generated-title, and one bulk resolved-title publication. Disabling
|
||||
generated titles removes that middle publication, independent of pane count.
|
||||
|
||||
The artifact records only fixed diagnostic fields needed for comparison:
|
||||
|
||||
- requested and completed batches and updates;
|
||||
- store action calls and observed publications;
|
||||
- elapsed time, throughput, and scheduling drift;
|
||||
- final-state verification;
|
||||
- renderer mean, p95, and maximum CPU;
|
||||
- renderer timer drift and long tasks;
|
||||
- mounted-card and listener counts.
|
||||
|
||||
Raw process inventories, temporary paths, pane identifiers, and DOM text are
|
||||
diagnostic-only and must not be embedded in the shareable report.
|
||||
|
||||
Run baseline and candidate on the same machine and OS with the same Electron
|
||||
build mode. CPU samples from macOS and Linux are comparable within that
|
||||
constraint; Windows process CPU collection currently cannot support this
|
||||
comparison.
|
||||
|
||||
## Harness
|
||||
|
||||
`pnpm run bench:idle-cpu` drives `config/scripts/run-idle-cpu-benchmark.mjs`,
|
||||
which composes four modules:
|
||||
|
||||
| Module | Responsibility |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `idle-cpu-renderer-scale-fixture.mjs` | Seeds the lineage, agent rows, and sidebar view state; takes the mounted-card and listener census |
|
||||
| `idle-cpu-renderer-timing-probe.mjs` | In-page timer drift and long-task probe; runs the no-op publication workload |
|
||||
| `idle-cpu-process-sampling.mjs` | Classifies the Electron process tree and samples per-role CPU/RSS |
|
||||
| `idle-cpu-synthetic-spinners.mjs` | Measurement-only visible spinners |
|
||||
|
||||
The sampling window extends past `--sample-ms` until the workload settles, and
|
||||
fails the run rather than reporting a truncated window if the workload overruns
|
||||
the guard. That is why a 2,000-publication run reports a measured window longer
|
||||
than the requested one.
|
||||
|
||||
`--zustand-publications` publishes an empty partial through the real store, so
|
||||
each publication costs exactly one full subscriber visit and nothing else. It
|
||||
isolates the `listeners x selector work` half of the burst-cost model from
|
||||
agent-status payload work, and it is store-API independent — it measures the
|
||||
same thing before and after the batching slice.
|
||||
|
||||
The agent-status write workload (`--agent-status-batches`,
|
||||
`--agent-status-write-mode`) is not in this harness yet. It depends on
|
||||
`setAgentStatuses`, so it lands with the store slice.
|
||||
|
||||
## Baseline on `main`
|
||||
|
||||
Measured on `main` at `077f5a11cd4` (macOS, arm64, 16 CPUs), Electron built with
|
||||
`electron-vite --mode e2e`, headless, 100 worktrees at lineage depth 99 with 100
|
||||
seeded agent rows, 10 s warmup and a 30 s sampling window.
|
||||
|
||||
Fixture scale is confirmed by the census rather than assumed: 100 store
|
||||
worktrees, 100 mounted cards, 100 mounted agent rows, and **9,279 store
|
||||
listeners**. That listener count is the multiplier the design targets.
|
||||
|
||||
2,000 no-op store publications at a 1 ms cadence, three repetitions. The
|
||||
listener census was 9,279 in every run.
|
||||
|
||||
| Measure | Median | Runs |
|
||||
| ------------------------ | ----------: | ------------------------------ |
|
||||
| Wall time to complete | 12,325.7 ms | 13,148.6 / 12,325.7 / 11,870.5 |
|
||||
| p50 scheduling drift | 5,150.3 ms | 5,432.3 / 5,150.3 / 4,945.2 |
|
||||
| p95 scheduling drift | 9,802.9 ms | 10,570.4 / 9,802.9 / 9,390.0 |
|
||||
| Renderer mean CPU | 18.25% | 20.25 / 18.25 / 15.88 |
|
||||
| Renderer p95 CPU | 32.59% | 40.07 / 32.59 / 31.28 |
|
||||
| Renderer timer drift p95 | 7.0 ms | 7.3 / 5.3 / 7.0 |
|
||||
|
||||
2,000 publications requested over 2 s take about 12 s, so the renderer sustains
|
||||
roughly 160 publications per second at this scale. Each publication is
|
||||
individually short - the long-task observer recorded zero entries in all three
|
||||
runs - so the cost surfaces as scheduling drift and sustained CPU rather than as
|
||||
discrete long tasks. Compare drift and CPU here, not long-task counts.
|
||||
|
||||
The idle control at the same scale with `--zustand-publications 0` reports 6.63%
|
||||
renderer mean CPU, 17.11% p95, and 1.6 ms p95 timer drift. Roughly 11.6 points
|
||||
of mean renderer CPU are therefore attributable to publication fanout rather than
|
||||
to the mounted fixture itself. 200 spinner animations run in both cases, so the
|
||||
control also bounds the animation cost out of the comparison.
|
||||
|
||||
Reproduce with:
|
||||
|
||||
```bash
|
||||
pnpm run bench:idle-cpu -- --worktrees 100 --lineage-depth 99 \
|
||||
--agents-per-worktree 1 --warmup-ms 10000 --sample-ms 30000 \
|
||||
--zustand-publications 2000 --zustand-publication-interval-ms 1 \
|
||||
--output /tmp/idle-cpu-baseline.json
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
Three repetitions used 100 mounted worktrees, lineage depth 99, 100 seeded
|
||||
agent rows, and verified final state. Medians from the regenerated evidence set
|
||||
are:
|
||||
|
||||
| Single 2,000-update burst | Sequential | Batched |
|
||||
| ------------------------- | ---------: | ---------: |
|
||||
| Status-state publications | 2,000 | 1 |
|
||||
| Store action time | 3,692.0 ms | 188.7 ms |
|
||||
| Update throughput | 541.7/s | 10,598.8/s |
|
||||
| Renderer mean CPU | 36.2% | 2.9% |
|
||||
| Renderer p95 CPU | 107.3% | 8.2% |
|
||||
| p95 long task | 4,653 ms | 216 ms |
|
||||
|
||||
The direct store transaction performs 99.95% fewer status-state publications,
|
||||
spends 94.9% less time in the store action, and processes updates 19.6 times
|
||||
faster. Renderer mean CPU falls 92.0%, renderer p95 CPU falls 92.4%, and the p95
|
||||
long task falls 95.4%.
|
||||
|
||||
The 60-burst × 32-update case at 33 ms is a sustained saturation stress, not a
|
||||
real-time production SLO. Publications fall from 1,920 to 60 and median store
|
||||
action time falls from 2,791.9 ms to 323.3 ms. Median completion time falls from
|
||||
5,298.2 ms to 2,710.6 ms, p95 scheduling drift falls from 3,073.0 ms to 664.9
|
||||
ms, and long-task count falls from 57 to 1. Renderer p95 CPU remains saturated
|
||||
and noisy in this cadence, so it is not used as the discriminating measure.
|
||||
|
||||
The 20-pane artificial OpenCode regression passes with 12.4 ms median key echo,
|
||||
25.2 ms worst key echo, 19.4 ms maximum timer drift, and zero dropped renderer
|
||||
backlogs.
|
||||
|
||||
These figures come from the bundled prototype and are restated here as the
|
||||
target. They are re-measured with the harness when the store slice lands.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- The 100-worktree fixture stays at or below the pinned listener budgets.
|
||||
- A deferred transaction performs one status-state publication while preserving
|
||||
ordered final state, including live-map eviction at the 500-row cap.
|
||||
- A 100-pane startup snapshot performs one status and one bulk resolved-title
|
||||
publication with generated titles disabled; enabling generated titles adds at
|
||||
most one ordered bulk publication while preserving final statuses and titles.
|
||||
- Sequential-versus-batch equivalence tests pass across same-pane transitions
|
||||
and side-effect-bearing updates.
|
||||
- Renderer CPU tails and scheduling drift improve in repeated candidate runs.
|
||||
- The 20-pane artificial terminal test reports no dropped output backlog and no
|
||||
material typing-latency regression.
|
||||
- Web typecheck, focused unit tests, lint, max-lines ratchet, and E2E build pass.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is renderer-local. It adds no RPC field, stream opcode, persisted data, Git
|
||||
command, or provider-specific contract. Native, WSL, SSH, relay, folder
|
||||
workspace, and git-worktree status events enter the same renderer action. Mixed
|
||||
client/server versions therefore need no capability negotiation.
|
||||
|
||||
## Failure containment
|
||||
|
||||
The first live event remains immediate. Startup replay and bounded pending
|
||||
retries fold synchronously without waiting for the 33 ms live-burst window, but
|
||||
publish their accepted updates together. Empty batches are no-ops. If an update
|
||||
is stale or targets retired authority, the reducer skips only that update and
|
||||
continues folding later events in order.
|
||||
@@ -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'
|
||||
@@ -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) => {
|
||||
|
||||
@@ -154,7 +154,7 @@ import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
|
||||
import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
|
||||
import { TextInputModal } from '../../../../src/components/TextInputModal'
|
||||
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
|
||||
import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor'
|
||||
import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader'
|
||||
import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments'
|
||||
import {
|
||||
CustomKeyModal,
|
||||
@@ -267,7 +267,6 @@ import {
|
||||
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
|
||||
updateTerminalCwdFromStreamEvent
|
||||
} from '../../../../src/session/mobile-session-route-helpers'
|
||||
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
|
||||
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
|
||||
import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
|
||||
import {
|
||||
@@ -275,7 +274,7 @@ import {
|
||||
dismissMobileSessionCreateWarningState,
|
||||
reconcileMobileSessionCreateWarningState
|
||||
} from '../../../../src/session/mobile-session-create-warning-state'
|
||||
import { colors, spacing } from '../../../../src/theme/mobile-theme'
|
||||
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'
|
||||
@@ -302,132 +301,6 @@ import type {
|
||||
|
||||
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
|
||||
|
||||
function MarkdownReader({
|
||||
documentId,
|
||||
doc,
|
||||
onRefresh,
|
||||
onChange,
|
||||
onSave,
|
||||
onCopy,
|
||||
onDiscard,
|
||||
keyboardLift
|
||||
}: {
|
||||
documentId: string
|
||||
doc: MarkdownDocState | undefined
|
||||
onRefresh: () => void
|
||||
onChange: (content: string) => void
|
||||
onSave: () => void
|
||||
onCopy: () => void
|
||||
onDiscard: () => void
|
||||
keyboardLift: number
|
||||
}) {
|
||||
// Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
|
||||
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
|
||||
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
|
||||
if (!doc || doc.status === 'loading') {
|
||||
return (
|
||||
<View style={styles.markdownState}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (doc.status === 'error') {
|
||||
return (
|
||||
<View style={styles.markdownState}>
|
||||
<Text style={styles.markdownError}>{doc.message}</Text>
|
||||
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
|
||||
<RefreshCw size={14} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownRefreshText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const statusText = doc.saveError
|
||||
? doc.saveError
|
||||
: doc.readOnlyReason
|
||||
? 'Read only'
|
||||
: doc.stale
|
||||
? 'Changed on desktop'
|
||||
: null
|
||||
const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable
|
||||
const showCopy = doc.saveError || !doc.editable
|
||||
const showSave = doc.isDirty || doc.saving
|
||||
const showFloatingActions = statusText || showRefresh || showCopy || showSave
|
||||
|
||||
return (
|
||||
<View style={styles.markdownEditor}>
|
||||
<MobileRichMarkdownEditor
|
||||
key={documentId}
|
||||
content={doc.localContent}
|
||||
editable={doc.editable && !doc.saving}
|
||||
onChange={onChange}
|
||||
onKeyboardInsetChange={setWebviewKeyboardInset}
|
||||
/>
|
||||
{showFloatingActions ? (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
styles.markdownFloatingBar,
|
||||
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
|
||||
{
|
||||
bottom: resolveMarkdownFloatingActionsBottom({
|
||||
keyboardLift: effectiveKeyboardLift,
|
||||
restingBottom: spacing.lg,
|
||||
liftedClearance: spacing.md
|
||||
})
|
||||
}
|
||||
]}
|
||||
>
|
||||
{statusText ? (
|
||||
<Text
|
||||
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{statusText}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.markdownFloatingActions}>
|
||||
{showCopy ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showRefresh ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
|
||||
<RefreshCw size={13} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{doc.isDirty ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showSave ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.markdownFloatingButton,
|
||||
styles.markdownSaveButton,
|
||||
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
|
||||
]}
|
||||
disabled={!doc.editable || !doc.isDirty || doc.saving}
|
||||
onPress={onSave}
|
||||
>
|
||||
{doc.saving ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Text style={styles.markdownFloatingButtonText}>Save</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffLineRow({
|
||||
line,
|
||||
title,
|
||||
@@ -1044,6 +917,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).
|
||||
@@ -1360,6 +1236,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.
|
||||
@@ -1392,6 +1269,7 @@ export default function SessionScreen() {
|
||||
clearNativeChatInputLease()
|
||||
terminalUnsubsRef.current.clear()
|
||||
subscribingHandlesRef.current.clear()
|
||||
leaseOnlyHandlesRef.current.clear()
|
||||
initializedHandlesRef.current.clear()
|
||||
terminalDiagnosticsRef.current.clearTerminalCache()
|
||||
viewportResubscribeBudgetRef.current.clear()
|
||||
@@ -1458,6 +1336,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)
|
||||
@@ -1646,6 +1529,7 @@ export default function SessionScreen() {
|
||||
streamRevision: coveredStreamRevision,
|
||||
subscriptionsRef: terminalUnsubsRef,
|
||||
subscribingRef: subscribingHandlesRef,
|
||||
leaseOnlyRef: leaseOnlyHandlesRef,
|
||||
webReadyRef: webReadyHandlesRef,
|
||||
initializedRef: initializedHandlesRef,
|
||||
subscribe: subscribeToTerminal,
|
||||
@@ -3212,6 +3096,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),
|
||||
@@ -4636,7 +4524,7 @@ export default function SessionScreen() {
|
||||
</View>
|
||||
) : activeMarkdownTab ? (
|
||||
<View style={styles.markdownFrame}>
|
||||
<MarkdownReader
|
||||
<MobileMarkdownReader
|
||||
documentId={activeMarkdownTab.id}
|
||||
doc={markdownDocs.get(activeMarkdownTab.id)}
|
||||
onRefresh={() => void readMarkdownTab(activeMarkdownTab)}
|
||||
|
||||
+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
|
||||
|
||||
@@ -253,6 +253,9 @@ platform :ios do
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
app_identifier: BUNDLE_ID,
|
||||
# distribute_only means there is no local .ipa to sniff, so fastlane would
|
||||
# otherwise fall through to an interactive platform prompt and crash in CI.
|
||||
app_platform: "ios",
|
||||
app_version: version,
|
||||
build_number: build_number,
|
||||
distribute_only: true,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createElement, createRef, forwardRef, useImperativeHandle } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MobileRichMarkdownEditor,
|
||||
type MobileRichMarkdownEditorHandle
|
||||
} from './MobileRichMarkdownEditor'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dismissKeyboard: vi.fn(),
|
||||
injectJavaScript: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
Keyboard: { dismiss: mocks.dismissKeyboard },
|
||||
Linking: { openURL: vi.fn() },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: ({ children, ...props }: { children?: unknown }) =>
|
||||
React.createElement('ScrollView', props, children),
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
View: 'View'
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-native-webview', () => {
|
||||
const WebView = forwardRef((props: Record<string, unknown>, ref) => {
|
||||
useImperativeHandle(ref, () => ({ injectJavaScript: mocks.injectJavaScript }))
|
||||
return createElement('WebView', props)
|
||||
})
|
||||
return { WebView, default: WebView }
|
||||
})
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Bold: 'Bold',
|
||||
ChevronDown: 'ChevronDown',
|
||||
Code2: 'Code2',
|
||||
FileCode2: 'FileCode2',
|
||||
Heading1: 'Heading1',
|
||||
Heading2: 'Heading2',
|
||||
Heading3: 'Heading3',
|
||||
ImageIcon: 'ImageIcon',
|
||||
Italic: 'Italic',
|
||||
Keyboard: 'Keyboard',
|
||||
Link: 'Link',
|
||||
List: 'List',
|
||||
ListOrdered: 'ListOrdered',
|
||||
ListTodo: 'ListTodo',
|
||||
Pilcrow: 'Pilcrow',
|
||||
Quote: 'Quote',
|
||||
Strikethrough: 'Strikethrough'
|
||||
}))
|
||||
|
||||
describe('MobileRichMarkdownEditor', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('exposes WebView keyboard dismissal to its native parent', () => {
|
||||
const editorRef = createRef<MobileRichMarkdownEditorHandle>()
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileRichMarkdownEditor, {
|
||||
ref: editorRef,
|
||||
content: '',
|
||||
editable: true,
|
||||
onChange: vi.fn()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
act(() => editorRef.current?.dismissKeyboard())
|
||||
|
||||
expect(mocks.injectJavaScript).toHaveBeenCalledWith(
|
||||
expect.stringContaining('window.__orcaRichMarkdown.dismissKeyboard()')
|
||||
)
|
||||
expect(mocks.dismissKeyboard).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,15 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, type ComponentType } from 'react'
|
||||
import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ComponentType,
|
||||
type ForwardedRef
|
||||
} from 'react'
|
||||
import { Keyboard, Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
|
||||
import {
|
||||
Bold,
|
||||
Code2,
|
||||
@@ -77,6 +87,10 @@ type Props = {
|
||||
onKeyboardInsetChange?: (bottom: number) => void
|
||||
}
|
||||
|
||||
export type MobileRichMarkdownEditorHandle = {
|
||||
dismissKeyboard: () => void
|
||||
}
|
||||
|
||||
type EditorWebViewMessage =
|
||||
| { type: 'ready' }
|
||||
| { type: 'change'; markdown: string; generation: number }
|
||||
@@ -107,12 +121,10 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [
|
||||
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
|
||||
]
|
||||
|
||||
function MobileRichMarkdownEditorInner({
|
||||
content,
|
||||
editable,
|
||||
onChange,
|
||||
onKeyboardInsetChange
|
||||
}: Props) {
|
||||
function MobileRichMarkdownEditorInner(
|
||||
{ content, editable, onChange, onKeyboardInsetChange }: Props,
|
||||
ref: ForwardedRef<MobileRichMarkdownEditorHandle>
|
||||
) {
|
||||
const webViewRef = useRef<WebView>(null)
|
||||
const readyRef = useRef(false)
|
||||
const documentGenerationRef = useRef(0)
|
||||
@@ -227,6 +239,15 @@ function MobileRichMarkdownEditorInner({
|
||||
[inject]
|
||||
)
|
||||
|
||||
const dismissKeyboard = useCallback(() => {
|
||||
// Why: the caret lives in the WebView, so the injected blur is what closes the keyboard;
|
||||
// Keyboard.dismiss only clears a native TextInput that stole focus first.
|
||||
inject('window.__orcaRichMarkdown && window.__orcaRichMarkdown.dismissKeyboard();')
|
||||
Keyboard.dismiss()
|
||||
}, [inject])
|
||||
|
||||
useImperativeHandle(ref, () => ({ dismissKeyboard }), [dismissKeyboard])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.toolbar}>
|
||||
@@ -278,7 +299,7 @@ function MobileRichMarkdownEditorInner({
|
||||
)
|
||||
}
|
||||
|
||||
export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner)
|
||||
export const MobileRichMarkdownEditor = memo(forwardRef(MobileRichMarkdownEditorInner))
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ function attrValue(tag: string, name: string): string {
|
||||
const valueStart = cursor
|
||||
if (quote) {
|
||||
const valueEnd = tag.indexOf(quote, cursor)
|
||||
if (valueEnd < 0) {
|
||||
if (valueEnd === -1) {
|
||||
return ''
|
||||
}
|
||||
cursor = valueEnd + 1
|
||||
|
||||
@@ -191,7 +191,7 @@ function closingMarkupTagNames(value: string): Set<string> {
|
||||
let cursor = 0
|
||||
while (cursor < value.length) {
|
||||
const start = value.indexOf('<', cursor)
|
||||
if (start < 0) {
|
||||
if (start === -1) {
|
||||
return names
|
||||
}
|
||||
const end = findMobileMarkdownMarkupTagEnd(value, start + 1)
|
||||
@@ -228,7 +228,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string {
|
||||
const closingTagNames = closingMarkupTagNames(value)
|
||||
while (cursor < value.length) {
|
||||
const start = value.indexOf('<', cursor)
|
||||
if (start < 0) {
|
||||
if (start === -1) {
|
||||
return output + value.slice(cursor)
|
||||
}
|
||||
output += value.slice(cursor, start)
|
||||
@@ -261,7 +261,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string {
|
||||
!/[-:]/.test(name) &&
|
||||
!knownMarkupTagNames.has(lowerName) &&
|
||||
!closingTagNames.has(lowerName)
|
||||
if (isNestedGeneric && nestedStart >= 0) {
|
||||
if (isNestedGeneric && nestedStart !== -1) {
|
||||
output += value.slice(start, nestedStart)
|
||||
cursor = nestedStart
|
||||
continue
|
||||
|
||||
@@ -11,9 +11,7 @@ function editorScript(): string {
|
||||
return script ?? ''
|
||||
}
|
||||
|
||||
function extractFunctionSource(script: string, name: string): string {
|
||||
const start = script.indexOf(`function ${name}`)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
function extractBracedSource(script: string, start: number, label: string): string {
|
||||
const bodyStart = script.indexOf('{', start)
|
||||
let depth = 0
|
||||
for (let index = bodyStart; index < script.length; index += 1) {
|
||||
@@ -28,7 +26,19 @@ function extractFunctionSource(script: string, name: string): string {
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not extract ${name}`)
|
||||
throw new Error(`Could not extract ${label}`)
|
||||
}
|
||||
|
||||
function extractFunctionSource(script: string, name: string): string {
|
||||
const start = script.indexOf(`function ${name}`)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
return extractBracedSource(script, start, name)
|
||||
}
|
||||
|
||||
function extractEditorListenerSource(script: string, type: string): string {
|
||||
const start = script.indexOf(`editor.addEventListener('${type}'`)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
return `${extractBracedSource(script, start, `${type} listener`)});`
|
||||
}
|
||||
|
||||
function runtimeMarkdownToHtml(markdown: string, editable: boolean): string {
|
||||
@@ -65,6 +75,134 @@ function runtimeListMarkdown(): (list: unknown) => string {
|
||||
return new Function(sources)() as (list: unknown) => string
|
||||
}
|
||||
|
||||
type FakeRange = {
|
||||
commonAncestorContainer: unknown
|
||||
cloneRange: () => FakeRange
|
||||
selectNodeContents: (node: unknown) => void
|
||||
collapse: (toStart: boolean) => void
|
||||
}
|
||||
|
||||
function createFakeRange(container: unknown): FakeRange {
|
||||
const range: FakeRange = {
|
||||
commonAncestorContainer: container,
|
||||
cloneRange: () => createFakeRange(range.commonAncestorContainer),
|
||||
selectNodeContents: (node) => {
|
||||
range.commonAncestorContainer = node
|
||||
},
|
||||
collapse: () => {}
|
||||
}
|
||||
return range
|
||||
}
|
||||
|
||||
type FakeClickEvent = {
|
||||
clientX: number
|
||||
clientY: number
|
||||
target: { closest: (selector: string) => unknown }
|
||||
preventDefault: () => void
|
||||
}
|
||||
|
||||
// Drives the editor's real selection and click handling against a stub DOM whose blur
|
||||
// drops the selection, the way WebKit does.
|
||||
function createSelectionRuntime(caretContainer: string | null) {
|
||||
const liveNodes = new Set(caretContainer ? [caretContainer] : [])
|
||||
const listeners = new Map<string, (event: FakeClickEvent) => void>()
|
||||
let focused = caretContainer != null
|
||||
let ranges: FakeRange[] = caretContainer ? [createFakeRange(caretContainer)] : []
|
||||
let caretAtPoint: string | null = null
|
||||
|
||||
const editor = {
|
||||
contains: (node: unknown) => typeof node === 'string' && liveNodes.has(node),
|
||||
focus: () => {
|
||||
focused = true
|
||||
fakeDocument.activeElement = editor
|
||||
},
|
||||
blur: () => {
|
||||
focused = false
|
||||
fakeDocument.activeElement = null
|
||||
ranges = []
|
||||
},
|
||||
addEventListener: (type: string, handler: (event: FakeClickEvent) => void) => {
|
||||
listeners.set(type, handler)
|
||||
}
|
||||
}
|
||||
const fakeDocument: {
|
||||
activeElement: unknown
|
||||
createRange: () => FakeRange
|
||||
caretRangeFromPoint: () => FakeRange | null
|
||||
} = {
|
||||
activeElement: focused ? editor : null,
|
||||
createRange: () => createFakeRange('detached'),
|
||||
caretRangeFromPoint: () => (caretAtPoint ? createFakeRange(caretAtPoint) : null)
|
||||
}
|
||||
const fakeWindow = {
|
||||
getSelection: () => ({
|
||||
get rangeCount() {
|
||||
return ranges.length
|
||||
},
|
||||
getRangeAt: (index: number) => ranges[index],
|
||||
removeAllRanges: () => {
|
||||
ranges = []
|
||||
},
|
||||
addRange: (range: FakeRange) => {
|
||||
ranges = [range]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const script = editorScript()
|
||||
const sources = [
|
||||
'var editor = arguments[0];',
|
||||
'var document = arguments[1];',
|
||||
'var window = arguments[2];',
|
||||
'var editable = true;',
|
||||
'var savedSelectionRange = null;',
|
||||
'var selectionDroppedOnBlur = false;',
|
||||
'function post() {}',
|
||||
'function emitChange() {}',
|
||||
extractFunctionSource(script, 'focusEditor'),
|
||||
extractFunctionSource(script, 'rememberSelection'),
|
||||
extractFunctionSource(script, 'applySelectionRange'),
|
||||
extractFunctionSource(script, 'caretRangeAtPoint'),
|
||||
extractFunctionSource(script, 'dismissKeyboard'),
|
||||
extractFunctionSource(script, 'restoreSelectionOrEnd'),
|
||||
extractEditorListenerSource(script, 'click'),
|
||||
'return { dismissKeyboard: dismissKeyboard, restoreSelectionOrEnd: restoreSelectionOrEnd };'
|
||||
].join('\n')
|
||||
const api = new Function(sources)(editor, fakeDocument, fakeWindow) as {
|
||||
dismissKeyboard: () => void
|
||||
restoreSelectionOrEnd: () => void
|
||||
}
|
||||
|
||||
return {
|
||||
dismissKeyboard: api.dismissKeyboard,
|
||||
restoreSelectionOrEnd: api.restoreSelectionOrEnd,
|
||||
tapAt: (container: string, options?: { uneditableAncestor?: string }) => {
|
||||
liveNodes.add(container)
|
||||
caretAtPoint = container
|
||||
listeners.get('click')?.({
|
||||
clientX: 12,
|
||||
clientY: 34,
|
||||
target: {
|
||||
closest: (selector: string) =>
|
||||
selector === '[contenteditable="false"]' ? (options?.uneditableAncestor ?? null) : null
|
||||
},
|
||||
preventDefault: () => {}
|
||||
})
|
||||
},
|
||||
detachEditorContent: () => liveNodes.clear(),
|
||||
selectedContainer: () => {
|
||||
if (ranges.length === 0) {
|
||||
return null
|
||||
}
|
||||
const container = ranges[0].commonAncestorContainer
|
||||
return container === editor ? 'editor-end' : (container as string)
|
||||
},
|
||||
get focused() {
|
||||
return focused
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile rich markdown editor HTML', () => {
|
||||
it('builds parseable WebView JavaScript', () => {
|
||||
const script = editorScript()
|
||||
@@ -233,4 +371,64 @@ describe('mobile rich markdown editor HTML', () => {
|
||||
expect(emitChange).not.toContain('window.setTimeout')
|
||||
expect(emitChange).toContain('generation: pendingGeneration')
|
||||
})
|
||||
|
||||
it('exposes a keyboard dismissal command that blurs WebView focus', () => {
|
||||
const script = editorScript()
|
||||
const dismissKeyboard = extractFunctionSource(script, 'dismissKeyboard')
|
||||
|
||||
expect(dismissKeyboard).toContain('rememberSelection()')
|
||||
expect(dismissKeyboard).toContain('document.activeElement.blur()')
|
||||
expect(dismissKeyboard).toContain('editor.blur()')
|
||||
expect(script).toContain('dismissKeyboard: dismissKeyboard')
|
||||
})
|
||||
|
||||
it('reclaims editor focus at the tapped caret after keyboard dismissal', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
runtime.tapAt('paragraph-7')
|
||||
|
||||
expect(runtime.focused).toBe(true)
|
||||
expect(runtime.selectedContainer()).toBe('paragraph-7')
|
||||
})
|
||||
|
||||
it('leaves task-list label taps to the checkbox instead of refocusing the editor', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
runtime.tapAt('paragraph-7', { uneditableAncestor: 'task-label' })
|
||||
|
||||
expect(runtime.focused).toBe(false)
|
||||
expect(runtime.selectedContainer()).toBe(null)
|
||||
})
|
||||
|
||||
it('restores the pre-dismissal caret so commands do not insert at the document end', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
expect(runtime.selectedContainer()).toBe(null)
|
||||
|
||||
runtime.restoreSelectionOrEnd()
|
||||
|
||||
expect(runtime.selectedContainer()).toBe('paragraph-3')
|
||||
expect(runtime.focused).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the document end when no caret was ever placed', () => {
|
||||
const runtime = createSelectionRuntime(null)
|
||||
|
||||
runtime.restoreSelectionOrEnd()
|
||||
|
||||
expect(runtime.selectedContainer()).toBe('editor-end')
|
||||
})
|
||||
|
||||
it('drops a remembered caret whose nodes left the document', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
runtime.detachEditorContent()
|
||||
runtime.restoreSelectionOrEnd()
|
||||
|
||||
expect(runtime.selectedContainer()).toBe('editor-end')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
|
||||
import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script'
|
||||
|
||||
export function escapeInjectedJavaScriptString(value: string): string {
|
||||
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
|
||||
@@ -559,6 +561,9 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
window.clearTimeout(inputTimer);
|
||||
documentGeneration = Number(generation) || 0;
|
||||
suppressInput = true;
|
||||
// Why: replacing innerHTML detaches the remembered caret's nodes.
|
||||
savedSelectionRange = null;
|
||||
selectionDroppedOnBlur = false;
|
||||
lastMarkdown = String(markdown || '');
|
||||
editor.innerHTML = markdownToHtml(lastMarkdown);
|
||||
syncTaskCheckboxesDisabled();
|
||||
@@ -571,39 +576,8 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
syncTaskCheckboxesDisabled();
|
||||
}
|
||||
|
||||
function focusEditor() {
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function restoreSelectionOrEnd() {
|
||||
focusEditor();
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount > 0) return;
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function wrapSelection(tagName) {
|
||||
restoreSelectionOrEnd();
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
var range = selection.getRangeAt(0);
|
||||
if (range.collapsed) return;
|
||||
var wrapper = document.createElement(tagName);
|
||||
try {
|
||||
range.surroundContents(wrapper);
|
||||
} catch (_error) {
|
||||
wrapper.appendChild(range.extractContents());
|
||||
range.insertNode(wrapper);
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.selectAllChildren(wrapper);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}
|
||||
function runCommand(command) {
|
||||
if (!editable || editor.getAttribute('contenteditable') !== 'true') return;
|
||||
restoreSelectionOrEnd();
|
||||
@@ -632,6 +606,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
}
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
selectionDroppedOnBlur = false;
|
||||
if (editable) emitChange();
|
||||
});
|
||||
editor.addEventListener('change', function (event) {
|
||||
@@ -654,7 +629,19 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
return;
|
||||
}
|
||||
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
|
||||
if (!input) return;
|
||||
if (!input) {
|
||||
if (!editable) return;
|
||||
// Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.
|
||||
var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]');
|
||||
if (uneditable && uneditable !== editor) return;
|
||||
selectionDroppedOnBlur = false;
|
||||
if (document.activeElement === editor) return;
|
||||
// Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.
|
||||
var caret = caretRangeAtPoint(event.clientX, event.clientY);
|
||||
focusEditor();
|
||||
if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);
|
||||
return;
|
||||
}
|
||||
if (!editable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -670,7 +657,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
}
|
||||
});
|
||||
|
||||
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown };
|
||||
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
|
||||
post({ type: 'ready' });
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Composed into the editor document after MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT, whose
|
||||
// rememberSelection/selectionDroppedOnBlur this depends on.
|
||||
export const MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT = `
|
||||
function dismissKeyboard() {
|
||||
// Why: WebKit discards the DOM selection on blur, so capture the caret before it goes.
|
||||
rememberSelection();
|
||||
selectionDroppedOnBlur = true;
|
||||
if (document.activeElement && document.activeElement.blur) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
editor.blur();
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,71 @@
|
||||
// Caret and selection management for the WebView editor. Split out so the editor
|
||||
// document script stays inside its line budget.
|
||||
export const MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT = `
|
||||
var savedSelectionRange = null;
|
||||
var selectionDroppedOnBlur = false;
|
||||
|
||||
function focusEditor() {
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function rememberSelection() {
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
var range = selection.getRangeAt(0);
|
||||
if (editor.contains(range.commonAncestorContainer)) savedSelectionRange = range.cloneRange();
|
||||
}
|
||||
|
||||
function applySelectionRange(range) {
|
||||
var selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
savedSelectionRange = range.cloneRange();
|
||||
}
|
||||
|
||||
function caretRangeAtPoint(x, y) {
|
||||
if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
|
||||
if (!document.caretPositionFromPoint) return null;
|
||||
var position = document.caretPositionFromPoint(x, y);
|
||||
if (!position) return null;
|
||||
var range = document.createRange();
|
||||
range.setStart(position.offsetNode, position.offset);
|
||||
range.collapse(true);
|
||||
return range;
|
||||
}
|
||||
|
||||
function restoreSelectionOrEnd() {
|
||||
focusEditor();
|
||||
var selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
// Why: the blur dropped the live selection, so commands would otherwise insert at the document end.
|
||||
if (selectionDroppedOnBlur && savedSelectionRange && editor.contains(savedSelectionRange.commonAncestorContainer)) {
|
||||
selectionDroppedOnBlur = false;
|
||||
applySelectionRange(savedSelectionRange);
|
||||
return;
|
||||
}
|
||||
if (selection.rangeCount > 0) return;
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
applySelectionRange(range);
|
||||
}
|
||||
|
||||
function wrapSelection(tagName) {
|
||||
restoreSelectionOrEnd();
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
var range = selection.getRangeAt(0);
|
||||
if (range.collapsed) return;
|
||||
var wrapper = document.createElement(tagName);
|
||||
try {
|
||||
range.surroundContents(wrapper);
|
||||
} catch (_error) {
|
||||
wrapper.appendChild(range.extractContents());
|
||||
range.insertNode(wrapper);
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.selectAllChildren(wrapper);
|
||||
emitChange();
|
||||
}
|
||||
`
|
||||
@@ -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 &&
|
||||
|
||||
@@ -132,7 +132,7 @@ export function getDirectoryCacheState(
|
||||
): DirectoryState | undefined {
|
||||
// Why: repository paths are arbitrary object keys; inherited keys like
|
||||
// "constructor" must not masquerade as loaded directory state.
|
||||
return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined
|
||||
return Object.hasOwn(cache, relativePath) ? cache[relativePath] : undefined
|
||||
}
|
||||
|
||||
export function joinRelativePath(parentPath: string, name: string): string {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user